diff --git a/backend/app.py b/backend/app.py index e5cdcdc..9610708 100644 --- a/backend/app.py +++ b/backend/app.py @@ -24,6 +24,7 @@ from routes import ( health, knowledge_bases, model_registry, + node_types, voice_webrtc, voice_ws, ) @@ -49,6 +50,7 @@ app.include_router(health.router) app.include_router(assistants.router) app.include_router(knowledge_bases.router) app.include_router(model_registry.router) +app.include_router(node_types.router) app.include_router(voice_webrtc.router) app.include_router(voice_ws.router) diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index 08569e6..ee82502 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -7,6 +7,7 @@ from db.session import get_session from fastapi import APIRouter, Depends, HTTPException from schemas import AssistantOut, AssistantUpsert from services.masking import mask, resolve_incoming_key +from services.node_specs import validate_graph from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +15,15 @@ router = APIRouter(prefix="/api/assistants", tags=["assistants"]) CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding") +def _validate_workflow(body: AssistantUpsert) -> None: + """workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。""" + if body.type != "workflow": + return + errors = validate_graph(body.graph or {}) + if errors: + raise HTTPException(400, "工作流校验失败:" + ";".join(errors)) + + async def _sync_bindings( session: AsyncSession, assistant_id: str, resource_ids: dict[str, str] ) -> None: @@ -82,6 +92,7 @@ async def list_assistants(session: AsyncSession = Depends(get_session)): async def create_assistant( body: AssistantUpsert, session: AsyncSession = Depends(get_session) ): + _validate_workflow(body) data = body.model_dump() resource_ids = data.pop("model_resource_ids") assistant = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **data) @@ -141,6 +152,7 @@ async def update_assistant( assistant = await session.get(Assistant, assistant_id) if not assistant: raise HTTPException(404, "助手不存在") + _validate_workflow(body) data = body.model_dump() resource_ids = data.pop("model_resource_ids") data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key) diff --git a/backend/routes/node_types.py b/backend/routes/node_types.py new file mode 100644 index 0000000..4624b4d --- /dev/null +++ b/backend/routes/node_types.py @@ -0,0 +1,14 @@ +"""工作流节点类型目录。前端画布据此渲染「添加节点」面板与节点编辑表单。 + +规格是只读的、与代码同生命周期(改了要重启后端 + 前端刷新),所以无需鉴权与缓存层。 +""" + +from fastapi import APIRouter +from services.node_specs import node_types_response + +router = APIRouter(prefix="/api/node-types", tags=["workflow"]) + + +@router.get("") +async def list_node_types(): + return node_types_response() diff --git a/backend/services/node_specs.py b/backend/services/node_specs.py new file mode 100644 index 0000000..3f18bbd --- /dev/null +++ b/backend/services/node_specs.py @@ -0,0 +1,161 @@ +"""工作流节点规格 + 图校验(对齐 dograh 的 node-spec / GraphConstraints 思路)。 + +当前实现 3 个核心节点:开始(startCall)/智能体(agentNode)/结束(endCall)。 +本模块是「节点类型」的唯一事实源: + - /api/node-types 接口直接吐这里的规格; + - 助手保存时用这里的约束校验 workflow 图。 + +新增节点类型只需在 NODE_SPECS 里加一条并补充约束。前端 specs.ts 与此保持一致。 +""" + +from __future__ import annotations + +from typing import Any + +# 规格版本号:节点定义有破坏性变更时 +1,前端可据此判断是否需要刷新缓存。 +SPEC_VERSION = "1" + +# 每个节点的图约束。None 表示不限制。 +# min_incoming / max_incoming:入边数量 +# min_outgoing / max_outgoing:出边数量 +NODE_SPECS: list[dict[str, Any]] = [ + { + "name": "startCall", + "displayName": "开始", + "category": "call_node", + "description": "工作流入口,每个流程有且仅有一个。播放开场白并进入首个节点。", + "icon": "Play", + "accent": "mint", + "addable": False, + "constraints": {"minIncoming": 0, "maxIncoming": 0}, + "fields": [ + {"key": "name", "label": "节点名称", "type": "text"}, + {"key": "greeting", "label": "开场白", "type": "textarea"}, + {"key": "prompt", "label": "全局提示词", "type": "textarea"}, + ], + }, + { + "name": "agentNode", + "displayName": "智能体节点", + "category": "call_node", + "description": "对话处理单元。按提示词与用户多轮交互,可有多个并通过条件边流转。", + "icon": "Bot", + "accent": "sky", + "addable": True, + "constraints": {"minIncoming": 1}, + "fields": [ + {"key": "name", "label": "节点名称", "type": "text"}, + {"key": "prompt", "label": "节点提示词", "type": "textarea", "required": True}, + {"key": "allowInterrupt", "label": "允许用户打断", "type": "switch"}, + ], + }, + { + "name": "endCall", + "displayName": "结束", + "category": "call_node", + "description": "终止节点,礼貌结束对话。可有多个,均无出边。", + "icon": "Flag", + "accent": "rose", + "addable": False, + "constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0}, + "fields": [ + {"key": "name", "label": "节点名称", "type": "text"}, + {"key": "prompt", "label": "结束语提示词", "type": "textarea"}, + ], + }, +] + +_SPEC_BY_NAME = {spec["name"]: spec for spec in NODE_SPECS} + + +def node_types_response() -> dict[str, Any]: + """/api/node-types 的响应体(camelCase,直接喂前端)。""" + return {"specVersion": SPEC_VERSION, "nodeTypes": NODE_SPECS} + + +def validate_graph(graph: dict[str, Any]) -> list[str]: + """校验 workflow 图,返回错误信息列表(空列表 = 通过)。 + + 基础规则(对齐 dograh 的核心不变量): + 1. 节点类型必须是已注册类型; + 2. 有且仅有一个 startCall; + 3. 至少有一个 endCall; + 4. 边的 source/target 必须指向存在的节点; + 5. 入边/出边数量满足各节点类型的约束。 + + 空图(无节点)视为草稿,直接放行,方便先存后编排。 + """ + errors: list[str] = [] + nodes = graph.get("nodes") or [] + edges = graph.get("edges") or [] + + if not nodes: + return errors # 草稿:放行 + + node_ids: set[str] = set() + type_counts: dict[str, int] = {} + node_type_by_id: dict[str, str] = {} + + for node in nodes: + node_id = node.get("id") + node_type = node.get("type") + if not node_id: + errors.append("存在缺少 id 的节点") + continue + if node_id in node_ids: + errors.append(f"节点 id 重复:{node_id}") + node_ids.add(node_id) + if node_type not in _SPEC_BY_NAME: + errors.append(f"未知节点类型:{node_type}(节点 {node_id})") + continue + node_type_by_id[node_id] = node_type + type_counts[node_type] = type_counts.get(node_type, 0) + 1 + + start_count = type_counts.get("startCall", 0) + if start_count == 0: + errors.append("工作流必须有一个「开始」节点") + elif start_count > 1: + errors.append("工作流只能有一个「开始」节点") + + if type_counts.get("endCall", 0) == 0: + errors.append("工作流至少需要一个「结束」节点") + + # 统计入边/出边 + incoming: dict[str, int] = {nid: 0 for nid in node_ids} + outgoing: dict[str, int] = {nid: 0 for nid in node_ids} + for edge in edges: + source = edge.get("source") + target = edge.get("target") + if source not in node_ids: + errors.append(f"连线指向了不存在的源节点:{source}") + continue + if target not in node_ids: + errors.append(f"连线指向了不存在的目标节点:{target}") + continue + outgoing[source] += 1 + incoming[target] += 1 + + for node_id, node_type in node_type_by_id.items(): + constraints = _SPEC_BY_NAME[node_type]["constraints"] + name = node_type + _check_count(errors, incoming[node_id], constraints, "Incoming", name, "入边") + _check_count(errors, outgoing[node_id], constraints, "Outgoing", name, "出边") + + return errors + + +def _check_count( + errors: list[str], + actual: int, + constraints: dict[str, int], + suffix: str, + node_type: str, + label: str, +) -> None: + lo = constraints.get(f"min{suffix}") + hi = constraints.get(f"max{suffix}") + display = _SPEC_BY_NAME[node_type]["displayName"] + if lo is not None and actual < lo: + errors.append(f"「{display}」节点{label}数量不能少于 {lo}(当前 {actual})") + if hi is not None and actual > hi: + errors.append(f"「{display}」节点{label}数量不能多于 {hi}(当前 {actual})") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9d75b9a..0094f13 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "ai-video-admin-frontend", "version": "0.1.0", "dependencies": { + "@xyflow/react": "^12.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.17.0", @@ -3776,6 +3777,55 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4455,6 +4505,48 @@ "win32" ] }, + "node_modules/@xyflow/react": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", + "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.77", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.77", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz", + "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5071,6 +5163,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -5318,6 +5416,111 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -11532,6 +11735,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 5de6772..cadaaed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@xyflow/react": "^12.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.17.0", diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index 39dbde7..a009d90 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -31,6 +31,7 @@ import { PhoneOff, Orbit, Waves, + Bug, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -38,6 +39,12 @@ import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { DropdownMenu, DropdownMenuContent, @@ -89,6 +96,14 @@ import { type ChatMessage, type VoicePreviewStatus, } from "@/hooks/use-voice-preview"; +import { + WorkflowEditor, + type WorkflowSettings, +} from "@/components/workflow/WorkflowEditor"; +import { + defaultGraph, + type WorkflowGraph, +} from "@/components/workflow/specs"; type RuntimeMode = "pipeline" | "realtime"; @@ -167,7 +182,7 @@ const typeToView = { dify: "create-dify", fastgpt: "create-fastgpt", opencode: "create-opencode", - workflow: "placeholder", + workflow: "workflow", } as const; type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType]; @@ -348,6 +363,16 @@ export function AssistantPage(props: AssistantPageProps) { // 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击 const [savedSnapshot, setSavedSnapshot] = useState(null); + // workflow 编辑器:名称 + 图(nodes/edges)。graph 实时由画布回传。 + const [workflowName, setWorkflowName] = useState(""); + const [workflowGraph, setWorkflowGraph] = useState(() => + defaultGraph(), + ); + const [workflowSettings, setWorkflowSettings] = useState({ + allowInterrupt: true, + }); + const [debugOpen, setDebugOpen] = useState(false); + const loadAssistants = useCallback(async () => { setListLoading(true); setListError(null); @@ -507,6 +532,14 @@ export function AssistantPage(props: AssistantPageProps) { const next = { ...openCodeForm, apiKey: "" }; setOpenCodeForm(next); setSavedSnapshot(JSON.stringify(next)); + } else if (view === "workflow") { + setSavedSnapshot( + JSON.stringify({ + name: workflowName, + graph: workflowGraph, + settings: workflowSettings, + }), + ); } } catch (error) { setSaveError(error instanceof Error ? error.message : "保存失败"); @@ -635,9 +668,29 @@ export function AssistantPage(props: AssistantPageProps) { } else if (assistant.type === "opencode") { setSavedSnapshot(JSON.stringify(fillOpenCodeForm(assistant))); } else { - // 工作流:暂时显示占位页 - setDraftName(assistant.name); - setDraftType(typeToLabel[assistant.type]); + // 工作流:回填名称与图(空图回落到默认 开始→智能体→结束) + const graph = + assistant.graph && + Array.isArray((assistant.graph as WorkflowGraph).nodes) && + (assistant.graph as WorkflowGraph).nodes.length > 0 + ? (assistant.graph as WorkflowGraph) + : defaultGraph(); + const wfSettings: WorkflowSettings = { + llm: assistant.modelResourceIds.LLM, + asr: assistant.modelResourceIds.ASR, + tts: assistant.modelResourceIds.TTS, + allowInterrupt: assistant.enableInterrupt, + }; + setWorkflowName(assistant.name); + setWorkflowGraph(graph); + setWorkflowSettings(wfSettings); + setSavedSnapshot( + JSON.stringify({ + name: assistant.name, + graph, + settings: wfSettings, + }), + ); } setView(typeToView[assistant.type]); } catch (error) { @@ -672,6 +725,22 @@ export function AssistantPage(props: AssistantPageProps) { ); } + function handleSaveWorkflow() { + void save( + baseUpsert({ + name: workflowName.trim(), + type: "workflow", + enableInterrupt: workflowSettings.allowInterrupt, + modelResourceIds: { + ...(workflowSettings.llm ? { LLM: workflowSettings.llm } : {}), + ...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}), + ...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}), + }, + graph: workflowGraph as unknown as Record, + }), + ); + } + // 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点) const activeFormJson = view === "create" @@ -682,7 +751,13 @@ export function AssistantPage(props: AssistantPageProps) { ? JSON.stringify(fastGptForm) : view === "create-opencode" ? JSON.stringify(openCodeForm) - : null; + : view === "workflow" + ? JSON.stringify({ + name: workflowName, + graph: workflowGraph, + settings: workflowSettings, + }) + : null; const dirty = activeFormJson !== null && savedSnapshot !== null && @@ -1114,59 +1189,73 @@ export function AssistantPage(props: AssistantPageProps) { ); } - if (view === "placeholder") { + if (view === "workflow") { return ( -
-
-
-

- {draftName.trim() || "创建助手"} -

-

- {draftType} 构建方式正在开发中,敬请期待。 -

+
+
+
+ router.push("/assistants")} /> + +
- +
+ {saveError && ( + + {saveError} + + )} + + +
-
-
+ -
-
-
- 建设中 -
-

- {draftType} 构建页面正在编写中 -

-

- 页面骨架与设计语言已就绪,后续将填充具体构建流程与数据。 -

-
-
+
+ + + e.preventDefault()} + className="w-[440px] gap-0 border-l border-hairline bg-card p-0 sm:max-w-[440px]" + > + + 语音调试 + + + +
); } @@ -1767,12 +1856,23 @@ function SegmentedIconButton({ ); } -function DebugDrawer({ assistantId }: { assistantId: string | null }) { +function DebugDrawer({ + assistantId, + asSheet = false, +}: { + assistantId: string | null; + asSheet?: boolean; +}) { const [showTranscript, setShowTranscript] = useState(false); const [vizStyle, setVizStyle] = useState("aura"); + // 内联(prompt 编辑器右栏)用 aside + 圆角边框;抽屉模式占满 Sheet。 + const containerClass = asSheet + ? "flex h-full min-w-0 flex-1 flex-col overflow-hidden" + : "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex"; + return ( -