Add workflow editor and node types support in frontend and backend
- Introduce a new workflow editor component for visualizing and managing workflows, allowing users to add nodes and define connections. - Implement backend support for node types, including validation and constraints for workflow graphs. - Add new API endpoints for retrieving node types and their specifications. - Enhance the AssistantPage to integrate the workflow editor, enabling users to create and edit workflows directly. - Update frontend components to support new workflow functionalities, including condition edges and generic nodes. - Refactor existing code to accommodate the new workflow features and improve overall structure.
This commit is contained in:
@@ -24,6 +24,7 @@ from routes import (
|
|||||||
health,
|
health,
|
||||||
knowledge_bases,
|
knowledge_bases,
|
||||||
model_registry,
|
model_registry,
|
||||||
|
node_types,
|
||||||
voice_webrtc,
|
voice_webrtc,
|
||||||
voice_ws,
|
voice_ws,
|
||||||
)
|
)
|
||||||
@@ -49,6 +50,7 @@ app.include_router(health.router)
|
|||||||
app.include_router(assistants.router)
|
app.include_router(assistants.router)
|
||||||
app.include_router(knowledge_bases.router)
|
app.include_router(knowledge_bases.router)
|
||||||
app.include_router(model_registry.router)
|
app.include_router(model_registry.router)
|
||||||
|
app.include_router(node_types.router)
|
||||||
app.include_router(voice_webrtc.router)
|
app.include_router(voice_webrtc.router)
|
||||||
app.include_router(voice_ws.router)
|
app.include_router(voice_ws.router)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from db.session import get_session
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from schemas import AssistantOut, AssistantUpsert
|
from schemas import AssistantOut, AssistantUpsert
|
||||||
from services.masking import mask, resolve_incoming_key
|
from services.masking import mask, resolve_incoming_key
|
||||||
|
from services.node_specs import validate_graph
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -14,6 +15,15 @@ router = APIRouter(prefix="/api/assistants", tags=["assistants"])
|
|||||||
CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding")
|
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(
|
async def _sync_bindings(
|
||||||
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
|
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -82,6 +92,7 @@ async def list_assistants(session: AsyncSession = Depends(get_session)):
|
|||||||
async def create_assistant(
|
async def create_assistant(
|
||||||
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
|
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
|
||||||
):
|
):
|
||||||
|
_validate_workflow(body)
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
resource_ids = data.pop("model_resource_ids")
|
resource_ids = data.pop("model_resource_ids")
|
||||||
assistant = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **data)
|
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)
|
assistant = await session.get(Assistant, assistant_id)
|
||||||
if not assistant:
|
if not assistant:
|
||||||
raise HTTPException(404, "助手不存在")
|
raise HTTPException(404, "助手不存在")
|
||||||
|
_validate_workflow(body)
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
resource_ids = data.pop("model_resource_ids")
|
resource_ids = data.pop("model_resource_ids")
|
||||||
data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key)
|
data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key)
|
||||||
|
|||||||
14
backend/routes/node_types.py
Normal file
14
backend/routes/node_types.py
Normal file
@@ -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()
|
||||||
161
backend/services/node_specs.py
Normal file
161
backend/services/node_specs.py
Normal file
@@ -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})")
|
||||||
231
frontend/package-lock.json
generated
231
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "ai-video-admin-frontend",
|
"name": "ai-video-admin-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xyflow/react": "^12.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
@@ -3776,6 +3777,55 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -4455,6 +4505,48 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -5071,6 +5163,12 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"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": {
|
"node_modules/cli-cursor": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||||
@@ -5318,6 +5416,111 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -11532,6 +11735,34 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xyflow/react": "^12.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
PhoneOff,
|
PhoneOff,
|
||||||
Orbit,
|
Orbit,
|
||||||
Waves,
|
Waves,
|
||||||
|
Bug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -38,6 +39,12 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -89,6 +96,14 @@ import {
|
|||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type VoicePreviewStatus,
|
type VoicePreviewStatus,
|
||||||
} from "@/hooks/use-voice-preview";
|
} 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";
|
type RuntimeMode = "pipeline" | "realtime";
|
||||||
|
|
||||||
@@ -167,7 +182,7 @@ const typeToView = {
|
|||||||
dify: "create-dify",
|
dify: "create-dify",
|
||||||
fastgpt: "create-fastgpt",
|
fastgpt: "create-fastgpt",
|
||||||
opencode: "create-opencode",
|
opencode: "create-opencode",
|
||||||
workflow: "placeholder",
|
workflow: "workflow",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType];
|
type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType];
|
||||||
@@ -348,6 +363,16 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
|
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
|
||||||
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// workflow 编辑器:名称 + 图(nodes/edges)。graph 实时由画布回传。
|
||||||
|
const [workflowName, setWorkflowName] = useState("");
|
||||||
|
const [workflowGraph, setWorkflowGraph] = useState<WorkflowGraph>(() =>
|
||||||
|
defaultGraph(),
|
||||||
|
);
|
||||||
|
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
||||||
|
allowInterrupt: true,
|
||||||
|
});
|
||||||
|
const [debugOpen, setDebugOpen] = useState(false);
|
||||||
|
|
||||||
const loadAssistants = useCallback(async () => {
|
const loadAssistants = useCallback(async () => {
|
||||||
setListLoading(true);
|
setListLoading(true);
|
||||||
setListError(null);
|
setListError(null);
|
||||||
@@ -507,6 +532,14 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
const next = { ...openCodeForm, apiKey: "" };
|
const next = { ...openCodeForm, apiKey: "" };
|
||||||
setOpenCodeForm(next);
|
setOpenCodeForm(next);
|
||||||
setSavedSnapshot(JSON.stringify(next));
|
setSavedSnapshot(JSON.stringify(next));
|
||||||
|
} else if (view === "workflow") {
|
||||||
|
setSavedSnapshot(
|
||||||
|
JSON.stringify({
|
||||||
|
name: workflowName,
|
||||||
|
graph: workflowGraph,
|
||||||
|
settings: workflowSettings,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSaveError(error instanceof Error ? error.message : "保存失败");
|
setSaveError(error instanceof Error ? error.message : "保存失败");
|
||||||
@@ -635,9 +668,29 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
} else if (assistant.type === "opencode") {
|
} else if (assistant.type === "opencode") {
|
||||||
setSavedSnapshot(JSON.stringify(fillOpenCodeForm(assistant)));
|
setSavedSnapshot(JSON.stringify(fillOpenCodeForm(assistant)));
|
||||||
} else {
|
} else {
|
||||||
// 工作流:暂时显示占位页
|
// 工作流:回填名称与图(空图回落到默认 开始→智能体→结束)
|
||||||
setDraftName(assistant.name);
|
const graph =
|
||||||
setDraftType(typeToLabel[assistant.type]);
|
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]);
|
setView(typeToView[assistant.type]);
|
||||||
} catch (error) {
|
} 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<string, unknown>,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点)
|
// 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点)
|
||||||
const activeFormJson =
|
const activeFormJson =
|
||||||
view === "create"
|
view === "create"
|
||||||
@@ -682,7 +751,13 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
? JSON.stringify(fastGptForm)
|
? JSON.stringify(fastGptForm)
|
||||||
: view === "create-opencode"
|
: view === "create-opencode"
|
||||||
? JSON.stringify(openCodeForm)
|
? JSON.stringify(openCodeForm)
|
||||||
: null;
|
: view === "workflow"
|
||||||
|
? JSON.stringify({
|
||||||
|
name: workflowName,
|
||||||
|
graph: workflowGraph,
|
||||||
|
settings: workflowSettings,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
const dirty =
|
const dirty =
|
||||||
activeFormJson !== null &&
|
activeFormJson !== null &&
|
||||||
savedSnapshot !== null &&
|
savedSnapshot !== null &&
|
||||||
@@ -1114,59 +1189,73 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view === "placeholder") {
|
if (view === "workflow") {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-6">
|
<div className="-mt-6 flex h-full flex-col gap-4">
|
||||||
<div className="flex items-start justify-between gap-6">
|
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<h1 className="font-display display-lg text-ink">
|
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||||||
{draftName.trim() || "创建助手"}
|
<EditableTitle value={workflowName} onChange={setWorkflowName} />
|
||||||
</h1>
|
<AssistantIdentity assistantId={editingId} />
|
||||||
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
|
|
||||||
{draftType} 构建方式正在开发中,敬请期待。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex shrink-0 gap-2">
|
||||||
variant="outline"
|
{saveError && (
|
||||||
size="lg"
|
<span className="self-center text-xs text-destructive">
|
||||||
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
{saveError}
|
||||||
onClick={() => router.push("/assistants")}
|
</span>
|
||||||
>
|
)}
|
||||||
<ChevronLeft size={16} />
|
<Button
|
||||||
返回列表
|
variant="outline"
|
||||||
</Button>
|
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
||||||
|
disabled={!editingId}
|
||||||
|
onClick={() => setDebugOpen(true)}
|
||||||
|
>
|
||||||
|
<Bug size={16} />
|
||||||
|
调试
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="gap-2"
|
||||||
|
disabled={saving || !dirty || !workflowName.trim()}
|
||||||
|
onClick={() => handleSaveWorkflow()}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save size={16} />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="relative overflow-hidden rounded-3xl border border-hairline bg-canvas-soft px-10 py-20 text-center">
|
<div className="min-h-0 flex-1">
|
||||||
<div
|
<WorkflowEditor
|
||||||
aria-hidden
|
value={workflowGraph}
|
||||||
className="pointer-events-none absolute -right-24 top-0 h-72 w-72 rounded-full opacity-50 blur-3xl"
|
onChange={setWorkflowGraph}
|
||||||
style={{
|
settings={workflowSettings}
|
||||||
backgroundImage:
|
onSettingsChange={setWorkflowSettings}
|
||||||
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 50%, transparent), transparent 70%)",
|
modelOptions={{
|
||||||
|
llm: credOptions("LLM"),
|
||||||
|
asr: credOptions("ASR"),
|
||||||
|
tts: credOptions("TTS"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
</div>
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute -left-20 bottom-0 h-64 w-64 rounded-full opacity-45 blur-3xl"
|
<Sheet open={debugOpen} onOpenChange={setDebugOpen} modal={false}>
|
||||||
style={{
|
<SheetContent
|
||||||
backgroundImage:
|
side="right"
|
||||||
"radial-gradient(circle, color-mix(in srgb, var(--gradient-lavender) 50%, transparent), transparent 70%)",
|
showOverlay={false}
|
||||||
}}
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
/>
|
className="w-[440px] gap-0 border-l border-hairline bg-card p-0 sm:max-w-[440px]"
|
||||||
<div className="relative">
|
>
|
||||||
<div className="caption-label inline-flex rounded-full bg-surface-strong px-3 py-1 text-muted-foreground">
|
<SheetHeader className="sr-only">
|
||||||
建设中
|
<SheetTitle>语音调试</SheetTitle>
|
||||||
</div>
|
</SheetHeader>
|
||||||
<p className="font-display display-sm mx-auto mt-5 max-w-md text-ink">
|
<DebugDrawer assistantId={editingId} asSheet />
|
||||||
{draftType} 构建页面正在编写中
|
</SheetContent>
|
||||||
</p>
|
</Sheet>
|
||||||
<p className="mx-auto mt-3 max-w-md text-sm leading-7 text-body">
|
|
||||||
页面骨架与设计语言已就绪,后续将填充具体构建流程与数据。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 [showTranscript, setShowTranscript] = useState(false);
|
||||||
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
const [vizStyle, setVizStyle] = useState<VizStyle>("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 (
|
return (
|
||||||
<aside className="hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex">
|
<aside className={containerClass}>
|
||||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
|
||||||
<div className="text-sm font-medium text-foreground">调试与预览</div>
|
<div className="text-sm font-medium text-foreground">调试与预览</div>
|
||||||
{SHOW_VOICE_VIZ && (
|
{SHOW_VOICE_VIZ && (
|
||||||
|
|||||||
@@ -50,14 +50,16 @@ function SheetContent({
|
|||||||
children,
|
children,
|
||||||
side = "right",
|
side = "right",
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
|
showOverlay = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
|
showOverlay?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
{showOverlay && <SheetOverlay />}
|
||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
data-side={side}
|
data-side={side}
|
||||||
|
|||||||
116
frontend/src/components/workflow/ConditionEdge.tsx
Normal file
116
frontend/src/components/workflow/ConditionEdge.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 条件边。边携带 condition(自然语言条件,LLM 据此决定是否走这条路径)与
|
||||||
|
* label(日志里识别该路径的短标签)。悬停/选中时在标签旁显示「编辑 / 删除」按钮。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BaseEdge,
|
||||||
|
EdgeLabelRenderer,
|
||||||
|
getSmoothStepPath,
|
||||||
|
type EdgeProps,
|
||||||
|
useReactFlow,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { EdgeActionContext } from "./context";
|
||||||
|
|
||||||
|
export function ConditionEdge({
|
||||||
|
id,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
data,
|
||||||
|
selected,
|
||||||
|
}: EdgeProps) {
|
||||||
|
const actions = useContext(EdgeActionContext);
|
||||||
|
const { setEdges } = useReactFlow();
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
// 点击标签:只选中这条边(露出编辑/删除按钮),不直接进入编辑。
|
||||||
|
const selectThisEdge = () =>
|
||||||
|
setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === id })));
|
||||||
|
const [path, labelX, labelY] = getSmoothStepPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourcePosition,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetPosition,
|
||||||
|
borderRadius: 8,
|
||||||
|
offset: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = ((data?.label as string) || (data?.condition as string) || "")
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
const expanded = hovered || selected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
path={path}
|
||||||
|
style={{
|
||||||
|
stroke: selected ? "var(--primary)" : "var(--muted-soft)",
|
||||||
|
strokeWidth: selected ? 2.5 : 1.5,
|
||||||
|
transition: "stroke 0.15s ease, stroke-width 0.15s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
className="nodrag nopan pointer-events-auto absolute flex items-center gap-1"
|
||||||
|
style={{
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="条件"
|
||||||
|
className={cn(
|
||||||
|
"max-w-[160px] truncate rounded-full border px-2.5 py-1 text-xs shadow-sm transition-colors",
|
||||||
|
label
|
||||||
|
? "border-hairline bg-card text-foreground hover:border-primary"
|
||||||
|
: "border-dashed border-hairline-strong bg-card/80 text-muted-soft hover:text-foreground",
|
||||||
|
selected && "border-primary ring-2 ring-primary/30",
|
||||||
|
)}
|
||||||
|
onClick={selectThisEdge}
|
||||||
|
>
|
||||||
|
{label || "+ 条件"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="编辑条件"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||||
|
onClick={() => actions.edit(id)}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{expanded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="删除连线"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-destructive"
|
||||||
|
onClick={() => actions.remove(id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const edgeTypes = { condition: ConditionEdge };
|
||||||
123
frontend/src/components/workflow/GenericNode.tsx
Normal file
123
frontend/src/components/workflow/GenericNode.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用节点渲染器。所有节点类型共用一个组件,规格来自 NodeSpecsContext
|
||||||
|
* (后端 /api/node-types)。悬停/选中时在右上角显示「编辑 / 删除」按钮。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { NodeActionContext, NodeSpecsContext } from "./context";
|
||||||
|
import { accentVar, type WorkflowNodeData } from "./specs";
|
||||||
|
|
||||||
|
export function GenericNode({ id, type, data, selected }: NodeProps) {
|
||||||
|
const specs = useContext(NodeSpecsContext);
|
||||||
|
const actions = useContext(NodeActionContext);
|
||||||
|
const spec = specs[type as string];
|
||||||
|
if (!spec) return null;
|
||||||
|
|
||||||
|
const nodeData = data as WorkflowNodeData;
|
||||||
|
const Icon = spec.icon;
|
||||||
|
const preview = (nodeData.greeting || nodeData.prompt || "")
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-node-id={id}
|
||||||
|
className={cn(
|
||||||
|
"group relative w-[228px] rounded-2xl border bg-card p-4 shadow-sm transition-all",
|
||||||
|
selected
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-hairline hover:shadow-md",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{spec.hasTarget && (
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-2.5 !w-2.5 !border-2 !border-card !bg-muted-soft"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 悬停/选中时出现的操作按钮 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"nodrag absolute -top-3 right-3 flex items-center gap-1 transition-opacity",
|
||||||
|
selected
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0 group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="编辑节点"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
actions.edit(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
{spec.addable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="删除节点"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground shadow-sm transition-colors hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
actions.remove(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-ink"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `radial-gradient(circle at 30% 30%, color-mix(in srgb, var(${accentVar(spec.accent)}) 70%, transparent), color-mix(in srgb, var(${accentVar(spec.accent)}) 35%, transparent))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">
|
||||||
|
{nodeData.name || spec.displayName}
|
||||||
|
</div>
|
||||||
|
<div className="caption-label text-muted-soft">{spec.displayName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<p className="mt-3 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||||
|
{preview}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-xs italic leading-5 text-muted-soft">
|
||||||
|
点击编辑节点内容…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{spec.hasSource && (
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-2.5 !w-2.5 !border-2 !border-card !bg-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodeTypes = {
|
||||||
|
startCall: GenericNode,
|
||||||
|
agentNode: GenericNode,
|
||||||
|
endCall: GenericNode,
|
||||||
|
};
|
||||||
662
frontend/src/components/workflow/WorkflowEditor.tsx
Normal file
662
frontend/src/components/workflow/WorkflowEditor.tsx
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流可视化编辑器。基于 @xyflow/react,样式套用 ai-video 设计 token。
|
||||||
|
*
|
||||||
|
* 工具栏:添加节点(弹窗选类型)、工作流设置(ASR/LLM/TTS、是否允许打断)。
|
||||||
|
* 节点与条件边:悬停/选中出现「编辑 / 删除」按钮,编辑统一用弹出对话框。
|
||||||
|
* 节点目录来自后端 /api/node-types。数据通过 value/onChange 与外部 graph 同步。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
addEdge,
|
||||||
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
|
type Connection,
|
||||||
|
Controls,
|
||||||
|
type Edge,
|
||||||
|
MiniMap,
|
||||||
|
type Node,
|
||||||
|
Panel,
|
||||||
|
ReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
useEdgesState,
|
||||||
|
useNodesState,
|
||||||
|
useReactFlow,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import { Loader2, Plus, Settings2, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import { edgeTypes } from "./ConditionEdge";
|
||||||
|
import {
|
||||||
|
EdgeActionContext,
|
||||||
|
NodeActionContext,
|
||||||
|
NodeSpecsContext,
|
||||||
|
} from "./context";
|
||||||
|
import { nodeTypes } from "./GenericNode";
|
||||||
|
import {
|
||||||
|
accentVar,
|
||||||
|
defaultGraph,
|
||||||
|
type NodeSpecMap,
|
||||||
|
type RuntimeNodeSpec,
|
||||||
|
type WorkflowGraph,
|
||||||
|
type WorkflowNodeData,
|
||||||
|
type WorkflowNodeType,
|
||||||
|
} from "./specs";
|
||||||
|
import { useNodeSpecs } from "./useNodeSpecs";
|
||||||
|
|
||||||
|
export type WorkflowSettings = {
|
||||||
|
llm?: string;
|
||||||
|
asr?: string;
|
||||||
|
tts?: string;
|
||||||
|
allowInterrupt: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelOption = { value: string; label: string };
|
||||||
|
|
||||||
|
export type WorkflowEditorProps = {
|
||||||
|
value?: WorkflowGraph;
|
||||||
|
onChange?: (graph: WorkflowGraph) => void;
|
||||||
|
settings: WorkflowSettings;
|
||||||
|
onSettingsChange: (settings: WorkflowSettings) => void;
|
||||||
|
modelOptions: { llm: ModelOption[]; asr: ModelOption[]; tts: ModelOption[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
let nodeSeq = 0;
|
||||||
|
const NONE = "__none__";
|
||||||
|
|
||||||
|
function toFlow(graph: WorkflowGraph): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
return {
|
||||||
|
nodes: graph.nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
position: n.position,
|
||||||
|
data: n.data,
|
||||||
|
})),
|
||||||
|
edges: graph.edges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
type: "condition",
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
data: e.data ?? {},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph {
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type as WorkflowNodeType,
|
||||||
|
position: n.position,
|
||||||
|
data: n.data as WorkflowNodeData,
|
||||||
|
})),
|
||||||
|
edges: edges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
data: (e.data as { condition?: string; label?: string }) ?? {},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Canvas({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
modelOptions,
|
||||||
|
specsByType,
|
||||||
|
}: WorkflowEditorProps & { specsByType: NodeSpecMap }) {
|
||||||
|
const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]);
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editingEdgeId, setEditingEdgeId] = useState<string | null>(null);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
|
|
||||||
|
// 回传画布状态给外部(助手 graph)。用 ref 避免把 onChange 放进依赖导致循环。
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current?.(fromFlow(nodes, edges));
|
||||||
|
}, [nodes, edges]);
|
||||||
|
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(connection: Connection) => {
|
||||||
|
setEdges((eds) =>
|
||||||
|
addEdge(
|
||||||
|
{
|
||||||
|
...connection,
|
||||||
|
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
|
||||||
|
type: "condition",
|
||||||
|
animated: true,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
eds,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
|
||||||
|
const isValidConnection = useCallback(
|
||||||
|
(c: Connection | Edge) => {
|
||||||
|
if (c.source === c.target) return false;
|
||||||
|
const target = nodes.find((n) => n.id === c.target);
|
||||||
|
if (!target) return false;
|
||||||
|
const spec = specsByType[target.type as string];
|
||||||
|
return !!spec?.hasTarget;
|
||||||
|
},
|
||||||
|
[nodes, specsByType],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNode = useCallback(
|
||||||
|
(spec: RuntimeNodeSpec) => {
|
||||||
|
nodeSeq += 1;
|
||||||
|
const id = `${spec.type}-${Date.now()}-${nodeSeq}`;
|
||||||
|
const position = screenToFlowPosition({
|
||||||
|
x: window.innerWidth / 2,
|
||||||
|
y: window.innerHeight / 2,
|
||||||
|
});
|
||||||
|
const data: WorkflowNodeData = { name: spec.displayName };
|
||||||
|
if (spec.type === "agentNode") {
|
||||||
|
data.allowInterrupt = true;
|
||||||
|
data.prompt = "";
|
||||||
|
}
|
||||||
|
setNodes((ns) => [...ns, { id, type: spec.type, position, data }]);
|
||||||
|
setAddOpen(false);
|
||||||
|
setEditingId(id);
|
||||||
|
},
|
||||||
|
[screenToFlowPosition, setNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateNodeData = useCallback(
|
||||||
|
(id: string, patch: Partial<WorkflowNodeData>) => {
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) =>
|
||||||
|
n.id === id ? { ...n, data: { ...n.data, ...patch } } : n,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteNode = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setNodes((ns) => ns.filter((n) => n.id !== id));
|
||||||
|
setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
|
||||||
|
setEditingId((cur) => (cur === id ? null : cur));
|
||||||
|
},
|
||||||
|
[setNodes, setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateEdgeData = useCallback(
|
||||||
|
(id: string, patch: { condition?: string; label?: string }) => {
|
||||||
|
setEdges((es) =>
|
||||||
|
es.map((e) =>
|
||||||
|
e.id === id ? { ...e, data: { ...(e.data ?? {}), ...patch } } : e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteEdge = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setEdges((es) => es.filter((e) => e.id !== id));
|
||||||
|
setEditingEdgeId((cur) => (cur === id ? null : cur));
|
||||||
|
},
|
||||||
|
[setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeActions = useMemo(
|
||||||
|
() => ({ edit: setEditingId, remove: deleteNode }),
|
||||||
|
[deleteNode],
|
||||||
|
);
|
||||||
|
const edgeActions = useMemo(
|
||||||
|
() => ({ edit: setEditingEdgeId, remove: deleteEdge }),
|
||||||
|
[deleteEdge],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editingNode = nodes.find((n) => n.id === editingId);
|
||||||
|
const editingSpec = editingNode ? specsByType[editingNode.type as string] : null;
|
||||||
|
const editingEdge = edges.find((e) => e.id === editingEdgeId);
|
||||||
|
const addableSpecs = Object.values(specsByType).filter((s) => s.addable);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeSpecsContext.Provider value={specsByType}>
|
||||||
|
<NodeActionContext.Provider value={nodeActions}>
|
||||||
|
<EdgeActionContext.Provider value={edgeActions}>
|
||||||
|
<div className="h-full w-full overflow-hidden rounded-2xl border border-hairline bg-canvas-soft">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
isValidConnection={isValidConnection}
|
||||||
|
onPaneClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingEdgeId(null);
|
||||||
|
}}
|
||||||
|
fitView
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
defaultEdgeOptions={{ type: "condition", animated: true }}
|
||||||
|
className="bg-canvas-soft"
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Dots}
|
||||||
|
gap={20}
|
||||||
|
size={1}
|
||||||
|
color="var(--hairline-strong)"
|
||||||
|
/>
|
||||||
|
<Controls className="!rounded-xl !border !border-hairline !bg-card !shadow-sm [&_button]:!border-hairline [&_button]:!bg-card [&_button]:!text-foreground" />
|
||||||
|
<MiniMap
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
className="!rounded-xl !border !border-hairline !bg-card"
|
||||||
|
maskColor="color-mix(in srgb, var(--canvas-soft) 70%, transparent)"
|
||||||
|
nodeColor="var(--surface-strong)"
|
||||||
|
/>
|
||||||
|
<Panel position="top-left" className="flex gap-2">
|
||||||
|
<Button size="sm" className="gap-2" onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus size={15} />
|
||||||
|
添加节点
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-hairline-strong bg-card text-foreground hover:bg-surface-strong"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings2 size={15} />
|
||||||
|
工作流设置
|
||||||
|
</Button>
|
||||||
|
</Panel>
|
||||||
|
</ReactFlow>
|
||||||
|
|
||||||
|
{/* 添加节点弹窗 */}
|
||||||
|
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-display text-ink">
|
||||||
|
添加节点
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
选择要添加到画布的节点类型。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2 py-2">
|
||||||
|
{addableSpecs.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-soft">暂无可添加的节点类型。</p>
|
||||||
|
)}
|
||||||
|
{addableSpecs.map((spec) => {
|
||||||
|
const Icon = spec.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={spec.type}
|
||||||
|
type="button"
|
||||||
|
className="flex items-start gap-3 rounded-xl border border-hairline bg-card p-3 text-left transition-colors hover:border-primary hover:bg-surface-strong/40"
|
||||||
|
onClick={() => addNode(spec)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-ink"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `radial-gradient(circle at 30% 30%, color-mix(in srgb, var(${accentVar(spec.accent)}) 70%, transparent), color-mix(in srgb, var(${accentVar(spec.accent)}) 35%, transparent))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
{spec.displayName}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||||
|
{spec.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 工作流设置弹窗 */}
|
||||||
|
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-display text-ink">
|
||||||
|
工作流设置
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
配置整个工作流使用的语音与大模型,以及交互策略。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-5 py-2">
|
||||||
|
<ModelSelect
|
||||||
|
label="大模型(LLM)"
|
||||||
|
value={settings.llm}
|
||||||
|
options={modelOptions.llm}
|
||||||
|
onChange={(v) => onSettingsChange({ ...settings, llm: v })}
|
||||||
|
/>
|
||||||
|
<ModelSelect
|
||||||
|
label="语音识别(ASR)"
|
||||||
|
value={settings.asr}
|
||||||
|
options={modelOptions.asr}
|
||||||
|
onChange={(v) => onSettingsChange({ ...settings, asr: v })}
|
||||||
|
/>
|
||||||
|
<ModelSelect
|
||||||
|
label="语音合成(TTS)"
|
||||||
|
value={settings.tts}
|
||||||
|
options={modelOptions.tts}
|
||||||
|
onChange={(v) => onSettingsChange({ ...settings, tts: v })}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center justify-between gap-3 pt-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
允许用户打断
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={settings.allowInterrupt}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onSettingsChange({ ...settings, allowInterrupt: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setSettingsOpen(false)}>完成</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 节点编辑弹窗 */}
|
||||||
|
<Dialog
|
||||||
|
open={!!editingNode}
|
||||||
|
onOpenChange={(open) => !open && setEditingId(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
|
{editingNode && editingSpec && (
|
||||||
|
<NodeForm
|
||||||
|
key={editingNode.id}
|
||||||
|
spec={editingSpec}
|
||||||
|
data={editingNode.data as WorkflowNodeData}
|
||||||
|
onSave={(patch) => {
|
||||||
|
updateNodeData(editingNode.id, patch);
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
onDelete={
|
||||||
|
editingSpec.addable
|
||||||
|
? () => deleteNode(editingNode.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 条件编辑弹窗 */}
|
||||||
|
<Dialog
|
||||||
|
open={!!editingEdge}
|
||||||
|
onOpenChange={(open) => !open && setEditingEdgeId(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
|
{editingEdge && (
|
||||||
|
<EdgeForm
|
||||||
|
key={editingEdge.id}
|
||||||
|
edge={editingEdge}
|
||||||
|
onSave={(patch) => {
|
||||||
|
updateEdgeData(editingEdge.id, patch);
|
||||||
|
setEditingEdgeId(null);
|
||||||
|
}}
|
||||||
|
onDelete={() => deleteEdge(editingEdge.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</EdgeActionContext.Provider>
|
||||||
|
</NodeActionContext.Provider>
|
||||||
|
</NodeSpecsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelect({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
options: ModelOption[];
|
||||||
|
onChange: (value: string | undefined) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||||
|
<Select
|
||||||
|
value={value ?? NONE}
|
||||||
|
onValueChange={(v) => onChange(v === NONE ? undefined : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="border-hairline-strong bg-background text-foreground">
|
||||||
|
<SelectValue placeholder="选择模型资源" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>未选择</SelectItem>
|
||||||
|
{options.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeForm({
|
||||||
|
spec,
|
||||||
|
data,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
spec: RuntimeNodeSpec;
|
||||||
|
data: WorkflowNodeData;
|
||||||
|
onSave: (patch: WorkflowNodeData) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<WorkflowNodeData>({ ...data });
|
||||||
|
const set = (key: string, val: unknown) =>
|
||||||
|
setDraft((d) => ({ ...d, [key]: val }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-display text-ink">
|
||||||
|
编辑{spec.displayName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{spec.description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 py-2">
|
||||||
|
{spec.fields.map((field) => {
|
||||||
|
const raw = draft[field.key];
|
||||||
|
if (field.type === "switch") {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={field.key}
|
||||||
|
className="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(raw)}
|
||||||
|
onCheckedChange={(checked) => set(field.key, checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive"> *</span>}
|
||||||
|
</label>
|
||||||
|
{field.type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
value={(raw as string) ?? ""}
|
||||||
|
onChange={(e) => set(field.key, e.target.value)}
|
||||||
|
className="resize-none border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={(raw as string) ?? ""}
|
||||||
|
onChange={(e) => set(field.key, e.target.value)}
|
||||||
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||||
|
{onDelete ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-hairline-strong text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
删除节点
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<Button onClick={() => onSave(draft)}>保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EdgeForm({
|
||||||
|
edge,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
edge: Edge;
|
||||||
|
onSave: (patch: { condition: string; label: string }) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const data = (edge.data ?? {}) as { condition?: string; label?: string };
|
||||||
|
const [label, setLabel] = useState(data.label ?? "");
|
||||||
|
const [condition, setCondition] = useState(data.condition ?? "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-display text-ink">编辑条件</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
条件用于让大模型判断对话是否应沿这条路径流转到下一节点。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 py-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">条件标签</label>
|
||||||
|
<Input
|
||||||
|
value={label}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="例如:用户想转人工"
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-soft">
|
||||||
|
日志中识别该路径的短标签,{label.length}/64
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">条件</label>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
value={condition}
|
||||||
|
placeholder="描述触发这条路径的条件,例如:用户明确表示要找人工客服。"
|
||||||
|
onChange={(e) => setCondition(e.target.value)}
|
||||||
|
className="resize-none border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-hairline-strong text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
删除连线
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onSave({ condition, label })}>保存条件</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowEditor(props: WorkflowEditorProps) {
|
||||||
|
const { byType, loading, error } = useNodeSpecs();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded-2xl border border-hairline bg-canvas-soft text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 animate-spin" size={18} />
|
||||||
|
正在加载节点目录…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded-2xl border border-hairline bg-canvas-soft px-6 text-center text-sm text-destructive">
|
||||||
|
加载节点目录失败:{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<Canvas {...props} specsByType={byType} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/components/workflow/context.ts
Normal file
23
frontend/src/components/workflow/context.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布内共享上下文。GenericNode / ConditionEdge 由 ReactFlow 渲染,拿不到外层
|
||||||
|
* 回调,统一通过这些 context 取节点规格与编辑/删除动作。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
import type { NodeSpecMap } from "./specs";
|
||||||
|
|
||||||
|
/** 节点类型 → 运行期规格(来自 /api/node-types) */
|
||||||
|
export const NodeSpecsContext = createContext<NodeSpecMap>({});
|
||||||
|
|
||||||
|
export type ElementActions = {
|
||||||
|
edit: (id: string) => void;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const noop: ElementActions = { edit: () => {}, remove: () => {} };
|
||||||
|
|
||||||
|
export const NodeActionContext = createContext<ElementActions>(noop);
|
||||||
|
export const EdgeActionContext = createContext<ElementActions>(noop);
|
||||||
135
frontend/src/components/workflow/specs.ts
Normal file
135
frontend/src/components/workflow/specs.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* 工作流节点的前端类型与运行期规格。
|
||||||
|
*
|
||||||
|
* 节点「目录」(有哪些类型、各自的字段与约束)由后端 /api/node-types 提供,
|
||||||
|
* 通过 useNodeSpecs 拉取后用 toRuntimeSpec 转成带 React 组件(图标)的运行期规格。
|
||||||
|
* 本文件只保留:类型定义、默认图、图标/配色解析。新增节点类型改后端即可。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as LucideIcons from "lucide-react";
|
||||||
|
import { Circle, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import type { NodeSpecDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export type WorkflowNodeType = "startCall" | "agentNode" | "endCall";
|
||||||
|
|
||||||
|
export type WorkflowNodeData = {
|
||||||
|
/** 节点显示名 */
|
||||||
|
name: string;
|
||||||
|
/** 开场白(仅 startCall) */
|
||||||
|
greeting?: string;
|
||||||
|
/** 节点提示词 */
|
||||||
|
prompt?: string;
|
||||||
|
/** 允许打断(仅 agentNode) */
|
||||||
|
allowInterrupt?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FieldSpec = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "textarea" | "switch";
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 解析后的运行期节点规格(DTO + 解析出的 React 图标 + 派生句柄) */
|
||||||
|
export type RuntimeNodeSpec = {
|
||||||
|
type: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
accent: string;
|
||||||
|
addable: boolean;
|
||||||
|
/** 入边句柄(开始节点没有) */
|
||||||
|
hasTarget: boolean;
|
||||||
|
/** 出边句柄(结束节点没有) */
|
||||||
|
hasSource: boolean;
|
||||||
|
fields: FieldSpec[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 渐变 token → CSS 变量名(图标底色用),未知配色回落到 sky */
|
||||||
|
export const ACCENT_VAR: Record<string, string> = {
|
||||||
|
mint: "--gradient-mint",
|
||||||
|
sky: "--gradient-sky",
|
||||||
|
rose: "--gradient-rose",
|
||||||
|
peach: "--gradient-peach",
|
||||||
|
lavender: "--gradient-lavender",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function accentVar(accent: string): string {
|
||||||
|
return ACCENT_VAR[accent] ?? ACCENT_VAR.sky;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按名字解析 Lucide 图标,找不到回落到 Circle(对齐 dograh resolveIcon)。 */
|
||||||
|
export function resolveIcon(name: string): LucideIcon {
|
||||||
|
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
|
||||||
|
return icons[name] ?? Circle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端 DTO → 运行期规格。hasTarget/hasSource 由入/出边上限派生。 */
|
||||||
|
export function toRuntimeSpec(dto: NodeSpecDto): RuntimeNodeSpec {
|
||||||
|
return {
|
||||||
|
type: dto.name,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
description: dto.description,
|
||||||
|
icon: resolveIcon(dto.icon),
|
||||||
|
accent: dto.accent,
|
||||||
|
addable: dto.addable,
|
||||||
|
hasTarget: dto.constraints.maxIncoming !== 0,
|
||||||
|
hasSource: dto.constraints.maxOutgoing !== 0,
|
||||||
|
fields: dto.fields.map((f) => ({
|
||||||
|
key: f.key,
|
||||||
|
label: f.label,
|
||||||
|
type: f.type,
|
||||||
|
required: f.required,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeSpecMap = Record<string, RuntimeNodeSpec>;
|
||||||
|
|
||||||
|
export type WorkflowGraph = {
|
||||||
|
nodes: Array<{
|
||||||
|
id: string;
|
||||||
|
type: WorkflowNodeType;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
data: WorkflowNodeData;
|
||||||
|
}>;
|
||||||
|
edges: Array<{
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
data?: { condition?: string; label?: string };
|
||||||
|
}>;
|
||||||
|
viewport?: { x: number; y: number; zoom: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 新建工作流的默认图:开始 → 智能体 → 结束 */
|
||||||
|
export function defaultGraph(): WorkflowGraph {
|
||||||
|
return {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "start",
|
||||||
|
type: "startCall",
|
||||||
|
position: { x: 80, y: 160 },
|
||||||
|
data: { name: "开始", greeting: "你好,我是 AI 视频助手,有什么可以帮你?" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "agent-1",
|
||||||
|
type: "agentNode",
|
||||||
|
position: { x: 400, y: 160 },
|
||||||
|
data: { name: "智能体节点", prompt: "", allowInterrupt: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "end",
|
||||||
|
type: "endCall",
|
||||||
|
position: { x: 720, y: 160 },
|
||||||
|
data: { name: "结束", prompt: "感谢你的来电,再见!" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: "e-start-agent", source: "start", target: "agent-1", data: {} },
|
||||||
|
{ id: "e-agent-end", source: "agent-1", target: "end", data: {} },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
61
frontend/src/components/workflow/useNodeSpecs.ts
Normal file
61
frontend/src/components/workflow/useNodeSpecs.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取并缓存 /api/node-types 节点目录(每会话一次,模块级缓存)。
|
||||||
|
* 对齐 dograh 的 useNodeSpecs:规格随后端 + 刷新生效。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { nodeTypesApi, type NodeTypesResponse } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
toRuntimeSpec,
|
||||||
|
type NodeSpecMap,
|
||||||
|
type RuntimeNodeSpec,
|
||||||
|
} from "./specs";
|
||||||
|
|
||||||
|
let _cache: NodeTypesResponse | null = null;
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
specs: RuntimeNodeSpec[];
|
||||||
|
byType: NodeSpecMap;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function build(data: NodeTypesResponse | null): {
|
||||||
|
specs: RuntimeNodeSpec[];
|
||||||
|
byType: NodeSpecMap;
|
||||||
|
} {
|
||||||
|
const specs = (data?.nodeTypes ?? []).map(toRuntimeSpec);
|
||||||
|
const byType: NodeSpecMap = {};
|
||||||
|
for (const spec of specs) byType[spec.type] = spec;
|
||||||
|
return { specs, byType };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNodeSpecs(): State {
|
||||||
|
const fetched = useRef(false);
|
||||||
|
const [state, setState] = useState<State>(() => {
|
||||||
|
const { specs, byType } = build(_cache);
|
||||||
|
return { specs, byType, loading: !_cache, error: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (_cache || fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
|
|
||||||
|
nodeTypesApi
|
||||||
|
.list()
|
||||||
|
.then((data) => {
|
||||||
|
_cache = data;
|
||||||
|
const { specs, byType } = build(data);
|
||||||
|
setState({ specs, byType, loading: false, error: null });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setState((s) => ({ ...s, loading: false, error: message }));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -172,3 +172,39 @@ export type KnowledgeBase = {
|
|||||||
export const knowledgeBasesApi = {
|
export const knowledgeBasesApi = {
|
||||||
list: () => request<KnowledgeBase[]>("/api/knowledge-bases"),
|
list: () => request<KnowledgeBase[]>("/api/knowledge-bases"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- 工作流节点类型目录 ----------
|
||||||
|
export type NodeFieldSpec = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "textarea" | "switch";
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeConstraints = {
|
||||||
|
minIncoming?: number;
|
||||||
|
maxIncoming?: number;
|
||||||
|
minOutgoing?: number;
|
||||||
|
maxOutgoing?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeSpecDto = {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
accent: string;
|
||||||
|
addable: boolean;
|
||||||
|
constraints: NodeConstraints;
|
||||||
|
fields: NodeFieldSpec[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeTypesResponse = {
|
||||||
|
specVersion: string;
|
||||||
|
nodeTypes: NodeSpecDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nodeTypesApi = {
|
||||||
|
list: () => request<NodeTypesResponse>("/api/node-types"),
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user