From e4811394adf525aaa5d7a4185f909a4df2660c55 Mon Sep 17 00:00:00 2001 From: shaohuzhang1 Date: Mon, 19 Oct 2026 19:44:55 +0800 Subject: [PATCH] feat: Initialize AI generation workflow functionality --- apps/models_provider/api/chat_completion.py | 31 ++ .../chat_completion_serializers.py | 145 ++++++ apps/models_provider/urls.py | 1 + apps/models_provider/views/model.py | 28 ++ ui/src/api/API_README.md | 13 +- ui/src/api/admin/core/request.ts | 3 +- .../admin/system/resource-management/model.ts | 21 +- .../resource-management/tool/tool-workflow.ts | 9 +- .../admin/system/shared-resources/model.ts | 8 +- .../shared-resources/tool/tool-workflow.ts | 9 +- ui/src/api/admin/workspace/model.ts | 8 +- ui/src/api/admin/workspace/tool/workflow.ts | 9 +- ui/src/api/types/application.ts | 15 + ui/src/views/workflow/application/index.vue | 26 +- .../workflow/components/validate-error.ts | 7 + ui/src/views/workflow/tool/index.vue | 54 ++- ui/src/workflow-canvas/WORKFLOW_README.md | 50 +- .../ai-generate/application.ts | 169 +++++++ ui/src/workflow-canvas/ai-generate/common.ts | 199 ++++++++ .../ai-generate/create-agent.ts | 184 +++++++ .../ai-generate/panel/AiGeneratePanel.vue | 215 +++++++++ .../ai-generate/panel/activity.ts | 240 ++++++++++ .../ai-generate/tools/canvas.ts | 288 +++++++++++ .../ai-generate/tools/index.ts | 447 ++++++++++++++++++ .../workflow-canvas/ai-generate/tools/node.ts | 409 ++++++++++++++++ .../ai-generate/tools/result.ts | 72 +++ ui/src/workflow-canvas/ai-generate/type.ts | 215 +++++++++ .../nodes/ai-chat-node/agent.ts | 204 ++++++++ .../nodes/ai-chat-node/index.ts | 3 +- .../nodes/condition-node/agent.ts | 161 +++++++ .../nodes/condition-node/index.ts | 3 +- .../nodes/document-extract-node/agent.ts | 38 ++ .../nodes/document-extract-node/index.ts | 3 +- .../workflow-canvas/nodes/form-node/agent.ts | 200 ++++++++ .../workflow-canvas/nodes/form-node/index.ts | 3 +- .../nodes/image-generate-node/agent.ts | 80 ++++ .../nodes/image-generate-node/index.ts | 2 + .../nodes/image-to-video-node/agent.ts | 83 ++++ .../nodes/image-to-video-node/index.ts | 2 + .../nodes/image-understand-node/agent.ts | 91 ++++ .../nodes/image-understand-node/index.ts | 2 + .../nodes/intent-node/agent.ts | 176 +++++++ .../nodes/intent-node/index.ts | 2 + .../nodes/loop-body-node/index.ts | 2 + .../nodes/loop-body-node/index.vue | 4 +- .../nodes/loop-break-node/agent.ts | 4 + .../nodes/loop-break-node/index.ts | 2 + .../nodes/loop-continue-node/agent.ts | 4 + .../nodes/loop-continue-node/index.ts | 2 + .../workflow-canvas/nodes/loop-node/agent.ts | 60 +++ .../nodes/loop-node/details/index.vue | 25 +- .../workflow-canvas/nodes/loop-node/index.ts | 2 + .../workflow-canvas/nodes/mcp-node/agent.ts | 174 +++++++ .../workflow-canvas/nodes/mcp-node/index.ts | 3 +- .../nodes/parameter-extraction-node/agent.ts | 100 ++++ .../nodes/parameter-extraction-node/index.ts | 3 +- .../nodes/question-node/agent.ts | 88 ++++ .../nodes/question-node/index.ts | 2 + .../workflow-canvas/nodes/reply-node/agent.ts | 73 +++ .../nodes/reply-node/details/index.vue | 15 +- .../workflow-canvas/nodes/reply-node/index.ts | 3 +- .../nodes/reply-node/index.vue | 29 +- .../nodes/reranker-node/agent.ts | 83 ++++ .../nodes/reranker-node/index.ts | 3 +- .../nodes/search-document-node/agent.ts | 124 +++++ .../nodes/search-document-node/index.ts | 3 +- .../nodes/search-knowledge-node/agent.ts | 102 ++++ .../nodes/search-knowledge-node/index.ts | 2 + .../nodes/speech-to-text-node/agent.ts | 68 +++ .../nodes/speech-to-text-node/index.ts | 2 + .../nodes/text-to-speech-node/agent.ts | 68 +++ .../nodes/text-to-speech-node/index.ts | 2 + .../nodes/text-to-video-node/agent.ts | 79 ++++ .../nodes/text-to-video-node/index.ts | 2 + .../nodes/tool-custom-node/agent.ts | 105 ++++ .../nodes/tool-custom-node/index.ts | 3 +- .../nodes/tool-lib-node/agent.ts | 85 ++++ .../nodes/tool-lib-node/index.ts | 3 +- .../tool-workflow-lib-node/details/index.vue | 7 +- .../nodes/variable-aggregation-node/agent.ts | 112 +++++ .../nodes/variable-aggregation-node/index.ts | 3 +- .../nodes/variable-assign-node/agent.ts | 94 ++++ .../nodes/variable-assign-node/index.ts | 3 +- .../nodes/variable-splitting-node/agent.ts | 90 ++++ .../nodes/variable-splitting-node/index.ts | 3 +- .../nodes/video-understand-node/agent.ts | 91 ++++ .../nodes/video-understand-node/index.ts | 2 + .../store/api/system-resource/index.ts | 1 + .../store/api/system-shared/index.ts | 1 + .../store/api/workspace/index.ts | 1 + ui/src/workflow-canvas/store/index.ts | 17 +- 91 files changed, 5622 insertions(+), 60 deletions(-) create mode 100644 apps/models_provider/api/chat_completion.py create mode 100644 apps/models_provider/serializers/chat_completion_serializers.py create mode 100644 ui/src/views/workflow/components/validate-error.ts create mode 100644 ui/src/workflow-canvas/ai-generate/application.ts create mode 100644 ui/src/workflow-canvas/ai-generate/common.ts create mode 100644 ui/src/workflow-canvas/ai-generate/create-agent.ts create mode 100644 ui/src/workflow-canvas/ai-generate/panel/AiGeneratePanel.vue create mode 100644 ui/src/workflow-canvas/ai-generate/panel/activity.ts create mode 100644 ui/src/workflow-canvas/ai-generate/tools/canvas.ts create mode 100644 ui/src/workflow-canvas/ai-generate/tools/index.ts create mode 100644 ui/src/workflow-canvas/ai-generate/tools/node.ts create mode 100644 ui/src/workflow-canvas/ai-generate/tools/result.ts create mode 100644 ui/src/workflow-canvas/ai-generate/type.ts create mode 100644 ui/src/workflow-canvas/nodes/ai-chat-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/condition-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/document-extract-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/form-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/image-generate-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/image-to-video-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/image-understand-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/intent-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/loop-break-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/loop-continue-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/loop-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/mcp-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/parameter-extraction-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/question-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/reply-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/reranker-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/search-document-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/search-knowledge-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/speech-to-text-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/text-to-speech-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/text-to-video-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/tool-custom-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/tool-lib-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/variable-aggregation-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/variable-assign-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/variable-splitting-node/agent.ts create mode 100644 ui/src/workflow-canvas/nodes/video-understand-node/agent.ts diff --git a/apps/models_provider/api/chat_completion.py b/apps/models_provider/api/chat_completion.py new file mode 100644 index 00000000000..94373630b0d --- /dev/null +++ b/apps/models_provider/api/chat_completion.py @@ -0,0 +1,31 @@ +# coding=utf-8 +from common.mixins.api_mixin import APIMixin +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter +from models_provider.serializers.chat_completion_serializers import ChatCompletionSerializers + + +class ChatCompletionAPI(APIMixin): + @staticmethod + def get_parameters(): + return [ + OpenApiParameter( + name="workspace_id", + description=_("workspace id"), + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, # type: ignore + required=True, + ), + OpenApiParameter( + name="model_id", + description=_("model id"), + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, # type: ignore + required=True, + ), + ] + + @staticmethod + def get_request(): + return ChatCompletionSerializers diff --git a/apps/models_provider/serializers/chat_completion_serializers.py b/apps/models_provider/serializers/chat_completion_serializers.py new file mode 100644 index 00000000000..c4c44c2df51 --- /dev/null +++ b/apps/models_provider/serializers/chat_completion_serializers.py @@ -0,0 +1,145 @@ +# coding=utf-8 +""" +@project: MaxKB +@file: chat_completion_serializers.py +@desc: 通用流式补全(支持 function calling),供前端工作流 AI 生成的 agent 循环逐轮调用 +""" + +import json + +from common.config.embedding_config import ModelManage +from common.exception.app_exception import AppApiException +from common.utils.common import to_stream_response_simple +from common.utils.logger import maxkb_logger +from django.db.models import QuerySet +from django.utils.translation import gettext_lazy as _ +from langchain_core.messages import convert_to_messages +from models_provider.base_model_provider import ModelTypeConst +from models_provider.models import Model +from models_provider.tools import ( + get_model, + get_model_default_params, + get_model_instance_by_model_workspace_id, + reset_model_params, +) +from rest_framework import serializers + +# messages 条数护栏,迭代上限由前端 agent 循环控制 +MAX_MESSAGES = 400 + + +class ChatCompletionSerializers(serializers.Serializer): + workspace_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Workspace ID")) + model_id = serializers.CharField(required=True, label=_("Model")) + model_params_setting = serializers.JSONField(required=False, default=dict, label=_("Model params setting")) + # OpenAI 格式消息:role 为 system/user/assistant/tool,assistant 可带 tool_calls 与 reasoning_content + messages = serializers.ListField(child=serializers.DictField(), required=True, label=_("Chat context")) + # OpenAI function 定义:[{type:'function', function:{name, description, parameters}}] + tools = serializers.ListField(child=serializers.DictField(), required=False, default=list, label=_("Tools")) + + def is_valid(self, *, raise_exception=False): + super().is_valid(raise_exception=True) + messages = self.data.get("messages") or [] + if not messages: + raise AppApiException(400, _("Messages cannot be empty")) + if len(messages) > MAX_MESSAGES: + raise AppApiException(400, _("Too many messages")) + for message in messages: + if message.get("role") not in ("system", "user", "assistant", "tool"): + raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct.")) + model = QuerySet(Model).filter(id=self.data.get("model_id")).first() + if model is None: + raise AppApiException(500, _("Model does not exist")) + if model.model_type != ModelTypeConst.LLM.name: + raise AppApiException(400, _("Model does not exists or is not an LLM model")) + # 共享/系统级模型:workspace_id 为字符串 "None",不校验传入的工作空间;否则必须属于传入的工作空间 + if ( + model.workspace_id != "None" + and self.data.get("workspace_id") + and self.data.get("workspace_id") != model.workspace_id + ): + raise AppApiException(403, _("Model is not visible to the workspace")) + return model + + def _get_model_instance(self, model): + model_id = self.data.get("model_id") + model_params_setting = self.data.get("model_params_setting") or {} + if model.workspace_id == "None": + default_model_params = get_model_default_params(model) + model_params = reset_model_params(default_model_params, **model_params_setting) + return ModelManage.get_model(model_id, lambda _id: get_model(model, **model_params)) + return get_model_instance_by_model_workspace_id( + model_id=model_id, workspace_id=model.workspace_id, **model_params_setting + ) + + def chat_completion(self): + model = self.is_valid(raise_exception=True) + messages = convert_to_messages([dict(m) for m in self.data.get("messages")]) + tools = [dict(t) for t in self.data.get("tools") or []] + + def event(payload: dict) -> str: + return "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n" + + def process(): + try: + model_instance = self._get_model_instance(model) + llm = model_instance.bind_tools(tools) if tools else model_instance + content = "" + reasoning = "" + finish_reason = None + usage = None + # 按流式 index 聚合工具调用:id/name 只在首片出现,arguments 逐片拼接 + tool_calls_by_index: dict = {} + for chunk in llm.stream(messages): + text = chunk.content if isinstance(chunk.content, str) else "" + if text: + content += text + yield event({"type": "delta", "content": text}) + reasoning_delta = (chunk.additional_kwargs or {}).get("reasoning_content") + if reasoning_delta: + reasoning += reasoning_delta + yield event({"type": "reasoning", "content": reasoning_delta}) + for tool_chunk in getattr(chunk, "tool_call_chunks", None) or []: + index = tool_chunk.get("index") or 0 + call = tool_calls_by_index.setdefault(index, {"id": "", "name": "", "arguments": ""}) + if tool_chunk.get("id"): + call["id"] = tool_chunk["id"] + if tool_chunk.get("name"): + call["name"] = call["name"] or tool_chunk["name"] + args_delta = tool_chunk.get("args") or "" + call["arguments"] += args_delta + if call["id"]: + yield event( + {"type": "tool_call", "id": call["id"], "name": call["name"], "arguments": args_delta} + ) + metadata = chunk.response_metadata or {} + if metadata.get("finish_reason"): + finish_reason = metadata["finish_reason"] + if getattr(chunk, "usage_metadata", None): + usage = dict(chunk.usage_metadata) + tool_calls = [ + { + "id": call["id"], + "type": "function", + "function": {"name": call["name"], "arguments": call["arguments"] or "{}"}, + } + for _index, call in sorted(tool_calls_by_index.items()) + if call["id"] and call["name"] + ] + yield event( + { + "type": "completion", + "content": content, + "reasoningKey": "reasoning_content" if reasoning else None, + "reasoningContent": reasoning or None, + "toolCalls": tool_calls, + "finishReason": finish_reason or ("tool_calls" if tool_calls else "stop"), + "usage": usage, + } + ) + except Exception as e: + maxkb_logger.error(f"chat completion error: {e}", exc_info=True) + yield event({"type": "error", "message": str(e)}) + yield "data: [DONE]\n\n" + + return to_stream_response_simple(process()) diff --git a/apps/models_provider/urls.py b/apps/models_provider/urls.py index 324cf2bc216..82262cf6070 100644 --- a/apps/models_provider/urls.py +++ b/apps/models_provider/urls.py @@ -17,6 +17,7 @@ path('workspace//model_list', views.ModelList.as_view()), path('workspace//model//model_params_form', views.ModelSetting.ModelParamsForm.as_view()), path('workspace//model//prompt_generate', views.ModelPromptGenerateView.as_view()), + path('workspace//model//chat_completion', views.ModelChatCompletionView.as_view()), path('workspace//model/', views.ModelSetting.Operate.as_view()), path('workspace//model//pause_download', views.ModelSetting.PauseDownload.as_view()), path('workspace//model//meta', views.ModelSetting.ModelMeta.as_view()), diff --git a/apps/models_provider/views/model.py b/apps/models_provider/views/model.py index 40283b6bda8..7d13f9499d3 100644 --- a/apps/models_provider/views/model.py +++ b/apps/models_provider/views/model.py @@ -20,9 +20,11 @@ from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema from models_provider.api.model import DefaultModelResponse, GetModelApi, ModelCreateAPI, ModelEditApi, ModelListResponse +from models_provider.api.chat_completion import ChatCompletionAPI from models_provider.api.prompt_generate import PromptGenerateAPI from models_provider.api.provide import ProvideApi from models_provider.models import Model +from models_provider.serializers.chat_completion_serializers import ChatCompletionSerializers from models_provider.serializers.model_serializer import ModelSerializer, WorkspaceSharedModelSerializer from models_provider.serializers.prompt_generate_serializers import PromptGenerateSerializers from rest_framework.request import Request @@ -413,3 +415,29 @@ def post(self, request: Request, workspace_id: str, model_id: str): return PromptGenerateSerializers( data={**request.data, "workspace_id": workspace_id, "model_id": model_id} ).generate_prompt() + + +class ModelChatCompletionView(APIView): + authentication_classes = [TokenAuth] + + @extend_schema( + methods=["POST"], + summary=_("Chat completion"), + description=_("Stream chat completion with function calling"), + operation_id=_("Chat completion"), # type: ignore + parameters=ChatCompletionAPI.get_parameters(), + request=ChatCompletionAPI.get_request(), + responses=None, + tags=[_("Model")], # type: ignore + ) + @has_permissions( + PermissionConstants.MODEL_READ.get_workspace_model_permission(), + PermissionConstants.MODEL_READ.get_workspace_permission_workspace_manage_role(), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + RoleConstants.USER.get_workspace_role(), + ) + def post(self, request: Request, workspace_id: str, model_id: str): + # agent 循环每轮都会调用,不记操作日志 + return ChatCompletionSerializers( + data={**request.data, "workspace_id": workspace_id, "model_id": model_id} + ).chat_completion() diff --git a/ui/src/api/API_README.md b/ui/src/api/API_README.md index c077e200417..83bd4520ddd 100644 --- a/ui/src/api/API_README.md +++ b/ui/src/api/API_README.md @@ -193,7 +193,8 @@ Action、Drawer 或 Dialog。复用方直接使用 `typeof XxxApi` 约束完整 智能体、工具、知识库 Workflow API 分别维护 application_version、tool_version、knowledge_version, 使用同名 getWorkflowVersions/putWorkflowVersion,类型为 WorkflowVersion/WorkflowVersionPayload。 前端版本标题上限 64、更新说明上限 1000,提交字段为 name、publish_desc, -不将资源 desc 当作版本说明。智能体后端已支持 publish_desc;工具和知识库版本当前只处理 name。 +不将资源 desc 当作版本说明。智能体和工具后端已支持 publish_desc;知识库版本当前只处理 name。 +智能体与工具的 putXxxPublish 提交 publish_name、publish_desc,由发布弹框填写。 三个版本列表均未返回历史默认模型配置,预览与恢复保留当前配置。 `admin/store.ts` 的 getStoreApplicationList、getStoreToolWorkflowList、getStoreKnowledgeList 查询三类模板, @@ -269,6 +270,16 @@ System 接口不依赖工作空间 ID。 `useWorkflowStore(resourceScope).postPromptGenerate` 调用;三个范围的 Store 适配器分别引用对应的 Model API,直接转发生成请求,不使用查询缓存或请求去重。 +### 流式补全 + +Workspace、System 资源管理和 System 共享资源均在对应范围的 `model.ts` 提供 +`postChatCompletion(modelId, payload, signal)`,请求结构为 `ChatCompletionPayload`(OpenAI 格式的 messages 与 tools)。 +路径为 `/workspace//model//chat_completion`、`/system/resource/model//chat_completion` 与 +`/system/shared/model//chat_completion`;当前后端仅实现 Workspace 接口,System 接口需部署提供对应扩展。 +均返回原始 SSE Response,事件为 `delta`、`reasoning`、`tool_call`、`completion` 与 `error`;`signal` 用于中止在途请求, +`postStream` 的第四个可选参数透传给 `fetch`。工作流 AI 生成通过 `useWorkflowStore(resourceScope).postChatCompletion` 调用, +Store 直接转发,不缓存或去重。 + ### 知识库维护 Workspace、System 资源管理、System 共享各自的 knowledge.ts 保留真实能力,Workspace 共享只查询。 diff --git a/ui/src/api/admin/core/request.ts b/ui/src/api/admin/core/request.ts index 674a94f8df9..5ed369db5e4 100644 --- a/ui/src/api/admin/core/request.ts +++ b/ui/src/api/admin/core/request.ts @@ -192,7 +192,7 @@ export async function downloadRequest(url: string, method: string, data?: unknow } /** 发送 POST 请求并返回可逐块读取的原始响应。 */ -export function postStream(base: string, path: string, data?: unknown): Promise { +export function postStream(base: string, path: string, data?: unknown, signal?: AbortSignal): Promise { const { auth, user } = useStore() const headers: Record = { 'Content-Type': 'application/json' } if (auth.token) { @@ -205,6 +205,7 @@ export function postStream(base: string, path: string, data?: unknown): Promise< method: 'POST', headers, body: data === undefined ? undefined : JSON.stringify(data), + signal, }) } diff --git a/ui/src/api/admin/system/resource-management/model.ts b/ui/src/api/admin/system/resource-management/model.ts index 722abbf0333..a92b708be58 100644 --- a/ui/src/api/admin/system/resource-management/model.ts +++ b/ui/src/api/admin/system/resource-management/model.ts @@ -1,6 +1,6 @@ import { del, get, post, postStream, put } from '../../core/request' import type { ParamsPage, ResponsePage } from '../../core/types' -import type { Dict, DynamicFormField, ModelItem, ModelPayload, PromptGeneratePayload } from '@/api/types' +import type { Dict, DynamicFormField, ModelItem, ModelPayload, ChatCompletionPayload, PromptGeneratePayload } from '@/api/types' import { ADMIN_API_BASE_PATH } from '@/api/constants' const prefix = '/system/resource/model' @@ -35,10 +35,25 @@ const getModelParamsForm = (modelId: string) => get(`${prefi const putModelParamsForm = (modelId: string, payload: DynamicFormField[]) => put(`${prefix}/${modelId}/model_params_form`, payload) - /** 使用指定模型流式生成或优化系统提示词。 */ const postPromptGenerate = (modelId: string, payload: PromptGeneratePayload) => { return postStream(ADMIN_API_BASE_PATH, `/system/resource/model/${modelId}/prompt_generate`, { ...payload }) } -export default { getModelPage, getModelListWithShared, getModelDetail, postModel, putModel, deleteModel, getModelParamsForm, putModelParamsForm, postPromptGenerate } +/** 流式补全(支持 function calling),返回原始 SSE Response;signal 用于中止在途请求。 */ +const postChatCompletion = (modelId: string, payload: ChatCompletionPayload, signal?: AbortSignal) => { + return postStream(ADMIN_API_BASE_PATH, `/system/resource/model/${modelId}/chat_completion`, { ...payload }, signal) +} + +export default { + getModelPage, + getModelListWithShared, + getModelDetail, + postModel, + putModel, + deleteModel, + getModelParamsForm, + putModelParamsForm, + postPromptGenerate, + postChatCompletion, +} diff --git a/ui/src/api/admin/system/resource-management/tool/tool-workflow.ts b/ui/src/api/admin/system/resource-management/tool/tool-workflow.ts index b496561f3ce..7d1cc3d5faf 100644 --- a/ui/src/api/admin/system/resource-management/tool/tool-workflow.ts +++ b/ui/src/api/admin/system/resource-management/tool/tool-workflow.ts @@ -29,9 +29,12 @@ const putToolWorkflow = (toolId: string, payload: ToolWorkflowPayload) => { return put(`${prefix}/${toolId}/workflow`, payload) } -/** 发布工具工作流。 */ -const putToolWorkflowPublish = (toolId: string) => { - return put(`${prefix}/${toolId}/publish`) +/** 发布工具工作流,并记录版本标题和更新说明。 */ +const putToolWorkflowPublish = (toolId: string, publishName: string, publishDesc?: string) => { + return put, boolean>(`${prefix}/${toolId}/publish`, { + publish_name: publishName, + publish_desc: publishDesc, + }) } /** 调试已保存的工具工作流,返回 SSE 响应。 */ diff --git a/ui/src/api/admin/system/shared-resources/model.ts b/ui/src/api/admin/system/shared-resources/model.ts index 3c8927b8a9e..3e02d56d03a 100644 --- a/ui/src/api/admin/system/shared-resources/model.ts +++ b/ui/src/api/admin/system/shared-resources/model.ts @@ -1,5 +1,5 @@ import { del, get, post, postStream, put } from '../../core/request' -import type { Dict, DynamicFormField, ModelItem, ModelPayload, PromptGeneratePayload } from '@/api/types' +import type { Dict, DynamicFormField, ModelItem, ModelPayload, ChatCompletionPayload, PromptGeneratePayload } from '@/api/types' import { ADMIN_API_BASE_PATH } from '@/api/constants' const prefix = '/system/shared/model' @@ -61,6 +61,11 @@ const postPromptGenerate = (modelId: string, payload: PromptGeneratePayload) => return postStream(ADMIN_API_BASE_PATH, `/system/shared/model/${modelId}/prompt_generate`, { ...payload }) } +/** 流式补全(支持 function calling),返回原始 SSE Response;signal 用于中止在途请求。 */ +const postChatCompletion = (modelId: string, payload: ChatCompletionPayload, signal?: AbortSignal) => { + return postStream(ADMIN_API_BASE_PATH, `/system/shared/model/${modelId}/chat_completion`, { ...payload }, signal) +} + export default { deleteModel, getModelDetail, @@ -70,6 +75,7 @@ export default { getModelParamsForm, postModel, postPromptGenerate, + postChatCompletion, putModel, putModelParamsForm, putPauseModelDownload, diff --git a/ui/src/api/admin/system/shared-resources/tool/tool-workflow.ts b/ui/src/api/admin/system/shared-resources/tool/tool-workflow.ts index 1bebb652f41..c6d7251a242 100644 --- a/ui/src/api/admin/system/shared-resources/tool/tool-workflow.ts +++ b/ui/src/api/admin/system/shared-resources/tool/tool-workflow.ts @@ -29,9 +29,12 @@ const putToolWorkflow = (toolId: string, payload: ToolWorkflowPayload) => { return put(`${prefix}/${toolId}/workflow`, payload) } -/** 发布工具工作流。 */ -const putToolWorkflowPublish = (toolId: string) => { - return put(`${prefix}/${toolId}/publish`) +/** 发布工具工作流,并记录版本标题和更新说明。 */ +const putToolWorkflowPublish = (toolId: string, publishName: string, publishDesc?: string) => { + return put, boolean>(`${prefix}/${toolId}/publish`, { + publish_name: publishName, + publish_desc: publishDesc, + }) } /** 调试已保存的工具工作流,返回 SSE 响应。 */ diff --git a/ui/src/api/admin/workspace/model.ts b/ui/src/api/admin/workspace/model.ts index 632a33591d4..eac2f14df55 100644 --- a/ui/src/api/admin/workspace/model.ts +++ b/ui/src/api/admin/workspace/model.ts @@ -1,7 +1,7 @@ import { del, get, post, postStream, put } from '../core/request' import type { Dict, DynamicFormField, ModelPayload, ModelItem } from '@/api/types' import { getWorkspaceId } from '@/utils/resource-context' -import type { PromptGeneratePayload } from '@/api/types' +import type { ChatCompletionPayload, PromptGeneratePayload } from '@/api/types' import { ADMIN_API_BASE_PATH } from '@/api/constants' const getPrefix = () => { @@ -72,6 +72,11 @@ const postPromptGenerate = (modelId: string, payload: PromptGeneratePayload) => }) } +/** 流式补全(支持 function calling),返回原始 SSE Response;signal 用于中止在途请求。 */ +const postChatCompletion = (modelId: string, payload: ChatCompletionPayload, signal?: AbortSignal) => { + return postStream(ADMIN_API_BASE_PATH, `/workspace/${getWorkspaceId()}/model/${modelId}/chat_completion`, { ...payload }, signal) +} + export default { deleteModel, getModelDetail, @@ -79,6 +84,7 @@ export default { getModelListWithShared, getModelMeta, getModelParamsForm, + postChatCompletion, postModel, postPromptGenerate, putModel, diff --git a/ui/src/api/admin/workspace/tool/workflow.ts b/ui/src/api/admin/workspace/tool/workflow.ts index 182e721089e..8be9091a75a 100644 --- a/ui/src/api/admin/workspace/tool/workflow.ts +++ b/ui/src/api/admin/workspace/tool/workflow.ts @@ -33,9 +33,12 @@ const putToolWorkflow = (toolId: string, payload: ToolWorkflowPayload) => { return put(`${getPrefix()}/${toolId}/workflow`, payload) } -/** 发布工具工作流。 */ -const putToolWorkflowPublish = (toolId: string) => { - return put(`${getPrefix()}/${toolId}/publish`) +/** 发布工具工作流,并记录版本标题和更新说明。 */ +const putToolWorkflowPublish = (toolId: string, publishName: string, publishDesc?: string) => { + return put, boolean>(`${getPrefix()}/${toolId}/publish`, { + publish_name: publishName, + publish_desc: publishDesc, + }) } /** 调试已保存的工具工作流,返回 SSE 响应。 */ diff --git a/ui/src/api/types/application.ts b/ui/src/api/types/application.ts index eeeb907daf2..1d6e796e417 100644 --- a/ui/src/api/types/application.ts +++ b/ui/src/api/types/application.ts @@ -105,6 +105,21 @@ export interface PromptGeneratePayload { model_params_setting?: Record } +/** 通用流式补全的 OpenAI 格式消息(工作流 AI 生成的 agent 循环使用)。 */ +export interface ChatCompletionMessage { + role: 'system' | 'user' | 'assistant' | 'tool' + content: string + tool_calls?: Array<{ id: string; type: string; function: { name: string; arguments: string } }> + tool_call_id?: string + reasoning_content?: string +} + +export interface ChatCompletionPayload { + messages: ChatCompletionMessage[] + tools?: Array<{ type: 'function'; function: { name: string; description: string; parameters: Record } }> + model_params_setting?: Record +} + /** 智能体模板复用工作流模板的展示与下载元数据。 */ export type ApplicationStoreTemplate = WorkflowStoreTemplate diff --git a/ui/src/views/workflow/application/index.vue b/ui/src/views/workflow/application/index.vue index a4f4c2f5bc3..6ec16871dac 100644 --- a/ui/src/views/workflow/application/index.vue +++ b/ui/src/views/workflow/application/index.vue @@ -13,6 +13,7 @@ import SystemResourceWorkflowApi from '@/api/admin/system/resource-management/ap import type { ApplicationDetail, ApplicationStoreTemplate, DefaultModelSettingPayload, WorkflowVersion, WorkflowVersionPayload } from '@/api/types' import { MsgConfirm, MsgError, MsgSuccess } from '@/utils/message' import WorkflowCanvas from '@/workflow-canvas/index.vue' +import AiGeneratePanel from '@/workflow-canvas/ai-generate/panel/AiGeneratePanel.vue' import { defaultApplicationNodes } from '@/workflow-canvas/config/node-mapping' import { WorkflowMode } from '@/workflow-canvas/types' import ButtonDefaultModelSetting from '../components/default-model-setting/ButtonDefaultModelSetting.vue' @@ -21,6 +22,7 @@ import ButtonPublishHistory from './ButtonPublishHistory.vue' import ButtonTemplateStore from './ButtonTemplateStore.vue' import DebugPanel from '@/conversation-panel/view/debug/index.vue' import EditPublishVersionDialog from '../components/publish-history/EditPublishVersionDialog.vue' +import { showWorkflowValidateError } from '../components/validate-error' import { getResourceScope, isSystemResource } from '@/utils/resource-context.ts' import { goBack } from './navigation' @@ -40,6 +42,14 @@ const route = useRoute() const applicationId = route.params.applicationId as string const workflowRef = useTemplateRef>('workflowRef') +const aiGeneratePanelRef = useTemplateRef>('aiGeneratePanelRef') + +/* AI 生成:在当前画布上按需求搭建并配置工作流 */ +function handleOpenAiGenerate() { + if (loading.value || historyVisible.value) return + closeDebug() + aiGeneratePanelRef.value?.open() +} /* 智能体工作流加载与保存 */ const applicationDetail = ref() @@ -231,9 +241,7 @@ function handleOpenPublish() { closeDebug() publishDialogRef.value?.open() }) - .catch(() => { - // 画布校验失败时保留编辑态,错误由画布提示。 - }) + .catch(showWorkflowValidateError) .finally(() => { loading.value = false }) @@ -332,6 +340,12 @@ onBeforeUnmount(() => stopAutoSave()) diff --git a/ui/src/workflow-canvas/WORKFLOW_README.md b/ui/src/workflow-canvas/WORKFLOW_README.md index 706bbcaf5d9..588dbe0c969 100644 --- a/ui/src/workflow-canvas/WORKFLOW_README.md +++ b/ui/src/workflow-canvas/WORKFLOW_README.md @@ -21,6 +21,7 @@ src/workflow-canvas/ ├── component/ # NodeAdd、NodeSearch、节点设置及 NodeControl 画布控制栏 ├── config/ # 节点数据、映射、常量及预留的本地化配置 ├── Execution-details/ # 执行详情:顶层分发入口与公共卡壳,节点内容由 nodes/*/details/ 提供 +├── ai-generate/ # 工作流 AI 生成:agent 引擎、画布适配、建图工具、节点配置 agent 与生成面板 ├── core/ # 稳定的画布内核与所有节点共用的基础能力 │ ├── edge/ # 普通边、循环边及边删除按钮 │ └── node-container/ # 节点容器、锚点按钮及私有的条件、操作下拉组件 @@ -134,7 +135,7 @@ LogicFlow 的节点拖拽;仅拦截 `mousedown` 无法隔离当前版本的 Po `useWorkflowStore(resourceScope)` 是画布资源接口适配器,不是 Pinia Store。它自动收集 `store/api/*/index.ts`, 查询方法按范围、方法与查询参数缓存结果并复用在途请求;`store.force.xxx()` 跳过已完成缓存,仍复用同键在途请求。 -`postPromptGenerate` 是流式生成方法,始终直接转发到对应范围的 Model API,不缓存或去重; +`postPromptGenerate` 与 `postChatCompletion` 是流式方法,始终直接转发到对应范围的 Model API,不缓存或去重; 通过普通 Store 或 `force` 调用都会发起新请求,返回独立的 Response 流。 模型选项统一调用 `store.getModelListWithShared(query)`,由对应范围 API 查询;Workspace 和 System 资源管理合并普通与共享模型,System 共享范围仅返回标记 `source: 'shared'` 的共享模型。 @@ -158,7 +159,7 @@ System 资源管理合并普通与共享模型,System 共享范围仅返回标 ### `nodes/` 每个已实现节点使用一个目录,目录中包含 LogicFlow 注册文件 `index.ts` 和节点视图 -`index.vue`。节点注册由 `index.vue` 中的 `import.meta.glob('./nodes/**/index.ts')` 自动收集, +`index.vue`。支持 AI 生成的节点另有 `agent.ts`,由 `index.ts` 默认导出的 `agent` 字段注册(见 `ai-generate/`)。节点注册由 `index.vue` 中的 `import.meta.glob('./nodes/**/index.ts')` 自动收集, 不要再维护一份逐项导入列表。 ### 通用节点规则 @@ -515,3 +516,48 @@ MkFormList 的排序、增删均以 `cloneDeep` 回写;MkTable 保留普通行 页面发布、历史、自动保存、调试和返回确认的当前行为统一见 View 文档,验证规则见 [AGENTS.md](../../AGENTS.md#验证)。 + +### `ai-generate/` + +工作流 AI 生成按用户需求在画布上规划、建节点、连线并配置字段,当前接入应用工作流(含循环体)。 + +- `create-agent.ts` 运行 agent 循环:每轮经 `useWorkflowStore(resourceScope).postChatCompletion` 流式补全,同轮工具并发执行, + 工具报错回喂模型自愈,委派工具返回可取消的 Executor,父 agent 停止时级联停止子 agent。 +- `tools/` 放工具与工具函数:`tools/index.ts` 为建图工具,`tools/canvas.ts` 为画布适配层,`tools/result.ts` 为工具结果, + `tools/node.ts` 为节点配置 agent 共用的工具与工具函数。 +- `tools/result.ts` 定义两层结果。工具层(agent 继续循环):`SuccessResult`(回喂模型)、`ErrorResult`(错误回喂模型自愈,工具卡标为失败); + agent 层(立即结束所在 agent):`AgentSuccessResult`(status=done,data 作为交付上抛)、`AgentErrorResult` + (status=error,message 作为失败原因上抛、不再续修,工具卡标为失败);同轮两者并存时失败优先。 + 普通返回值按 `SuccessResult` 处理,抛出的异常按 `ErrorResult` 处理。 +- `tools/canvas.ts` 是唯一读写 LogicFlow 的适配层:画布以 `lf_id` 标识(主画布 `main`,循环体为循环节点 id, + 经循环体模型的 `getLoopLogicFlow()` 取得);节点属性经 `reactive(model)` 写回,保证节点表单同步刷新。 + 可引用字段与 `NodeCascader` 口径一致,提示词变量为 `{{节点名称.字段}}`、全局变量为 `{{global.字段}}`。 +- `tools/index.ts` 提供建图工具;`configure_node(nodeId, args, requirement, returns?)` 相当于调用节点函数:按节点 `params` 校验实参 + (形参存在、引用在上游、array / object 形参的上游返回类型对得上),带 `x-bind` 的形参由框架直接写入 `node_data`, + 再起该节点的配置子 agent 按 `requirement` 写入配置。 +- 节点配置 agent 放在各节点目录的 `agent.ts`(如 `nodes/ai-chat-node/agent.ts`),default 导出 `NodeAgentModule`, + 并由该节点 `index.ts` 默认导出的 `agent` 字段注册,与 `details` 同一份节点配置。每个节点被当作一个函数, + `params` / `config` / `returns` 均为 JSON Schema(OpenAI function calling 的 parameters 格式): + - `description`:函数说明(做什么、何时用、与上下游怎么配合); + - `params`:形参,运行时由上游提供,实参为引用 `[节点ID, 字段]`,由父 agent 连线决定。属性上的 `x-bind` 表示实参写入的 + `node_data` 字段;未声明的(提示词素材、判断条件里的变量)交由配置 agent 使用。带 `items` 且无 `x-bind` 的 array 形参可接收多个引用; + - `config`:配置,搭画布时确定的常量,同时作为配置 agent `update` 工具的 parameters; + - `returns`:返回,固定字段写在 `properties`(键为节点 `config.fields` 中的字段名);按配置生成的字段用 `additionalProperties` + 说明,`x-from` 指向生成它的配置项,无动态返回时为 `false`。 + - 渲染:父 agent 与规划器的节点目录渲染为函数签名(`x-` 扩展字段不渲染),据此组合节点设计画布,不使用固定范式; + - `useAgent(context)`:自建该节点配置 agent 的 prompt 与工具,`context.args` 为已绑定的实参、`context.requirement` 为配置要求、 + `context.expectedReturns` 为要求产出的动态返回字段。写回遵循节点视图的 `node_data` 协议,经 `createUpdateTool` + 写入后调用节点视图注册的 `validate()` 并核对要求的返回字段,通过即返回 `AgentSuccessResult` 交付节点的输出字段(`config.fields`), + 不通过返回 `ErrorResult` 让模型续修;建图连线以交付的输出字段为准; + - 分支:判断器与意图识别按分支 id 逐个增、改、删(`get_branches` / `add_branch` / `update_branch` / + `delete_branch`),删除分支时清理其出口连线,最后由 `validate` 工具收尾。 +- `tools/index.ts` 经 `import.meta.glob('../../nodes/*/index.ts')` 读取各节点的 `agent` 字段(`nodeAgentFor`), + 未提供 `agent` 的节点不进入 AI 可选目录;`tools/node.ts` 提供 `useNodeAgent`、Break / Continue 共用的条件 agent、 + `get_field_options`、`update` / `validate` 工具、写入后校验、模型来源(`modelSlot` 按模型类型与字段前缀适配 LLM / IMAGE / TTI / STT / TTS 等)、 + `get_models`、知识库查询、条件项与节点函数签名渲染(`renderNodeFunction`)等共用能力。 +- `application.ts` 维护应用工作流父 agent 与规划器;`panel/` 为生成面板, + 由 `views/workflow/application/` 挂载并通过 `WorkflowCanvas.getLogicFlow()` 取得主画布。 + 面板把用户需求(QUESTION)与 agent 事件(TEXT / REASONING / TOOL / GROUP / FAILURE)转成 conversation-panel 内容项, + 经 `ContentList` 渲染;委派工具为一个 `GROUP`,子 agent 的执行过程放在其 `children` 中。 + +生成结果只改画布,不自动保存;调试日志写入 localStorage 的 `workflow-ai-generate-debug-log`。 diff --git a/ui/src/workflow-canvas/ai-generate/application.ts b/ui/src/workflow-canvas/ai-generate/application.ts new file mode 100644 index 00000000000..68e446432dd --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/application.ts @@ -0,0 +1,169 @@ +import { getMenuNodes } from '@/workflow-canvas/node-menu/menu' +import { WorkflowNodeType } from '@/workflow-canvas/types' +import { loopModeOf } from './tools/canvas' +import { inheritMessages, useAgentConfig } from './common' +import { createAgent } from './create-agent' +import { AgentSuccessResult, ErrorResult } from './tools/result' +import { renderNodeFunction } from './tools/node' +import { + addEdgeTool, + addNodeTool, + configureNodeTool, + deleteEdgeTool, + deleteNodeTool, + getCanvasDetailTool, + getNodeAnchorsTool, + layoutTool, + nodeAgentFor, + getToolsTool, + validateTool, +} from './tools' +import type { AgentBindings, AgentTool, RunnableAgent } from './type' + +/** + * 应用工作流(Application)的父 agent:把每个节点当作一个函数(说明、形参、返回字段), + * 先委派规划器据需求组合出蓝图,再亲手建图(含循环体,经 lf_id 跨画布),字段配置委派给各节点配置 agent,最后校验。 + */ + +function nodeLine(type: string, label: string): string { + return renderNodeFunction(type, label, nodeAgentFor(type)!) +} + +/** 可用节点函数:只列已接入 AI 生成的节点,循环体专属节点单列 */ +function renderCatalog(bindings: AgentBindings): string { + const supported = (nodes: Array<{ type: string; label: string }>) => nodes.filter((node) => nodeAgentFor(node.type)) + const mainGroups = getMenuNodes(bindings.workflowMode).map((group) => ({ label: group.label, nodes: supported(group.list) })) + const mainTypes = new Set(mainGroups.flatMap((group) => group.nodes.map((node) => node.type))) + const loopOnly = supported(getMenuNodes(loopModeOf(bindings.workflowMode)).flatMap((group) => group.list)).filter( + (node) => !mainTypes.has(node.type), + ) + const sections = mainGroups + .filter((group) => group.nodes.length) + .map((group) => `【${group.label}】\n${group.nodes.map((node) => nodeLine(node.type, node.label)).join('\n\n')}`) + sections.push(`【工具】\n${nodeLine(WorkflowNodeType.ToolLib, '工具')}`) + if (loopOnly.length) + sections.push(`【仅循环体内可用(lf_id=所在循环节点 id)】\n${loopOnly.map((node) => nodeLine(node.type, node.label)).join('\n\n')}`) + return sections.join('\n\n') +} + +const GUIDE = `你是 MaxKB「智能体工作流」的搭建助手,在 LogicFlow 画布上按用户需求用建图工具亲手把流程搭出来(含循环体)。下方「可用节点」把每个节点写成一个函数:注释为说明(做什么、何时用、与上下游怎么配合)与可配置项,形参为它需要上游提供的数据,返回值为可被 [节点ID, 字段] 引用的输出字段([字段名] 为按配置生成的动态字段)。搭画布就是把这些函数组合成一段程序:判断器 / 意图识别是分支,循环节点是 for,前一个节点的返回值是后一个节点的实参。 + +约定: +- 画布固定节点:base-node(基本信息)与 start-node(开始,id 固定 "start-node"),不可新增/删除/配置。start-node 输出 question(用户问题);全局变量用 ["global", 字段] 引用。 +- 引用格式:[节点ID, 字段];提示词里的变量写 {{节点名称.字段}}(如 {{开始.question}})。节点名称以 add_node 返回的 name 为准。 +- 画布用 lf_id 标识:主画布 "main"(或省略);循环体 = 该循环节点 id。每个循环体固定有 loop-start-node(输出 index、item)。 + +【从零搭建】 +1. 先调 plan:规划器据需求组合节点函数,产出蓝图 { blueprint, notes }。之后照蓝图建。 +2. 用 add_node(type, source_anchor_id) 建节点并接上游:第一个节点接 "start-node";普通链式连接传上游节点 id;判断器 / 意图识别要先 configure_node 配好分支(requirement 写清分几路、各路条件或分类),再 get_node_anchors 取各分支出口接下游;多分支汇合用 add_edge 补入边。 +3. 循环:主画布 add_node 建 loop-node 并 configure_node 配循环方式;再带 lf_id=该循环节点 id 往循环体建(首节点 source_anchor_id="loop-start-node"),循环体内节点用 lf_id=该循环 id 配置;无限循环必须放 loop-break-node。 +4. 节点建好接好后 configure_node(nodeId, args, requirement, returns?) 配置,相当于调用这个函数: + - args 传实参:形参名 → 上游引用 [节点ID, 字段](多引用形参传列表),按蓝图的数据流给,框架会校验并绑定; + - requirement 写配置要求(提示词只写要求、模型 / 知识库只写意图),节点配置 agent 会生成内容并查出真实资源; + - 节点有 [字段名] 动态返回时,returns 列出要求产出的字段名(如参数提取的 city、date),下游按这些字段引用。 + 同一轮可并发配置多个节点;但实参要引用上游的动态返回字段时,先配上游,按其交付的 outputs 再配下游。 +5. 流程末端必须是能作为结束的节点(AI 对话、指定回复、表单等),面向用户的最终回答 is_result=true,中间加工步骤 is_result=false。 +6. 全部完成后调 validate,problems 非空就按提示修复,直到 qualified=true,再调 layout 整理布局。 + +【续聊修改】先 get_canvas_detail 看现状,再用 add_node / add_edge / delete_node / delete_edge / configure_node 就地修改(循环体内传对应 lf_id),改完同样 validate。 + +完成后用一段话向用户总结搭了什么、还需要用户补充什么(如尚未创建知识库)即结束。` + +function buildPrompt(bindings: AgentBindings): string { + return `${GUIDE}\n\n======== 可用节点 ========\n${renderCatalog(bindings)}` +} + +const PLAN_GUIDE = `你是 MaxKB「智能体工作流」的规划器。下方【可用节点】把每个节点写成一个函数(说明、形参、返回字段)。根据用户需求,像写程序一样组合这些函数,产出一份执行蓝图供搭建 agent 照着建。你只规划、不改画布。 + +思路: +- 先拆解需求:最终要给用户什么输出;为得到它需要哪些中间数据;每份数据由哪个节点函数的返回值提供。 +- 按数据依赖排顺序:每个节点的形参都要有上游返回值(或开始节点的 question、全局变量)来提供,类型要对得上(如循环的 array 需要上游返回 array)。 +- 需要按条件 / 分类走不同处理时用分支(condition-node / intent-node),需要逐项处理一组数据时用循环(loop-node)。 +- 只放需求实际需要的节点;末端必须是能输出给用户的节点,最终回答 is_result=true、中间步骤 false。 +- 需要外部工具时先 get_tools 看有哪些可用工具。需求含糊时按最常见理解规划,并在 notes 写明假设。 + +blueprint 元素 { type, intent, args?, returns?, children?, branches? }: +- intent:一句话写清它在链路里做什么与配置要求(AI 对话的角色 / 目标 / 输出、检索什么、判断条件、表单收集哪些字段); +- args:每个形参由谁提供,写成 形参名 → "上游节点.返回字段"(如 question → "开始.question"、materials → ["知识库检索.data", "开始.question"]); +- returns:有 [字段名] 动态返回的节点,列出要产出的字段名与类型(如参数提取 { city: string, date: string }); +- loop-node 用 children 装循环体;condition-node / intent-node 用 branches([{cond, children}],最后一个为 else / 其他)。 +想清楚后调 submit_plan 交出,交出即结束。` + +const PLAN_TASK = '结合用户这次的需求,组合可用节点函数设计出蓝图,调用 submit_plan 交出。' + +function submitPlanTool(): AgentTool { + return { + name: 'submit_plan', + description: '交出规划:blueprint 节点树,notes 设计思路与关键假设。交出即结束。', + parameters: { + type: 'object', + properties: { + blueprint: { type: 'array', items: { type: 'object' } }, + notes: { type: 'string' }, + }, + required: ['blueprint'], + }, + apply: (args) => + new AgentSuccessResult({ + blueprint: Array.isArray(args.blueprint) ? args.blueprint : [], + notes: typeof args.notes === 'string' ? args.notes : '', + }), + } +} + +/** 规划委派:起规划器子 agent(继承父对话),产出蓝图后上抛给父 agent */ +function planTool(bindings: AgentBindings): AgentTool { + return { + name: 'plan', + description: '(从零搭建的第一步)委派规划器据需求组合节点函数并产出蓝图,返回 { blueprint, notes }。', + parameters: { type: 'object', properties: {} }, + apply: () => { + const plannerBindings: AgentBindings = { + ...bindings, + position: [...(bindings.position ?? []), 'plan'], + messages: inheritMessages(bindings), + maxIterations: 10, + } + const planner = createAgent( + useAgentConfig( + { + prompt: `${PLAN_GUIDE}\n\n======== 可用节点 ========\n${renderCatalog(bindings)}`, + tools: [getToolsTool(bindings), submitPlanTool()], + }, + plannerBindings, + ), + ) + const promise = planner.run(PLAN_TASK).then((result) => { + const plan = result.data as { blueprint?: unknown[] } | undefined + if (result.status !== 'done' || !plan || typeof plan !== 'object' || !plan.blueprint?.length) { + return new ErrorResult(typeof result.data === 'string' && result.data ? result.data : '规划未产出蓝图') + } + return plan + }) + return { promise, cancel: planner.stop } + }, + } +} + +/** 应用工作流父 agent 的装配工厂 */ +export function useApplicationAgent(bindings: AgentBindings): RunnableAgent { + return useAgentConfig( + { + prompt: buildPrompt(bindings), + tools: [ + planTool(bindings), + getCanvasDetailTool(bindings), + getNodeAnchorsTool(bindings), + getToolsTool(bindings), + addNodeTool(bindings), + addEdgeTool(bindings), + deleteNodeTool(bindings), + deleteEdgeTool(bindings), + configureNodeTool(bindings), + validateTool(bindings), + layoutTool(bindings), + ], + }, + bindings, + ) +} diff --git a/ui/src/workflow-canvas/ai-generate/common.ts b/ui/src/workflow-canvas/ai-generate/common.ts new file mode 100644 index 00000000000..a018f412197 --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/common.ts @@ -0,0 +1,199 @@ +import type { ChatCompletionPayload } from '@/api/types' +import { useWorkflowStore } from '@/workflow-canvas/store' +import type { AgentBindings, AgentConfig, AgentMessage, AgentTool, Call, RunnableAgent, SseEvent, StreamItem, ToolCall } from './type' + +/** + * 跨模块共用的基础设施:agent 装配(useAgentConfig)、工具 schema、SSE 流式补全、 + * 委派时的历史裁剪(inheritMessages)与调试日志。 + */ + +const DEFAULT_MAX_ITERATIONS = 60 + +/** + * 装配可运行 agent:统一负责唯一一条 system(本 agent prompt + 整体目标背景,继承来的父 system 并入其前), + * 非 system 历史原样接在后面;结果回写 bindings.messages,供本 agent 的委派工具裁剪给下一级。 + */ +export function useAgentConfig(config: AgentConfig, bindings: AgentBindings): RunnableAgent { + const ownPrompt = bindings.context + ? `${config.prompt}\n\n【本次生成的整体目标(背景,供理解链路;你仍只做自己那份)】\n${bindings.context}` + : config.prompt + const inherited = bindings.messages ?? [] + const inheritedSystem = inherited + .filter((message) => message.role === 'system') + .map((message) => message.content) + .filter(Boolean) + .join('\n\n') + const prompt = inheritedSystem ? `${inheritedSystem}\n\n──────\n\n${ownPrompt}` : ownPrompt + const messages: AgentMessage[] = [{ role: 'system', content: prompt }, ...inherited.filter((message) => message.role !== 'system')] + bindings.messages = messages + return { + prompt, + messages, + tools: config.tools, + modelId: bindings.modelId, + resourceScope: bindings.resourceScope, + call: bindings.call, + position: bindings.position ?? [], + maxIterations: bindings.maxIterations ?? DEFAULT_MAX_ITERATIONS, + } +} + +/** + * 从父 agent 历史裁出子 agent 的初始历史:去掉在途这一轮尚未回填的 tool 结果, + * 并把触发委派的 assistant 消息只留正文,避免悬空的 tool_calls 让下一轮请求被拒。 + */ +export function inheritMessages(bindings: AgentBindings): AgentMessage[] { + const source = bindings.messages ?? [] + let end = source.length + while (end > 0 && source[end - 1]?.role === 'tool') end-- + const trimmed = source.slice(0, end) + const last = trimmed.at(-1) + if (last?.role === 'assistant' && last.tool_calls) { + if (last.content) trimmed[trimmed.length - 1] = { role: 'assistant', content: last.content } + else trimmed.pop() + } + return trimmed +} + +export function toToolSchemas(tools: AgentTool[]): NonNullable { + return tools.map((tool) => ({ + type: 'function', + function: { name: tool.name, description: tool.description, parameters: tool.parameters }, + })) +} + +// ============================================================ +// SSE 流式补全 +// ============================================================ + +/** 增量解析 SSE 文本块,返回完整事件与剩余未完整的 buffer。 */ +export function parseSseChunk(buffer: string): { events: Array; rest: string } { + const events: Array = [] + const parts = buffer.split('\n\n') + const rest = parts.pop() ?? '' + for (const part of parts) { + for (const line of part.split('\n')) { + if (!line.startsWith('data: ')) continue + const data = line.slice(6).trim() + if (data === '[DONE]') { + events.push('DONE') + continue + } + try { + events.push(JSON.parse(data) as SseEvent) + } catch { + // 忽略无法解析的行 + } + } + } + return { events, rest } +} + +/** + * 经工作流 Store 按资源范围调用流式补全接口,逐条把条目交给 call.onNext;正常结束 onComplete(), + * error 事件、非 SSE 响应、网络错误或缺少 completion 一律 onComplete(error)。 + */ +export async function chatCompletion( + resourceScope: string, + modelId: string, + payload: ChatCompletionPayload, + call: Call, + signal: AbortSignal, +): Promise { + try { + const response = await useWorkflowStore(resourceScope).postChatCompletion(modelId, payload, signal) + const contentType = response.headers.get('Content-Type') ?? '' + if (!contentType.includes('text/event-stream')) { + const result = (await response.json().catch(() => null)) as { message?: string } | null + throw new Error(result?.message ?? `HTTP ${response.status}`) + } + if (!response.body) throw new Error(`HTTP ${response.status}`) + + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + let completed = false + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const { events, rest } = parseSseChunk(buffer) + buffer = rest + for (const event of events) { + if (event === 'DONE') continue + if (event.type === 'delta') call.onNext({ type: 'delta', content: String(event.content ?? '') }) + else if (event.type === 'reasoning') call.onNext({ type: 'reasoning', content: String(event.content ?? '') }) + else if (event.type === 'tool_call') { + call.onNext({ type: 'tool_call', id: String(event.id), name: String(event.name ?? ''), arguments: String(event.arguments ?? '') }) + } else if (event.type === 'completion') { + completed = true + call.onNext({ + type: 'completion', + result: { + content: String(event.content ?? ''), + toolCalls: (event.toolCalls as ToolCall[] | undefined) ?? [], + finishReason: (event.finishReason as string | undefined) ?? null, + usage: (event.usage as Record | undefined) ?? null, + reasoningKey: (event.reasoningKey as string | undefined) ?? null, + reasoningContent: (event.reasoningContent as string | undefined) ?? null, + }, + }) + } else if (event.type === 'error') { + throw new Error(String(event.message ?? '模型调用失败')) + } + } + } + if (!completed) throw new Error('流式响应缺少完整结果') + call.onComplete?.() + } catch (error) { + call.onComplete?.(error instanceof Error ? error : new Error(String(error))) + } +} + +// ============================================================ +// 调试日志 +// ============================================================ + +/** + * 生成过程调试日志(写 localStorage,每条带 position 层级路径),仅供排查;异常一律吞掉。 + * 浏览器控制台复制:copy(localStorage.getItem('workflow-ai-generate-debug-log')) + */ +const DEBUG_KEY = 'workflow-ai-generate-debug-log' +const DEBUG_MAX_ENTRIES = 3000 +const DEBUG_MAX_CHARS = 3_000_000 + +export function clipForLog(value: unknown, limit = 6000): unknown { + if (value === undefined) return undefined + let text: string + try { + text = typeof value === 'string' ? value : JSON.stringify(value) + } catch { + text = String(value) + } + return text.length <= limit ? value : `${text.slice(0, limit)}…(+${text.length - limit}字)` +} + +export function logDebug(entry: Record): void { + try { + const raw = localStorage.getItem(DEBUG_KEY) + const entries: unknown[] = raw ? JSON.parse(raw) : [] + entries.push({ ts: new Date().toISOString().slice(11, 23), ...entry }) + while (entries.length > DEBUG_MAX_ENTRIES) entries.shift() + let serialized = JSON.stringify(entries) + while (serialized.length > DEBUG_MAX_CHARS && entries.length > 1) { + entries.shift() + serialized = JSON.stringify(entries) + } + localStorage.setItem(DEBUG_KEY, serialized) + } catch { + // localStorage 不可用或超限时忽略 + } +} + +export function clearDebugLog(): void { + try { + localStorage.removeItem(DEBUG_KEY) + } catch { + // ignore + } +} diff --git a/ui/src/workflow-canvas/ai-generate/create-agent.ts b/ui/src/workflow-canvas/ai-generate/create-agent.ts new file mode 100644 index 00000000000..c1266c1b251 --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/create-agent.ts @@ -0,0 +1,184 @@ +import { chatCompletion, clipForLog, logDebug, toToolSchemas } from './common' +import { AgentErrorResult, AgentSuccessResult, ErrorResult, isFailureResult, toToolResult, type ToolResult } from './tools/result' +import type { + AgentHandle, + AgentMessage, + AgentPayload, + AgentResult, + AgentTool, + Call, + ChatCompletionResult, + Executor, + RunnableAgent, + StreamItem, + ToolCall, +} from './type' + +/** 工具结果回喂模型的截断上限,避免上下文膨胀 */ +const TOOL_RESULT_LIMIT = 8000 +/** tool_end 事件里结果摘要的展示上限 */ +const RESULT_SUMMARY_LIMIT = 200 + +function truncate(text: string, limit: number): string { + return text.length > limit ? `${text.slice(0, limit)}…(truncated)` : text +} + +function stringify(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value ?? null) +} + +function isExecutor(value: unknown): value is Executor { + const candidate = value as Executor | null + return !!candidate && typeof candidate === 'object' && typeof candidate.cancel === 'function' && candidate.promise instanceof Promise +} + +function toExecutor(value: unknown): Executor { + if (isExecutor(value)) return value + return { promise: Promise.resolve(value), cancel: () => {} } +} + +/** + * 运行一台 agent:每轮流式补全 → 并发执行本轮工具 → 回填结果,直到模型不再调工具、 + * 某工具返回 AgentSuccessResult / AgentErrorResult 结束、被停止或超过轮次。工具返回 ErrorResult 或抛错时把错误回喂模型自愈。 + */ +export function createAgent(agent: RunnableAgent): AgentHandle { + const { tools, modelId, resourceScope, call, position, maxIterations } = agent + const controller = new AbortController() + const stopCallbacks: Array<() => void> = [] + const messages: AgentMessage[] = agent.messages + + const emit = (payload: AgentPayload) => call.onNext({ ...payload, position }) + + const stop = () => { + controller.abort() + stopCallbacks.forEach((callback) => callback()) + } + + const finish = (result: AgentResult, error?: Error): AgentResult => { + logDebug({ kind: 'end', pos: position, status: result.status, data: clipForLog(stringify(result.data ?? ''), 800) }) + if (result.status === 'error') emit({ type: 'error', message: stringify(result.data ?? '') }) + else if (result.status === 'done') emit({ type: 'done', data: result.data }) + call.onComplete?.(error) + return result + } + + async function run(requirement: string): Promise { + if (!modelId) return finish({ status: 'error', data: '缺少模型' }) + logDebug({ kind: 'run', pos: position, requirement: clipForLog(requirement, 800) }) + messages.push({ role: 'user', content: requirement }) + + try { + for (let iteration = 1; ; iteration++) { + if (controller.signal.aborted) return finish({ status: 'stopped' }) + if (iteration > maxIterations) return finish({ status: 'error', data: `超过最大轮次 ${maxIterations}` }) + + const response = await runTurn(resourceScope, modelId, messages, tools, emit, controller.signal) + const toolCalls = response.toolCalls ?? [] + const assistantMessage: AgentMessage = { role: 'assistant', content: response.content || '' } + // thinking 模型要求思考内容按原字段名回传,否则下一轮请求会被拒绝 + if (response.reasoningKey && response.reasoningContent) assistantMessage[response.reasoningKey] = response.reasoningContent + if (toolCalls.length) assistantMessage.tool_calls = toolCalls + messages.push(assistantMessage) + + if (!toolCalls.length) return finish({ status: 'done', data: response.content || '' }) + logDebug({ + kind: 'turn', + pos: position, + iteration, + say: clipForLog(response.content, 500), + calls: toolCalls.map((toolCall) => toolCall.function.name), + }) + + // 同一轮的调用彼此看不到结果,不存在返回值依赖;画布改动在 apply 内同步完成,可安全并发。 + const results = await Promise.all(toolCalls.map((toolCall) => runToolCall(toolCall, tools, emit, stopCallbacks))) + if (controller.signal.aborted) return finish({ status: 'stopped' }) + // 同轮既有成功又有失败的 agent 结果时,失败优先 + const failed = results.find((result): result is AgentErrorResult => result instanceof AgentErrorResult) + if (failed) return finish({ status: 'error', data: failed.message }) + const succeeded = results.find((result): result is AgentSuccessResult => result instanceof AgentSuccessResult) + if (succeeded) return finish({ status: 'done', data: succeeded.data }) + + toolCalls.forEach((toolCall, index) => { + const limit = tools.find((tool) => tool.name === toolCall.function.name)?.resultLimit ?? TOOL_RESULT_LIMIT + logDebug({ + kind: 'call', + pos: position, + name: toolCall.function.name, + args: clipForLog(toolCall.function.arguments), + result: clipForLog(results[index]!.toContent()), + }) + messages.push({ role: 'tool', tool_call_id: toolCall.id, content: truncate(stringify(results[index]!.toContent()), limit) }) + }) + } + } catch (error) { + if (controller.signal.aborted) return finish({ status: 'stopped' }) + const err = error instanceof Error ? error : new Error(String(error)) + return finish({ status: 'error', data: err.message }, err) + } + } + + return { run, stop } +} + +/** 跑一轮补全:把流式条目翻成事件下发,并返回本轮完整结果。 */ +function runTurn( + resourceScope: string, + modelId: string, + messages: AgentMessage[], + tools: AgentTool[], + emit: (payload: AgentPayload) => void, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let result: ChatCompletionResult | null = null + const startedToolIds = new Set() + const sink: Call = { + onNext: (item) => { + if (item.type === 'delta') { + if (item.content) emit({ type: 'narration', delta: item.content }) + } else if (item.type === 'reasoning') { + if (item.content) emit({ type: 'reasoning', delta: item.content }) + } else if (item.type === 'tool_call') { + if (!startedToolIds.has(item.id)) { + startedToolIds.add(item.id) + emit({ type: 'tool_start', id: item.id, name: item.name }) + } + if (item.arguments) emit({ type: 'tool_args', id: item.id, delta: item.arguments }) + } else { + result = item.result + } + }, + onComplete: (error) => { + if (error) reject(error) + else if (result) resolve(result) + else reject(new Error('本轮结束但未收到完整结果')) + }, + } + chatCompletion(resourceScope, modelId, { messages, tools: toToolSchemas(tools) }, sink, signal) + }) +} + +/** 执行单个工具调用,结果统一成 ToolResult;异常不抛出,转成 ErrorResult 让模型纠偏。 */ +async function runToolCall( + toolCall: ToolCall, + tools: AgentTool[], + emit: (payload: AgentPayload) => void, + stopCallbacks: Array<() => void>, +): Promise { + const name = toolCall.function.name + try { + const args = toolCall.function.arguments ? (JSON.parse(toolCall.function.arguments) as Record) : {} + const tool = tools.find((candidate) => candidate.name === name) + if (!tool) throw new Error(`未知工具: ${name}`) + const executor = toExecutor(tool.apply(args)) + stopCallbacks.push(executor.cancel) + const result = toToolResult(await executor.promise) + const ok = !isFailureResult(result) + emit({ type: 'tool_end', id: toolCall.id, ok, result: truncate(stringify(result.toContent()), RESULT_SUMMARY_LIMIT) }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + emit({ type: 'tool_end', id: toolCall.id, ok: false, result: message }) + return new ErrorResult(message) + } +} diff --git a/ui/src/workflow-canvas/ai-generate/panel/AiGeneratePanel.vue b/ui/src/workflow-canvas/ai-generate/panel/AiGeneratePanel.vue new file mode 100644 index 00000000000..b3225f13b90 --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/panel/AiGeneratePanel.vue @@ -0,0 +1,215 @@ + + + diff --git a/ui/src/workflow-canvas/ai-generate/panel/activity.ts b/ui/src/workflow-canvas/ai-generate/panel/activity.ts new file mode 100644 index 00000000000..eb95d5a236f --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/panel/activity.ts @@ -0,0 +1,240 @@ +import { ref } from 'vue' +import { v7 as uuidv7 } from 'uuid' +import { aggregators } from '@/conversation-panel/core/aggregators' +import type { AgentEvent } from '../type' + +/** + * 生成面板的活动:把扁平 AgentEvent 流(每条带 position)转成 conversation-panel 的内容项, + * 由 ContentList 渲染,数据格式与对话流一致。 + * 用户每次发送的需求作为 QUESTION 项插在顶层;同一 position 的正文 / 思考增量续写到同一条 TEXT / REASONING; + * 普通工具为平铺的 TOOL(相邻工具由 ContentList 折叠为 GROUP_TOOL); + * 委派工具为一个 GROUP,其起的子 agent 活动按 position 逐层包进 GROUP.children,经 aggregators 合并。 + * 内容项 id 用 uuid v7,与对话流一样按 id 排序即生成先后。 + */ + +/** conversation-panel 内容项(字段与 content/items/*.vue 读取的一致) */ +export interface ContentItem { + type: string + id: string + [field: string]: unknown +} + +const TOOL_LABELS: Record = { + plan: '规划流程', + get_canvas_detail: '查看画布', + get_node_anchors: '查看节点出口', + get_tools: '查询工具', + add_node: '添加节点', + add_edge: '添加连线', + delete_node: '删除节点', + delete_edge: '删除连线', + configure_node: '配置节点', + validate: '校验工作流', + layout: '整理布局', + submit_plan: '提交规划', + get_field_options: '查看可引用变量', + get_models: '查询模型', + get_knowledge_list: '查询知识库', + get_skill_resources: '查询技能资源', + update: '写入配置', + get_branches: '查看分支', + add_branch: '新增分支', + update_branch: '修改分支', + delete_branch: '删除分支', +} + +export function toolLabel(name: string) { + return TOOL_LABELS[name] ?? name +} + +/** 会起子 agent 的委派工具:展示为 GROUP,承载子 agent 活动 */ +const DELEGATE_TOOLS = new Set(['plan', 'configure_node']) + +/** 一次工具调用的本地记录:普通工具对应 TOOL 内容项,委派工具对应 GROUP */ +interface ToolCall { + tool: string + contentId: string + /** 发起调用的作用域(position 拼成的键) */ + scopeKey: string + argumentsText: string + delegate: boolean + /** 委派 GROUP 已被子作用域认领 */ + claimed: boolean +} + +function parseArgs(text: string): Record | undefined { + try { + return JSON.parse(text || '{}') as Record + } catch { + return undefined + } +} + +/** 工具标题:中文名,配置节点补节点 id,添加节点补名称 / 类型 */ +function toolTitle(toolCall: ToolCall): string { + const label = toolLabel(toolCall.tool) + const args = parseArgs(toolCall.argumentsText) + const hint = toolCall.tool === 'add_node' ? (args?.name ?? args?.type) : args?.nodeId + return hint ? `${label} · ${String(hint)}` : label +} + +/** 委派工具起的子作用域,其 position 末段的语义键 */ +function spawnKeyOf(toolCall: ToolCall): string | undefined { + if (toolCall.tool === 'configure_node') { + const nodeId = parseArgs(toolCall.argumentsText)?.nodeId + return typeof nodeId === 'string' ? nodeId : undefined + } + if (toolCall.tool === 'plan') return 'plan' + return undefined +} + +function tail(text: string) { + const trimmed = text.trim() + return trimmed.length > 40 ? `…${trimmed.slice(-40)}` : trimmed +} + +function parentKeyOf(scopeKey: string) { + const index = scopeKey.lastIndexOf('/') + return index >= 0 ? scopeKey.slice(0, index) : '' +} + +export function createActivity() { + const activityItems = ref([]) + const lastActivity = ref('') + /** 子作用域 → 承载它的 GROUP id */ + const groupIdByScope = new Map() + const toolCallById = new Map() + /** 各作用域最后一条正文 / 思考:同类增量续写到同一项 */ + const lastStreamByScope = new Map() + + function reset() { + activityItems.value = [] + groupIdByScope.clear() + toolCallById.clear() + lastStreamByScope.clear() + lastActivity.value = '' + } + + /** 顶层按 id + type 合并;GROUP 分片经其聚合函数逐层合并 children */ + function mergeTop(chunk: ContentItem) { + const aggregator = aggregators[chunk.type] + const contents = activityItems.value + const index = contents.findIndex((content) => content.id === chunk.id && content.type === chunk.type) + if (index >= 0) contents[index] = aggregator ? aggregator(contents[index], chunk) : { ...contents[index], ...chunk } + else contents.push(aggregator ? aggregator({}, chunk) : chunk) + } + + /** 把分片按作用域链逐层包进 GROUP 外壳,再合并到顶层 */ + function emit(scopeKey: string, chunk: ContentItem) { + ensureScope(scopeKey) + let wrapped = chunk + for (let key = scopeKey; key !== ''; key = parentKeyOf(key)) { + wrapped = { type: 'GROUP', id: groupIdByScope.get(key)!, children: [wrapped] } + } + mergeTop(wrapped) + } + + /** 本作用域的正文 / 思考到此为止:思考标为结束(思考卡随之收起),后续增量另起新项 */ + function settle(scopeKey: string) { + const last = lastStreamByScope.get(scopeKey) + if (!last) return + lastStreamByScope.delete(scopeKey) + if (last.type === 'REASONING') emit(scopeKey, { type: 'REASONING', id: last.id, status: 'SUCCESS' }) + } + + /** + * 子作用域绑定到起它的委派 GROUP。同一节点可能被多次 configure_node,position 相同但属于不同轮委派: + * 父作用域每出现一个尚未认领的匹配委派,就视为新一轮并改绑;找不到时单列一个以语义键为名的 GROUP 承载。 + */ + function ensureScope(scopeKey: string) { + if (scopeKey === '') return + const parentKey = parentKeyOf(scopeKey) + const segment = scopeKey.slice(scopeKey.lastIndexOf('/') + 1) + ensureScope(parentKey) + const delegation = [...toolCallById.values()] + .reverse() + .find((toolCall) => toolCall.delegate && !toolCall.claimed && toolCall.scopeKey === parentKey && spawnKeyOf(toolCall) === segment) + if (delegation) { + delegation.claimed = true + groupIdByScope.set(scopeKey, delegation.contentId) + lastStreamByScope.delete(scopeKey) + return + } + if (groupIdByScope.has(scopeKey)) return + const groupId = uuidv7() + groupIdByScope.set(scopeKey, groupId) + settle(parentKey) + emit(parentKey, { type: 'GROUP', id: groupId, name: segment, children: [] }) + } + + /** 用户发送的需求:顶层追加一条 QUESTION 项,之后的生成内容另起新项 */ + function pushQuestion(text: string) { + settle('') + mergeTop({ type: 'QUESTION', id: uuidv7(), content: text }) + } + + function pushEvent(event: AgentEvent) { + const scopeKey = event.position.join('/') + ensureScope(scopeKey) + if (event.type === 'narration' || event.type === 'reasoning') { + const type = event.type === 'narration' ? 'TEXT' : 'REASONING' + let last = lastStreamByScope.get(scopeKey) + if (last?.type !== type) { + settle(scopeKey) + last = { type, id: uuidv7(), text: '' } + lastStreamByScope.set(scopeKey, last) + } + last.text += event.delta + emit(scopeKey, { type, id: last.id, content: event.delta, ...(type === 'REASONING' ? { status: 'RUNNING' } : {}) }) + lastActivity.value = tail(last.text) + } else if (event.type === 'tool_start') { + settle(scopeKey) + const delegate = DELEGATE_TOOLS.has(event.name) + const toolCall: ToolCall = { tool: event.name, contentId: uuidv7(), scopeKey, argumentsText: '', delegate, claimed: false } + toolCallById.set(event.id, toolCall) + const name = toolLabel(event.name) + emit( + scopeKey, + delegate + ? { type: 'GROUP', id: toolCall.contentId, name, status: 'RUNNING', children: [] } + : { type: 'TOOL', id: toolCall.contentId, name, arguments: '', content: '', status: 'RUNNING' }, + ) + lastActivity.value = name + } else if (event.type === 'tool_args') { + const toolCall = toolCallById.get(event.id) + if (!toolCall) return + toolCall.argumentsText += event.delta + const name = toolTitle(toolCall) + emit( + toolCall.scopeKey, + toolCall.delegate + ? { type: 'GROUP', id: toolCall.contentId, name } + : { type: 'TOOL', id: toolCall.contentId, name, arguments: event.delta }, + ) + } else if (event.type === 'tool_end') { + const toolCall = toolCallById.get(event.id) + if (!toolCall) return + const status = event.ok ? 'SUCCESS' : 'FAIL' + // 委派的返回值由子 agent 的正文体现;失败时把错误作为组内最后一条 FAILURE + emit( + toolCall.scopeKey, + toolCall.delegate + ? { + type: 'GROUP', + id: toolCall.contentId, + status, + children: event.ok ? [] : [{ type: 'FAILURE', id: uuidv7(), content: event.result }], + } + : { type: 'TOOL', id: toolCall.contentId, status, content: event.result }, + ) + } else if (event.type === 'done') { + settle(scopeKey) + } else if (event.type === 'error') { + settle(scopeKey) + // 子 agent 的失败已体现在其委派 GROUP 里,只有顶层失败单列一条错误 + if (scopeKey === '') emit('', { type: 'FAILURE', id: uuidv7(), content: event.message }) + } + } + + return { activityItems, lastActivity, pushQuestion, pushEvent, reset } +} diff --git a/ui/src/workflow-canvas/ai-generate/tools/canvas.ts b/ui/src/workflow-canvas/ai-generate/tools/canvas.ts new file mode 100644 index 00000000000..23ca0800995 --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/tools/canvas.ts @@ -0,0 +1,288 @@ +import { nextTick, reactive } from 'vue' +import { cloneDeep } from 'lodash' +import type LogicFlow from '@logicflow/core' +import type { BaseNodeModel, Model } from '@logicflow/core' +import type { ModelConfig } from '@/api/types' +import type { WorkflowNodeModel } from '@/workflow-canvas/core/workflow-node' +import { getMenuNodes } from '@/workflow-canvas/node-menu/menu' +import { WorkflowMode, WorkflowNodeType, type ShapeItem, type WorkflowNodeField } from '@/workflow-canvas/types' + +/** + * MaxKB 画布适配层:AI 生成通过这里读写 LogicFlow 图,屏蔽主画布 / 循环体画布的差异。 + * 画布用 lf_id 标识:主画布为 "main",循环体为所属循环节点的 id。 + */ + +export const MAIN_LF_ID = 'main' + +/** 画布固定节点:不可新增、删除或由 AI 配置 */ +export const FIXED_NODE_TYPES: string[] = [ + WorkflowNodeType.Base, + WorkflowNodeType.Start, + WorkflowNodeType.LoopStartNode, + WorkflowNodeType.LoopBodyNode, +] + +type LoopBodyModel = BaseNodeModel & { getLoopLogicFlow?: () => LogicFlow | null; loopLayout?: () => unknown } +type BranchNodeData = { branch?: Array<{ id: string; type?: string; content?: string; isOther?: boolean }> } +type DagreExtension = { dagre?: { layout?: () => Promise | unknown } } + +export interface CanvasRef { + lf: LogicFlow + lfId: string + mode: WorkflowMode + /** 循环层深(主画布为 -1,最外层循环体为 0) */ + depth: number +} + +export interface OutgoingAnchor { + anchorId: string + label: string +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export function loopModeOf(mode: WorkflowMode): WorkflowMode { + if (mode === WorkflowMode.Knowledge) return WorkflowMode.KnowledgeLoop + if (mode === WorkflowMode.Tool) return WorkflowMode.ToolLoop + return WorkflowMode.ApplicationLoop +} + +export function getNode(lf: LogicFlow, nodeId: string): WorkflowNodeModel | undefined { + return (lf.graphModel.getNodeModelById(nodeId) as unknown as WorkflowNodeModel | undefined) ?? undefined +} + +export function nodeNameOf(node: BaseNodeModel): string { + return String(node.properties.stepName ?? node.type) +} + +/** 取循环节点挂载的循环体画布;循环体在 Vue 挂载后才创建,最多等待 3 秒 */ +export async function getLoopBodyLf(lf: LogicFlow, loopNodeId: string): Promise { + for (let attempt = 0; attempt < 30; attempt++) { + const body = lf.graphModel.getNodeOutgoingNode(loopNodeId).find((node) => String(node.type) === WorkflowNodeType.LoopBodyNode) as + | LoopBodyModel + | undefined + const bodyLf = body?.getLoopLogicFlow?.() + if (bodyLf) return bodyLf + await sleep(100) + } + return null +} + +/** 广度遍历主画布及所有已展开的循环体画布 */ +export async function listCanvases(rootLf: LogicFlow, rootMode: WorkflowMode): Promise { + const canvases: CanvasRef[] = [{ lf: rootLf, lfId: MAIN_LF_ID, mode: rootMode, depth: -1 }] + for (let index = 0; index < canvases.length; index++) { + const canvas = canvases[index]! + for (const node of canvas.lf.graphModel.nodes) { + if (String(node.type) !== WorkflowNodeType.LoopNode) continue + const bodyLf = await getLoopBodyLf(canvas.lf, node.id) + if (bodyLf) canvases.push({ lf: bodyLf, lfId: node.id, mode: loopModeOf(rootMode), depth: canvas.depth + 1 }) + } + } + return canvases +} + +/** 按 lf_id 取画布;循环 id 会在所有层级里查找 */ +export async function resolveCanvas(rootLf: LogicFlow, rootMode: WorkflowMode, lfId?: string): Promise { + const target = !lfId || lfId === MAIN_LF_ID ? MAIN_LF_ID : lfId + const canvas = (await listCanvases(rootLf, rootMode)).find((item) => item.lfId === target) + if (canvas) return canvas + return { error: `找不到画布 lf_id=${lfId}:主画布传 "main",循环体传所属循环节点 id(循环节点需处于展开状态)` } +} + +/** 按节点 id 在所有画布中定位节点所在画布 */ +export async function findNodeCanvas(rootLf: LogicFlow, rootMode: WorkflowMode, nodeId: string): Promise { + return (await listCanvases(rootLf, rootMode)).find((canvas) => !!getNode(canvas.lf, nodeId)) +} + +/** 节点在当前画布模式下可新增时返回菜单模板 */ +export function findNodeTemplate(mode: WorkflowMode, type: string): ShapeItem | undefined { + return getMenuNodes(mode) + .flatMap((group) => group.list) + .find((node) => node.type === type) +} + +/** + * 写回节点属性。节点视图拿到的是 reactive(model),经同一代理写入才能触发 Vue 表单刷新。 + */ +export function writeNodeProperties(node: BaseNodeModel, properties: Record) { + const reactiveNode = reactive(node) as BaseNodeModel + Object.entries(properties).forEach(([key, value]) => { + reactiveNode.properties[key] = value + }) +} + +export function readNodeData(node: BaseNodeModel): T { + return cloneDeep((node.properties.node_data ?? {}) as T) +} + +/** 节点的输出口:判断器 / 意图识别按分支,其余为正常输出与(开启时的)异常输出 */ +export function getOutgoingAnchors(node: BaseNodeModel): OutgoingAnchor[] { + const nodeData = node.properties.node_data as BranchNodeData | undefined + if (String(node.type) === WorkflowNodeType.Condition) { + return (nodeData?.branch ?? []).map((branch) => ({ anchorId: `${node.id}_${branch.id}_right`, label: `分支 ${branch.type ?? branch.id}` })) + } + if (String(node.type) === WorkflowNodeType.IntentNode) { + return (nodeData?.branch ?? []).map((branch) => ({ + anchorId: `${node.id}_${branch.id}_right`, + label: `分类 ${branch.isOther ? '其他' : branch.content || branch.id}`, + })) + } + return (node.getDefaultAnchor() as Model.AnchorConfig[]) + .filter((anchor) => anchor.type === 'right') + .map((anchor) => ({ anchorId: String(anchor.id), label: String(anchor.id).endsWith('_exception_right') ? '异常输出' : '正常输出' })) +} + +/** 把上游节点 id 或完整出口锚点 id 解析成出口;不存在时返回错误文本 */ +export function resolveSourceAnchor(lf: LogicFlow, source: string): { node: WorkflowNodeModel; anchorId: string } | { error: string } { + const direct = getNode(lf, source) + if (direct) { + const anchors = getOutgoingAnchors(direct) + if (anchors.length === 1 || [WorkflowNodeType.Condition, WorkflowNodeType.IntentNode].every((type) => type !== String(direct.type))) { + const anchor = anchors.find((item) => item.anchorId === `${direct.id}_right`) ?? anchors[0] + if (anchor) return { node: direct, anchorId: anchor.anchorId } + } + return { error: `节点 ${source} 有多个分支出口,请先 get_node_anchors 取具体出口的 source_anchor_id` } + } + const owner = lf.graphModel.nodes.find((node) => source.startsWith(`${node.id}_`)) + if (owner && getOutgoingAnchors(owner).some((anchor) => anchor.anchorId === source)) { + return { node: owner as unknown as WorkflowNodeModel, anchorId: source } + } + return { error: `出口不存在: ${source}(普通连接传上游节点 id,分支出口用 get_node_anchors 查询)` } +} + +/** 判断器与意图识别的锚点依赖 branch_condition_list,写入分支后按 id 同步,保证新出口立即可连线 */ +export function refreshBranchAnchors(node: BaseNodeModel) { + const nodeData = node.properties.node_data as BranchNodeData | undefined + if (String(node.type) === WorkflowNodeType.Condition) { + const previous = (node.properties.branch_condition_list as Array<{ id: string; height: number }> | undefined) ?? [] + writeNodeProperties(node, { + branch_condition_list: (nodeData?.branch ?? []).map((branch, index) => ({ + id: branch.id, + index, + height: previous.find((item) => item.id === branch.id)?.height ?? 44, + })), + }) + } + ;(node as BaseNodeModel & { refreshBranch?: () => void }).refreshBranch?.() +} + +export function addEdge(lf: LogicFlow, sourceNodeId: string, sourceAnchorId: string, targetNodeId: string): string | null { + if (sourceNodeId === targetNodeId) return '不能连接到自身' + const duplicated = lf.graphModel.edges.some((edge) => edge.sourceAnchorId === sourceAnchorId && edge.targetNodeId === targetNodeId) + if (duplicated) return null + lf.graphModel.addEdge({ + type: 'app-edge', + sourceNodeId, + sourceAnchorId, + targetNodeId, + targetAnchorId: `${targetNodeId}_left`, + }) + getNode(lf, targetNodeId)?.clearNextNodeField(true) + return null +} + +/** 新节点放在上游右侧,按上游已有出边数纵向错开,避免重叠;最终由自动布局整理 */ +export function addNode( + lf: LogicFlow, + template: ShapeItem, + sourceNodeId: string, + propertiesOverride: Record = {}, +): WorkflowNodeModel { + const source = getNode(lf, sourceNodeId) + const outgoingCount = source ? lf.graphModel.getNodeOutgoingEdge(sourceNodeId).length : 0 + const x = (source?.x ?? 400) + (source?.width ?? 320) / 2 + 400 + const y = (source?.y ?? 400) + outgoingCount * 420 + const node = lf.graphModel.addNode({ + type: String(template.type), + properties: { ...cloneDeep(template.properties ?? {}), ...propertiesOverride }, + x, + y, + }) + return node as unknown as WorkflowNodeModel +} + +export function getDefaultModelConfig(lf: LogicFlow, modelType: string): ModelConfig | undefined { + const graphModel = lf.graphModel as LogicFlow['graphModel'] & { getDefaultModelSettings?: () => Partial> | undefined } + return graphModel.getDefaultModelSettings?.()?.[modelType] +} + +/** 自动布局:先整理各循环体,再整理主画布 */ +export async function layoutCanvas(rootLf: LogicFlow) { + await nextTick() + try { + await Promise.all( + rootLf.graphModel.nodes + .filter((node) => String(node.type) === WorkflowNodeType.LoopBodyNode) + .map((node) => (node as LoopBodyModel).loopLayout?.()), + ) + await nextTick() + await (rootLf.extension as DagreExtension).dagre?.layout?.() + } catch { + // 布局失败不影响生成结果 + } +} + +// ============================================================ +// 上游字段与引用 +// ============================================================ + +export interface FieldOptionGroup { + /** 引用路径第一段:节点 id,或 global / chat / loop 等分组 */ + nodeId: string + /** 节点名称(提示词变量 {{节点名称.字段}} 使用) */ + name: string + fields: Array<{ value: string; label: string }> +} + +/** 与 NodeCascader 一致的可引用字段:开始节点、全局/会话变量、上游节点;循环体内追加父画布字段 */ +export function getFieldOptions(lf: LogicFlow, nodeId: string): FieldOptionGroup[] { + const node = getNode(lf, nodeId) + if (!node) return [] + const graphModel = lf.graphModel as LogicFlow['graphModel'] & { + getUpNodeFieldList?: (containSelf: boolean, useCache: boolean) => WorkflowNodeField[] + } + const groups = [...node.getUpNodeFieldList(false, false), ...(graphModel.getUpNodeFieldList?.(false, false) ?? [])] + const seen = new Set() + return groups + .filter((group) => group.children?.length && !seen.has(group.value) && seen.add(group.value)) + .map((group) => ({ + nodeId: group.value, + name: group.label, + fields: (group.children ?? []).map((field) => ({ value: field.value, label: field.label })), + })) +} + +/** 校验 [节点ID, 字段] 引用是否在上游可引用字段内;合法返回 null */ +export function validateReference(lf: LogicFlow, nodeId: string, reference: unknown, label = '引用'): string | null { + if (!Array.isArray(reference) || reference.length < 2) return `${label} 应为 [节点ID, 字段] 形式` + const [refNodeId, field] = reference.map(String) + const group = getFieldOptions(lf, nodeId).find((item) => item.nodeId === refNodeId) + if (!group) return `${label} 引用的节点 ${refNodeId} 不在上游,先 get_field_options 查可引用的节点` + if (!group.fields.some((item) => item.value === field)) { + return `${label} 引用的字段 ${refNodeId}.${field} 不存在,可用字段:${group.fields.map((item) => item.value).join(', ')}` + } + return null +} + +/** 提示词变量:{{节点名称.字段}},全局变量为 {{global.字段}} */ +export function promptVariablesOf(lf: LogicFlow, nodeId: string): string[] { + return getFieldOptions(lf, nodeId).flatMap((group) => + group.fields.map((field) => (group.nodeId === 'global' ? `{{global.${field.value}}}` : `{{${group.name}.${field.value}}}`)), + ) +} + +/** 检查提示词里的 {{…}} 变量是否都可解析;合法返回 null */ +export function validatePromptVariables(lf: LogicFlow, nodeId: string, text: unknown, label: string): string | null { + if (typeof text !== 'string') return null + const available = new Set(promptVariablesOf(lf, nodeId)) + const invalid = [...text.matchAll(/\{\{[^{}]+\}\}/g)].map((match) => match[0]).filter((variable) => !available.has(variable)) + if (!invalid.length) return null + return `${label} 中的变量 ${invalid.join('、')} 无法解析,可用变量:${[...available].join('、') || '无'}` +} + +/** 节点输出字段(config.fields) */ +export function nodeOutputFields(node: BaseNodeModel): Array<{ value: string; label: string }> { + const config = node.properties.config as { fields?: Array<{ value: string; label: string }> } | undefined + return (config?.fields ?? []).map((field) => ({ value: field.value, label: field.label })) +} diff --git a/ui/src/workflow-canvas/ai-generate/tools/index.ts b/ui/src/workflow-canvas/ai-generate/tools/index.ts new file mode 100644 index 00000000000..6c64d129c6d --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/tools/index.ts @@ -0,0 +1,447 @@ +import { cloneDeep } from 'lodash' +import { TOOL_TYPE } from '@/api/enums' +import type LogicFlow from '@logicflow/core' +import { WorkFlowInstance } from '@/workflow-canvas/core/validate' +import { toolLibNode } from '@/workflow-canvas/config/node-data' +import { useWorkflowStore } from '@/workflow-canvas/store' +import { WorkflowMode, WorkflowNodeType, type ShapeItem } from '@/workflow-canvas/types' +import { + FIXED_NODE_TYPES, + MAIN_LF_ID, + addEdge, + addNode, + findNodeCanvas, + findNodeTemplate, + getNode, + getOutgoingAnchors, + layoutCanvas, + listCanvases, + nodeNameOf, + nodeOutputFields, + readNodeData, + resolveCanvas, + resolveSourceAnchor, + validateReference, + writeNodeProperties, + type CanvasRef, +} from './canvas' +import { createAgent } from '../create-agent' +import { formatValidationError, isReference, summarizeNodeData } from './node' +import { ErrorResult } from './result' +import type { AgentBindings, AgentTool, JsonSchema, NodeAgentModule, NodeReference } from '../type' + + +/** 节点配置 agent 取自各节点 index.ts 默认导出的 agent 字段;未提供的节点不进入 AI 可选目录 */ +const nodeRegistryModules = import.meta.glob<{ default: { type: string; agent?: NodeAgentModule } }>('../../nodes/*/index.ts', { eager: true }) +const nodeAgentByType = new Map( + Object.values(nodeRegistryModules).flatMap(({ default: node }) => (node.agent ? [[node.type, node.agent] as const] : [])), +) + +export function nodeAgentFor(type: string): NodeAgentModule | undefined { + return nodeAgentByType.get(type) +} + +/** + * 建图工具(各画布父 agent 共用):查看画布、取出口、加删节点与连线、委派节点配置、校验。 + * 都带 lf_id 跨画布操作:主画布 "main"(或省略),循环体为所属循环节点 id。 + */ + +const LF_ID_SCHEMA = { type: 'string', description: '画布:主画布 "main"(或省略);循环体传所属循环节点 id' } +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +function describeCanvas(canvas: CanvasRef) { + const nodes = canvas.lf.graphModel.nodes.filter((node) => String(node.type) !== WorkflowNodeType.LoopBodyNode) + return { + lf_id: canvas.lfId, + nodes: nodes.map((node) => ({ + id: node.id, + type: String(node.type), + name: nodeNameOf(node), + outputs: nodeOutputFields(node).map((field) => field.value), + node_data: summarizeNodeData(node.properties.node_data), + })), + edges: canvas.lf.graphModel.edges + .filter((edge) => edge.type !== 'loop-edge') + .map((edge) => { + const source = canvas.lf.graphModel.getNodeModelById(edge.sourceNodeId) + const label = source ? getOutgoingAnchors(source).find((anchor) => anchor.anchorId === edge.sourceAnchorId)?.label : undefined + return { source_anchor_id: edge.sourceAnchorId, from: edge.sourceNodeId, outlet: label, to: edge.targetNodeId } + }), + } +} + +async function canvasOrError(bindings: AgentBindings, lfId: unknown) { + return resolveCanvas(bindings.rootLf, bindings.workflowMode, typeof lfId === 'string' ? lfId : undefined) +} + +export function getCanvasDetailTool(bindings: AgentBindings): AgentTool { + return { + name: 'get_canvas_detail', + description: '查看整张工作流现状:主画布与各循环体(loopBodies[循环节点id])的节点(id/type/name/输出字段/配置摘要)与连线。', + parameters: { type: 'object', properties: {} }, + resultLimit: 20000, + apply: async () => { + const canvases = await listCanvases(bindings.rootLf, bindings.workflowMode) + const [main, ...loops] = canvases + return { main: describeCanvas(main!), loopBodies: Object.fromEntries(loops.map((canvas) => [canvas.lfId, describeCanvas(canvas)])) } + }, + } +} + +export function getNodeAnchorsTool(bindings: AgentBindings): AgentTool { + return { + name: 'get_node_anchors', + description: + '取节点的全部出口(判断器 / 意图识别每个分支一个出口),返回 [{source_anchor_id, label, connected_to}],连接分支下游时传其 source_anchor_id。', + parameters: { type: 'object', properties: { nodeId: { type: 'string' }, lf_id: LF_ID_SCHEMA }, required: ['nodeId'] }, + apply: async (args) => { + const canvas = await canvasOrError(bindings, args.lf_id) + if ('error' in canvas) return canvas + const node = getNode(canvas.lf, String(args.nodeId)) + if (!node) return { error: `节点不存在: ${args.nodeId}` } + return getOutgoingAnchors(node).map((anchor) => ({ + source_anchor_id: anchor.anchorId, + label: anchor.label, + connected_to: canvas.lf.graphModel.edges.filter((edge) => edge.sourceAnchorId === anchor.anchorId).map((edge) => edge.targetNodeId), + })) + }, + } +} + +/** 工具节点的模板:按工具详情补齐参数与名称,与资源菜单拖入的节点一致 */ +async function toolNodeTemplate(bindings: AgentBindings, toolId: string): Promise { + const tool = await useWorkflowStore(bindings.resourceScope) + .force.getToolById(toolId) + .catch(() => null) + if (!tool?.id) return { error: `工具不存在: ${toolId}` } + const inputFields = (tool.input_field_list ?? []).map((field) => ({ ...field, value: field.source === 'reference' ? [] : '' })) + return { + ...cloneDeep(toolLibNode), + properties: { + ...cloneDeep(toolLibNode.properties), + stepName: tool.name, + node_data: { ...cloneDeep(tool), input_field_list: inputFields, tool_lib_id: tool.id }, + }, + } +} + +/** 列出可在应用工作流中使用的自定义工具(父 agent 选型用) */ +export function getToolsTool(bindings: AgentBindings): AgentTool { + return { + name: 'get_tools', + description: '列出可用的自定义工具 [{id,name,desc,inputs:[{name,type,source,is_required,desc}]}],add_node 新增 tool-lib-node 时传其 tool_id。', + parameters: { type: 'object', properties: {} }, + apply: async () => { + const tools = await useWorkflowStore(bindings.resourceScope).getToolListWithShared({ tool_type: TOOL_TYPE.CUSTOM }) + return tools + .filter((tool) => tool.is_active && tool.tool_type === TOOL_TYPE.CUSTOM) + .map((tool) => ({ + id: tool.id, + name: tool.name, + desc: tool.desc, + inputs: (tool.input_field_list ?? []).map((field) => ({ + name: field.name, + type: field.type, + source: field.source, + is_required: field.is_required, + desc: field.desc, + })), + })) + }, + } +} + +export function addNodeTool(bindings: AgentBindings): AgentTool { + return { + name: 'add_node', + description: + '新增节点并从上游出口接一条入边(建即接,杜绝孤立节点)。source_anchor_id:普通连接传上游节点 id;接判断器/意图识别某分支时传 get_node_anchors 返回的出口 id。' + + '循环体首个节点接 "loop-start-node"。tool-lib-node 需同时传 tool_id(get_tools 查询)。返回 { nodeId, name, outputs }。', + parameters: { + type: 'object', + properties: { + type: { type: 'string', description: '节点类型(节点目录中的 type)' }, + source_anchor_id: { type: 'string', description: '上游节点 id 或出口 id' }, + name: { type: 'string', description: '节点名称(可选,体现用途,如 "产品知识检索")' }, + tool_id: { type: 'string', description: 'type=tool-lib-node 时必填' }, + lf_id: LF_ID_SCHEMA, + }, + required: ['type', 'source_anchor_id'], + }, + apply: async (args) => { + const canvas = await canvasOrError(bindings, args.lf_id) + if ('error' in canvas) return canvas + const type = String(args.type) + if (FIXED_NODE_TYPES.includes(type)) return { error: `${type} 是画布固定节点,不能新增` } + if (!nodeAgentFor(type)) return { error: `暂不支持 AI 生成 ${type},请从节点目录中选择` } + let template: ShapeItem | { error: string } | undefined + if (type === WorkflowNodeType.ToolLib) { + if (!args.tool_id) return { error: 'tool-lib-node 需要 tool_id,先 get_tools 查询' } + template = await toolNodeTemplate(bindings, String(args.tool_id)) + } else { + template = findNodeTemplate(canvas.mode, type) + } + if (!template) { + return { + error: + canvas.mode === WorkflowMode.ApplicationLoop ? `循环体内不能使用 ${type}` : `主画布不能使用 ${type}(Break/Continue 只能建在循环体内)`, + } + } + if ('error' in template) return template + const source = resolveSourceAnchor(canvas.lf, String(args.source_anchor_id)) + if ('error' in source) return source + const node = addNode(canvas.lf, template, source.node.id, args.name ? { stepName: String(args.name) } : {}) + const error = addEdge(canvas.lf, source.node.id, source.anchorId, node.id) + if (error) return { error } + return { nodeId: node.id, name: nodeNameOf(node), lf_id: canvas.lfId, outputs: nodeOutputFields(node) } + }, + } +} + +export function addEdgeTool(bindings: AgentBindings): AgentTool { + return { + name: 'add_edge', + description: '在同一画布内补一条连线(如多分支汇合到同一节点)。source_anchor_id 为上游节点 id 或出口 id,target 为下游节点 id。', + parameters: { + type: 'object', + properties: { source_anchor_id: { type: 'string' }, target: { type: 'string' }, lf_id: LF_ID_SCHEMA }, + required: ['source_anchor_id', 'target'], + }, + apply: async (args) => { + const canvas = await canvasOrError(bindings, args.lf_id) + if ('error' in canvas) return canvas + const source = resolveSourceAnchor(canvas.lf, String(args.source_anchor_id)) + if ('error' in source) return source + const target = getNode(canvas.lf, String(args.target)) + if (!target) return { error: `目标节点不在该画布: ${args.target}` } + if ([...FIXED_NODE_TYPES].includes(String(target.type))) return { error: '固定节点不能作为连线终点' } + const error = addEdge(canvas.lf, source.node.id, source.anchorId, target.id) + return error ? { error } : { ok: true } + }, + } +} + +export function deleteNodeTool(bindings: AgentBindings): AgentTool { + return { + name: 'delete_node', + description: '删除节点及其连线(固定节点不可删除;删除循环节点会一并删除其循环体)。', + parameters: { type: 'object', properties: { nodeId: { type: 'string' }, lf_id: LF_ID_SCHEMA }, required: ['nodeId'] }, + apply: async (args) => { + const canvas = await canvasOrError(bindings, args.lf_id) + if ('error' in canvas) return canvas + const node = getNode(canvas.lf, String(args.nodeId)) + if (!node) return { error: `节点不存在: ${args.nodeId}` } + if (FIXED_NODE_TYPES.includes(String(node.type))) return { error: '固定节点不能删除' } + const downstream = canvas.lf.graphModel.getNodeOutgoingNode(node.id) + canvas.lf.graphModel + .getNodeOutgoingNode(node.id) + .filter((item) => String(item.type) === WorkflowNodeType.LoopBodyNode) + .forEach((body) => canvas.lf.deleteNode(body.id)) + canvas.lf.deleteNode(node.id) + downstream.forEach((item) => (item as unknown as { clearNextNodeField?: (containSelf: boolean) => void }).clearNextNodeField?.(true)) + return { ok: true } + }, + } +} + +export function deleteEdgeTool(bindings: AgentBindings): AgentTool { + return { + name: 'delete_edge', + description: '删除一条连线:source_anchor_id 为出口 id(或上游节点 id),target 为下游节点 id。', + parameters: { + type: 'object', + properties: { source_anchor_id: { type: 'string' }, target: { type: 'string' }, lf_id: LF_ID_SCHEMA }, + required: ['source_anchor_id', 'target'], + }, + apply: async (args) => { + const canvas = await canvasOrError(bindings, args.lf_id) + if ('error' in canvas) return canvas + const sourceAnchorId = String(args.source_anchor_id) + const edges = canvas.lf.graphModel.edges.filter( + (edge) => edge.targetNodeId === args.target && (edge.sourceAnchorId === sourceAnchorId || edge.sourceNodeId === sourceAnchorId), + ) + if (!edges.length) return { error: '连线不存在' } + edges.forEach((edge) => canvas.lf.deleteEdge(edge.id)) + getNode(canvas.lf, String(args.target))?.clearNextNodeField(true) + return { ok: true } + }, + } +} + +// ============================================================ +// 节点配置委派 +// ============================================================ + +/** 上游引用的返回类型:取上游节点函数 returns 声明的固定字段类型,未声明(动态字段、开始节点、全局变量)返回 undefined */ +function referenceType(lf: LogicFlow, [refNodeId, field]: NodeReference): string | undefined { + const refNode = getNode(lf, refNodeId) + const returns = refNode ? nodeAgentFor(String(refNode.type))?.returns : undefined + const type = returns?.properties[field]?.type + return typeof type === 'string' ? type : undefined +} + +/** 按形参 schema 校验实参:形参存在、引用合法、array / object 形参的上游类型对得上 */ +function checkArgs( + lf: LogicFlow, + nodeId: string, + module: NodeAgentModule, + rawArgs: unknown, +): { args: Record } | { error: string } { + const input = rawArgs && typeof rawArgs === 'object' ? (rawArgs as Record) : {} + const { properties, required = [], additionalProperties } = module.params + const missing = required.filter((name) => input[name] === undefined) + if (missing.length) return { error: `缺少入参:${missing.join('、')}` } + const args: Record = {} + for (const [name, value] of Object.entries(input)) { + const schema = (properties[name] ?? (typeof additionalProperties === 'object' ? additionalProperties : undefined)) as JsonSchema | undefined + if (!schema) return { error: `没有入参 ${name},可用入参:${Object.keys(properties).join('、') || '无'}` } + // 带 items 的 array 形参可接收多个引用(如提示词素材、重排内容),其余形参只接收一个引用 + const multiple = schema.type === 'array' && schema.items !== undefined + const references = isReference(value) ? [value] : multiple && Array.isArray(value) && value.every(isReference) ? (value as NodeReference[]) : undefined + if (!references) return { error: multiple ? `入参 ${name} 应为引用 [节点ID, 字段] 或其列表` : `入参 ${name} 应为一个引用 [节点ID, 字段]` } + // 多引用形参逐个按 items 类型比对,单引用形参按形参类型比对 + const expectedType = multiple ? (schema.items as JsonSchema).type : schema.type + for (const reference of references) { + const error = validateReference(lf, nodeId, reference, `入参 ${name}`) + if (error) return { error } + const actualType = referenceType(lf, reference) + if ((expectedType === 'array' || expectedType === 'object') && actualType && actualType !== expectedType && actualType !== 'any') { + return { error: `入参 ${name} 需要 ${expectedType},引用的 ${reference.join('.')} 为 ${actualType}` } + } + } + args[name] = multiple ? references : references[0]! + } + return { args } +} + +/** x-bind 形参:实参由框架直接写入 node_data 的对应字段 */ +function writeBoundArgs(node: ReturnType, module: NodeAgentModule, args: Record) { + if (!node) return + const boundData: Record = {} + for (const [name, value] of Object.entries(args)) { + const field = module.params.properties[name]?.['x-bind'] + if (typeof field !== 'string') continue + boundData[field] = isReference(value) ? [...value] : value.map((reference) => [...reference]) + } + if (Object.keys(boundData).length) writeNodeProperties(node, { node_data: { ...readNodeData>(node), ...boundData } }) +} + +export function configureNodeTool(bindings: AgentBindings): AgentTool { + return { + name: 'configure_node', + description: + '把节点当函数调用:args 传实参(形参名 → 上游引用 [节点ID, 字段],可多个时传列表),由框架校验并绑定;' + + 'requirement 写配置要求(按节点目录里该节点的配置项描述:提示词等生成内容只写要生成什么,模型、知识库等资源只写意图,别猜 id);' + + '节点有按配置生成的返回字段时,returns 列出要求产出的字段名,下游按这些字段引用。' + + '多个节点可在同一轮并发配置。返回 { nodeId, result:{ outputs } } 或 { nodeId, error }。', + parameters: { + type: 'object', + properties: { + nodeId: { type: 'string' }, + args: { type: 'object', description: '实参:形参名 → [节点ID, 字段] 或其列表' }, + requirement: { type: 'string', description: '配置要求' }, + returns: { type: 'array', items: { type: 'string' }, description: '要求产出的动态返回字段名(可选)' }, + lf_id: LF_ID_SCHEMA, + }, + required: ['nodeId', 'requirement'], + }, + apply: (args) => { + let child: ReturnType | undefined + const promise = (async () => { + const nodeId = String(args.nodeId) + const canvas = await findNodeCanvas(bindings.rootLf, bindings.workflowMode, nodeId) + if (!canvas) return new ErrorResult(`节点不存在: ${nodeId}`, { nodeId }) + const node = getNode(canvas.lf, nodeId)! + const module = nodeAgentFor(String(node.type)) + if (!module) return new ErrorResult(`${node.type} 暂不支持 AI 配置`, { nodeId }) + const checked = checkArgs(canvas.lf, nodeId, module, args.args) + if ('error' in checked) return new ErrorResult(checked.error, { nodeId }) + // 等待节点表单完成挂载与默认值初始化,再写入配置 + await sleep(80) + writeBoundArgs(getNode(canvas.lf, nodeId), module, checked.args) + const childBindings: AgentBindings = { + ...bindings, + position: [...(bindings.position ?? []), nodeId], + messages: undefined, + maxIterations: 30, + } + const requirement = typeof args.requirement === 'string' ? args.requirement.trim() : '' + const expectedReturns = Array.isArray(args.returns) ? args.returns.map(String) : [] + child = createAgent( + module.useAgent({ lf: canvas.lf, nodeId, workflowMode: canvas.mode, bindings: childBindings, args: checked.args, requirement, expectedReturns }), + ) + const result = await child.run( + requirement ? `请按以下要求配置本节点(生成内容的字段按描述生成完整内容,资源字段查出真实 id):\n${requirement}` : '请结合整体目标与已绑定入参,自行判断并配置本节点。', + ) + if (result.status !== 'done') { + return new ErrorResult(typeof result.data === 'string' && result.data ? result.data : `配置未完成(${result.status})`, { nodeId }) + } + // 交付(result.outputs)里带回节点配置后的真实输出字段 + return { nodeId, result: result.data } + })() + return { promise, cancel: () => child?.stop() } + }, + } +} + +// ============================================================ +// 校验 +// ============================================================ + +function graphOf(lf: LogicFlow) { + const graph = lf.getGraphData() as LogicFlow.GraphData + return { + nodes: graph.nodes.filter((node) => node.type !== WorkflowNodeType.LoopBodyNode), + edges: graph.edges.filter((edge) => edge.type !== 'loop-edge'), + } as unknown as ConstructorParameters[0] +} + +export function validateTool(bindings: AgentBindings): AgentTool { + return { + name: 'validate', + description: + '校验整张工作流:连线完整性(未接入流程的节点、未连接的分支、不能作为结束的节点)、循环体结构与各节点表单必填项。返回 { qualified, problems }。', + parameters: { type: 'object', properties: {} }, + apply: async () => { + const problems: string[] = [] + const canvases = await listCanvases(bindings.rootLf, bindings.workflowMode) + for (const canvas of canvases) { + const where = canvas.lfId === MAIN_LF_ID ? '主画布' : `循环体(${canvas.lfId})` + try { + const instance = new WorkFlowInstance(graphOf(canvas.lf), canvas.mode) + if (canvas.lfId === MAIN_LF_ID) instance.is_valid() + else { + instance.is_loop_valid() + const loopNode = canvases.flatMap((item) => item.lf.graphModel.nodes).find((node) => node.id === canvas.lfId) + if ((loopNode?.properties.node_data as { loop_type?: string } | undefined)?.loop_type === 'LOOP' && !instance.exist_break_node()) { + problems.push(`${where}:无限循环的循环体需要包含 Break 节点`) + } + } + } catch (error) { + problems.push(`${where}:${typeof error === 'string' ? error : formatValidationError(error)}`) + } + const results = await Promise.allSettled( + canvas.lf.graphModel.nodes + .filter((node) => String(node.type) !== WorkflowNodeType.LoopBodyNode) + .map((node) => (node as unknown as { validate?: () => Promise }).validate?.()), + ) + results.forEach((result) => { + if (result.status === 'rejected') problems.push(`${where}:${formatValidationError(result.reason)}`) + }) + } + return { qualified: problems.length === 0, problems } + }, + } +} + +export function layoutTool(bindings: AgentBindings): AgentTool { + return { + name: 'layout', + description: '自动整理画布布局(主画布与所有循环体)。', + parameters: { type: 'object', properties: {} }, + apply: async () => { + await layoutCanvas(bindings.rootLf) + return { ok: true } + }, + } +} diff --git a/ui/src/workflow-canvas/ai-generate/tools/node.ts b/ui/src/workflow-canvas/ai-generate/tools/node.ts new file mode 100644 index 00000000000..19aa6241777 --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/tools/node.ts @@ -0,0 +1,409 @@ +import { nextTick } from 'vue' +import KnowledgeApi from '@/api/admin/workspace/knowledge/knowledge' +import type { KnowledgeItem } from '@/api/types' +import { compareList } from '@/workflow-canvas/config/constants' +import { useWorkflowStore } from '@/workflow-canvas/store' +import { WorkflowNodeType } from '@/workflow-canvas/types' +import { useAgentConfig } from '../common' +import type { AgentTool, JsonObjectSchema, JsonSchema, NodeAgentContext, NodeAgentModule, NodeReference, RunnableAgent } from '../type' +import { + getDefaultModelConfig, + getFieldOptions, + getNode, + nodeNameOf, + nodeOutputFields, + promptVariablesOf, + readNodeData, + validateReference, + writeNodeProperties, +} from './canvas' +import { AgentSuccessResult, ErrorResult } from './result' + +/** + * 节点配置 agent 的装配与共用工具:各节点 agent.ts 经 useNodeAgent 组装,由节点 index.ts 的 agent 字段注册; + * 另含实参处理、可引用变量查询、写入后校验收尾、update / validate 工具、模型来源、条件项与节点函数签名渲染。 + */ + +/** get_field_options 工具:本节点可引用的上游变量与提示词变量 */ +export function getFieldOptionsTool(context: NodeAgentContext): AgentTool { + return { + name: 'get_field_options', + description: '本节点可引用的上游变量:groups 为 [{nodeId, name, fields:[{value,label}]}],promptVariables 为可直接写进提示词的变量。', + parameters: { type: 'object', properties: {} }, + apply: () => ({ groups: getFieldOptions(context.lf, context.nodeId), promptVariables: promptVariablesOf(context.lf, context.nodeId) }), + } +} + +// ============================================================ +// 实参 +// ============================================================ + +/** 是否为单个引用 [节点ID, 字段] */ +export function isReference(value: unknown): value is NodeReference { + return Array.isArray(value) && value.length === 2 && value.every((item) => typeof item === 'string') +} + +/** 某个形参的实参,统一为引用列表 */ +export function argReferences(context: NodeAgentContext, name: string): NodeReference[] { + const value = context.args[name] + if (!value) return [] + return isReference(value) ? [value] : value +} + +/** 某个形参的单个实参(只取第一个引用) */ +export function argReference(context: NodeAgentContext, name: string): NodeReference | undefined { + return argReferences(context, name)[0] +} + +/** 引用在提示词里的写法:{{节点名称.字段}},全局变量为 {{global.字段}} */ +export function promptVariableOf(context: NodeAgentContext, [refNodeId, field]: NodeReference): string { + if (refNodeId === 'global') return `{{global.${field}}}` + const group = getFieldOptions(context.lf, context.nodeId).find((item) => item.nodeId === refNodeId) + return `{{${group?.name ?? refNodeId}.${field}}}` +} + +/** 已绑定实参的说明,写进节点配置 agent 的 prompt */ +function describeArgs(context: NodeAgentContext): string { + const lines = Object.keys(context.args).map((name) => { + const references = argReferences(context, name).map((reference) => `${JSON.stringify(reference)}(提示词写作 ${promptVariableOf(context, reference)})`) + return `- ${name} = ${references.join('、')}` + }) + return lines.length ? `已绑定入参(由父 agent 连线决定,需要引用上游数据时使用这些,不要另选):\n${lines.join('\n')}` : '本节点没有绑定入参。' +} + +// ============================================================ +// 写入后校验收尾 +// ============================================================ + +type ValidationError = { node?: { properties?: { stepName?: string } }; errMessage?: unknown } + +/** 把节点 validate() 的拒绝原因(el-form 字段错误或文本)整理为可读文本 */ +export function formatValidationError(error: unknown): string { + const { node, errMessage } = (error ?? {}) as ValidationError + const prefix = node?.properties?.stepName ? `${node.properties.stepName}:` : '' + if (typeof errMessage === 'string') return prefix + errMessage + if (errMessage && typeof errMessage === 'object') { + const messages = Object.values(errMessage as Record>) + .flat() + .map((item) => item?.message) + .filter(Boolean) + if (messages.length) return prefix + messages.join(';') + } + return prefix + (typeof error === 'string' ? error : JSON.stringify(errMessage ?? error)) +} + +export function summarizeNodeData(value: unknown): unknown { + const text = JSON.stringify(value ?? {}) + return text.length > 600 ? `${text.slice(0, 600)}…` : value +} + +/** + * 调用节点视图注册的 validate()(表单必填与引用有效性),与发布前校验同一套规则。 + * 等两次 nextTick,让表单先按新写入的 node_data 渲染;节点未挂载表单时视为通过。 + */ +export async function runNodeValidate(context: NodeAgentContext): Promise { + await nextTick() + await nextTick() + const node = getNode(context.lf, context.nodeId) as (ReturnType & { validate?: () => Promise }) | undefined + if (!node) return `节点不存在: ${context.nodeId}` + try { + await node.validate?.() + return null + } catch (error) { + return formatValidationError(error) + } +} + +/** + * 校验通过即交卷(stop),交付节点配置后的真实输出字段(config.fields,与变量选择器一致), + * 父 agent 据此接下游;不通过返回 ErrorResult 让模型续修。 + */ +export async function finishWithValidate(context: NodeAgentContext): Promise { + const error = await runNodeValidate(context) + if (error) return new ErrorResult(`节点校验未通过:${error}`) + const node = getNode(context.lf, context.nodeId) + const outputs = node ? nodeOutputFields(node) : [] + // 父 agent 要求的动态返回字段必须产出,下游才能按约定引用 + const missing = context.expectedReturns.filter((field) => !outputs.some((output) => output.value === field)) + if (missing.length) return new ErrorResult(`缺少要求产出的返回字段:${missing.join('、')},请按要求补充后重新写入`) + return new AgentSuccessResult({ outputs }) +} + +/** update 工具:apply 写入节点(返回错误文本或 null),写入成功后自动校验并以 stop 收尾 */ +export function createUpdateTool( + context: NodeAgentContext, + config: JsonObjectSchema, + apply: (args: Record) => string | null | Promise, + description = '写入本节点配置;写入后自动校验,通过即配置完成。', +): AgentTool { + return { + name: 'update', + description, + parameters: config, + apply: async (args) => { + if (!getNode(context.lf, context.nodeId)) return new ErrorResult(`节点不存在: ${context.nodeId}`) + const error = await apply(args) + if (error) return new ErrorResult(error) + return finishWithValidate(context) + }, + } +} + +/** validate 工具:分支类节点逐个增改删后调用它校验收尾 */ +export function createValidateTool(context: NodeAgentContext): AgentTool { + return { + name: 'validate', + description: '全部配置完成后调用:校验本节点配置,通过即配置完成、任务结束;不通过返回 error 继续修。', + parameters: { type: 'object', properties: {} }, + apply: () => finishWithValidate(context), + } +} + +// ============================================================ +// 模型来源 +// ============================================================ + +/** 模型字段:default=使用工作流默认模型,否则为 get_models 返回的模型 id */ +export const MODEL_FIELD_SCHEMA = { + type: 'string', + description: '模型:"default" 表示使用工作流默认模型(已配置时首选);否则传 get_models 返回的模型 id', +} + +/** 节点的模型槽位:模型类型与写入 node_data 的字段名(与 NodeModelSelect 的 fields 一致) */ +export interface ModelSlot { + type: string + label: string + fields: { source: string; id: string; reference: string } +} + +/** 按模型类型与字段前缀生成模型槽位,如 STT 节点的前缀为 stt_model */ +export function modelSlot(type: string, label: string, prefix = 'model'): ModelSlot { + return { type, label, fields: { source: `${prefix}_id_type`, id: `${prefix}_id`, reference: `${prefix}_id_reference` } } +} + +export const LLM_MODEL = modelSlot('LLM', '大语言模型') + +/** 把模型参数解析为节点的模型字段(来源 / 模型 id / 引用) */ +export function resolveModelFields(context: NodeAgentContext, model: unknown, slot: ModelSlot = LLM_MODEL): Record | string { + const { source, id, reference } = slot.fields + if (model === undefined || model === null || model === '' || model === 'default') { + if (!getDefaultModelConfig(context.lf, slot.type)?.model_id) { + return `当前工作流未配置默认${slot.label}(${slot.type}),请先 get_models 选择一个模型 id` + } + return { [source]: 'default', [id]: '', [reference]: [] } + } + return { [source]: 'custom', [id]: String(model), [reference]: [] } +} + +/** 列出可用模型:经工作流 Store 按画布资源范围查询(与节点模型下拉共用缓存),并告知默认模型是否已配置 */ +export function getModelsTool(context: NodeAgentContext, slot: ModelSlot = LLM_MODEL): AgentTool { + return { + name: 'get_models', + description: `列出可用的${slot.label}(${slot.type}),返回 { defaultConfigured, models:[{id,name,provider}] }。defaultConfigured=true 时优先用 "default"。`, + parameters: { type: 'object', properties: {} }, + apply: async () => { + const models = await useWorkflowStore(context.bindings.resourceScope).getModelListWithShared({ model_type: slot.type }) + return { + defaultConfigured: !!getDefaultModelConfig(context.lf, slot.type)?.model_id, + models: models.map((model) => ({ id: model.id, name: model.name, provider: model.provider, model_name: model.model_name })), + } + }, + } +} + +// ============================================================ +// 知识库 +// ============================================================ + +let knowledgeCache: Promise | undefined + +/** 当前工作空间的知识库(缓存,失败时清掉以便重试) */ +export const loadKnowledge = () => + (knowledgeCache ??= KnowledgeApi.getAllKnowledge().catch((error) => { + knowledgeCache = undefined + throw error + })) + +export const getKnowledgeListTool: AgentTool = { + name: 'get_knowledge_list', + description: '列出当前工作空间的知识库 [{id,name,desc,embedding_model_id}]。同一检索节点只能选向量模型相同的知识库。', + parameters: { type: 'object', properties: {} }, + apply: async () => + (await loadKnowledge()).map((knowledge) => ({ + id: knowledge.id, + name: knowledge.name, + desc: knowledge.desc, + embedding_model_id: knowledge.embedding_model_id, + })), +} + +// ============================================================ +// 条件项 +// ============================================================ + +export const VALUE_LESS_COMPARES = ['is_null', 'is_not_null', 'is_true', 'is_not_true'] + +/** 条件项 schema:判断器分支、Break/Continue 条件共用 */ +export const CONDITION_SCHEMA = { + type: 'object', + properties: { + field: { type: 'array', items: { type: 'string' }, description: '被比较的变量 [节点ID, 字段],从 get_field_options 取' }, + compare: { + type: 'string', + enum: compareList.map((item) => item.value), + description: compareList.map((item) => `${item.value}=${item.label}`).join(','), + }, + value: { type: 'string', description: '比较值;为空/不为空/为真/不为真无需比较值' }, + }, + required: ['field', 'compare'], +} + +export interface ConditionItem { + field: string[] + compare: string + value: string | number +} + +/** 规整并校验条件列表;返回错误文本或规整后的列表 */ +export function normalizeConditions(context: NodeAgentContext, raw: unknown, label: string): ConditionItem[] | string { + if (!Array.isArray(raw)) return `${label} 应为数组` + const conditions: ConditionItem[] = [] + for (const [index, item] of raw.entries()) { + const condition = item as Partial + const error = validateReference(context.lf, context.nodeId, condition.field, `${label}[${index}].field`) + if (error) return error + if (!compareList.some((compare) => compare.value === condition.compare)) return `${label}[${index}].compare 不合法:${condition.compare}` + const compare = String(condition.compare) + conditions.push({ + field: (condition.field as unknown[]).map(String), + compare, + // 无比较值的运算沿用节点协议写入占位值 1 + value: VALUE_LESS_COMPARES.includes(compare) ? 1 : String(condition.value ?? ''), + }) + } + return conditions +} + +// ============================================================ +// 节点配置 agent 装配 +// ============================================================ + +const NODE_GUIDE = `你是 MaxKB 工作流某个节点的配置专家,只配置已绑定的这一个节点,不改动画布结构。 +节点被当作一个函数:入参(上游数据)已由父 agent 绑定,你只负责配置(模型、提示词、资源、阈值、分支等常量)。 +工作方式: +1. 需要引用上游数据时使用下方「已绑定入参」;提示词里按其给出的 {{节点名称.字段}} 写法原样使用。get_field_options 可查看全部可引用变量,仅在入参未覆盖时参考。 +2. 需要选资源(模型、知识库等)时用对应查询工具拿真实 id,不要猜。 +3. 写入配置后系统会自动做节点校验:通过即配置完成、任务结束;返回 error 就按提示修正后重试。` + +/** 节点配置 agent 骨架:通用指引 + 节点专属 prompt + 已绑定入参与要求产出的返回字段,工具前置 get_field_options */ +export function useNodeAgent(context: NodeAgentContext, config: { prompt: string; tools: AgentTool[] }): RunnableAgent { + const node = getNode(context.lf, context.nodeId) + const expected = context.expectedReturns.length ? `\n要求产出的返回字段(字段名必须一致):${context.expectedReturns.join('、')}` : '' + return useAgentConfig( + { + prompt: `${NODE_GUIDE}\n\n${config.prompt}\n\n本节点:id=${context.nodeId},名称=${node ? nodeNameOf(node) : context.nodeId}\n${describeArgs(context)}${expected}`, + tools: [getFieldOptionsTool(context), ...config.tools], + }, + context.bindings, + ) +} + +/** Break / Continue 共用的条件配置 agent */ +export function createLoopConditionAgent(type: WorkflowNodeType.LoopBreakNode | WorkflowNodeType.LoopContinueNode, label: string): NodeAgentModule { + const config: JsonObjectSchema = { + type: 'object', + properties: { + condition: { type: 'string', enum: ['and', 'or'], description: '多条条件之间:and 全部满足 / or 任一满足' }, + conditions: { type: 'array', items: CONDITION_SCHEMA, description: '条件列表,为空表示无条件执行' }, + }, + required: ['conditions'], + } + return { + description: + type === WorkflowNodeType.LoopBreakNode + ? '相当于循环里的 if (...) break:满足条件时跳出循环,条件为空则无条件跳出;仅循环体内可用,无限循环(LOOP)必须放一个' + : '相当于循环里的 if (...) continue:满足条件时跳过本轮剩余节点、进入下一轮;仅循环体内可用', + params: { + type: 'object', + properties: { + fields: { type: 'array', items: {}, description: '条件里被比较的变量,常用 loop-start-node 的 index / item' }, + }, + }, + config, + returns: { type: 'object', properties: {}, additionalProperties: false }, + useAgent: (context) => + useNodeAgent(context, { + prompt: [ + `${type}(${label})配置要点:`, + '- conditions 为空表示无条件执行;条件 field 使用已绑定入参 fields 中的引用。', + '- condition:多条条件之间 and(全部满足)/ or(任一满足)。', + ].join('\n'), + tools: [ + createUpdateTool(context, config, (args) => { + const node = getNode(context.lf, context.nodeId)! + const conditions = normalizeConditions(context, args.conditions ?? [], 'conditions') + if (typeof conditions === 'string') return conditions + writeNodeProperties(node, { + node_data: { + ...readNodeData>(node), + condition: args.condition === 'or' ? 'or' : 'and', + condition_list: conditions, + }, + }) + return null + }), + ], + }), + } +} + +// ============================================================ +// 节点函数签名 +// ============================================================ + +/** JSON Schema 渲染成 TS 风格的类型 */ +function schemaType(schema: JsonSchema | undefined): string { + if (!schema) return 'any' + if (Array.isArray(schema.enum)) return schema.enum.map((value) => JSON.stringify(value)).join(' | ') + if (schema.type === 'array') return `${schemaType(schema.items as JsonSchema | undefined)}[]` + return typeof schema.type === 'string' ? schema.type : 'any' +} + +function fieldLine(name: string, schema: JsonSchema, optional: boolean): string { + const description = typeof schema.description === 'string' ? ` /* ${schema.description} */` : '' + return `${name}${optional ? '?' : ''}: ${schemaType(schema)}${description}` +} + +/** 对象 schema 的字段列表;additionalProperties 渲染为索引签名 */ +function objectFields(schema: JsonObjectSchema, indexName: string): string[] { + const required = new Set(schema.required ?? []) + const fields = Object.entries(schema.properties).map(([name, property]) => fieldLine(name, property, !required.has(name))) + const extra = schema.additionalProperties + if (extra && typeof extra === 'object') fields.push(fieldLine(`[${indexName}]`, extra, false)) + return fields +} + +/** 配置项只列名称与说明的首句,控制目录篇幅 */ +function configSummary(schema: JsonObjectSchema): string { + const items = Object.entries(schema.properties).map(([name, property]) => { + const description = typeof property.description === 'string' ? property.description.split(/[,;。,;]/)[0] : '' + return description ? `${name}(${description})` : name + }) + return items.join('、') || '无' +} + +/** + * 把节点渲染成函数签名,供建图 agent 像组合函数一样设计画布: + * // 名称:说明 + * // 配置:配置项 + * type(形参): { 返回字段 } + */ +export function renderNodeFunction(type: string, label: string, module: NodeAgentModule): string { + const params = objectFields(module.params, '参数名') + const returns = objectFields(module.returns, '字段名') + return [ + `// ${label}:${module.description}`, + `// 配置:${configSummary(module.config)}`, + `${type}(${params.join(', ')}): ${returns.length ? `{ ${returns.join('; ')} }` : 'void'}`, + ].join('\n') +} diff --git a/ui/src/workflow-canvas/ai-generate/tools/result.ts b/ui/src/workflow-canvas/ai-generate/tools/result.ts new file mode 100644 index 00000000000..609f19bdd29 --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/tools/result.ts @@ -0,0 +1,72 @@ +/** + * 工具执行结果:工具返回哪一种结果,决定 agent 循环接下来怎么走。 + * 工具层(agent 继续循环): + * - SuccessResult:工具成功,内容回喂模型,循环继续; + * - ErrorResult:工具失败,错误回喂模型让其自行修正,循环继续,工具卡标为失败。 + * agent 层(立即结束所在 agent): + * - AgentSuccessResult:agent 成功结束(status=done),data 作为该 agent 的交付上抛给调用方(如父 agent 的委派工具); + * - AgentErrorResult:agent 失败结束(status=error),message 作为失败原因上抛,不再让模型续修,工具卡标为失败。 + * 工具直接返回普通值时按 SuccessResult 处理;抛出的异常按 ErrorResult 处理。 + */ +export abstract class ToolResult { + /** 回喂给模型的内容 */ + abstract toContent(): unknown +} + +export class SuccessResult extends ToolResult { + constructor(readonly data?: T) { + super() + } + + toContent() { + return this.data ?? { ok: true } + } +} + +export class ErrorResult extends ToolResult { + constructor( + readonly message: string, + /** 结构化的错误细节(如校验失败的字段列表),可选 */ + readonly detail?: unknown, + ) { + super() + } + + toContent() { + return this.detail === undefined ? { error: this.message } : { error: this.message, detail: this.detail } + } +} + +export class AgentSuccessResult extends ToolResult { + constructor(readonly data?: T) { + super() + } + + toContent() { + return this.data ?? { ok: true } + } +} + +export class AgentErrorResult extends ToolResult { + constructor( + readonly message: string, + /** 结构化的错误细节,可选 */ + readonly detail?: unknown, + ) { + super() + } + + toContent() { + return this.detail === undefined ? { error: this.message } : { error: this.message, detail: this.detail } + } +} + +/** 把工具返回值统一成 ToolResult:普通值视为成功 */ +export function toToolResult(value: unknown): ToolResult { + return value instanceof ToolResult ? value : new SuccessResult(value) +} + +/** 工具卡是否标为成功:两种失败结果都标为失败 */ +export function isFailureResult(result: ToolResult): boolean { + return result instanceof ErrorResult || result instanceof AgentErrorResult +} diff --git a/ui/src/workflow-canvas/ai-generate/type.ts b/ui/src/workflow-canvas/ai-generate/type.ts new file mode 100644 index 00000000000..8dd5784a859 --- /dev/null +++ b/ui/src/workflow-canvas/ai-generate/type.ts @@ -0,0 +1,215 @@ +import type LogicFlow from '@logicflow/core' +import type { ChatCompletionMessage } from '@/api/types' +import type { WorkflowMode } from '@/workflow-canvas/types' + +/** + * 工作流 AI 生成的类型定义(纯类型)。 + * 运行引擎在 create-agent.ts,SSE 与 agent 装配在 common.ts,画布适配在 tools/canvas.ts, + * 建图工具在 tools/index.ts,节点配置 agent 在各节点目录的 agent.ts,画布父 agent 在 application.ts。 + */ + +// ============================================================ +// 流式补全 +// ============================================================ + +export interface ToolCall { + id: string + type: string + function: { name: string; arguments: string } +} + +export interface ChatCompletionResult { + content: string + toolCalls: ToolCall[] + finishReason: string | null + usage?: Record | null + /** thinking 模型的思考内容及其原始字段名(回传 assistant 消息时按原字段名带回) */ + reasoningKey?: string | null + reasoningContent?: string | null +} + +/** 流式订阅者:onNext 逐条收条目,onComplete 收尾(error 有值即失败)。 */ +export interface Call { + onNext(item: T): void + onComplete?(error?: Error): void +} + +/** + * 后端一轮流式补全逐条下发的条目: + * - delta / reasoning:增量文本 + * - tool_call:某工具调用的一个片段(id + name + 本片 arguments 增量) + * - completion:整轮最终结果(累积后的完整 toolCalls) + */ +export type StreamItem = + | { type: 'delta'; content: string } + | { type: 'reasoning'; content: string } + | { type: 'tool_call'; id: string; name: string; arguments: string } + | { type: 'completion'; result: ChatCompletionResult } + +export interface SseEvent { + type: 'delta' | 'reasoning' | 'tool_call' | 'completion' | 'error' + [key: string]: unknown +} + +/** agent 历史消息:OpenAI 格式,assistant 可按原字段名携带思考内容。 */ +export type AgentMessage = ChatCompletionMessage & Record + +// ============================================================ +// Agent 事件 / 工具 / 绑定 +// ============================================================ + +/** + * createAgent 对外下发的事件负载: + * - narration / reasoning:assistant 正文 / 思考的增量文本 + * - tool_start / tool_args / tool_end:工具卡的建立、入参增量与执行结果 + * - done / error:本 agent 终态,done.data 为本 agent 交付 + */ +export type AgentPayload = + | { type: 'narration'; delta: string } + | { type: 'reasoning'; delta: string } + | { type: 'tool_start'; id: string; name: string } + | { type: 'tool_args'; id: string; delta: string } + | { type: 'tool_end'; id: string; ok: boolean; result: string } + | { type: 'done'; data?: unknown } + | { type: 'error'; message: string } + +/** + * 一条对外事件 = 负载 + position(从事件树根到所属 agent 的语义键链)。 + * 顶层 agent 为 [],委派工具起的子 agent 在父 position 后追加语义键(节点 id、'plan'、'validate')。 + */ +export type AgentEvent = AgentPayload & { position: string[] } + +/** 可取消的工具执行:委派型工具返回它,父 agent 停止时级联 cancel。 */ +export interface Executor { + promise: Promise + cancel: () => void +} + +/** 模型可调用的一个函数。 */ +export interface AgentTool { + /** 函数名(下发给模型的 tool 名) */ + name: string + description: string + /** 参数 JSON Schema */ + parameters: Record + /** 执行体:只接收模型给的 args,其余上下文在构造期闭包 */ + apply: (args: Record) => unknown | Promise | Executor + /** 结果回喂模型的截断上限(字符),缺省走全局上限 */ + resultLimit?: number +} + +/** 构造期绑定:装配 agent 与建工具时使用的上下文。 */ +export interface AgentBindings { + /** 主画布 LogicFlow 实例;循环体画布经 lf_id 从主画布展开 */ + rootLf: LogicFlow + /** 模型 id(委派的子 agent 复用同一模型) */ + modelId: string + /** 对外事件出口 */ + call: Call + /** 主画布模式 */ + workflowMode: WorkflowMode + /** 画布资源范围(workspace 等),资源查询经 useWorkflowStore(resourceScope) */ + resourceScope: string + /** 画布所属资源 id(应用 / 工具 / 知识库),MCP 等按资源查询的接口使用 */ + resourceId?: string + /** 事件树层级路径(顶层=[]) */ + position?: string[] + /** 循环轮次上限 */ + maxIterations?: number + /** 本次生成的整体目标(用户需求),折进各层 system 作背景 */ + context?: string + /** 本 agent 的消息历史(活引用);委派时传入从父裁出的初始历史 */ + messages?: AgentMessage[] +} + +/** 一台 agent 的声明式配置。 */ +export interface AgentConfig { + prompt: string + tools: AgentTool[] +} + +/** 装配好、可交给 createAgent 运行的 agent。 */ +export interface RunnableAgent { + prompt: string + messages: AgentMessage[] + tools: AgentTool[] + modelId: string + /** 画布资源范围,补全请求经 useWorkflowStore(resourceScope) 发起 */ + resourceScope: string + call: Call + position: string[] + maxIterations: number +} + +/** 一次 run 的终态结果。 */ +export interface AgentResult { + status: 'done' | 'error' | 'stopped' + /** 隐式结束为最后一段正文;终止工具结束为其交出的结构化数据 */ + data?: unknown +} + +export interface AgentHandle { + /** 追加一条 user 消息并循环到终态;非重入 */ + run: (requirement: string) => Promise + /** 中断在途请求并级联停止委派中的子 agent */ + stop: () => void +} + +// ============================================================ +// 节点配置 agent +// ============================================================ + +/** JSON Schema(OpenAI function calling 的 parameters 格式) */ +export type JsonSchema = Record + +/** + * 对象形 JSON Schema:节点的 params / config / returns 统一使用。 + * 框架扩展字段(渲染给模型时去掉): + * - params 属性上的 x-bind:实参由框架直接写入 node_data 的该字段;未声明则交由节点配置 agent 使用; + * - returns.additionalProperties 上的 x-from:动态返回字段来自哪一项配置。 + */ +export interface JsonObjectSchema { + [key: string]: unknown + type: 'object' + properties: Record + required?: string[] + additionalProperties?: boolean | JsonSchema +} + +/** 实参:对上游输出的引用 [节点ID, 字段],如 ['start-node', 'question'] */ +export type NodeReference = [string, string] + +/** 节点配置 agent 运行时的上下文 */ +export interface NodeAgentContext { + /** 被配置节点所在画布 */ + lf: LogicFlow + /** 被配置节点 id */ + nodeId: string + /** 所在画布模式(主画布 Application,循环体 ApplicationLoop) */ + workflowMode: WorkflowMode + /** 本节点配置 agent 的绑定(position 已追加节点 id) */ + bindings: AgentBindings + /** 父 agent 传入并已校验的实参:形参名 → 引用(可多个) */ + args: Record + /** 父 agent 写的配置要求 */ + requirement: string + /** 父 agent 要求产出的动态返回字段名 */ + expectedReturns: string[] +} + +/** + * 节点 agent.ts 的默认导出:把节点当作一个函数暴露给建图 agent,由节点 index.ts 的 agent 字段注册。 + * params / config / returns 均为 JSON Schema: + * - description 为函数说明:做什么、何时用、与上下游怎么配合; + * - params 为形参:运行时由上游提供,实参为引用 [节点ID, 字段],由父 agent 连线决定; + * - config 为配置:搭画布时确定的常量,由节点配置 agent 按 requirement 决定,同时作为其 update 工具的 parameters; + * - returns 为返回:固定字段写在 properties,由配置生成的字段用 additionalProperties 说明(无则为 false); + * - useAgent 装配节点配置 agent:只负责配置,形参已由框架绑定;通过校验后交付真实输出字段(config.fields)。 + */ +export interface NodeAgentModule { + description: string + params: JsonObjectSchema + config: JsonObjectSchema + returns: JsonObjectSchema + useAgent: (context: NodeAgentContext) => RunnableAgent +} diff --git a/ui/src/workflow-canvas/nodes/ai-chat-node/agent.ts b/ui/src/workflow-canvas/nodes/ai-chat-node/agent.ts new file mode 100644 index 00000000000..a9bf0ab0fd0 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/ai-chat-node/agent.ts @@ -0,0 +1,204 @@ +import { pick } from 'lodash' +import { TOOL_TYPE } from '@/api/enums' +import type { ToolItem } from '@/api/types' +import { useWorkflowStore } from '@/workflow-canvas/store' +import { getNode, readNodeData, validatePromptVariables, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { argReference, finishWithValidate, getModelsTool, MODEL_FIELD_SCHEMA, resolveModelFields, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import { ErrorResult } from '@/workflow-canvas/ai-generate/tools/result' +import type { AgentTool, JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' +import type { AiChatNodeForm, ToolResourceOption } from './types' + +/** + * ai-chat-node(AI 对话)的 AI 生成配置;node_data 协议以节点视图 index.vue 的 defaultForm 为准。 + * 入参(提示词素材、图片、视频)由父 agent 绑定;config 即 update 的参数,写入并校验通过后交付节点的输出字段(config.fields)。 + * 关联应用(application_ids)与自定义 MCP 服务(mcp_servers)暂不由 AI 配置。 + */ + +/** 节点「工具」可选的工具类型,与资源设置的工具选择弹窗一致;Skills 与 MCP 各自单列 */ +const NODE_TOOL_TYPES: string[] = [TOOL_TYPE.CUSTOM, TOOL_TYPE.WORKFLOW, TOOL_TYPE.INTERNAL] + +const ID_LIST_SCHEMA = (description: string) => ({ type: 'array', items: { type: 'string' }, description }) + +interface SkillResources { + tools: ToolItem[] + skills: ToolItem[] + mcps: ToolItem[] +} + +/** 节点可挂载的技能资源:工具、Skills、MCP,均只取已启用的 */ +async function loadSkillResources(context: NodeAgentContext): Promise { + const store = useWorkflowStore(context.bindings.resourceScope) + const [toolGroups, skills, mcps] = await Promise.all([ + Promise.all(NODE_TOOL_TYPES.map((toolType) => store.getToolListWithShared({ tool_type: toolType }))), + store.getToolListWithShared({ tool_type: TOOL_TYPE.SKILL }), + store.getToolListWithShared({ tool_type: TOOL_TYPE.MCP }), + ]) + const activeOf = (resources: ToolItem[], toolTypes: string[]) => + resources.filter((resource) => resource.is_active && toolTypes.includes(resource.tool_type)) + return { + tools: activeOf(toolGroups.flat(), NODE_TOOL_TYPES), + skills: activeOf(skills, [TOOL_TYPE.SKILL]), + mcps: activeOf(mcps, [TOOL_TYPE.MCP]), + } +} + +/** 把模型给的 id 列表对照真实资源,返回节点回显用的快照;有不存在的 id 时返回错误文本 */ +function resolveResources(ids: unknown, resources: ToolItem[], label: string): ToolResourceOption[] | string { + if (!Array.isArray(ids)) return `${label} 应为 id 数组` + const missingIds = ids.filter((id) => !resources.some((resource) => resource.id === id)) + if (missingIds.length) return `${label} 中的 ${missingIds.join('、')} 不存在或未启用,先 get_skill_resources 查询` + return ids.map((id) => pick(resources.find((resource) => resource.id === id)!, ['desc', 'icon', 'id', 'name', 'source', 'tool_type'])) +} + +function skillResourcesTool(context: NodeAgentContext): AgentTool { + return { + name: 'get_skill_resources', + description: '列出可挂到本节点的技能资源:tools(工具)、skills(Skills)、mcps(MCP),均为 [{id,name,desc}]。', + parameters: { type: 'object', properties: {} }, + apply: async () => { + const { tools, skills, mcps } = await loadSkillResources(context) + const brief = (resources: ToolItem[]) => resources.map(({ id, name, desc }) => ({ id, name, desc })) + return { tools: brief(tools), skills: brief(skills), mcps: brief(mcps) } + }, + } +} + +const params: JsonObjectSchema = { + type: 'object', + properties: { + materials: { type: 'array', items: {}, description: '提示词素材:用户问题、检索结果、上游输出等,写进提示词' }, + images: { type: 'array', description: '要识别的图片', 'x-bind': 'image_list' }, + videos: { type: 'array', description: '要识别的视频', 'x-bind': 'video_list' }, + }, + required: ['materials'], +} + +const config: JsonObjectSchema = { + type: 'object', + properties: { + model: MODEL_FIELD_SCHEMA, + system: { type: 'string', description: '系统提示词(角色设定),写清身份、目标、回答规范与限制' }, + prompt: { type: 'string', description: '用户提示词,以 {{节点名称.字段}} 写入入参 materials' }, + dialogue_number: { type: 'number', description: '携带的历史对话轮数,0 表示不带历史,默认 1' }, + dialogue_type: { type: 'string', enum: ['WORKFLOW', 'NODE'], description: '历史来源:WORKFLOW 工作流问答(默认),NODE 本节点问答' }, + is_result: { type: 'boolean', description: '是否把回答输出给用户:最终回答为 true,中间加工为 false' }, + vision: { type: 'boolean', description: '是否开启视觉(识别图片 / 视频),绑定了入参 images / videos 时默认开启' }, + tool_ids: ID_LIST_SCHEMA('挂载的工具 id 列表(get_skill_resources.tools),传空数组表示清空'), + skill_tool_ids: ID_LIST_SCHEMA('挂载的 Skills id 列表(get_skill_resources.skills),传空数组表示清空'), + mcp_tool_ids: ID_LIST_SCHEMA('挂载的 MCP id 列表(get_skill_resources.mcps),传空数组表示清空'), + mcp_output_enable: { type: 'boolean', description: '是否输出技能的执行过程,默认 true' }, + reasoning_content_enable: { type: 'boolean', description: '是否输出思考过程(仅思考模型需要)' }, + }, + required: ['model', 'prompt'], +} + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + answer: { type: 'string', description: '回答内容' }, + reasoning_content: { type: 'string', description: '思考过程(开启输出思考时有值)' }, + history_message: { type: 'array', description: '本节点的历史消息' }, + }, + additionalProperties: false, +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'ai-chat-node(AI 对话)配置要点:', + '- model:工作流已配置默认模型时传 "default",否则先 get_models 选真实模型 id。', + '- system:角色设定,写清身份、目标、回答规范与限制(按用户需求生成完整内容,不要留占位)。', + '- prompt:用户提示词,必填,按已绑定入参 materials 给出的 {{节点名称.字段}} 写法引用素材;基于检索时同时引用检索结果与用户问题。', + '- dialogue_number / dialogue_type:携带的历史对话轮数(0 表示不带历史);历史来源 WORKFLOW 为整个工作流的问答,NODE 为本节点自己的问答。', + '- is_result:是否把回答输出给用户;作为流程最终回答时为 true,作为中间加工步骤时为 false。', + '- vision:图片 / 视频已由入参 images / videos 绑定,绑定时默认开启视觉,无需配置。', + '- tool_ids / skill_tool_ids / mcp_tool_ids:需求要求节点调用工具、Skills 或 MCP 时,先 get_skill_resources 取真实 id 再挂载;不需要时不传。', + '- mcp_output_enable:是否把技能的执行过程输出给用户,默认 true。', + ].join('\n'), + tools: [ + { + name: 'update', + description: + '配置当前 AI 对话节点:模型、系统/用户提示词、历史对话、是否输出给用户、视觉、技能(工具 / Skills / MCP)、是否输出思考过程。只传要设置的字段,其余保持不变。' + + '写入后自动校验,通过即配置完成并交付本节点的输出字段;不通过返回 error 继续修。', + parameters: config, + apply: async (args) => { + const node = getNode(context.lf, context.nodeId) + if (!node) return new ErrorResult(`节点不存在: ${context.nodeId}`) + const modelFields = resolveModelFields(context, args.model) + if (typeof modelFields === 'string') return new ErrorResult(modelFields) + const promptError = + validatePromptVariables(context.lf, context.nodeId, args.prompt, 'prompt') ?? + validatePromptVariables(context.lf, context.nodeId, args.system, 'system') + if (promptError) return new ErrorResult(promptError) + const nodeData = readNodeData>(node) + + // 视觉:绑定了图片 / 视频入参时默认开启 + const hasMedia = !!(argReference(context, 'images') || argReference(context, 'videos')) + const visionFields: Partial = { + vision: typeof args.vision === 'boolean' ? args.vision : (nodeData.vision || hasMedia), + } + + // 技能挂载:id 对照真实资源,同时写回节点回显所需的快照 + const skillFields: Partial = {} + if (args.tool_ids !== undefined || args.skill_tool_ids !== undefined || args.mcp_tool_ids !== undefined) { + const resources = await loadSkillResources(context) + if (args.tool_ids !== undefined) { + const tools = resolveResources(args.tool_ids, resources.tools, 'tool_ids') + if (typeof tools === 'string') return new ErrorResult(tools) + Object.assign(skillFields, { tool_ids: tools.map(({ id }) => id), tool_list: tools }) + } + if (args.skill_tool_ids !== undefined) { + const skills = resolveResources(args.skill_tool_ids, resources.skills, 'skill_tool_ids') + if (typeof skills === 'string') return new ErrorResult(skills) + Object.assign(skillFields, { skill_tool_ids: skills.map(({ id }) => id), skill_tool_list: skills }) + } + if (args.mcp_tool_ids !== undefined) { + const mcps = resolveResources(args.mcp_tool_ids, resources.mcps, 'mcp_tool_ids') + if (typeof mcps === 'string') return new ErrorResult(mcps) + Object.assign(skillFields, { mcp_tool_ids: mcps.map(({ id }) => id), mcp_source: 'referencing' }) + } + } + + writeNodeProperties(node, { + node_data: { + ...nodeData, + ...modelFields, + ...visionFields, + ...skillFields, + system: typeof args.system === 'string' ? args.system : (nodeData.system ?? ''), + prompt: String(args.prompt ?? nodeData.prompt ?? ''), + dialogue_number: Number(args.dialogue_number ?? nodeData.dialogue_number ?? 1), + dialogue_type: args.dialogue_type === 'NODE' || args.dialogue_type === 'WORKFLOW' ? args.dialogue_type : (nodeData.dialogue_type ?? 'WORKFLOW'), + is_result: typeof args.is_result === 'boolean' ? args.is_result : (nodeData.is_result ?? true), + mcp_output_enable: typeof args.mcp_output_enable === 'boolean' ? args.mcp_output_enable : (nodeData.mcp_output_enable ?? true), + model_setting: { + reasoning_content_start: '', + reasoning_content_end: '', + ...nodeData.model_setting, + reasoning_content_enable: + typeof args.reasoning_content_enable === 'boolean' + ? args.reasoning_content_enable + : !!nodeData.model_setting?.reasoning_content_enable, + }, + }, + }) + // 写入后自动校验:通过返回 AgentSuccessResult 交付输出字段并结束,不通过返回 ErrorResult 让模型续修 + return finishWithValidate(context) + }, + }, + getModelsTool(context), + skillResourcesTool(context), + ], + }) +} + +export default { + description: + '调用大模型生成回答,可流式输出给用户,可挂载工具 / Skills / MCP。面向用户的最终回答 is_result=true,中间加工步骤为 false;' + + '基于知识库回答时提示词同时引用检索结果 data 与用户问题,并要求仅依据已知信息作答;后一步加工时引用前一步的 answer', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/ai-chat-node/index.ts b/ui/src/workflow-canvas/nodes/ai-chat-node/index.ts index 63ad7e2d14e..7b35a1b73c7 100644 --- a/ui/src/workflow-canvas/nodes/ai-chat-node/index.ts +++ b/ui/src/workflow-canvas/nodes/ai-chat-node/index.ts @@ -1,5 +1,6 @@ import AiChatNode from './index.vue' import AiChatNodeDetail from './details/index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types.ts' @@ -9,4 +10,4 @@ class AiChatNodeView extends WorkflowNodeView { } } -export default { type: WorkflowNodeType.AiChat, model: WorkflowNodeModel, view: AiChatNodeView, details: AiChatNodeDetail } +export default { type: WorkflowNodeType.AiChat, model: WorkflowNodeModel, view: AiChatNodeView, details: AiChatNodeDetail, agent } diff --git a/ui/src/workflow-canvas/nodes/condition-node/agent.ts b/ui/src/workflow-canvas/nodes/condition-node/agent.ts new file mode 100644 index 00000000000..1dd99416a5f --- /dev/null +++ b/ui/src/workflow-canvas/nodes/condition-node/agent.ts @@ -0,0 +1,161 @@ +import { randomId } from '@/utils/common' +import { getNode, readNodeData, refreshBranchAnchors, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { CONDITION_SCHEMA, createValidateTool, normalizeConditions, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { AgentTool, JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** + * condition-node(判断器)的 AI 生成配置。 + * 分支结构固定为 1 个 IF(首)+ 0..N 个 ELSE IF + 1 个 ELSE(尾),每个分支一个出口锚点 `${nodeId}_${branchId}_right`。 + * 分支按 id 逐个增、改、删,不整体替换,保证已连的出口不漂移;删除分支时同步清理其出口的下游连线。 + */ + +interface Branch { + id: string + type: string + condition: 'and' | 'or' + conditions: unknown[] +} + +const BRANCH_PARAMETERS = { + condition: { type: 'string', enum: ['and', 'or'], description: '本分支多条件之间:and 全部满足 / or 任一满足,默认 and' }, + conditions: { type: 'array', items: CONDITION_SCHEMA, description: '本分支的条件,至少一条' }, +} + +const params: JsonObjectSchema = { + type: 'object', + properties: { + fields: { type: 'array', items: {}, description: '分支条件里被比较的上游变量,可多个' }, + }, +} + +const config: JsonObjectSchema = { + type: 'object', + properties: { + branches: { + type: 'array', + items: { type: 'object', properties: BRANCH_PARAMETERS }, + description: 'IF / ELSE IF 分支及其条件,ELSE 固定兜底;经分支工具逐个配置', + }, + }, + required: ['branches'], +} + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + branch_name: { type: 'string', description: '命中的分支名称' }, + }, + additionalProperties: false, +} + +function useAgent(context: NodeAgentContext) { + const node = () => getNode(context.lf, context.nodeId)! + + const readBranches = (): Branch[] => { + const branches = readNodeData<{ branch?: Branch[] }>(node()).branch + if (Array.isArray(branches) && branches.length) return branches + return [ + { id: randomId(), type: 'IF', condition: 'and', conditions: [] }, + { id: randomId(), type: 'ELSE', condition: 'and', conditions: [] }, + ] + } + + /** 写回分支:ELSE 固定在末尾,条件分支按顺序重排类型,并刷新出口锚点 */ + const writeBranches = (branches: Branch[]) => { + const conditionBranches = branches.filter((branch) => branch.type !== 'ELSE') + const elseBranch = branches.find((branch) => branch.type === 'ELSE') ?? { id: randomId(), type: 'ELSE', condition: 'and', conditions: [] } + conditionBranches.forEach((branch, index) => (branch.type = index === 0 ? 'IF' : `ELSE IF ${index}`)) + writeNodeProperties(node(), { node_data: { ...readNodeData>(node()), branch: [...conditionBranches, elseBranch] } }) + refreshBranchAnchors(node()) + } + + const describe = (branch: Branch) => ({ + branch_id: branch.id, + type: branch.type, + source_anchor_id: `${context.nodeId}_${branch.id}_right`, + condition: branch.condition, + conditions: branch.conditions, + }) + + const tools: AgentTool[] = [ + { + name: 'get_branches', + description: '查看本节点当前全部分支(含默认的 IF 与 ELSE)及其 branch_id、出口 source_anchor_id、条件。增改删前先调用它。', + parameters: { type: 'object', properties: {} }, + apply: () => ({ branches: readBranches().map(describe) }), + }, + { + name: 'update_branch', + description: '原地修改 IF 或某个 ELSE IF 分支的条件,branch_id 与出口不变、已连的边不受影响。默认 IF 分支要先用它配好条件。', + parameters: { type: 'object', properties: { branch_id: { type: 'string' }, ...BRANCH_PARAMETERS }, required: ['branch_id', 'conditions'] }, + apply: (args) => { + const branches = readBranches() + const branch = branches.find((item) => item.id === args.branch_id) + if (!branch) return { error: `分支不存在: ${args.branch_id}` } + if (branch.type === 'ELSE') return { error: 'ELSE 分支没有条件' } + const conditions = normalizeConditions(context, args.conditions, 'conditions') + if (typeof conditions === 'string') return { error: conditions } + if (!conditions.length) return { error: '条件分支至少一条条件' } + branch.conditions = conditions + if (args.condition) branch.condition = args.condition === 'or' ? 'or' : 'and' + writeBranches(branches) + return describe(branch) + }, + }, + { + name: 'add_branch', + description: '新增一个 ELSE IF 分支(自动插在 ELSE 前),返回其 branch_id 与出口 source_anchor_id,供建图 agent 连线。', + parameters: { type: 'object', properties: BRANCH_PARAMETERS, required: ['conditions'] }, + apply: (args) => { + const conditions = normalizeConditions(context, args.conditions, 'conditions') + if (typeof conditions === 'string') return { error: conditions } + if (!conditions.length) return { error: '条件分支至少一条条件' } + const branches = readBranches() + const branch: Branch = { id: randomId(), type: 'ELSE IF', condition: args.condition === 'or' ? 'or' : 'and', conditions } + branches.splice(Math.max(branches.length - 1, 0), 0, branch) + writeBranches(branches) + return describe(branch) + }, + }, + { + name: 'delete_branch', + description: '删除一个 ELSE IF 分支,并清理其出口已连的下游连线。IF 与 ELSE 固定保留。', + parameters: { type: 'object', properties: { branch_id: { type: 'string' } }, required: ['branch_id'] }, + apply: (args) => { + const branches = readBranches() + const index = branches.findIndex((item) => item.id === args.branch_id) + if (index < 0) return { error: `分支不存在: ${args.branch_id}` } + if (index === 0 || branches[index]!.type === 'ELSE') return { error: '只能删除 ELSE IF 分支(IF 与 ELSE 固定保留)' } + const anchorId = `${context.nodeId}_${args.branch_id}_right` + const edges = context.lf.graphModel.edges.filter((edge) => edge.sourceAnchorId === anchorId) + edges.forEach((edge) => context.lf.deleteEdge(edge.id)) + branches.splice(index, 1) + writeBranches(branches) + return { deleted: args.branch_id, removedEdges: edges.length } + }, + }, + createValidateTool(context), + ] + + return useNodeAgent(context, { + prompt: [ + 'condition-node(判断器)配置要点:', + '- 固定结构:IF(第一个)+ 0..N 个 ELSE IF + ELSE(最后)。IF 与 ELSE 固定存在;ELSE IF 用 add_branch 增、delete_branch 删。', + '- 先 get_branches 看现有分支(默认 1 个 IF + 1 个 ELSE);先想清楚要分几路、每路判什么。', + '- 第一路条件用 update_branch 配到默认 IF 上,其余每一路各 add_branch 一条 ELSE IF;ELSE 留作兜底。', + '- 条件 field 使用已绑定入参 fields 中的引用;compare 取枚举值;为空/不为空/为真/不为真无需 value。', + '- 全部配好后调 validate 收尾。结论里逐条列出各分支的 branch_id、source_anchor_id 与条件含义,供建图 agent 连线。', + ].join('\n'), + tools, + }) +} + +export default { + description: + '相当于 if / else if / else:按条件把流程分到不同出口(IF、ELSE IF n、ELSE 各一个出口);先 configure_node 配好分支,再 get_node_anchors 取各分支出口接下游。' + + '常见:知识库检索后 is_hit_handling_method_list 非空 → 指定回复引用 directly_return,paragraph_list 非空 → AI 对话,否则 → 兜底回复', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/condition-node/index.ts b/ui/src/workflow-canvas/nodes/condition-node/index.ts index 27ad2a68724..5139509df33 100644 --- a/ui/src/workflow-canvas/nodes/condition-node/index.ts +++ b/ui/src/workflow-canvas/nodes/condition-node/index.ts @@ -1,5 +1,6 @@ import ConditionNodeVue from './index.vue' import ConditionNodeDetail from './details/index.vue' +import agent from './agent' import { type BaseEdgeModel, type Model } from '@logicflow/core' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -72,4 +73,4 @@ class ConditionModel extends WorkflowNodeModel { } } -export default { type: WorkflowNodeType.Condition, model: ConditionModel, view: ConditionNodeView, details: ConditionNodeDetail } +export default { type: WorkflowNodeType.Condition, model: ConditionModel, view: ConditionNodeView, details: ConditionNodeDetail, agent } diff --git a/ui/src/workflow-canvas/nodes/document-extract-node/agent.ts b/ui/src/workflow-canvas/nodes/document-extract-node/agent.ts new file mode 100644 index 00000000000..c565e75f11a --- /dev/null +++ b/ui/src/workflow-canvas/nodes/document-extract-node/agent.ts @@ -0,0 +1,38 @@ +import { createUpdateTool, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** document-extract-node(文档内容提取)的 AI 生成配置:只有入参,无需额外配置 */ + +const params: JsonObjectSchema = { + type: 'object', + properties: { + documents: { type: 'array', description: '要提取内容的文档,常来自开始节点用户上传的 document', 'x-bind': 'document_list' }, + }, + required: ['documents'], +} + +const config: JsonObjectSchema = { type: 'object', properties: {} } + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + content: { type: 'string', description: '提取出的文档文本,适合放进提示词' }, + document_list: { type: 'array', description: '文档列表' }, + }, + additionalProperties: false, +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: ['document-extract-node(文档内容提取)配置要点:', '- 文档已由入参 documents 绑定,无需其他配置,直接调用 update 完成校验。'].join('\n'), + tools: [createUpdateTool(context, config, () => null)], + }) +} + +export default { + description: '提取用户上传文档的文本内容,供下游 AI 对话基于文档回答(如总结、问答)', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/document-extract-node/index.ts b/ui/src/workflow-canvas/nodes/document-extract-node/index.ts index 7ae673f1cc6..5cf233e1e60 100644 --- a/ui/src/workflow-canvas/nodes/document-extract-node/index.ts +++ b/ui/src/workflow-canvas/nodes/document-extract-node/index.ts @@ -1,5 +1,6 @@ import DocumentExtractNode from './index.vue' import DocumentExtractNodeDetail from './details/index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -9,4 +10,4 @@ class DocumentExtractNodeView extends WorkflowNodeView { } } -export default { type: WorkflowNodeType.DocumentExtractNode, model: WorkflowNodeModel, view: DocumentExtractNodeView, details: DocumentExtractNodeDetail } +export default { type: WorkflowNodeType.DocumentExtractNode, model: WorkflowNodeModel, view: DocumentExtractNodeView, details: DocumentExtractNodeDetail, agent } diff --git a/ui/src/workflow-canvas/nodes/form-node/agent.ts b/ui/src/workflow-canvas/nodes/form-node/agent.ts new file mode 100644 index 00000000000..c4398ab4b05 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/form-node/agent.ts @@ -0,0 +1,200 @@ +import { getNode, readNodeData, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { createUpdateTool, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** 可由 AI 生成的字段类型,数据结构与 mk-dynamics-form 各字段配置器的 getData 一致 */ +const INPUT_TYPES = [ + 'TextInput', + 'TextareaInput', + 'PasswordInput', + 'SingleSelect', + 'MultiSelect', + 'RadioCard', + 'RadioRow', + 'MultiRow', + 'SwitchInput', + 'Slider', + 'DatePicker', +] as const +type FormInputType = (typeof INPUT_TYPES)[number] + +const TEXT_TYPES: FormInputType[] = ['TextInput', 'TextareaInput', 'PasswordInput'] +const OPTION_TYPES: FormInputType[] = ['SingleSelect', 'MultiSelect', 'RadioCard', 'RadioRow', 'MultiRow'] +const MULTI_OPTION_TYPES: FormInputType[] = ['MultiSelect', 'MultiRow'] +const DATE_FORMATS: Record = { year: 'YYYY', month: 'YYYY-MM', date: 'YYYY-MM-DD', datetime: 'YYYY-MM-DD HH:mm:ss' } + +interface FormFieldArgs { + field: string + label: string + input_type: FormInputType + required?: boolean + tooltip?: string + default_value?: unknown + options?: string[] + min_length?: number + max_length?: number + min?: number + max?: number + step?: number + date_type?: keyof typeof DATE_FORMATS +} + +/** 文本类字段的长度校验规则,与文本配置器一致 */ +function textRules(field: FormFieldArgs, required: boolean, minLength: number, maxLength: number) { + const lengthRule = { min: minLength, max: maxLength, message: `${field.label}长度在 ${minLength} 到 ${maxLength} 个字符`, trigger: 'blur' } + if (!required) return [lengthRule] + const requiredRule = field.input_type === 'PasswordInput' ? { required: true, message: `请输入 ${field.label}` } : { whitespace: true, required: true, message: `请输入 ${field.label}` } + return [requiredRule, lengthRule] +} + +function toFormField(field: FormFieldArgs) { + const required = field.required !== false + const base = { + field: field.field, + label: { label: field.label, input_type: 'TooltipLabel', attrs: { tooltip: field.tooltip ?? '' }, props_info: {} }, + input_type: field.input_type, + required, + show_default_value: true, + visibility_rules: null, + } + if (TEXT_TYPES.includes(field.input_type)) { + const minLength = Math.max(0, Number(field.min_length ?? 0)) + const maxLength = Math.max(minLength || 1, Number(field.max_length ?? 200)) + const typeAttrs = + field.input_type === 'TextareaInput' ? { rows: 3 } : field.input_type === 'PasswordInput' ? { type: 'password', 'show-password': true } : {} + return { + ...base, + default_value: field.default_value ?? '', + attrs: { maxlength: maxLength, minlength: minLength, 'show-word-limit': true, ...typeAttrs }, + props_info: { rules: textRules(field, required, minLength, maxLength) }, + } + } + if (OPTION_TYPES.includes(field.input_type)) { + return { + ...base, + attrs: {}, + default_value: field.default_value ?? (MULTI_OPTION_TYPES.includes(field.input_type) ? [] : ''), + text_field: 'label', + value_field: 'value', + assignment_method: 'custom', + option_list: (field.options ?? []).map((option) => ({ label: option, value: option })), + } + } + if (field.input_type === 'Slider') { + const min = Number(field.min ?? 0) + const max = Number(field.max ?? 20) + return { + ...base, + attrs: { min, max, step: Number(field.step ?? 1), 'show-input-controls': true, 'show-input': true }, + props_info: { rules: [{ message: `请输入 ${field.label}`, trigger: 'blur', required }] }, + default_value: field.default_value ?? min, + } + } + if (field.input_type === 'DatePicker') { + const dateType = field.date_type && DATE_FORMATS[field.date_type] ? field.date_type : 'datetime' + const format = DATE_FORMATS[dateType] + return { ...base, attrs: { type: dateType, format, 'value-format': format }, default_value: field.default_value ?? '' } + } + return { ...base, attrs: {}, default_value: field.default_value ?? false } +} + +/** form-node(表单收集)的 AI 生成配置 */ + +const params: JsonObjectSchema = { type: 'object', properties: {} } + +const config: JsonObjectSchema = { + type: 'object', + properties: { + fields: { + type: 'array', + items: { + type: 'object', + properties: { + field: { type: 'string' }, + label: { type: 'string' }, + input_type: { type: 'string', enum: [...INPUT_TYPES] }, + required: { type: 'boolean', description: '是否必填,默认 true' }, + tooltip: { type: 'string', description: '字段提示说明(可选)' }, + default_value: { description: '默认值(可选);多选类为数组,开关为布尔,滑块为数字' }, + options: { type: 'array', items: { type: 'string' }, description: '选项类字段的选项' }, + min_length: { type: 'number', description: '文本类最小长度,默认 0' }, + max_length: { type: 'number', description: '文本类最大长度,默认 200' }, + min: { type: 'number', description: '滑块最小值,默认 0' }, + max: { type: 'number', description: '滑块最大值,默认 20' }, + step: { type: 'number', description: '滑块步长,默认 1' }, + date_type: { type: 'string', enum: Object.keys(DATE_FORMATS), description: '日期粒度,默认 datetime' }, + }, + required: ['field', 'label', 'input_type'], + }, + description: '要收集的表单字段:field 英文变量名,label 显示名,input_type 控件类型', + }, + form_content_format: { type: 'string', description: '引导文字,含 {{form}}' }, + }, + required: ['fields'], +} + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + form_data: { type: 'object', description: '表单全部内容(字段名 → 填写值)' }, + }, + additionalProperties: { + description: '每个表单字段一个返回字段:字段名为 config.fields[].field;类型按控件:开关 boolean、滑块 number、多选 array,其余 string', + 'x-from': 'fields', + }, +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'form-node(表单收集)配置要点:', + '- fields:要收集的字段,field 为英文变量名(字母开头,下划线分隔),label 为中文显示名。', + '- input_type:文本 TextInput / 多行文本 TextareaInput / 密码 PasswordInput;单选下拉 SingleSelect / 多选下拉 MultiSelect / 选项卡 RadioCard / 单行选项卡 RadioRow / 单行多选卡 MultiRow(选项类需给 options);开关 SwitchInput;滑块 Slider(min / max / step);日期 DatePicker(date_type)。', + '- 可选:required(默认必填)、tooltip 提示、default_value 默认值、文本类 min_length / max_length。', + '- form_content_format:展示给用户的引导文字,必须包含占位符 {{form}}(表单渲染位置)。', + '- 下游可用 [本节点ID, 字段名] 或 {{节点名称.字段名}} 引用各字段。', + ].join('\n'), + tools: [ + createUpdateTool(context, config, (args) => { + const node = getNode(context.lf, context.nodeId)! + const fields = (Array.isArray(args.fields) ? args.fields : []) as FormFieldArgs[] + if (!fields.length) return 'fields 至少一个字段' + const names = new Set() + for (const field of fields) { + if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(String(field.field))) return `字段名不合法:${field.field}` + if (names.has(field.field)) return `字段名重复:${field.field}` + if (!INPUT_TYPES.includes(field.input_type)) return `input_type 不支持:${field.input_type}` + if (OPTION_TYPES.includes(field.input_type) && !field.options?.length) return `选项字段 ${field.field} 需要 options` + if (field.input_type === 'Slider' && Number(field.max ?? 20) <= Number(field.min ?? 0)) return `滑块字段 ${field.field} 的 max 需大于 min` + names.add(field.field) + } + const format = + typeof args.form_content_format === 'string' && args.form_content_format.includes('{{form}}') + ? args.form_content_format + : '你好,请先填写下面表单内容:\n{{form}}\n填写后请点击【提交】按钮进行提交。' + writeNodeProperties(node, { + node_data: { + ...readNodeData>(node), + is_result: true, + form_field_list: fields.map(toFormField), + form_content_format: format, + }, + config: { + ...(node.properties.config as Record), + fields: [{ label: '表单全部内容', value: 'form_data' }, ...fields.map((field) => ({ label: field.label, value: field.field }))], + }, + }) + node.clearNextNodeField(true) + return null + }), + ], + }) +} + +export default { + description: '向用户展示表单并等待提交,提交后流程继续;用于先收集信息(报名、工单、预约)再处理,下游以 {{表单节点名称.字段名}} 引用填写值', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/form-node/index.ts b/ui/src/workflow-canvas/nodes/form-node/index.ts index 44ae71d3d31..f9caec194a4 100644 --- a/ui/src/workflow-canvas/nodes/form-node/index.ts +++ b/ui/src/workflow-canvas/nodes/form-node/index.ts @@ -1,5 +1,6 @@ import FormNodeVue from './index.vue' import FormNodeDetail from './details/index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -17,4 +18,4 @@ class FormNodeModel extends WorkflowNodeModel { } } -export default { type: WorkflowNodeType.FormNode, model: FormNodeModel, view: FormNodeView, details: FormNodeDetail } +export default { type: WorkflowNodeType.FormNode, model: FormNodeModel, view: FormNodeView, details: FormNodeDetail, agent } diff --git a/ui/src/workflow-canvas/nodes/image-generate-node/agent.ts b/ui/src/workflow-canvas/nodes/image-generate-node/agent.ts new file mode 100644 index 00000000000..070334148b8 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/image-generate-node/agent.ts @@ -0,0 +1,80 @@ +import { getNode, readNodeData, validatePromptVariables, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { createUpdateTool, getModelsTool, MODEL_FIELD_SCHEMA, modelSlot, resolveModelFields, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** image-generate-node(图片生成)的 AI 生成配置;node_data 协议以节点视图 index.vue 的 defaultForm 为准 */ + +const MODEL = modelSlot('TTI', '文生图模型') + +const params: JsonObjectSchema = { + type: 'object', + properties: { + materials: { type: 'array', items: {}, description: '提示词素材:用户需求、上游输出等,写进提示词' }, + }, +} + +const config: JsonObjectSchema = { + type: 'object', + properties: { + model: MODEL_FIELD_SCHEMA, + prompt: { type: 'string', description: '正向提示词:要生成什么样的图片,以 {{节点名称.字段}} 写入入参 materials' }, + negative_prompt: { type: 'string', description: '反向提示词:不希望出现的内容,可省略' }, + dialogue_number: { type: 'number', description: '携带的历史对话轮数,默认 0' }, + dialogue_type: { type: 'string', enum: ['NODE', 'WORKFLOW'], description: '历史来源:NODE 本节点问答(默认),WORKFLOW 工作流问答' }, + is_result: { type: 'boolean', description: '是否把结果输出给用户,默认 true' }, + }, + required: ['model', 'prompt'], +} + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + answer: { type: 'string', description: '含生成图片的回答内容,可直接输出给用户' }, + image: { type: 'array', description: '生成的图片列表' }, + }, + additionalProperties: false, +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'image-generate-node(图片生成)配置要点:', + '- model:工作流已配置默认文生图模型时传 "default",否则先 get_models 选真实模型 id。', + '- prompt 写清要生成的图片的主体、风格与细节,按已绑定入参 materials 的写法引用素材;negative_prompt 可省略。', + '- is_result:结果直接给用户时为 true。', + ].join('\n'), + tools: [ + createUpdateTool(context, config, (args) => { + const node = getNode(context.lf, context.nodeId)! + const modelFields = resolveModelFields(context, args.model, MODEL) + if (typeof modelFields === 'string') return modelFields + const error = + validatePromptVariables(context.lf, context.nodeId, args.prompt, 'prompt') ?? + validatePromptVariables(context.lf, context.nodeId, args.negative_prompt, 'negative_prompt') + if (error) return error + const nodeData = readNodeData>(node) + writeNodeProperties(node, { + node_data: { + ...nodeData, + ...modelFields, + prompt: String(args.prompt ?? nodeData.prompt ?? ''), + negative_prompt: typeof args.negative_prompt === 'string' ? args.negative_prompt : (nodeData.negative_prompt ?? ''), + dialogue_number: Number(args.dialogue_number ?? nodeData.dialogue_number ?? 0), + dialogue_type: args.dialogue_type === 'NODE' || args.dialogue_type === 'WORKFLOW' ? args.dialogue_type : (nodeData.dialogue_type ?? 'NODE'), + is_result: typeof args.is_result === 'boolean' ? args.is_result : (nodeData.is_result ?? true), + }, + }) + return null + }), + getModelsTool(context, MODEL), + ], + }) +} + +export default { + description: '用文生图模型按提示词生成图片', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/image-generate-node/index.ts b/ui/src/workflow-canvas/nodes/image-generate-node/index.ts index e21eeebf5ec..a245093c102 100644 --- a/ui/src/workflow-canvas/nodes/image-generate-node/index.ts +++ b/ui/src/workflow-canvas/nodes/image-generate-node/index.ts @@ -1,5 +1,6 @@ import ImageGenerateNodeVue from './index.vue' import ImageGenerateNodeDetail from './details/index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types.ts' @@ -14,4 +15,5 @@ export default { model: WorkflowNodeModel, view: ImageGenerateNodeView, details: ImageGenerateNodeDetail, + agent, } diff --git a/ui/src/workflow-canvas/nodes/image-to-video-node/agent.ts b/ui/src/workflow-canvas/nodes/image-to-video-node/agent.ts new file mode 100644 index 00000000000..6049606a521 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/image-to-video-node/agent.ts @@ -0,0 +1,83 @@ +import { getNode, readNodeData, validatePromptVariables, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { createUpdateTool, getModelsTool, MODEL_FIELD_SCHEMA, modelSlot, resolveModelFields, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** image-to-video-node(图生视频)的 AI 生成配置;node_data 协议以节点视图 index.vue 的 defaultForm 为准 */ + +const MODEL = modelSlot('ITV', '图生视频模型') + +const params: JsonObjectSchema = { + type: 'object', + properties: { + materials: { type: 'array', items: {}, description: '提示词素材:用户需求、上游输出等,写进提示词' }, + first_frame: { type: 'array', description: '首帧图片', 'x-bind': 'first_frame_url' }, + last_frame: { type: 'array', description: '尾帧图片(可选)', 'x-bind': 'last_frame_url' }, + }, + required: ['first_frame'], +} + +const config: JsonObjectSchema = { + type: 'object', + properties: { + model: MODEL_FIELD_SCHEMA, + prompt: { type: 'string', description: '正向提示词:要生成什么样的视频,以 {{节点名称.字段}} 写入入参 materials' }, + negative_prompt: { type: 'string', description: '反向提示词:不希望出现的内容,可省略' }, + dialogue_number: { type: 'number', description: '携带的历史对话轮数,默认 0' }, + dialogue_type: { type: 'string', enum: ['NODE', 'WORKFLOW'], description: '历史来源:NODE 本节点问答(默认),WORKFLOW 工作流问答' }, + is_result: { type: 'boolean', description: '是否把结果输出给用户,默认 true' }, + }, + required: ['model', 'prompt'], +} + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + video: { type: 'array', description: '生成的视频列表' }, + }, + additionalProperties: false, +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'image-to-video-node(图生视频)配置要点:', + '- model:工作流已配置默认图生视频模型时传 "default",否则先 get_models 选真实模型 id。', + '- prompt 写清要生成的视频的主体、风格与细节,按已绑定入参 materials 的写法引用素材;negative_prompt 可省略。', + '- 首帧 / 尾帧图片已由入参 first_frame / last_frame 绑定,无需配置。', + '- is_result:结果直接给用户时为 true。', + ].join('\n'), + tools: [ + createUpdateTool(context, config, (args) => { + const node = getNode(context.lf, context.nodeId)! + const modelFields = resolveModelFields(context, args.model, MODEL) + if (typeof modelFields === 'string') return modelFields + const error = + validatePromptVariables(context.lf, context.nodeId, args.prompt, 'prompt') ?? + validatePromptVariables(context.lf, context.nodeId, args.negative_prompt, 'negative_prompt') + if (error) return error + const nodeData = readNodeData>(node) + writeNodeProperties(node, { + node_data: { + ...nodeData, + ...modelFields, + prompt: String(args.prompt ?? nodeData.prompt ?? ''), + negative_prompt: typeof args.negative_prompt === 'string' ? args.negative_prompt : (nodeData.negative_prompt ?? ''), + dialogue_number: Number(args.dialogue_number ?? nodeData.dialogue_number ?? 0), + dialogue_type: args.dialogue_type === 'NODE' || args.dialogue_type === 'WORKFLOW' ? args.dialogue_type : (nodeData.dialogue_type ?? 'NODE'), + is_result: typeof args.is_result === 'boolean' ? args.is_result : (nodeData.is_result ?? true), + }, + }) + return null + }), + getModelsTool(context, MODEL), + ], + }) +} + +export default { + description: '用图生视频模型以首帧(可选尾帧)图片为基础按提示词生成视频;图片常来自开始节点用户上传的 image', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/image-to-video-node/index.ts b/ui/src/workflow-canvas/nodes/image-to-video-node/index.ts index c7aabd4537a..bdd568e4200 100644 --- a/ui/src/workflow-canvas/nodes/image-to-video-node/index.ts +++ b/ui/src/workflow-canvas/nodes/image-to-video-node/index.ts @@ -1,5 +1,6 @@ import ImageToVideoNodeVue from './index.vue' import ImageToVideoNodeDetail from './details/index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types.ts' @@ -14,4 +15,5 @@ export default { model: WorkflowNodeModel, view: ImageToVideoNodeView, details: ImageToVideoNodeDetail, + agent, } diff --git a/ui/src/workflow-canvas/nodes/image-understand-node/agent.ts b/ui/src/workflow-canvas/nodes/image-understand-node/agent.ts new file mode 100644 index 00000000000..bfbd506ef28 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/image-understand-node/agent.ts @@ -0,0 +1,91 @@ +import { getNode, readNodeData, validatePromptVariables, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { createUpdateTool, getModelsTool, MODEL_FIELD_SCHEMA, modelSlot, resolveModelFields, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** image-understand-node(图片理解)的 AI 生成配置;node_data 协议以节点视图 index.vue 的 defaultForm 为准 */ + +const MODEL = modelSlot('IMAGE', '视觉模型') + +const params: JsonObjectSchema = { + type: 'object', + properties: { + materials: { type: 'array', items: {}, description: '提示词素材:用户问题、上游输出等,写进提示词' }, + images: { type: 'array', description: '要识别的图片', 'x-bind': 'image_list' }, + }, + required: ['images'], +} + +const config: JsonObjectSchema = { + type: 'object', + properties: { + model: MODEL_FIELD_SCHEMA, + system: { type: 'string', description: '系统提示词(角色设定),可省略' }, + prompt: { type: 'string', description: '用户提示词:要从图片中看出什么,以 {{节点名称.字段}} 写入入参 materials' }, + dialogue_number: { type: 'number', description: '携带的历史对话轮数,默认 0' }, + dialogue_type: { type: 'string', enum: ['NODE', 'WORKFLOW'], description: '历史来源:NODE 本节点问答(默认),WORKFLOW 工作流问答' }, + is_result: { type: 'boolean', description: '是否把回答输出给用户,默认 true' }, + reasoning_content_enable: { type: 'boolean', description: '是否输出思考过程(仅思考模型需要)' }, + }, + required: ['model', 'prompt'], +} + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + answer: { type: 'string', description: '图片理解的回答内容' }, + }, + additionalProperties: false, +} + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'image-understand-node(图片理解)配置要点:', + '- model:工作流已配置默认视觉模型时传 "default",否则先 get_models 选真实模型 id。', + '- 图片已由入参 images 绑定;prompt 写清要从图片中识别什么,按已绑定入参 materials 的写法引用素材。', + '- is_result:作为最终回答时为 true,作为中间加工步骤时为 false。', + ].join('\n'), + tools: [ + createUpdateTool(context, config, (args) => { + const node = getNode(context.lf, context.nodeId)! + const modelFields = resolveModelFields(context, args.model, MODEL) + if (typeof modelFields === 'string') return modelFields + const error = + validatePromptVariables(context.lf, context.nodeId, args.prompt, 'prompt') ?? + validatePromptVariables(context.lf, context.nodeId, args.system, 'system') + if (error) return error + const nodeData = readNodeData & { model_setting?: Record }>(node) + writeNodeProperties(node, { + node_data: { + ...nodeData, + ...modelFields, + system: typeof args.system === 'string' ? args.system : (nodeData.system ?? ''), + prompt: String(args.prompt ?? nodeData.prompt ?? ''), + dialogue_number: Number(args.dialogue_number ?? nodeData.dialogue_number ?? 0), + dialogue_type: args.dialogue_type === 'NODE' || args.dialogue_type === 'WORKFLOW' ? args.dialogue_type : (nodeData.dialogue_type ?? 'NODE'), + is_result: typeof args.is_result === 'boolean' ? args.is_result : (nodeData.is_result ?? true), + model_setting: { + reasoning_content_start: '', + reasoning_content_end: '', + ...nodeData.model_setting, + reasoning_content_enable: + typeof args.reasoning_content_enable === 'boolean' + ? args.reasoning_content_enable + : !!nodeData.model_setting?.reasoning_content_enable, + }, + }, + }) + return null + }), + getModelsTool(context, MODEL), + ], + }) +} + +export default { + description: '用视觉模型理解图片并按提示词回答(看图问答、图片描述、OCR 等);图片常来自开始节点用户上传的 image', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/image-understand-node/index.ts b/ui/src/workflow-canvas/nodes/image-understand-node/index.ts index e118e623df9..a7d488ae7be 100644 --- a/ui/src/workflow-canvas/nodes/image-understand-node/index.ts +++ b/ui/src/workflow-canvas/nodes/image-understand-node/index.ts @@ -1,5 +1,6 @@ import ImageUnderstandNodeVue from './index.vue' import ImageUnderstandNodeDetail from './details/index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types.ts' @@ -14,4 +15,5 @@ export default { model: WorkflowNodeModel, view: ImageUnderstandNodeView, details: ImageUnderstandNodeDetail, + agent, } diff --git a/ui/src/workflow-canvas/nodes/intent-node/agent.ts b/ui/src/workflow-canvas/nodes/intent-node/agent.ts new file mode 100644 index 00000000000..bea7cfeb259 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/intent-node/agent.ts @@ -0,0 +1,176 @@ +import { omit } from 'lodash' +import { randomId } from '@/utils/common' +import { getNode, readNodeData, refreshBranchAnchors, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { createValidateTool, getModelsTool, MODEL_FIELD_SCHEMA, resolveModelFields, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { AgentTool, JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** + * intent-node(意图识别)的 AI 生成配置。 + * 每个分类一个出口锚点 `${nodeId}_${branchId}_right`,"其他"分类固定在末尾。 + * 分类按 id 逐个增、改、删,保证已连的出口不漂移;删除分类时同步清理其出口的下游连线。 + */ + +interface IntentBranch { + id: string + content: string + isOther: boolean +} + +const params: JsonObjectSchema = { + type: 'object', + properties: { + text: { type: 'string', description: '被分类的文本,通常为用户问题', 'x-bind': 'content_list' }, + }, + required: ['text'], +} + +const config: JsonObjectSchema = { + type: 'object', + properties: { + model: MODEL_FIELD_SCHEMA, + dialogue_number: { type: 'number', description: '历史对话轮数,默认 1' }, + dialogue_type: { type: 'string', enum: ['WORKFLOW', 'NODE'], description: '历史来源:WORKFLOW 工作流问答(默认),NODE 本节点问答' }, + branches: { type: 'array', items: { type: 'string' }, description: '分类列表,清晰互斥;"其他"固定兜底,经分类工具逐个配置' }, + }, + required: ['model', 'branches'], +} + +/** update 只写模型与历史对话,分类由 add_branch / update_branch 管理 */ +const updateSchema: JsonObjectSchema = { ...config, properties: omit(config.properties, 'branches'), required: ['model'] } + +const returns: JsonObjectSchema = { + type: 'object', + properties: { + category: { type: 'string', description: '命中的分类' }, + reason: { type: 'string', description: '分类理由' }, + }, + additionalProperties: false, +} + +function useAgent(context: NodeAgentContext) { + const node = () => getNode(context.lf, context.nodeId)! + + const readBranches = (): IntentBranch[] => { + const branches = readNodeData<{ branch?: IntentBranch[] }>(node()).branch + if (Array.isArray(branches) && branches.length) return branches + return [ + { id: randomId(), content: '', isOther: false }, + { id: randomId(), content: '其他', isOther: true }, + ] + } + + /** 写回分类:"其他"固定在末尾,并刷新出口锚点 */ + const writeBranches = (branches: IntentBranch[]) => { + const other = branches.find((branch) => branch.isOther) ?? { id: randomId(), content: '其他', isOther: true } + writeNodeProperties(node(), { + node_data: { ...readNodeData>(node()), branch: [...branches.filter((branch) => !branch.isOther), other] }, + }) + refreshBranchAnchors(node()) + } + + const describe = (branch: IntentBranch) => ({ + branch_id: branch.id, + content: branch.content, + is_other: branch.isOther, + source_anchor_id: `${context.nodeId}_${branch.id}_right`, + }) + + const tools: AgentTool[] = [ + { + name: 'update', + description: '写入模型与历史对话(轮数、来源);被分类文本已由入参 text 绑定,分类用 add_branch / update_branch 管理。', + parameters: updateSchema, + apply: (args) => { + const modelFields = resolveModelFields(context, args.model) + if (typeof modelFields === 'string') return { error: modelFields } + const nodeData = readNodeData>(node()) + writeNodeProperties(node(), { + node_data: { + ...nodeData, + ...modelFields, + dialogue_type: args.dialogue_type === 'NODE' || args.dialogue_type === 'WORKFLOW' ? args.dialogue_type : (nodeData.dialogue_type ?? 'WORKFLOW'), + dialogue_number: Number(args.dialogue_number ?? nodeData.dialogue_number ?? 1), + }, + }) + return { ok: true } + }, + }, + { + name: 'get_branches', + description: '查看当前全部分类(含默认的空分类与"其他")及其 branch_id、出口 source_anchor_id。', + parameters: { type: 'object', properties: {} }, + apply: () => ({ branches: readBranches().map(describe) }), + }, + { + name: 'update_branch', + description: '原地修改某个分类的描述,branch_id 与出口不变。默认的空分类要先用它填上第一个分类。"其他"不可修改。', + parameters: { type: 'object', properties: { branch_id: { type: 'string' }, content: { type: 'string' } }, required: ['branch_id', 'content'] }, + apply: (args) => { + const branches = readBranches() + const branch = branches.find((item) => item.id === args.branch_id) + if (!branch) return { error: `分类不存在: ${args.branch_id}` } + if (branch.isOther) return { error: '"其他"分类不可修改' } + if (!String(args.content ?? '').trim()) return { error: 'content 不能为空' } + branch.content = String(args.content).trim() + writeBranches(branches) + return describe(branch) + }, + }, + { + name: 'add_branch', + description: '新增一个分类(自动插在"其他"前),返回其 branch_id 与出口 source_anchor_id。', + parameters: { + type: 'object', + properties: { content: { type: 'string', description: '分类描述,清晰且与其他分类互斥' } }, + required: ['content'], + }, + apply: (args) => { + if (!String(args.content ?? '').trim()) return { error: 'content 不能为空' } + const branches = readBranches() + const branch: IntentBranch = { id: randomId(), content: String(args.content).trim(), isOther: false } + writeBranches([...branches.filter((item) => !item.isOther), branch, ...branches.filter((item) => item.isOther)]) + return describe(branch) + }, + }, + { + name: 'delete_branch', + description: '删除一个分类,并清理其出口已连的下游连线。"其他"固定保留,且至少保留一个普通分类。', + parameters: { type: 'object', properties: { branch_id: { type: 'string' } }, required: ['branch_id'] }, + apply: (args) => { + const branches = readBranches() + const branch = branches.find((item) => item.id === args.branch_id) + if (!branch) return { error: `分类不存在: ${args.branch_id}` } + if (branch.isOther) return { error: '"其他"分类固定保留' } + if (branches.filter((item) => !item.isOther).length <= 1) return { error: '至少保留一个普通分类' } + const anchorId = `${context.nodeId}_${branch.id}_right` + const edges = context.lf.graphModel.edges.filter((edge) => edge.sourceAnchorId === anchorId) + edges.forEach((edge) => context.lf.deleteEdge(edge.id)) + writeBranches(branches.filter((item) => item.id !== branch.id)) + return { deleted: branch.id, removedEdges: edges.length } + }, + }, + getModelsTool(context), + createValidateTool(context), + ] + + return useNodeAgent(context, { + prompt: [ + 'intent-node(意图识别)配置要点:', + '- 先 update 写入 model(已配置默认模型时传 "default",否则 get_models 选 id);被分类文本已由入参 text 绑定。', + '- 再管理分类:get_branches 看现状;第一个分类用 update_branch 填到默认的空分类上,其余各 add_branch 一个;"其他"固定在末尾作兜底,无需新增。', + '- 分类描述要清晰互斥(如 "售前咨询"、"售后问题")。', + '- 全部配好后调 validate 收尾。结论里逐条列出各分类的 branch_id 与 source_anchor_id,供建图 agent 连线。', + ].join('\n'), + tools, + }) +} + +export default { + description: + '相当于 switch:用大模型把文本归入预设分类,每个分类一个出口,"其他"兜底;用于按问题类型走不同处理(售前 / 售后、闲聊 / 业务)。' + + '先 configure_node 配好分类,再 get_node_anchors 取各分类出口接下游', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/intent-node/index.ts b/ui/src/workflow-canvas/nodes/intent-node/index.ts index a3871dd6a6b..15e0e6430e8 100644 --- a/ui/src/workflow-canvas/nodes/intent-node/index.ts +++ b/ui/src/workflow-canvas/nodes/intent-node/index.ts @@ -1,5 +1,6 @@ import IntentNodeViewVue from './index.vue' import IntentNodeDetail from './details/index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' import { randomId } from '@/utils/common' @@ -123,4 +124,5 @@ export default { model: IntentNodeModel, view: IntentNodeView, details: IntentNodeDetail, + agent, } diff --git a/ui/src/workflow-canvas/nodes/loop-body-node/index.ts b/ui/src/workflow-canvas/nodes/loop-body-node/index.ts index 9819a52a3d6..29c27079819 100644 --- a/ui/src/workflow-canvas/nodes/loop-body-node/index.ts +++ b/ui/src/workflow-canvas/nodes/loop-body-node/index.ts @@ -1,11 +1,13 @@ import LoopBodyNodeVue from './index.vue' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' +import type LogicFlow from '@logicflow/core' import type { Model } from '@logicflow/core' class LoopBodyModel extends WorkflowNodeModel { setLoopBody?: () => void loopLayout?: () => void + getLoopLogicFlow?: () => LogicFlow | null getDefaultAnchor(): Model.AnchorConfig[] { return [{ x: this.x, y: this.y - this.height / 2, id: `${this.id}_children`, type: 'children', edgeAddable: false }] diff --git a/ui/src/workflow-canvas/nodes/loop-body-node/index.vue b/ui/src/workflow-canvas/nodes/loop-body-node/index.vue index 9d58bff186a..37498ae87eb 100644 --- a/ui/src/workflow-canvas/nodes/loop-body-node/index.vue +++ b/ui/src/workflow-canvas/nodes/loop-body-node/index.vue @@ -100,7 +100,7 @@ const renderGraphData = (data?: LogicFlow.GraphConfigData) => { textEdit: false, adjustEdge: false, adjustEdgeStartAndEnd: false, - background: { backgroundColor: '#f5f6f7' }, + background: { background: '#f5f6f7' }, grid: { size: 20, type: 'dot', config: { color: '#DEE0E3', thickness: 1 } }, keyboard: { enabled: true }, isSilentMode: false, @@ -166,6 +166,8 @@ onMounted(() => { set(model, 'set_loop_body', setLoopBody) set(model, 'validate', validate) set(model, 'loopLayout', loopLayout) + // 供工作流 AI 生成按循环节点 id 定位循环体画布 + set(model, 'getLoopLogicFlow', () => lf.value) }) onUnmounted(() => { diff --git a/ui/src/workflow-canvas/nodes/loop-break-node/agent.ts b/ui/src/workflow-canvas/nodes/loop-break-node/agent.ts new file mode 100644 index 00000000000..1ac0ffb4222 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/loop-break-node/agent.ts @@ -0,0 +1,4 @@ +import { createLoopConditionAgent } from '@/workflow-canvas/ai-generate/tools/node' +import { WorkflowNodeType } from '@/workflow-canvas/types' + +export default createLoopConditionAgent(WorkflowNodeType.LoopBreakNode, 'Break') diff --git a/ui/src/workflow-canvas/nodes/loop-break-node/index.ts b/ui/src/workflow-canvas/nodes/loop-break-node/index.ts index 4fc6c31821f..fd35a64caa0 100644 --- a/ui/src/workflow-canvas/nodes/loop-break-node/index.ts +++ b/ui/src/workflow-canvas/nodes/loop-break-node/index.ts @@ -1,5 +1,6 @@ import LoopBreakNodeVue from './index.vue' import LoopBreakNodeDetail from './details/index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -14,4 +15,5 @@ export default { model: WorkflowNodeModel, view: LoopBreakNodeView, details: LoopBreakNodeDetail, + agent, } diff --git a/ui/src/workflow-canvas/nodes/loop-continue-node/agent.ts b/ui/src/workflow-canvas/nodes/loop-continue-node/agent.ts new file mode 100644 index 00000000000..c8859290f06 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/loop-continue-node/agent.ts @@ -0,0 +1,4 @@ +import { createLoopConditionAgent } from '@/workflow-canvas/ai-generate/tools/node' +import { WorkflowNodeType } from '@/workflow-canvas/types' + +export default createLoopConditionAgent(WorkflowNodeType.LoopContinueNode, 'Continue') diff --git a/ui/src/workflow-canvas/nodes/loop-continue-node/index.ts b/ui/src/workflow-canvas/nodes/loop-continue-node/index.ts index bf2bae73e2e..637516372ad 100644 --- a/ui/src/workflow-canvas/nodes/loop-continue-node/index.ts +++ b/ui/src/workflow-canvas/nodes/loop-continue-node/index.ts @@ -1,5 +1,6 @@ import LoopContinueNodeVue from './index.vue' import LoopContinueNodeDetail from './details/index.vue' +import agent from './agent' import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' import { WorkflowNodeType } from '@/workflow-canvas/types' @@ -14,4 +15,5 @@ export default { model: WorkflowNodeModel, view: LoopContinueNodeView, details: LoopContinueNodeDetail, + agent, } diff --git a/ui/src/workflow-canvas/nodes/loop-node/agent.ts b/ui/src/workflow-canvas/nodes/loop-node/agent.ts new file mode 100644 index 00000000000..1c62d2706e2 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/loop-node/agent.ts @@ -0,0 +1,60 @@ +import { getNode, readNodeData, writeNodeProperties } from '@/workflow-canvas/ai-generate/tools/canvas' +import { argReference, createUpdateTool, useNodeAgent } from '@/workflow-canvas/ai-generate/tools/node' +import type { JsonObjectSchema, NodeAgentContext, NodeAgentModule } from '@/workflow-canvas/ai-generate/type' + +/** loop-node(循环)的 AI 生成配置 */ + +const params: JsonObjectSchema = { + type: 'object', + properties: { + array: { type: 'array', description: 'ARRAY 模式遍历的数组', 'x-bind': 'array' }, + }, +} + +const config: JsonObjectSchema = { + type: 'object', + properties: { + loop_type: { type: 'string', enum: ['ARRAY', 'NUMBER', 'LOOP'], description: 'ARRAY 遍历入参 array,NUMBER 固定次数,LOOP 无限循环' }, + number: { type: 'number', description: 'NUMBER 时的次数' }, + }, + required: ['loop_type'], +} + +const returns: JsonObjectSchema = { type: 'object', properties: {}, additionalProperties: false } + +function useAgent(context: NodeAgentContext) { + return useNodeAgent(context, { + prompt: [ + 'loop-node(循环)配置要点:只配循环本身,循环体由建图 agent 另行搭建。', + '- loop_type:ARRAY 遍历已绑定入参 array;NUMBER 循环 number 次;LOOP 无限循环(循环体内必须放 Break)。', + ].join('\n'), + tools: [ + createUpdateTool(context, config, (args) => { + const node = getNode(context.lf, context.nodeId)! + const loopType = String(args.loop_type) + if (!['ARRAY', 'NUMBER', 'LOOP'].includes(loopType)) return `loop_type 不合法:${loopType}` + const array = argReference(context, 'array') + if (loopType === 'ARRAY' && !array) return 'ARRAY 模式需要父节点传入入参 array,请改用 NUMBER / LOOP 或说明缺少该入参' + writeNodeProperties(node, { + node_data: { + ...readNodeData>(node), + loop_type: loopType, + array: loopType === 'ARRAY' && array ? [...array] : [], + number: loopType === 'NUMBER' ? Math.max(1, Number(args.number ?? 1)) : 1, + }, + }) + return null + }), + ], + }) +} + +export default { + description: + '相当于 for:ARRAY 遍历数组、NUMBER 固定次数、LOOP 无限循环(循环体内必须放 Break)。循环体在 lf_id=该循环节点 id 的画布中搭建,' + + '首节点接 loop-start-node,以 [loop-start-node, item] / [loop-start-node, index] 取当前元素与下标;要遍历的数组常由参数提取的 array 参数产出', + params, + config, + returns, + useAgent, +} satisfies NodeAgentModule diff --git a/ui/src/workflow-canvas/nodes/loop-node/details/index.vue b/ui/src/workflow-canvas/nodes/loop-node/details/index.vue index b988e911c2a..dcb80a727d2 100644 --- a/ui/src/workflow-canvas/nodes/loop-node/details/index.vue +++ b/ui/src/workflow-canvas/nodes/loop-node/details/index.vue @@ -18,12 +18,25 @@ const props = withDefaults( }, ) -// 每一轮循环的子节点执行详情,键为轮次下标。 -const loopKeys = computed(() => Object.keys(props.data?.loop_node_data || {})) -const currentLoop = ref(loopKeys.value[0] ?? '') +// 每一轮循环的子节点执行详情:新引擎为 children(按轮次的二维数组),兼容历史数据的 loop_node_data(以轮次下标为键的对象)。 +const loopRounds = computed(() => { + if (Array.isArray(props.data?.children)) return props.data.children + return Object.values(props.data?.loop_node_data || {}).map((round) => Object.values(round || {}) as ExecutionNodeDetail[]) +}) +const loopKeys = computed(() => loopRounds.value.map((_, index) => String(index))) +const currentLoop = ref('0') + +// 循环设置:新引擎放在 params 内,历史数据在顶层。 +const loopType = computed(() => props.data?.params?.loop_type ?? props.data?.loop_type) +const loopSetting = computed(() => { + const params = props.data?.params ?? props.data + if (loopType.value === 'NUMBER') return params?.number ?? '-' + if (loopType.value === 'ARRAY') return Array.isArray(params?.array) ? JSON.stringify(params.array) : '-' + return loopKeys.value.length || '-' +}) // 当前选中轮次的子节点数组,交回顶层 dispatcher 递归渲染(其内部已按 index 排序、按类型分发)。 -const currentLoopDetail = computed(() => Object.values(props.data?.loop_node_data?.[currentLoop.value] || {})) +const currentLoopDetail = computed(() => loopRounds.value[Number(currentLoop.value)] || [])