From 7dc0c6fccfc2d54e65b2600b46a34fa377207a3e Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Thu, 20 Aug 2026 09:28:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(admin):=20=E6=A8=A1=E5=9E=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20-=20=E4=BF=AE=E5=A4=8D=E8=83=BD=E5=8A=9B=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E9=A2=9C=E8=89=B2=E4=B8=8E=E8=A1=A8=E6=A0=BC=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/logical-models/logical-models-page.tsx | 2 +- .../logical-models/model-routing-capabilities.tsx | 4 ++-- web/src/styles/globals.css | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/web/src/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index 98b039933..6e144fded 100644 --- a/web/src/pages/admin/logical-models/logical-models-page.tsx +++ b/web/src/pages/admin/logical-models/logical-models-page.tsx @@ -261,7 +261,7 @@ export default function LogicalModelsPage() { toolbarActiveFilters={keyword ? { setKeyword(""); setPage(1); }} /> : null} toolbarActive={Boolean(keyword)} onReset={() => { setKeyword(""); setPage(1); }} - table={{ rowKey: "id", size: "small", loading, pagination: false, columns: modelColumns, dataSource: paginatedModels, scroll: { x: 980 } }} + table={{ className: "admin-logical-model-table", rowKey: "id", size: "small", loading, pagination: false, columns: modelColumns, dataSource: paginatedModels, scroll: { x: 980 } }} empty={} footer={ { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />} /> diff --git a/web/src/pages/admin/logical-models/model-routing-capabilities.tsx b/web/src/pages/admin/logical-models/model-routing-capabilities.tsx index 45667d11d..bb950660b 100644 --- a/web/src/pages/admin/logical-models/model-routing-capabilities.tsx +++ b/web/src/pages/admin/logical-models/model-routing-capabilities.tsx @@ -439,11 +439,11 @@ export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) { return (
{labels.slice(0, 4).map((label) => ( - + {label} ))} - {labels.length > 4 ? +{labels.length - 4} : null} + {labels.length > 4 ? +{labels.length - 4} : null}
); } diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 9756d5d1d..2bf3e009b 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -7891,6 +7891,21 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: border-inline-start: 0; } +.admin-shell .admin-logical-model-table .ant-table-tbody > tr > td { + height: auto; + vertical-align: top; +} + +.admin-shell .admin-logical-model-table .admin-capability-summary-tag { + max-width: 100%; + margin-inline-end: 0; + overflow-wrap: anywhere; + border: 1px solid color-mix(in srgb, var(--foreground) 14%, transparent) !important; + background: color-mix(in srgb, var(--foreground) 6%, var(--admin-layer-1)) !important; + color: color-mix(in srgb, var(--foreground) 72%, transparent) !important; + white-space: normal; +} + .admin-shell .admin-table-clickable-row { cursor: pointer; } From 56f5f651cdce0fa991dc82234155c0222875427e Mon Sep 17 00:00:00 2001 From: MasterChiefCN Date: Thu, 20 Aug 2026 09:34:17 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(admin):=20=E6=A8=A1=E5=9E=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20-=20=E5=A2=9E=E5=8A=A0=E5=89=8D=E5=8F=B0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/logical_models.go | 12 ++++ backend/internal/repository/logical_models.go | 37 ++++++++++++ .../repository/logical_models_test.go | 60 +++++++++++++++++++ backend/internal/service/logical_models.go | 25 ++++++++ .../logical-models/logical-models-page.tsx | 33 +++++++++- web/src/services/api/logical-models.ts | 4 ++ 6 files changed, 170 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/logical_models.go b/backend/internal/handler/logical_models.go index e7a177d08..6a5c26454 100644 --- a/backend/internal/handler/logical_models.go +++ b/backend/internal/handler/logical_models.go @@ -58,6 +58,18 @@ func RegisterLogicalModelRoutes(r *gin.RouterGroup, svc *service.Service) { r.PATCH("/admin/logical-models/:id", func(c *gin.Context) { saveAdminLogicalModel(c, svc, c.Param("id")) }) + r.DELETE("/admin/logical-models/:id", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteAdminLogicalModel(actor, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) r.POST("/admin/logical-models/:id/simulate", func(c *gin.Context) { actor, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/repository/logical_models.go b/backend/internal/repository/logical_models.go index e8e7bc797..96b81c94d 100644 --- a/backend/internal/repository/logical_models.go +++ b/backend/internal/repository/logical_models.go @@ -16,6 +16,8 @@ type LogicalModelGraph struct { ChannelModels []model.ChannelModel } +var ErrLogicalModelInUse = errors.New("logical model is in use") + func (r *Repository) LogicalModels(includeDisabled bool) ([]model.LogicalModel, error) { var items []model.LogicalModel query := r.db.Order("sort_order asc, created_at asc") @@ -401,6 +403,41 @@ func (r *Repository) SaveLogicalModelBundle(item *model.LogicalModel, revision * }) } +func (r *Repository) DeleteLogicalModel(id string, now time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // 先停用并锁定目录主体,避免删除期间仍被新的数据库查询选中。 + result := tx.Model(&model.LogicalModel{}). + Where("id = ?", id). + Updates(map[string]any{"enabled": false, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + + var activeTasks int64 + if err := tx.Model(&model.Task{}). + Where("logical_model_id = ? AND status IN ?", id, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Count(&activeTasks).Error; err != nil { + return err + } + if activeTasks > 0 { + return ErrLogicalModelInUse + } + + // revision 与 route 是不可变的任务快照;删除目录主体时保留它们供历史记录追溯。 + deleted := tx.Where("id = ?", id).Delete(&model.LogicalModel{}) + if deleted.Error != nil { + return deleted.Error + } + if deleted.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + func (r *Repository) DeleteLogicalModelRoute(id string) error { return r.db.Model(&model.LogicalModelRoute{}).Where("id = ?", id).Updates(map[string]any{"enabled": false, "weight": 0, "updated_at": time.Now()}).Error } diff --git a/backend/internal/repository/logical_models_test.go b/backend/internal/repository/logical_models_test.go index b064f5823..8b8c1f968 100644 --- a/backend/internal/repository/logical_models_test.go +++ b/backend/internal/repository/logical_models_test.go @@ -1,6 +1,7 @@ package repository import ( + "errors" "testing" "time" @@ -48,3 +49,62 @@ func TestSaveLogicalModelBundleAllocatesMonotonicRevisionSequence(t *testing.T) t.Fatalf("updated model state = %#v", updated) } } + +func TestDeleteLogicalModelPreservesPublishedHistory(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:logical-model-delete-history?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.LogicalModel{}, &model.LogicalModelRevision{}, &model.LogicalModelRoute{}, &model.Task{}); err != nil { + t.Fatal(err) + } + repo := New(db) + + item := &model.LogicalModel{ID: "LMODEL_DELETE", Code: "delete-demo", Name: "Delete Demo", Capability: "text", Enabled: true, PricePolicy: "unified", BillingMode: "fixed_request", CreatedAt: time.Now(), UpdatedAt: time.Now()} + revision := &model.LogicalModelRevision{ID: "REVISION_DELETE", LogicalModelID: item.ID, CapabilitySpecJSON: `{"version":1,"capability":"text"}`, DefaultOptionsJSON: `{}`, CreatedAt: time.Now()} + routes := []model.LogicalModelRoute{{ID: "ROUTE_DELETE", ChannelModelID: "CMODEL_DELETE", Enabled: true, Weight: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}} + if err := repo.SaveLogicalModelBundle(item, revision, routes, true); err != nil { + t.Fatal(err) + } + + if err := repo.DeleteLogicalModel(item.ID, time.Now()); err != nil { + t.Fatal(err) + } + if err := db.First(&model.LogicalModel{}, "id = ?", item.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("logical model lookup error = %v, want record not found", err) + } + for label, target := range map[string]any{"revision": &model.LogicalModelRevision{}, "route": &model.LogicalModelRoute{}} { + if err := db.First(target).Error; err != nil { + t.Fatalf("%s history was not preserved: %v", label, err) + } + } +} + +func TestDeleteLogicalModelRejectsActiveTask(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:logical-model-delete-active-task?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.LogicalModel{}, &model.Task{}); err != nil { + t.Fatal(err) + } + repo := New(db) + item := model.LogicalModel{ID: "LMODEL_ACTIVE", Code: "active-demo", Name: "Active Demo", Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Task{ID: "TASK_ACTIVE", LogicalModelID: item.ID, Status: model.TaskStatusRunning, CreatedAt: time.Now(), UpdatedAt: time.Now()}).Error; err != nil { + t.Fatal(err) + } + + if err := repo.DeleteLogicalModel(item.ID, time.Now()); !errors.Is(err, ErrLogicalModelInUse) { + t.Fatalf("DeleteLogicalModel() error = %v, want ErrLogicalModelInUse", err) + } + var preserved model.LogicalModel + if err := db.First(&preserved, "id = ?", item.ID).Error; err != nil { + t.Fatalf("active logical model was removed: %v", err) + } + if !preserved.Enabled { + t.Fatal("active logical model disable was not rolled back") + } +} diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index 39ccc9270..af42060b4 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -408,6 +408,31 @@ func (s *Service) SaveAdminLogicalModel(actor *model.User, id string, req Logica return s.buildAdminLogicalModel(*item, graph, systemChannelByID) } +func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + item, err := s.repo.LogicalModel(strings.TrimSpace(id)) + if logicalModelNotFound(err) { + return BadAuthRequest("前台模型不存在或已删除") + } + if err != nil { + return err + } + if err := s.repo.DeleteLogicalModel(item.ID, time.Now()); err != nil { + if errors.Is(err, repository.ErrLogicalModelInUse) { + return BadAuthRequest("前台模型仍被排队中或进行中任务使用,请等待任务结束后再删除") + } + if logicalModelNotFound(err) { + return BadAuthRequest("前台模型不存在或已删除") + } + return err + } + s.invalidateRouteCatalog() + _ = s.appendAdminAudit(actor, "logical_model.delete", "logical_model", item.ID, "删除前台模型目录主体", map[string]any{"code": item.Code, "name": item.Name}) + return nil +} + func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalModelRequest) (*model.LogicalModel, *model.LogicalModelRevision, []model.LogicalModelRoute, bool, error) { code := strings.ToLower(strings.TrimSpace(req.Code)) name := strings.TrimSpace(req.Name) diff --git a/web/src/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index 98b039933..45696aebb 100644 --- a/web/src/pages/admin/logical-models/logical-models-page.tsx +++ b/web/src/pages/admin/logical-models/logical-models-page.tsx @@ -1,7 +1,7 @@ import { Alert, App, Button, Drawer, Form, Input, InputNumber, Modal, Segmented, Select, Switch, Table, Tag } from "antd"; import type { FormInstance } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { FlaskConical, GitBranch, Layers3, Pencil, Plus, Search } from "lucide-react"; +import { FlaskConical, GitBranch, Layers3, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "react"; import { PaginationBar } from "@/components/layout/workspace-page"; @@ -14,6 +14,7 @@ import { listAdminChannels } from "@/services/api/auth"; import { listAdminChannelModels, type ChannelModel } from "@/services/api/wallet"; import { createAdminLogicalModel, + deleteAdminLogicalModel, listAdminLogicalModels, simulateAdminLogicalModel, updateAdminLogicalModel, @@ -71,6 +72,7 @@ export default function LogicalModelsPage() { const [channelEnabled, setChannelEnabled] = useState>({}); const [editingModel, setEditingModel] = useState(); const [saving, setSaving] = useState(false); + const [deletingModelId, setDeletingModelId] = useState(); const [simulatingModel, setSimulatingModel] = useState(); const [simulationIntent, setSimulationIntent] = useState(); const [simulationResult, setSimulationResult] = useState(); @@ -180,6 +182,21 @@ export default function LogicalModelsPage() { } }; + const removeModel = async (item: AdminLogicalModel) => { + setDeletingModelId(item.id); + try { + await deleteAdminLogicalModel(item.id); + setModels((current) => current.filter((model) => model.id !== item.id)); + if (paginatedModels.length === 1 && page > 1) setPage(page - 1); + message.success("前台模型已删除"); + } catch (error) { + message.error(error instanceof Error ? error.message : "删除前台模型失败"); + throw error; + } finally { + setDeletingModelId(undefined); + } + }; + const openSimulation = (item: AdminLogicalModel) => { setSimulationIntent({ capability: item.capability, @@ -238,9 +255,23 @@ export default function LogicalModelsPage() { render: (_, item) => ( , onClick: () => openModel(item) }} + visibleActionCount={1} actions={[ { key: "simulate", label: "模拟供应线路匹配", icon: , onClick: () => openSimulation(item) }, { key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) }, + { + key: "delete", + label: "删除模型", + icon: , + danger: true, + disabled: deletingModelId === item.id, + confirm: { + title: `删除前台模型“${item.name}”?`, + description: "删除后模型将从目录中移除,不能在页面恢复;历史任务和版本记录会保留。排队中或进行中的任务仍在使用时无法删除。", + okText: "确认删除", + }, + onClick: () => removeModel(item), + }, ]} /> ), diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 9c2813c33..ece0396c3 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -103,6 +103,10 @@ export function updateAdminLogicalModel(id: string, input: LogicalModelMutation) return request<{ model: AdminLogicalModel }>(apiClient.patch(`/admin/logical-models/${encodeURIComponent(id)}`, input)); } +export function deleteAdminLogicalModel(id: string) { + return request<{ ok: boolean }>(apiClient.delete(`/admin/logical-models/${encodeURIComponent(id)}`)); +} + export function simulateAdminLogicalModel(id: string, intent: ModelRequestIntent) { return request(apiClient.post(`/admin/logical-models/${encodeURIComponent(id)}/simulate`, intent)); } From 31026a112dee37a4b1c067b1ee2b8cb4a90de6c7 Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Thu, 20 Aug 2026 09:59:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(canvas):=20=E5=9C=A8=E7=BA=BF=20Agent?= =?UTF-8?q?=20-=20=E7=B3=BB=E7=BB=9F=E6=A8=A1=E5=9E=8B=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=BB=BB=E5=8A=A1=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/provider.go | 81 +++++++++++++++++++ backend/internal/service/provider_test.go | 53 ++++++++++++ .../canvas/canvas-assistant-panel.tsx | 45 +++++------ web/src/services/api/generation-task.ts | 37 +++++++++ web/src/services/api/image.ts | 23 +++++- .../generation-task-durability.node.test.ts | 25 ++++++ 6 files changed, 239 insertions(+), 25 deletions(-) diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index fe43e9ff9..088ea900b 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -35,11 +35,17 @@ type canvasGenerationInput struct { TextHistory []providerTextMessage `json:"textHistory"` Mask *providerMedia `json:"mask"` Metadata map[string]interface{} `json:"metadata"` + AgentRequests *agentToolRequests `json:"agentRequests"` ImageCapability *ImageCapabilityConfig `json:"-"` StreamText bool `json:"-"` // 分镜请求使用上游 SSE 保活;最终结构仍在流结束后统一校验。 VideoCapability *VideoCapabilityConfig `json:"-"` } +type agentToolRequests struct { + Responses map[string]interface{} `json:"responses"` + ChatCompletion map[string]interface{} `json:"chatCompletion"` +} + type providerTextMessage struct { Role string `json:"role"` Content string `json:"content"` @@ -247,6 +253,9 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string case "image": return runImageTask(ctx, input) case "text": + if input.AgentRequests != nil { + return runAgentToolTask(ctx, input) + } result, taskErr := runTextTask(ctx, input) if taskErr == nil && promptTemplateOperation != "" { taskErr = validatePromptTemplateResult(promptTemplateOperation, result) @@ -261,6 +270,78 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } } +func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + request := input.AgentRequests.ChatCompletion + path := "/chat/completions" + protocol := "chat-completion" + if input.Config.InterfaceType == string(model.ChannelInterfaceOpenAIResponse) { + request = input.AgentRequests.Responses + path = "/responses" + protocol = "responses" + } + if request == nil { + return nil, errors.New("画布 Agent 工具请求缺少协议参数") + } + body := cloneStringAnyMap(request) + body["model"] = input.Config.Model + var payload map[string]interface{} + if err := postJSON(ctx, input.Config, path, body, &payload); err != nil { + return nil, err + } + return parseAgentToolPayload(payload, protocol) +} + +func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map[string]interface{}, error) { + if err := validateTextPayload(payload); err != nil { + return nil, err + } + result := map[string]interface{}{"mode": "text", "text": "", "toolCalls": []interface{}{}} + if protocol == "responses" { + result["text"] = firstNonEmptyString(stringField(payload, "output_text"), extractResponseText(payload)) + calls := make([]interface{}, 0) + for _, value := range interfaceSlice(payload["output"]) { + item, _ := value.(map[string]interface{}) + if stringField(item, "type") != "function_call" { + continue + } + calls = append(calls, map[string]interface{}{"id": firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": stringField(item, "arguments")}}) + } + result["toolCalls"] = calls + return result, nil + } + choices := interfaceSlice(payload["choices"]) + if len(choices) == 0 { + return nil, errors.New("画布 Agent 接口没有返回 choices") + } + choice, _ := choices[0].(map[string]interface{}) + message, _ := choice["message"].(map[string]interface{}) + result["text"] = stringField(message, "content") + if reasoning := firstNonEmptyString(stringField(message, "reasoning_content"), stringField(message, "reasoning")); reasoning != "" { + result["reasoning"] = reasoning + } + calls := make([]interface{}, 0) + for _, value := range interfaceSlice(message["tool_calls"]) { + item, _ := value.(map[string]interface{}) + function, _ := item["function"].(map[string]interface{}) + calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(function, "name"), "arguments": stringField(function, "arguments")}}) + } + result["toolCalls"] = calls + return result, nil +} + +func interfaceSlice(value interface{}) []interface{} { + items, _ := value.([]interface{}) + return items +} + +func cloneStringAnyMap(value map[string]interface{}) map[string]interface{} { + cloned := make(map[string]interface{}, len(value)+1) + for key, item := range value { + cloned[key] = item + } + return cloned +} + type styleExecutionPlanDocument struct { SchemaVersion int `json:"schemaVersion"` ProfilePresetID string `json:"profilePresetId"` diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 13190b4ab..b20741ce1 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -107,6 +107,59 @@ data: [DONE] } } +func TestParseAgentToolPayloadSupportsChatCompletions(t *testing.T) { + result, err := parseAgentToolPayload(map[string]interface{}{ + "choices": []interface{}{map[string]interface{}{ + "message": map[string]interface{}{ + "content": "准备读取画布", + "tool_calls": []interface{}{map[string]interface{}{ + "id": "call-1", + "function": map[string]interface{}{"name": "canvas_get_state", "arguments": `{}`}, + }}, + }, + }}, + }, "chat-completion") + if err != nil { + t.Fatalf("parseAgentToolPayload() error = %v", err) + } + if result["text"] != "准备读取画布" { + t.Fatalf("text = %v", result["text"]) + } + calls, _ := result["toolCalls"].([]interface{}) + if len(calls) != 1 { + t.Fatalf("toolCalls = %#v", result["toolCalls"]) + } + call, _ := calls[0].(map[string]interface{}) + function, _ := call["function"].(map[string]interface{}) + if call["id"] != "call-1" || function["name"] != "canvas_get_state" || function["arguments"] != `{}` { + t.Fatalf("tool call = %#v", call) + } +} + +func TestParseAgentToolPayloadSupportsResponses(t *testing.T) { + result, err := parseAgentToolPayload(map[string]interface{}{ + "output": []interface{}{ + map[string]interface{}{"type": "message", "content": []interface{}{map[string]interface{}{"type": "output_text", "text": "开始操作"}}}, + map[string]interface{}{"type": "function_call", "call_id": "call-2", "name": "canvas_apply_ops", "arguments": `{"ops":[]}`}, + }, + }, "responses") + if err != nil { + t.Fatalf("parseAgentToolPayload() error = %v", err) + } + if result["text"] != "开始操作" { + t.Fatalf("text = %v", result["text"]) + } + calls, _ := result["toolCalls"].([]interface{}) + if len(calls) != 1 { + t.Fatalf("toolCalls = %#v", result["toolCalls"]) + } + call, _ := calls[0].(map[string]interface{}) + function, _ := call["function"].(map[string]interface{}) + if call["id"] != "call-2" || function["name"] != "canvas_apply_ops" || function["arguments"] != `{"ops":[]}` { + t.Fatalf("tool call = %#v", call) + } +} + func TestPostStreamingTextSetsStreamHeaders(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index b930b1539..6c045a8f2 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -8,6 +8,7 @@ import { modelDisplayName, modelIcon, normalizeModelOptionValue, resolveModelCha import { canvasThemes } from "@/lib/canvas-theme"; import { nanoid } from "nanoid"; import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image"; +import { logicalModelIDForConfig, runBackendToolGenerationTask } from "@/services/api/generation-task"; import { imageToDataUrl } from "@/services/image-storage"; import { isCanvasGenerationDurableAckError, persistCanvasCinematicSessionContinuationEffect } from "@/services/canvas-generation-consumer"; import { consumeGenerationTaskAgent } from "@/services/project-asset-sync"; @@ -376,6 +377,7 @@ export function CanvasAssistantPanel({ const hasMessages = messages.length > 0; const agentBusy = isRunning || safeSessions.some((session) => session.pendingBackendSession?.status === "pending"); const activeModel = effectiveConfig.textModel || effectiveConfig.model; + const activeModelName = activeModel ? modelDisplayName(effectiveConfig, activeModel) : ""; const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]); const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]); const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]); @@ -606,17 +608,10 @@ export function CanvasAssistantPanel({ const messages = await buildToolAgentMessages(snapshotRef.current, history, userMessage); addOnlineLog(`Agent Tool Loop ${loop.step} 开始`, { toolChoice: "required" }); let streamed = ""; - const result = await requestToolResponse( - { ...requestConfig, systemPrompt: "" }, - messages, - ONLINE_AGENT_TOOLS, - "required", - (text) => { - streamed = text; - if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); - }, - { promptCacheKey: canvasAgentPromptCacheKey(sessionId) }, - ); + const result = await requestOnlineAgentModel({ ...requestConfig, systemPrompt: "" }, messages, "required", userMessage.text, (text) => { + streamed = text; + if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); + }, canvasAgentPromptCacheKey(sessionId)); addOnlineLog("模型工具回复", result); if (result.toolCalls.length) { const writableCalls = result.toolCalls.filter(isWritableToolCall); @@ -672,17 +667,10 @@ export function CanvasAssistantPanel({ } const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model }; let streamed = ""; - const next = await requestToolResponse( - { ...requestConfig, systemPrompt: "" }, - nextMessages, - ONLINE_AGENT_TOOLS, - "auto", - (text) => { - streamed = text; - if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); - }, - { promptCacheKey: canvasAgentPromptCacheKey(sessionId) }, - ); + const next = await requestOnlineAgentModel({ ...requestConfig, systemPrompt: "" }, nextMessages, "auto", "继续处理画布工具结果", (text) => { + streamed = text; + if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text }); + }, canvasAgentPromptCacheKey(sessionId)); addOnlineLog(`Agent Tool Loop ${step + 1} 回复`, next); if (next.toolCalls.length) { const writableCalls = next.toolCalls.filter(isWritableToolCall); @@ -1019,7 +1007,7 @@ export function CanvasAssistantPanel({ /> {view === "setup" ? ( - navigateToSettings({ continueCreation: true })} /> + navigateToSettings({ continueCreation: true })} /> ) : (
{view === "history" ? ( @@ -1036,7 +1024,7 @@ export function CanvasAssistantPanel({ setOnlineLogs([])} /> ) : messages.length ? ( @@ -1590,6 +1578,15 @@ function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage { return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) }; } +async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void, promptCacheKey: string) { + if (logicalModelIDForConfig(config)) { + const result = await runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice }); + if (result.content.trim()) onDelta(result.content); + return result; + } + return requestToolResponse(config, messages, ONLINE_AGENT_TOOLS, toolChoice, onDelta, { promptCacheKey }); +} + function summarizeToolCalls(calls: ResponseToolCall[]) { return calls.map((call) => toolCallLabel(call.function.name)).join(",") || "工具调用"; } diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index a463987fa..8f63919f5 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -10,6 +10,7 @@ import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type A import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; +import { buildBackendToolRequests, type ResponseFunctionTool, type ResponseInputMessage, type ToolChoice, type ToolResponseResult } from "@/services/api/image"; export type BackendGenerationMode = "text" | "image" | "video" | "audio"; @@ -19,6 +20,8 @@ export type BackendGenerationResult = { video?: { dataUrl: string; storageKey?: string; width?: number; height?: number; durationMs?: number; bytes?: number; mimeType?: string }; audio?: { dataUrl: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; format?: string }; text?: string; + toolCalls?: Array<{ id: string; type: "function"; function: { name: string; arguments: string }; thoughtSignature?: string }>; + reasoning?: string; }; type BackendGenerationTaskOptions = { @@ -105,6 +108,40 @@ export async function runBackendGenerationTask( return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate }, prepared, dependencies); } +export async function runBackendToolGenerationTask(options: { + prompt: string; + config: AiConfig; + messages: ResponseInputMessage[]; + tools: ResponseFunctionTool[]; + toolChoice: ToolChoice; + signal?: AbortSignal; +}): Promise { + throwIfAborted(options.signal); + const logicalModelId = logicalModelIDForConfig(options.config); + if (!logicalModelId) throw new Error("当前模型不是平台系统模型"); + const task = await createGenerationTask({ + type: "canvas_text", + operation: "text", + prompt: options.prompt, + model: options.config.model, + logicalModelId, + input: { + mode: "text", + prompt: options.prompt, + config: backendProviderConfig(options.config), + agentRequests: buildBackendToolRequests(options.messages, options.tools, options.toolChoice), + metadata: { source: "canvas-online-agent" }, + }, + }); + const completed = await waitForGenerationTask(task.id, { signal: options.signal, initialTask: task }); + const result = parseBackendGenerationResult(completed); + return { + content: result.text || "", + toolCalls: result.toolCalls || [], + ...(result.reasoning ? { reasoning: result.reasoning } : {}), + }; +} + export async function runBackendGenerationTaskBatch(options: BackendGenerationTaskOptions & { count: number }, dependencies: GenerationTaskDependencies = defaultDependencies) { const count = Math.max(1, Math.min(15, Math.floor(Number(options.count)) || 1)); throwIfAborted(options.signal); diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index fede3cce3..70c711cd4 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -46,7 +46,11 @@ export type ToolResponseResult = { reasoning?: string; }; -type ToolChoice = "auto" | "required" | { type: "function"; name: string }; +export type ToolChoice = "auto" | "required" | { type: "function"; name: string }; +export type BackendToolRequests = { + responses: Record; + chatCompletion: Record; +}; type ResponseMessageContent = AiTextMessage["content"] | string; type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string } | { type: "input_file"; filename: string; file_data?: string; file_url?: string }; type ResponseInputItem = { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } | { type: "function_call"; call_id: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string }; @@ -407,6 +411,23 @@ function toChatCompletionToolChoice(toolChoice: ToolChoice) { return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice; } +export function buildBackendToolRequests(messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice): BackendToolRequests { + return { + responses: { + input: toResponseInput(messages), + tools: tools.map(toResponseTool), + tool_choice: toolChoice, + parallel_tool_calls: false, + }, + chatCompletion: { + messages: toChatCompletionMessages(messages), + tools, + tool_choice: toChatCompletionToolChoice(toolChoice), + parallel_tool_calls: false, + }, + }; +} + function isToolChoiceCompatibilityError(error: unknown) { const message = error instanceof Error ? error.message : ""; return /tool[_\s-]?choice|thinking\s+mode/i.test(message); diff --git a/web/test/generation-task-durability.node.test.ts b/web/test/generation-task-durability.node.test.ts index 009847d30..40b09a600 100644 --- a/web/test/generation-task-durability.node.test.ts +++ b/web/test/generation-task-durability.node.test.ts @@ -4,6 +4,31 @@ import { test } from "node:test"; import type { GenerationTask } from "../src/services/api/task-center"; import { applyGenerationConsumerEffect } from "../src/services/generation-consumer-dedupe"; import { createGenerationTaskMaterializer, type GenerationTaskEffectResult, type GenerationTaskEffectStore } from "../src/services/generation-task-materializer"; +import { buildBackendToolRequests } from "../src/services/api/image"; + +test("managed canvas agent task keeps tool definitions and tool results", () => { + const requests = buildBackendToolRequests( + [ + { role: "user", content: "读取画布" }, + { type: "function_call", call_id: "call-1", name: "canvas_get_state", arguments: "{}" }, + { role: "tool", tool_call_id: "call-1", content: "{\"nodes\":[]}" }, + ], + [{ type: "function", function: { name: "canvas_get_state", parameters: { type: "object" } } }], + "required", + ); + + assert.equal(requests.responses.tool_choice, "required"); + assert.deepEqual(requests.responses.input, [ + { role: "user", content: "读取画布" }, + { type: "function_call", call_id: "call-1", name: "canvas_get_state", arguments: "{}" }, + { type: "function_call_output", call_id: "call-1", output: "{\"nodes\":[]}" }, + ]); + assert.deepEqual(requests.chatCompletion.messages, [ + { role: "user", content: "读取画布" }, + { role: "assistant", content: null, tool_calls: [{ id: "call-1", type: "function", function: { name: "canvas_get_state", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "call-1", content: "{\"nodes\":[]}" }, + ]); +}); test("node message and agent consumers receive stable effect keys across three replays", async () => { const completed = new Map(); From a5bac9ec78b7db29f21316f1ed5b808a28294b97 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 20 Aug 2026 13:24:09 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(platform):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=20-=20=E5=90=88=E5=B9=B6PR=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=BD=92=E6=A1=A3=E9=87=8D=E8=AF=95=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/model/models_logical_model.go | 22 ++--- backend/internal/repository/finance.go | 6 ++ backend/internal/repository/logical_models.go | 63 ++++++++++---- .../repository/logical_models_test.go | 47 ++++++++-- backend/internal/service/admin_audit.go | 16 +++- backend/internal/service/finance.go | 2 +- backend/internal/service/logical_models.go | 15 +++- backend/internal/service/model_capability.go | 11 ++- backend/internal/service/model_router.go | 62 +++++++++++++- backend/internal/service/provider.go | 52 +++++++++++- backend/internal/service/provider_test.go | 45 +++++++++- backend/internal/service/service.go | 3 + .../logical-models/logical-models-page.tsx | 85 ++++++++++++++----- .../generation-task-durability.node.test.ts | 6 +- 14 files changed, 365 insertions(+), 70 deletions(-) diff --git a/backend/internal/model/models_logical_model.go b/backend/internal/model/models_logical_model.go index 7adf9b209..762f7c1c5 100644 --- a/backend/internal/model/models_logical_model.go +++ b/backend/internal/model/models_logical_model.go @@ -20,16 +20,18 @@ type LogicalModel struct { Enabled bool `json:"enabled" gorm:"index"` SortOrder int `json:"sortOrder" gorm:"index"` // RevisionSequence 记录已经分配的 revision 版本号,由数据库原子递增。 - RevisionSequence int `json:"-" gorm:"not null;default:0"` - ActiveRevisionID string `json:"activeRevisionId" gorm:"size:36;index"` - PricePolicy string `json:"pricePolicy" gorm:"size:24;default:unified"` - BillingMode string `json:"billingMode" gorm:"size:32"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` - OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"` - CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + RevisionSequence int `json:"-" gorm:"not null;default:0"` + ActiveRevisionID string `json:"activeRevisionId" gorm:"size:36;index"` + PricePolicy string `json:"pricePolicy" gorm:"size:24;default:unified"` + BillingMode string `json:"billingMode" gorm:"size:32"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` + OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"` + CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"` + // ArchivedAt 仅从可选目录隐藏模型;历史任务、计费和审计仍需读取主体及不可变 revision。 + ArchivedAt *time.Time `json:"-" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // LogicalModelRevision 固化一次可发布的前台能力合同。默认值属于前台模型,不能由供应商隐式决定。 diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 6436a3869..337bff5ea 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -211,6 +211,9 @@ func (r *Repository) CreditLedgerReferenceExists(referenceKey string) (bool, err func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int) error { return r.db.Transaction(func(tx *gorm.DB) error { + if err := r.requireActiveLogicalModelForTask(tx, task); err != nil { + return err + } if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil { return err } @@ -223,6 +226,9 @@ func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *mo func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int) error { return r.db.Transaction(func(tx *gorm.DB) error { + if err := r.requireActiveLogicalModelForTask(tx, task); err != nil { + return err + } if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil { return err } diff --git a/backend/internal/repository/logical_models.go b/backend/internal/repository/logical_models.go index 96b81c94d..34c6fc09a 100644 --- a/backend/internal/repository/logical_models.go +++ b/backend/internal/repository/logical_models.go @@ -7,6 +7,7 @@ import ( "infinite-canvas/backend/internal/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type LogicalModelGraph struct { @@ -17,10 +18,11 @@ type LogicalModelGraph struct { } var ErrLogicalModelInUse = errors.New("logical model is in use") +var ErrLogicalModelUnavailable = errors.New("logical model is unavailable") func (r *Repository) LogicalModels(includeDisabled bool) ([]model.LogicalModel, error) { var items []model.LogicalModel - query := r.db.Order("sort_order asc, created_at asc") + query := r.db.Where("archived_at IS NULL").Order("sort_order asc, created_at asc") if !includeDisabled { query = query.Where("enabled = ?", true) } @@ -403,19 +405,17 @@ func (r *Repository) SaveLogicalModelBundle(item *model.LogicalModel, revision * }) } -func (r *Repository) DeleteLogicalModel(id string, now time.Time) error { +func (r *Repository) ArchiveLogicalModel(id string, audit *model.AdminAuditEvent, now time.Time) error { return r.db.Transaction(func(tx *gorm.DB) error { - // 先停用并锁定目录主体,避免删除期间仍被新的数据库查询选中。 - result := tx.Model(&model.LogicalModel{}). - Where("id = ?", id). - Updates(map[string]any{"enabled": false, "updated_at": now}) - if result.Error != nil { - return result.Error + // PostgreSQL 下锁定主体,使任务创建与归档在同一行上串行,避免检查后仍写入新任务。 + var item model.LogicalModel + query := tx.Where("id = ? AND archived_at IS NULL", id) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound + if err := query.First(&item).Error; err != nil { + return err } - var activeTasks int64 if err := tx.Model(&model.Task{}). Where("logical_model_id = ? AND status IN ?", id, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). @@ -425,19 +425,48 @@ func (r *Repository) DeleteLogicalModel(id string, now time.Time) error { if activeTasks > 0 { return ErrLogicalModelInUse } - - // revision 与 route 是不可变的任务快照;删除目录主体时保留它们供历史记录追溯。 - deleted := tx.Where("id = ?", id).Delete(&model.LogicalModel{}) - if deleted.Error != nil { - return deleted.Error + archived := tx.Model(&model.LogicalModel{}). + Where("id = ? AND archived_at IS NULL", id). + Updates(map[string]any{"enabled": false, "archived_at": now, "updated_at": now}) + if archived.Error != nil { + return archived.Error } - if deleted.RowsAffected != 1 { + if archived.RowsAffected != 1 { return gorm.ErrRecordNotFound } + if audit != nil { + if err := tx.Create(audit).Error; err != nil { + return err + } + } return nil }) } +// requireActiveLogicalModelForTask 与归档共用主体行锁,保证新任务只引用仍在目录中的当前 revision。 +func (r *Repository) requireActiveLogicalModelForTask(tx *gorm.DB, task *model.Task) error { + if task == nil || task.LogicalModelID == "" { + return nil + } + var item model.LogicalModel + query := tx.Select("id").Where( + "id = ? AND enabled = ? AND archived_at IS NULL AND active_revision_id = ?", + task.LogicalModelID, + true, + task.LogicalModelRevisionID, + ) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrLogicalModelUnavailable + } + return err + } + return nil +} + func (r *Repository) DeleteLogicalModelRoute(id string) error { return r.db.Model(&model.LogicalModelRoute{}).Where("id = ?", id).Updates(map[string]any{"enabled": false, "weight": 0, "updated_at": time.Now()}).Error } diff --git a/backend/internal/repository/logical_models_test.go b/backend/internal/repository/logical_models_test.go index 8b8c1f968..cd1faee1e 100644 --- a/backend/internal/repository/logical_models_test.go +++ b/backend/internal/repository/logical_models_test.go @@ -50,7 +50,7 @@ func TestSaveLogicalModelBundleAllocatesMonotonicRevisionSequence(t *testing.T) } } -func TestDeleteLogicalModelPreservesPublishedHistory(t *testing.T) { +func TestArchiveLogicalModelPreservesPublishedHistory(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:logical-model-delete-history?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) @@ -67,11 +67,22 @@ func TestDeleteLogicalModelPreservesPublishedHistory(t *testing.T) { t.Fatal(err) } - if err := repo.DeleteLogicalModel(item.ID, time.Now()); err != nil { + if err := repo.ArchiveLogicalModel(item.ID, nil, time.Now()); err != nil { t.Fatal(err) } - if err := db.First(&model.LogicalModel{}, "id = ?", item.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("logical model lookup error = %v, want record not found", err) + var archived model.LogicalModel + if err := db.First(&archived, "id = ?", item.ID).Error; err != nil { + t.Fatalf("logical model lookup error = %v", err) + } + if archived.ArchivedAt == nil || archived.Enabled { + t.Fatalf("archived logical model = %#v", archived) + } + items, err := repo.LogicalModels(true) + if err != nil { + t.Fatalf("LogicalModels() error = %v", err) + } + if len(items) != 0 { + t.Fatalf("LogicalModels() = %#v, want archived model hidden", items) } for label, target := range map[string]any{"revision": &model.LogicalModelRevision{}, "route": &model.LogicalModelRoute{}} { if err := db.First(target).Error; err != nil { @@ -80,7 +91,7 @@ func TestDeleteLogicalModelPreservesPublishedHistory(t *testing.T) { } } -func TestDeleteLogicalModelRejectsActiveTask(t *testing.T) { +func TestArchiveLogicalModelRejectsActiveTask(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:logical-model-delete-active-task?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) @@ -97,8 +108,8 @@ func TestDeleteLogicalModelRejectsActiveTask(t *testing.T) { t.Fatal(err) } - if err := repo.DeleteLogicalModel(item.ID, time.Now()); !errors.Is(err, ErrLogicalModelInUse) { - t.Fatalf("DeleteLogicalModel() error = %v, want ErrLogicalModelInUse", err) + if err := repo.ArchiveLogicalModel(item.ID, nil, time.Now()); !errors.Is(err, ErrLogicalModelInUse) { + t.Fatalf("ArchiveLogicalModel() error = %v, want ErrLogicalModelInUse", err) } var preserved model.LogicalModel if err := db.First(&preserved, "id = ?", item.ID).Error; err != nil { @@ -108,3 +119,25 @@ func TestDeleteLogicalModelRejectsActiveTask(t *testing.T) { t.Fatal("active logical model disable was not rolled back") } } + +func TestCreateTaskRejectsArchivedLogicalModel(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:logical-model-archived-task?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.LogicalModel{}, &model.Task{}); err != nil { + t.Fatal(err) + } + repo := New(db) + item := model.LogicalModel{ID: "LMODEL_ARCHIVED_TASK", Code: "archived-task", Name: "Archived Task", Enabled: true, ActiveRevisionID: "REVISION_ARCHIVED_TASK", CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + if err := repo.ArchiveLogicalModel(item.ID, nil, time.Now()); err != nil { + t.Fatal(err) + } + task := &model.Task{ID: "TASK_ARCHIVED_MODEL", UserID: "USER_1", LogicalModelID: item.ID, LogicalModelRevisionID: item.ActiveRevisionID, Status: model.TaskStatusQueued, CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := repo.CreateTaskWithActiveLimit(task, 5); !errors.Is(err, ErrLogicalModelUnavailable) { + t.Fatalf("CreateTaskWithActiveLimit() error = %v, want ErrLogicalModelUnavailable", err) + } +} diff --git a/backend/internal/service/admin_audit.go b/backend/internal/service/admin_audit.go index 454075d0b..3db2f0fb4 100644 --- a/backend/internal/service/admin_audit.go +++ b/backend/internal/service/admin_audit.go @@ -34,21 +34,29 @@ type AdminAuditPage struct { } func (s *Service) appendAdminAudit(actor *model.User, action string, targetType string, targetID string, summary string, metadata any) error { + event, err := newAdminAuditEvent(actor, action, targetType, targetID, summary, metadata) + if err != nil { + return err + } + return s.repo.AppendAdminAudit(event) +} + +func newAdminAuditEvent(actor *model.User, action string, targetType string, targetID string, summary string, metadata any) (*model.AdminAuditEvent, error) { if actor == nil { - return Unauthorized("请先登录") + return nil, Unauthorized("请先登录") } encoded := "" if metadata != nil { data, err := json.Marshal(metadata) if err != nil { - return err + return nil, err } encoded = string(data) } - return s.repo.AppendAdminAudit(&model.AdminAuditEvent{ + return &model.AdminAuditEvent{ ID: newID(), ActorUserID: actor.ID, Action: strings.TrimSpace(action), TargetType: strings.TrimSpace(targetType), TargetID: strings.TrimSpace(targetID), Summary: truncateRunes(strings.TrimSpace(summary), 500), MetadataJSON: encoded, CreatedAt: time.Now(), - }) + }, nil } func (s *Service) AdminUserDetail(actor *model.User, userID string) (*AdminUserDetail, error) { diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 59d1138f5..44dce5b9c 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -457,7 +457,7 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, input map[string]any) (*model.BillingOrder, error) { logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil || !logicalModel.Enabled || logicalModel.ActiveRevisionID != task.LogicalModelRevisionID { + if err != nil || (!logicalModel.Enabled && logicalModel.ArchivedAt == nil) || logicalModel.ActiveRevisionID != task.LogicalModelRevisionID { return nil, BadAuthRequest("所选模型计费配置已失效,请重新选择") } route, err := s.repo.LogicalModelRoute(task.RouteID) diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index af42060b4..8be0840da 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -419,9 +419,16 @@ func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error { if err != nil { return err } - if err := s.repo.DeleteLogicalModel(item.ID, time.Now()); err != nil { + if item.ArchivedAt != nil { + return BadAuthRequest("前台模型不存在或已删除") + } + audit, err := newAdminAuditEvent(actor, "logical_model.archive", "logical_model", item.ID, "归档前台模型", map[string]any{"code": item.Code, "name": item.Name}) + if err != nil { + return err + } + if err := s.repo.ArchiveLogicalModel(item.ID, audit, time.Now()); err != nil { if errors.Is(err, repository.ErrLogicalModelInUse) { - return BadAuthRequest("前台模型仍被排队中或进行中任务使用,请等待任务结束后再删除") + return BadAuthRequest("前台模型仍被排队中或进行中任务使用,请等待任务结束后再归档") } if logicalModelNotFound(err) { return BadAuthRequest("前台模型不存在或已删除") @@ -429,7 +436,6 @@ func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error { return err } s.invalidateRouteCatalog() - _ = s.appendAdminAudit(actor, "logical_model.delete", "logical_model", item.ID, "删除前台模型目录主体", map[string]any{"code": item.Code, "name": item.Name}) return nil } @@ -489,6 +495,9 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo if err != nil { return nil, nil, nil, false, err } + if item.ArchivedAt != nil { + return nil, nil, nil, false, BadAuthRequest("前台模型不存在或已删除") + } } item.Code, item.Name, item.Icon, item.Description, item.Capability = code, name, strings.TrimSpace(req.Icon), strings.TrimSpace(req.Description), capability item.Enabled, item.SortOrder, item.PricePolicy, item.BillingMode = req.Enabled, req.SortOrder, pricePolicy, billingMode diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index ccc76f59a..a663eab52 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -286,7 +286,16 @@ func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capa return spec, nil } if config == nil { - return spec, BadAuthRequest("渠道模型尚未配置能力参数") + switch capability { + case "text": + return spec, BadAuthRequest("渠道文本模型尚未配置能力参数") + case "image": + return spec, BadAuthRequest("渠道图片模型尚未配置能力参数") + case "video": + return spec, BadAuthRequest("渠道视频模型尚未配置能力参数") + default: + return spec, BadAuthRequest("渠道模型尚未配置能力参数") + } } switch capability { case "text": diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index 9ed0b7e33..624576157 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -1018,7 +1018,17 @@ func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any return nil } intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) - routed, err := s.ResolveLogicalModel(task.LogicalModelID, intent) + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return err + } + var routed *RoutedModel + if logicalModel.ArchivedAt != nil { + // 归档只隐藏新任务的公开目录;历史任务重试必须使用任务快照,不能重新从公开目录选路。 + routed, err = s.resolveArchivedTaskRoute(task, intent) + } else { + routed, err = s.ResolveLogicalModel(task.LogicalModelID, intent) + } if err != nil { return err } @@ -1042,6 +1052,56 @@ func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any return nil } +// resolveArchivedTaskRoute 恢复归档模型任务保存的 revision、route 和 channel model 快照。 +// 归档模型不得重新进入公开路由目录;原供应线路失效时应明确拒绝重试,避免静默切换到未知配置。 +func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequestIntent) (*RoutedModel, error) { + if task == nil || task.LogicalModelID == "" || task.LogicalModelRevisionID == "" || task.RouteID == "" || task.ChannelModelID == "" { + return nil, BadAuthRequest("历史任务缺少完整的模型服务快照,无法重试") + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + if logicalModel.ArchivedAt == nil { + return nil, BadAuthRequest("任务模型已恢复为可用模型,请重新选择后重试") + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil || revision.LogicalModelID != logicalModel.ID { + return nil, BadAuthRequest("历史任务前台模型版本不存在或归属不一致") + } + route, err := s.repo.LogicalModelRoute(task.RouteID) + if err != nil || route.LogicalModelRevisionID != revision.ID || route.ChannelModelID != task.ChannelModelID || !route.Enabled || route.Weight <= 0 { + return nil, BadAuthRequest("历史任务原模型供应线路已失效,无法重试") + } + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) + if err != nil || !channelModel.Enabled { + return nil, BadAuthRequest("历史任务原模型服务已失效,无法重试") + } + if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { + return nil, BadAuthRequest("历史任务原模型渠道已失效,无法重试") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + intent.Options = mergeIntentDefaults(intent.Options, defaults) + if match := MatchCapability(productSpec, intent); !match.Matched { + return nil, BadAuthRequest("历史任务不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) + } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") + } + if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { + return nil, BadAuthRequest("历史任务原模型价格配置已失效,无法重试") + } + return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}, nil +} + func (s *Service) finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) { if attempt == nil { return diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 088ea900b..c96c65e31 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -285,12 +285,40 @@ func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[str body := cloneStringAnyMap(request) body["model"] = input.Config.Model var payload map[string]interface{} - if err := postJSON(ctx, input.Config, path, body, &payload); err != nil { + err := postJSON(ctx, input.Config, path, body, &payload) + if protocol == "chat-completion" && isAgentToolChoiceCompatibilityError(err) { + if !isAutoAgentToolChoice(body["tool_choice"]) { + autoBody := cloneStringAnyMap(body) + autoBody["tool_choice"] = "auto" + payload = nil + err = postJSON(ctx, input.Config, path, autoBody, &payload) + } + if isAgentToolChoiceCompatibilityError(err) { + withoutToolChoice := cloneStringAnyMap(body) + delete(withoutToolChoice, "tool_choice") + payload = nil + err = postJSON(ctx, input.Config, path, withoutToolChoice, &payload) + } + } + if err != nil { return nil, err } return parseAgentToolPayload(payload, protocol) } +func isAgentToolChoiceCompatibilityError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "tool_choice") || strings.Contains(message, "tool choice") || strings.Contains(message, "tool-choice") || strings.Contains(message, "thinking mode") +} + +func isAutoAgentToolChoice(value interface{}) bool { + choice, ok := value.(string) + return ok && strings.EqualFold(strings.TrimSpace(choice), "auto") +} + func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map[string]interface{}, error) { if err := validateTextPayload(payload); err != nil { return nil, err @@ -298,6 +326,9 @@ func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map result := map[string]interface{}{"mode": "text", "text": "", "toolCalls": []interface{}{}} if protocol == "responses" { result["text"] = firstNonEmptyString(stringField(payload, "output_text"), extractResponseText(payload)) + if reasoning := extractResponseReasoning(payload); reasoning != "" { + result["reasoning"] = reasoning + } calls := make([]interface{}, 0) for _, value := range interfaceSlice(payload["output"]) { item, _ := value.(map[string]interface{}) @@ -329,6 +360,25 @@ func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map return result, nil } +func extractResponseReasoning(payload map[string]interface{}) string { + var chunks []string + for _, value := range interfaceSlice(payload["output"]) { + item, _ := value.(map[string]interface{}) + if stringField(item, "type") != "reasoning" { + continue + } + for _, key := range []string{"summary", "content"} { + for _, part := range interfaceSlice(item[key]) { + record, _ := part.(map[string]interface{}) + if text := strings.TrimSpace(stringField(record, "text")); text != "" { + chunks = append(chunks, text) + } + } + } + } + return strings.Join(chunks, "\n") +} + func interfaceSlice(value interface{}) []interface{} { items, _ := value.([]interface{}) return items diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index b20741ce1..6ed8aba79 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -113,7 +113,7 @@ func TestParseAgentToolPayloadSupportsChatCompletions(t *testing.T) { "message": map[string]interface{}{ "content": "准备读取画布", "tool_calls": []interface{}{map[string]interface{}{ - "id": "call-1", + "id": "call-1", "function": map[string]interface{}{"name": "canvas_get_state", "arguments": `{}`}, }}, }, @@ -139,6 +139,7 @@ func TestParseAgentToolPayloadSupportsChatCompletions(t *testing.T) { func TestParseAgentToolPayloadSupportsResponses(t *testing.T) { result, err := parseAgentToolPayload(map[string]interface{}{ "output": []interface{}{ + map[string]interface{}{"type": "reasoning", "summary": []interface{}{map[string]interface{}{"type": "summary_text", "text": "先读取画布,再决定操作"}}}, map[string]interface{}{"type": "message", "content": []interface{}{map[string]interface{}{"type": "output_text", "text": "开始操作"}}}, map[string]interface{}{"type": "function_call", "call_id": "call-2", "name": "canvas_apply_ops", "arguments": `{"ops":[]}`}, }, @@ -149,6 +150,9 @@ func TestParseAgentToolPayloadSupportsResponses(t *testing.T) { if result["text"] != "开始操作" { t.Fatalf("text = %v", result["text"]) } + if result["reasoning"] != "先读取画布,再决定操作" { + t.Fatalf("reasoning = %v", result["reasoning"]) + } calls, _ := result["toolCalls"].([]interface{}) if len(calls) != 1 { t.Fatalf("toolCalls = %#v", result["toolCalls"]) @@ -160,6 +164,45 @@ func TestParseAgentToolPayloadSupportsResponses(t *testing.T) { } } +func TestRunAgentToolTaskFallsBackToolChoice(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + var choices []interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + choice, exists := body["tool_choice"] + if exists { + choices = append(choices, choice) + } else { + choices = append(choices, nil) + } + w.Header().Set("Content-Type", "application/json") + if len(choices) < 3 { + _, _ = w.Write([]byte(`{"error":{"message":"tool_choice is incompatible with thinking mode"}}`)) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"完成","tool_calls":[]}}]}`)) + })) + defer server.Close() + + config := providerConfig{BaseURL: server.URL, APIKey: "key", Model: "thinking-model", AllowLocalChannel: true} + result, err := runAgentToolTask(withProviderOutboundPolicy(context.Background(), config), canvasGenerationInput{ + Config: config, + AgentRequests: &agentToolRequests{ChatCompletion: map[string]interface{}{"messages": []interface{}{}, "tool_choice": "required"}}, + }) + if err != nil { + t.Fatalf("runAgentToolTask() error = %v", err) + } + if result["text"] != "完成" { + t.Fatalf("text = %v", result["text"]) + } + if len(choices) != 3 || choices[0] != "required" || choices[1] != "auto" || choices[2] != nil { + t.Fatalf("tool choices = %#v", choices) + } +} + func TestPostStreamingTextSetsStreamHeaders(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index b9097073c..d9c76237f 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -407,6 +407,9 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, if errors.Is(err, repository.ErrInsufficientCredits) { return nil, BadAuthRequest("积分不足,请先使用兑换码充值") } + if errors.Is(err, repository.ErrLogicalModelUnavailable) { + return nil, BadAuthRequest("所选模型已停用、归档或配置已更新,请重新选择") + } if err != nil { return nil, err } diff --git a/web/src/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index afac0e355..9319f1aad 100644 --- a/web/src/pages/admin/logical-models/logical-models-page.tsx +++ b/web/src/pages/admin/logical-models/logical-models-page.tsx @@ -1,7 +1,7 @@ import { Alert, App, Button, Drawer, Form, Input, InputNumber, Modal, Segmented, Select, Switch, Table, Tag } from "antd"; import type { FormInstance } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { FlaskConical, GitBranch, Layers3, Pencil, Plus, Search, Trash2 } from "lucide-react"; +import { Archive, FlaskConical, GitBranch, Layers3, Pencil, Plus, Search } from "lucide-react"; import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "react"; import { PaginationBar } from "@/components/layout/workspace-page"; @@ -86,9 +86,7 @@ export default function LogicalModelsPage() { setLoading(true); try { const [modelResult, firstChannelPage] = await Promise.all([listAdminLogicalModels(), listAdminChannels({ page: 1, limit: 100 })]); - const remainingChannelPages = await Promise.all( - Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.limit) - 1) }, (_, index) => listAdminChannels({ page: index + 2, limit: firstChannelPage.limit })), - ); + const remainingChannelPages = await Promise.all(Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.limit) - 1) }, (_, index) => listAdminChannels({ page: index + 2, limit: firstChannelPage.limit }))); const channels = [firstChannelPage, ...remainingChannelPages].flatMap((result) => result.channels); const channelModelResults = await Promise.all(channels.map((channel) => listAdminChannelModels(channel.id))); setModels(modelResult.models); @@ -188,9 +186,9 @@ export default function LogicalModelsPage() { await deleteAdminLogicalModel(item.id); setModels((current) => current.filter((model) => model.id !== item.id)); if (paginatedModels.length === 1 && page > 1) setPage(page - 1); - message.success("前台模型已删除"); + message.success("前台模型已归档"); } catch (error) { - message.error(error instanceof Error ? error.message : "删除前台模型失败"); + message.error(error instanceof Error ? error.message : "归档前台模型失败"); throw error; } finally { setDeletingModelId(undefined); @@ -260,15 +258,15 @@ export default function LogicalModelsPage() { { key: "simulate", label: "模拟供应线路匹配", icon: , onClick: () => openSimulation(item) }, { key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) }, { - key: "delete", - label: "删除模型", - icon: , + key: "archive", + label: "归档模型", + icon: , danger: true, disabled: deletingModelId === item.id, confirm: { - title: `删除前台模型“${item.name}”?`, - description: "删除后模型将从目录中移除,不能在页面恢复;历史任务和版本记录会保留。排队中或进行中的任务仍在使用时无法删除。", - okText: "确认删除", + title: `归档前台模型“${item.name}”?`, + description: "归档后模型将从公开目录中移除,不能在页面恢复;历史任务和版本记录会保留。排队中或进行中的任务仍在使用时无法归档。", + okText: "确认归档", }, onClick: () => removeModel(item), }, @@ -288,13 +286,49 @@ export default function LogicalModelsPage() { } > } allowClear value={keyword} onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索模型名称、代码或能力" className="app-list-search" />} - toolbarActiveFilters={keyword ? { setKeyword(""); setPage(1); }} /> : null} + toolbar={ + } + allowClear + value={keyword} + onChange={(event) => { + setKeyword(event.target.value); + setPage(1); + }} + placeholder="搜索模型名称、代码或能力" + className="app-list-search" + /> + } + toolbarActiveFilters={ + keyword ? ( + { + setKeyword(""); + setPage(1); + }} + /> + ) : null + } toolbarActive={Boolean(keyword)} - onReset={() => { setKeyword(""); setPage(1); }} + onReset={() => { + setKeyword(""); + setPage(1); + }} table={{ className: "admin-logical-model-table", rowKey: "id", size: "small", loading, pagination: false, columns: modelColumns, dataSource: paginatedModels, scroll: { x: 980 } }} empty={} - footer={ { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />} + footer={ + { + setPage(nextPageSize !== pageSize ? 1 : nextPage); + setPageSize(nextPageSize); + }} + /> + } /> !saving && setEditingModel(undefined)} rootClassName="admin-drawer" - footer={
} + footer={ +
+ + +
+ } >
setSimulatingModel(undefined)} styles={{ body: { maxHeight: "min(72vh, 720px)", overflowY: "auto" } }} footer={[ - , + , , @@ -496,9 +541,7 @@ function RouteFields({
供应线路 {fields.indexOf(field) + 1}
-
- {selected ? `${channelNames[selected.channelId]} / ${selected.displayName || selected.modelKey}` : "选择一个可承接请求的渠道模型"} -
+
{selected ? `${channelNames[selected.channelId]} / ${selected.displayName || selected.modelKey}` : "选择一个可承接请求的渠道模型"}