From 6ea65f644b6a63db82265498dfa04a9b285732f0 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Sat, 14 Mar 2026 23:42:53 +0800 Subject: [PATCH] Fix issue (#6560) * perf: mcp json schema type * fix: workflow form value reset * fix: ts * fix: test --- .claude/CLAUDE.md | 7 +- .../workflow-debug-form-input-bug-analysis.md | 199 ---------- .../ssrf-vulnerability-fix.md | 0 .../issue/workflow-form-input-restore-bug.md | 343 ++++++++++++++++++ document/data/doc-last-modified.json | 2 +- packages/global/common/system/constants.ts | 1 + packages/global/core/app/jsonschema.ts | 59 ++- packages/service/common/system/utils.ts | 3 + packages/service/core/app/mcp.ts | 34 +- .../core/workflow/dispatch/ai/extract.ts | 9 +- .../workflow/dispatch/ai/tool/toolCall.ts | 14 +- packages/service/support/wallet/sub/utils.ts | 2 +- packages/web/hooks/useConfirm.tsx | 4 +- .../core/app/FileSelector/index.tsx | 56 ++- .../components/core/app/formRender/index.tsx | 1 + .../core/chat/ChatContainer/ChatBox/utils.ts | 15 + .../core/chat/components/AIResponseBox.tsx | 27 +- .../Interactive/InteractiveComponents.tsx | 4 +- .../Flow/nodes/render/NodeCard.tsx | 57 +-- test/cases/global/core/app/jsonschema.test.ts | 118 ++++++ 20 files changed, 635 insertions(+), 320 deletions(-) delete mode 100644 .claude/design/workflow-debug-form-input-bug-analysis.md rename .claude/{design => issue}/ssrf-vulnerability-fix.md (100%) create mode 100644 .claude/issue/workflow-form-input-restore-bug.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 826fa11b0e..35c0d55441 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,10 +5,11 @@ ## 输出要求 1. 输出语言:中文 -2. 输出的设计文档位置:.claude/design,以 Markdown 文件为主。 +2. 输出的设计文档位置:.claude/design,问题分析文档位置: .claude/issue,以 Markdown 文件为主。 3. 输出 Plan 时,均需写入 .claude/plan 目录下,以 Markdown 文件为主。 -4. 文件输出,使用正确的编码格式,例如UTF-8。 -5. 如果用户未指明,不要随意编写总结报告。 +4. 相同需求文档,尽量写在一起,或者创建要给目录一起管理,不要随意平铺一堆不同版本的相同问题的文档。 +5. 文件输出,使用正确的编码格式,例如UTF-8。 +6. 如果用户未指明,不要随意编写总结报告。 ## 项目概述 diff --git a/.claude/design/workflow-debug-form-input-bug-analysis.md b/.claude/design/workflow-debug-form-input-bug-analysis.md deleted file mode 100644 index 656d9be65a..0000000000 --- a/.claude/design/workflow-debug-form-input-bug-analysis.md +++ /dev/null @@ -1,199 +0,0 @@ -# 工作流调试弹窗表单输入内容清空问题分析 - -## 问题描述 - -**位置**: 工作流画布右侧的 ChatTest 调试弹窗(运行测试对话窗口) -**前提**: 包含表单输入节点(用户可输入内容) -**现象**: 用户填写内容后提交,工作流继续运行。关闭调试弹窗后再打开,历史记录中的表单内容被清空 -**预期**: 内容不应被清空 - -## 数据流分析 - -### 正常流程 - -1. **用户提交表单** → `AIResponseBox.tsx` 中的 `RenderUserFormInteractive` 组件 -2. **调用 handleFormSubmit** → 将表单数据 JSON 化并通过 `onSendPrompt` 发送 -3. **发送到后端** → `/api/core/chat/chatTest` 接收请求 -4. **工作流执行** → `dispatchWorkFlow` 处理表单输入节点 -5. **保存聊天记录** → 调用 `updateInteractiveChat` 更新数据库 -6. **更新 interactive** → `saveChat.ts` 中更新 `inputForm[].value` -7. **关闭弹窗** → 调试状态保存 -8. **重新打开弹窗** → 从数据库读取聊天记录 -9. **渲染表单** → `RenderUserFormInteractive` 使用 `item.value ?? item.defaultValue` - -### 关键代码位置 - -#### 1. 前端表单提交 (`AIResponseBox.tsx`) - -```typescript -// 第 231-237 行:计算 defaultValues -const defaultValues = useMemo(() => { - return interactive.params.inputForm?.reduce((acc: Record, item, index) => { - // 使用 ?? 运算符,只有 undefined 或 null 时才使用 defaultValue - acc[item.key] = item.value ?? item.defaultValue; - return acc; - }, {}); -}, [interactive]); -``` - -#### 2. 后端保存逻辑 (`saveChat.ts`) - -```typescript -// 第 495-525 行:更新 inputForm 值 -if ( - (finalInteractive.type === 'userInput' || finalInteractive.type === 'agentPlanAskUserForm') && - typeof parsedUserInteractiveVal === 'object' -) { - finalInteractive.params.inputForm = finalInteractive.params.inputForm.map((item) => { - const itemValue = parsedUserInteractiveVal[item.key]; - if (itemValue === undefined) return item; - - return { - ...item, - value: itemValue // ✅ 保存用户输入的值 - }; - }); - finalInteractive.params.submitted = true; // ✅ 标记为已提交 -} - -// 第 533 行:将更新后的 interactive 赋值给最后一条消息 -chatItem.value[chatItem.value.length - 1].interactive = interactive; -``` - -#### 3. API 调用 (`chatTest.ts`) - -```typescript -// 第 263-267 行:根据是否有 interactive 选择保存方式 -if (interactive) { - await updateInteractiveChat({ - interactive, - ...params - }); -} else { - await pushChatRecords(params); -} -``` - -## 问题排查 - -需要验证以下几点: - -1. **后端是否正确保存了 `inputForm[].value`?** - - 检查数据库中的 `chat_items` 集合 - - 查看 `value` 字段中的 `interactive.params.inputForm` 是否包含用户提交的值 - -2. **前端是否正确读取了保存的值?** - - 检查 `getChatRecords` API 返回的数据 - - 查看 `interactive.params.inputForm[].value` 是否存在 - -3. **是否有其他地方覆盖了 `interactive` 数据?** - - 检查是否有缓存或状态管理覆盖了数据库的值 - -## 调试步骤 - -### 1. 检查数据库保存 - -在 `saveChat.ts` 的 `updateInteractiveChat` 函数中添加日志: - -```typescript -// 第 498 行之后 -finalInteractive.params.inputForm = finalInteractive.params.inputForm.map((item) => { - const itemValue = parsedUserInteractiveVal[item.key]; - if (itemValue === undefined) return item; - - console.log('Saving form value:', { key: item.key, value: itemValue }); // 添加日志 - - return { - ...item, - value: itemValue - }; -}); -``` - -### 2. 检查 API 返回数据 - -在 `AIResponseBox.tsx` 中添加日志: - -```typescript -// 第 231 行之后 -const defaultValues = useMemo(() => { - console.log('Interactive data:', interactive); // 添加日志 - console.log('InputForm:', interactive.params.inputForm); // 添加日志 - - return interactive.params.inputForm?.reduce((acc: Record, item, index) => { - console.log('Form item:', { key: item.key, value: item.value, defaultValue: item.defaultValue }); // 添加日志 - acc[item.key] = item.value ?? item.defaultValue; - return acc; - }, {}); -}, [interactive]); -``` - -### 3. 检查数据库记录 - -直接查询 MongoDB: - -```javascript -db.chat_items.find({ - chatId: "your_chat_id", - obj: "AI" -}).sort({ _id: -1 }).limit(1) -``` - -查看返回的 `value` 字段中的 `interactive.params.inputForm` 是否包含 `value` 属性。 - -## 可能的原因 - -### 原因 1: 数据库未正确保存 - -如果 `updateInteractiveChat` 没有被正确调用,或者保存失败,数据库中就不会有用户提交的值。 - -**验证方法**: 检查数据库记录 - -### 原因 2: API 返回数据不完整 - -如果 `getChatRecords` API 没有返回完整的 `interactive` 数据,前端就无法显示用户提交的值。 - -**验证方法**: 检查 API 响应 - -### 原因 3: 前端状态管理问题 - -如果前端有缓存或状态管理覆盖了数据库的值,也会导致表单内容被清空。 - -**验证方法**: 检查 React 组件的 props 和 state - -## 临时解决方案 - -如果问题是由于数据库未正确保存导致的,可以使用 `sessionStorage` 作为临时方案: - -```typescript -// 在 AIResponseBox.tsx 的 defaultValues 计算中 -const defaultValues = useMemo(() => { - // 尝试从 sessionStorage 恢复数据 - let savedData: Record = {}; - if (typeof window !== 'undefined') { - try { - const saved = sessionStorage.getItem(`interactiveForm_${chatItemDataId}`); - if (saved) { - savedData = JSON.parse(saved); - } - } catch (error) { - console.warn('Failed to parse saved form data:', error); - } - } - - return interactive.params.inputForm?.reduce((acc: Record, item, index) => { - // 优先使用 item.value,其次使用 sessionStorage,最后使用 defaultValue - acc[item.key] = item.value ?? savedData[item.key] ?? item.defaultValue; - return acc; - }, {}); -}, [interactive, chatItemDataId]); -``` - -但这只是临时方案,根本问题还是需要确保数据库正确保存了用户提交的值。 - -## 下一步 - -1. 添加日志验证数据流 -2. 检查数据库记录 -3. 根据调试结果确定具体原因 -4. 实施修复方案 diff --git a/.claude/design/ssrf-vulnerability-fix.md b/.claude/issue/ssrf-vulnerability-fix.md similarity index 100% rename from .claude/design/ssrf-vulnerability-fix.md rename to .claude/issue/ssrf-vulnerability-fix.md diff --git a/.claude/issue/workflow-form-input-restore-bug.md b/.claude/issue/workflow-form-input-restore-bug.md new file mode 100644 index 0000000000..57b7dabf31 --- /dev/null +++ b/.claude/issue/workflow-form-input-restore-bug.md @@ -0,0 +1,343 @@ +# 工作流表单输入节点重新打开预览页面后表单内容恢复默认值问题 + +## 问题描述 + +在工作流中添加表单输入节点后,在运行预览页面进行对话测试时: + +1. 触发表单输入交互 +2. 正常填写表单并提交 +3. 任务继续运行成功 +4. **关闭预览页面** +5. **重新打开预览页面** +6. **问题**: 表单内容被恢复为默认值,而不是用户之前填写的值 + +## 根本原因分析 + +### 1. 数据结构设计 + +根据类型定义 `packages/global/core/workflow/template/system/interactive/type.ts`: + +```typescript +export type UserInputFormItemType = { + key: string; + label: string; + value: any; // 用户填写的值 + defaultValue?: any; // 默认值 + required: boolean; + // ... +} + +export type UserInputInteractive = { + type: 'userInput'; + params: { + description: string; + inputForm: UserInputFormItemType[]; + submitted?: boolean; // 是否已提交 + } +} +``` + +**设计意图**: +- `value` 字段用于存储用户填写的值 +- `submitted` 标记表单是否已提交 +- 这些数据应该保存在聊天记录的 `interactive` 对象中 + +### 2. 实际实现的问题 + +#### 问题 1: sessionStorage 的冗余使用 + +在 `AIResponseBox.tsx` 的 `RenderUserFormInteractive` 组件中(第 248-271 行): + +```typescript +if (typeof window !== 'undefined') { + const dataToSave = { ...data }; + // ... 处理文件数据 + sessionStorage.setItem(`interactiveForm_${chatItemDataId}`, JSON.stringify(dataToSave)); +} +``` + +**问题**: +- 表单提交时保存到 `sessionStorage`,但**从未读取** +- 通过全局搜索确认: 只有写入,没有任何读取操作 +- 这是一个**无效的代码**,增加了复杂度但没有实际作用 + +#### 问题 2: defaultValues 计算逻辑不完整 + +在 `RenderUserFormInteractive` 组件中(第 231-237 行): + +```typescript +const defaultValues = useMemo(() => { + return interactive.params.inputForm?.reduce((acc: Record, item) => { + acc[item.key] = item.value ?? item.defaultValue; + return acc; + }, {}); +}, [interactive]); +``` + +**逻辑**: `item.value` 优先于 `item.defaultValue` + +**问题**: 当页面重新打开时,`interactive.params` 从聊天记录中恢复,但: +- 如果后端没有正确保存用户填写的 `value` 到 `interactive.params.inputForm` +- 或者前端没有正确更新 `interactive` 对象 +- 就会导致 `item.value` 为空,回退到 `defaultValue` + +### 3. 数据流分析 + +**正常流程(应该是这样)**: +``` +用户填写表单 + → 提交时发送到后端 + → 后端更新 interactive.params.inputForm[].value + → 后端保存到聊天记录 + → 关闭预览页面 + → 重新打开预览页面 + → 从聊天记录恢复 interactive + → defaultValues 从 item.value 读取 + → 表单显示用户填写的值 ✅ +``` + +**实际流程(出问题了)**: +``` +用户填写表单 + → 提交时发送到后端 + → 后端处理但可能没有更新 interactive.params.inputForm[].value + → 或者前端没有正确更新本地的 interactive 对象 + → 关闭预览页面 + → 重新打开预览页面 + → 从聊天记录恢复 interactive + → interactive.params.inputForm[].value 为空 + → defaultValues 回退到 item.defaultValue + → 表单显示默认值 ❌ +``` + +### 4. 核心问题定位 ✅ + +**问题确认**: 前端在表单提交后,只更新了 `submitted: true`,但**没有更新 `inputForm[].value`** + +在 `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts` 的 `rewriteHistoriesByInteractiveResponse` 函数中(第 154-168 行): + +```typescript +if ( + finalInteractive.type === 'userInput' || + finalInteractive.type === 'agentPlanAskUserForm' +) { + return { + ...val, + interactive: { + ...finalInteractive, + params: { + ...finalInteractive.params, + submitted: true // ✅ 只设置了 submitted + // ❌ 但没有更新 inputForm[].value + } + } + }; +} +``` + +**分析**: +- 用户提交的表单数据在 `interactiveVal` 参数中(JSON 字符串格式) +- 函数只是简单地标记 `submitted: true` +- 没有解析 `interactiveVal` 并更新 `params.inputForm[].value` +- 导致重新打开页面时,`item.value` 仍然是空的,回退到 `defaultValue` + +## 重要发现: sessionStorage 的设计意图 + +经过深入分析,发现 `sessionStorage` 的使用**可能有其合理性**: + +### chatItemDataId 的含义 + +- `chatItemDataId` 是**每条聊天消息的唯一标识** (不是 chatId) +- 一个对话(chatId)中可能有**多条消息**,每条消息有不同的 `dataId` +- 一个工作流中可能有**多个表单输入节点**,每个节点触发时会创建新的消息 + +### 可能的场景 + +**场景 1: 同一对话中多个表单输入** +``` +对话开始 + → 触发表单输入节点 A (dataId: xxx-1) + → 用户填写表单 A + → 提交,继续执行 + → 触发表单输入节点 B (dataId: xxx-2) + → 用户填写表单 B + → 关闭预览页面 + → 重新打开 + → 需要恢复两个表单的数据 +``` + +**场景 2: 表单数据的临时性** +- 用户可能在填写过程中关闭页面(未提交) +- sessionStorage 可以保存**未提交的草稿** +- 重新打开时恢复草稿,避免用户重新填写 + +### 为什么后端保存不够? + +1. **未提交的数据**: 用户填写了一半但未提交,后端没有这些数据 +2. **多个表单实例**: 同一对话中可能有多个表单输入节点,需要分别保存 +3. **临时状态**: 表单的临时编辑状态(如文件上传中)不应该保存到后端 + +## 影响范围 + +- **影响文件**: + - `projects/app/src/components/core/chat/components/AIResponseBox.tsx` + - `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts` +- **影响组件**: `RenderUserFormInteractive`, `rewriteHistoriesByInteractiveResponse` +- **影响场景**: + - 所有使用表单输入节点的工作流 + - 在预览页面关闭后重新打开时 + - 同一对话中有多个表单输入节点时 + +## 解决方案(修正版) + +### 方案 1: 双重保存机制 - sessionStorage + interactive.params (推荐) + +结合两种机制的优点: +- **sessionStorage**: 保存未提交的草稿和临时状态 +- **interactive.params**: 保存已提交的最终数据 + +#### 步骤 1: 修复 `rewriteHistoriesByInteractiveResponse` (已提交数据) + +**文件**: `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts` + +```typescript +if ( + finalInteractive.type === 'userInput' || + finalInteractive.type === 'agentPlanAskUserForm' +) { + // 解析用户提交的表单数据 + let submittedData: Record = {}; + try { + submittedData = JSON.parse(interactiveVal); + } catch (error) { + console.warn('Failed to parse form input data', error); + } + + // 更新 inputForm 中的 value + const updatedInputForm = finalInteractive.params.inputForm.map((item) => ({ + ...item, + value: submittedData[item.key] ?? item.value ?? item.defaultValue + })); + + return { + ...val, + interactive: { + ...finalInteractive, + params: { + ...finalInteractive.params, + inputForm: updatedInputForm, + submitted: true + } + } + }; +} +``` + +#### 步骤 2: 修复 `defaultValues` 计算逻辑 (恢复草稿) + +**文件**: `projects/app/src/components/core/chat/components/AIResponseBox.tsx` + +```typescript +const defaultValues = useMemo(() => { + // 1. 优先从 sessionStorage 恢复数据(包括未提交的草稿) + let savedData: Record | null = null; + if (typeof window !== 'undefined') { + try { + const saved = sessionStorage.getItem(`interactiveForm_${chatItemDataId}`); + if (saved) { + savedData = JSON.parse(saved); + } + } catch (error) { + console.warn('Failed to restore form data from sessionStorage', error); + } + } + + // 2. 构建 defaultValues + // 优先级: sessionStorage(草稿) > item.value(已提交) > item.defaultValue(默认) + return interactive.params.inputForm?.reduce((acc: Record, item) => { + if (savedData && item.key in savedData) { + // 优先使用 sessionStorage 中的数据(可能是未提交的草稿) + acc[item.key] = savedData[item.key]; + } else { + // 否则使用 item.value(已提交的数据) 或 defaultValue + acc[item.key] = item.value ?? item.defaultValue; + } + return acc; + }, {}); +}, [interactive, chatItemDataId]); +``` + +#### 步骤 3: 清理 sessionStorage (可选优化) + +在表单提交成功后,清理对应的 sessionStorage: + +```typescript +const handleFormSubmit = useCallback( + (data: Record) => { + const finalData: Record = {}; + interactive.params.inputForm?.forEach((item) => { + if (item.key in data) { + finalData[item.key] = data[item.key]; + } + }); + + // 保存到 sessionStorage (用于页面关闭后恢复) + if (typeof window !== 'undefined') { + const dataToSave = { ...data }; + // ... 处理文件数据 + sessionStorage.setItem(`interactiveForm_${chatItemDataId}`, JSON.stringify(dataToSave)); + } + + onSendPrompt(JSON.stringify(finalData)); + + // 可选: 提交成功后清理 sessionStorage + // setTimeout(() => { + // sessionStorage.removeItem(`interactiveForm_${chatItemDataId}`); + // }, 1000); + }, + [chatItemDataId, interactive.params.inputForm] +); +``` + +**优点**: +- 保留 sessionStorage 的草稿保存功能 +- 同时修复已提交数据的持久化问题 +- 支持多个表单输入节点的场景 +- 向后兼容 + +**缺点**: +- 需要修改两个地方 +- 逻辑稍微复杂一些 + +### 方案 2: 仅修复 interactive.params (简化方案) + +如果不需要草稿保存功能,可以只修复 `rewriteHistoriesByInteractiveResponse`,删除 sessionStorage 相关代码。 + +**优点**: 简单,代码更清晰 +**缺点**: 失去草稿保存功能 + +## 推荐实施方案 + +**推荐方案 1**,原因: +1. 保留了 sessionStorage 的设计意图(草稿保存) +2. 修复了已提交数据的持久化问题 +3. 支持复杂场景(多个表单、未提交草稿) +4. 向后兼容,不破坏现有功能 + +## 相关文件 + +- `projects/app/src/components/core/chat/components/AIResponseBox.tsx` - 表单渲染和提交逻辑 +- `projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx` - 表单输入组件 +- `projects/app/src/web/core/chat/context/chatItemContext.tsx` - Chat 上下文管理 +- `packages/service/core/workflow/dispatch/interactive/formInput.ts` - 后端表单输入处理 + +## 测试建议 + +修复后需要测试以下场景: + +1. **基本场景**: 填写表单 → 提交 → 关闭预览 → 重新打开 → 验证表单内容保持 +2. **多次提交**: 填写 → 提交 → 修改 → 再次提交 → 关闭 → 重新打开 → 验证最后一次提交的内容 +3. **文件上传**: 包含文件选择的表单,验证文件信息正确恢复 +4. **必填项验证**: 验证必填项的验证逻辑不受影响 +5. **多个表单**: 同一对话中多个表单输入节点,验证各自独立保存和恢复 +6. **清空对话**: 点击"重新开始"后,验证表单数据被正确清空 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 98056c10ae..0717934084 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -235,7 +235,7 @@ "document/content/docs/self-host/upgrading/4-14/4148.mdx": "2026-03-09T17:39:53+08:00", "document/content/docs/self-host/upgrading/4-14/41481.en.mdx": "2026-03-09T12:02:02+08:00", "document/content/docs/self-host/upgrading/4-14/41481.mdx": "2026-03-09T17:39:53+08:00", - "document/content/docs/self-host/upgrading/4-14/4149.mdx": "2026-03-12T00:15:29+08:00", + "document/content/docs/self-host/upgrading/4-14/4149.mdx": "2026-03-14T22:07:04+08:00", "document/content/docs/self-host/upgrading/outdated/40.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/outdated/40.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/outdated/41.en.mdx": "2026-03-03T17:39:47+08:00", diff --git a/packages/global/common/system/constants.ts b/packages/global/common/system/constants.ts index ab7a281be6..c7e39564f1 100644 --- a/packages/global/common/system/constants.ts +++ b/packages/global/common/system/constants.ts @@ -6,5 +6,6 @@ export const DEFAULT_TEAM_AVATAR = `/imgs/avatar/defaultTeamAvatar.svg`; export const DEFAULT_ORG_AVATAR = '/imgs/avatar/defaultOrgAvatar.svg'; export const DEFAULT_USER_AVATAR = '/imgs/avatar/BlueAvatar.svg'; +export const isDevEnv = process.env.NODE_ENV === 'development'; export const isProduction = process.env.NODE_ENV === 'production'; export const isTestEnv = process.env.NODE_ENV === 'test'; diff --git a/packages/global/core/app/jsonschema.ts b/packages/global/core/app/jsonschema.ts index 557034eadf..345b351796 100644 --- a/packages/global/core/app/jsonschema.ts +++ b/packages/global/core/app/jsonschema.ts @@ -9,13 +9,51 @@ import { i18nT } from '../../../web/i18n/utils'; import z from 'zod'; export const JsonSchemaPropertiesItemSchema = z.object({ - description: z.string().optional(), - 'x-tool-description': z.string().optional(), - type: z.any(), - enum: z.array(z.string()).optional(), - minimum: z.number().optional(), - maximum: z.number().optional(), - items: z.any().optional() // Array 时候有 + // 基本类型定义 + type: z.any().optional(), // 可能不存在(使用 anyOf/oneOf 时) + + // 组合类型(JSON Schema 规范) + anyOf: z.array(z.any()).optional(), // 任意一个匹配(联合类型,如 Optional[T]) + oneOf: z.array(z.any()).optional(), // 只能匹配一个 + allOf: z.array(z.any()).optional(), // 必须全部匹配 + not: z.any().optional(), // 不匹配 + + // 枚举和常量 + enum: z.array(z.string()).optional(), // 枚举值 + const: z.any().optional(), // 常量值 + + // 字符串约束 + minLength: z.number().optional(), // 最小长度 + maxLength: z.number().optional(), // 最大长度 + pattern: z.string().optional(), // 正则表达式 + format: z.string().optional(), // 格式(email, uri, date-time 等) + + // 数字约束 + minimum: z.number().optional(), // 最小值 + maximum: z.number().optional(), // 最大值 + exclusiveMinimum: z.union([z.number(), z.boolean()]).optional(), // 排他最小值 + exclusiveMaximum: z.union([z.number(), z.boolean()]).optional(), // 排他最大值 + multipleOf: z.number().optional(), // 倍数 + + // 数组约束 + items: z.any().optional(), // 数组项类型 + minItems: z.number().optional(), // 最小项数 + maxItems: z.number().optional(), // 最大项数 + uniqueItems: z.boolean().optional(), // 唯一项 + + // 对象约束 + properties: z.record(z.string(), z.any()).optional(), // 对象属性 + required: z.array(z.string()).optional(), // 必填字段 + additionalProperties: z.union([z.boolean(), z.any()]).optional(), // 额外属性 + + // 元数据 + title: z.string().optional(), // 标题 + description: z.string().optional(), // 描述 + default: z.any().optional(), // 默认值 + examples: z.array(z.any()).optional(), // 示例 + + // 自定义扩展(FastGPT 专用) + 'x-tool-description': z.string().optional() // 工具描述 }); export type JsonSchemaPropertiesItemType = z.infer; @@ -37,16 +75,19 @@ export const getNodeInputTypeFromSchemaInputType = ({ type, arrayItems }: { - type: string; + type: string | undefined; arrayItems?: { type: string }; }) => { + // 如果 type 为 undefined,返回 any 类型(处理 anyOf/oneOf 等联合类型) + if (!type) return WorkflowIOValueTypeEnum.any; + if (type === 'string') return WorkflowIOValueTypeEnum.string; if (type === 'number' || type === 'integer') return WorkflowIOValueTypeEnum.number; if (type === 'boolean') return WorkflowIOValueTypeEnum.boolean; if (type === 'object') return WorkflowIOValueTypeEnum.object; + // Array if (type !== 'array') return WorkflowIOValueTypeEnum.any; - if (!arrayItems) return WorkflowIOValueTypeEnum.arrayAny; const itemType = arrayItems.type; diff --git a/packages/service/common/system/utils.ts b/packages/service/common/system/utils.ts index c10a0b1898..2f5321f9b9 100644 --- a/packages/service/common/system/utils.ts +++ b/packages/service/common/system/utils.ts @@ -1,8 +1,11 @@ import { isIP } from 'net'; import * as dns from 'node:dns/promises'; import { SERVICE_LOCAL_HOST } from './tools'; +import { isDevEnv } from '@fastgpt/global/common/system/constants'; export const isInternalAddress = async (url: string): Promise => { + if (isDevEnv) return false; + const isInternalIPv6 = (ip: string): boolean => { // 移除 IPv6 地址中的方括号(如果有) const cleanIp = ip.replace(/^\[|\]$/g, ''); diff --git a/packages/service/core/app/mcp.ts b/packages/service/core/app/mcp.ts index 0799043d71..aad0da6a12 100644 --- a/packages/service/core/app/mcp.ts +++ b/packages/service/core/app/mcp.ts @@ -120,24 +120,24 @@ export class MCPClient { const tools = await Promise.all( response.tools.map(async (tool) => { - let processedSchema; - - if (tool.inputSchema) { - try { - // Deep clone to avoid dereference() mutating the original object - const schemaClone = JSON.parse(JSON.stringify(tool.inputSchema)); - processedSchema = await $RefParser.dereference(schemaClone, { - resolve: { - // Disable file and HTTP $ref resolution to prevent SSRF - file: false, - http: false - } - }); - } catch (error) { - logger.error(`Failed to dereference schema for tool "${tool.name}":`, { error }); - processedSchema = tool.inputSchema; + const processedSchema = await (async () => { + if (tool.inputSchema) { + try { + // Deep clone to avoid dereference() mutating the original object + const schemaClone = JSON.parse(JSON.stringify(tool.inputSchema)); + return await $RefParser.dereference(schemaClone, { + resolve: { + // Disable file and HTTP $ref resolution to prevent SSRF + file: false, + http: false + } + }); + } catch (error) { + logger.error(`Failed to dereference schema for tool "${tool.name}":`, { error }); + return tool.inputSchema; + } } - } + })(); return { name: tool.name, diff --git a/packages/service/core/workflow/dispatch/ai/extract.ts b/packages/service/core/workflow/dispatch/ai/extract.ts index 2003b585da..20b4f7911c 100644 --- a/packages/service/core/workflow/dispatch/ai/extract.ts +++ b/packages/service/core/workflow/dispatch/ai/extract.ts @@ -31,6 +31,7 @@ import { getExtractJsonToolPrompt } from '@fastgpt/global/core/ai/prompt/agent'; import { createLLMResponse } from '../../../ai/llm/request'; +import type { JsonSchemaPropertiesItemType } from '@fastgpt/global/core/app/jsonschema'; type Props = ModuleDispatchProps<{ [NodeInputKeyEnum.history]?: ChatItemType[]; @@ -162,13 +163,7 @@ export async function dispatchContentExtract(props: Props): Promise { } const getJsonSchema = ({ params: { extractKeys } }: ActionProps) => { - const properties: Record< - string, - { - type: string; - description: string; - } - > = {}; + const properties: Record = {}; extractKeys.forEach((item) => { const jsonSchema = item.valueType ? valueTypeJsonSchemaMap[item.valueType] || toolValueTypeList[0].jsonSchema diff --git a/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts b/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts index 91afcbb8ed..aa13985603 100644 --- a/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts +++ b/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts @@ -17,6 +17,7 @@ import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { toolValueTypeList, valueTypeJsonSchemaMap } from '@fastgpt/global/core/workflow/constants'; import { runAgentCall } from '../../../../ai/llm/agentCall'; import type { ToolCallChildrenInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type'; +import type { JsonSchemaPropertiesItemType } from '@fastgpt/global/core/app/jsonschema'; type ResponseType = { requestIds: string[]; @@ -70,18 +71,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise = {}; + const properties: Record = {}; item.toolParams.forEach((item) => { const jsonSchema = item.valueType ? valueTypeJsonSchemaMap[item.valueType] || toolValueTypeList[0].jsonSchema diff --git a/packages/service/support/wallet/sub/utils.ts b/packages/service/support/wallet/sub/utils.ts index d7c0378183..d0a76addf3 100644 --- a/packages/service/support/wallet/sub/utils.ts +++ b/packages/service/support/wallet/sub/utils.ts @@ -242,7 +242,7 @@ export const getTeamPlanStatus = async ({ teamPoint.updateTeamPointsCache({ teamId, totalPoints, surplusPoints }); return { - [SubTypeEnum.standard]: + standard: standardPlan.currentSubLevel === StandardSubLevelEnum.custom && standardConstants ? { ...standardPlan, diff --git a/packages/web/hooks/useConfirm.tsx b/packages/web/hooks/useConfirm.tsx index d27035ff75..99b1ede406 100644 --- a/packages/web/hooks/useConfirm.tsx +++ b/packages/web/hooks/useConfirm.tsx @@ -124,7 +124,8 @@ export const useConfirm = (props?: { const isInputDeleteConfirmValid = !isInputDelete ? true - : !!customContentInputConfirmText && inputValue.trim() === customContentInputConfirmText; + : !!customContentInputConfirmText && + inputValue.trim() === customContentInputConfirmText.trim(); return ( setInputValue(e.target.value)} placeholder={t('common:confirm_input_delete_placeholder', { confirmText: customContentInputConfirmText diff --git a/projects/app/src/components/core/app/FileSelector/index.tsx b/projects/app/src/components/core/app/FileSelector/index.tsx index 6861f9bbd9..472475cd73 100644 --- a/projects/app/src/components/core/app/FileSelector/index.tsx +++ b/projects/app/src/components/core/app/FileSelector/index.tsx @@ -44,13 +44,15 @@ const FileSelector = ({ customFileExtensionList, canLocalUpload, canUrlUpload, - isDisabled = false + isDisabled = false, + isInvalid = false }: AppFileSelectConfigType & { value: UserInputFileItemType[]; onChange: (e: any[]) => void; canLocalUpload?: boolean; canUrlUpload?: boolean; isDisabled?: boolean; + isInvalid?: boolean; }) => { const { feConfigs } = useSystemStore(); const { teamPlanStatus } = useUserStore(); @@ -382,7 +384,7 @@ const FileSelector = ({ px={3} py={[4, 7]} border={'1.5px dashed'} - borderColor={'myGray.250'} + borderColor={isInvalid ? 'red.500' : 'myGray.250'} borderRadius={'md'} userSelect={'none'} {...(isMaxSelected || disabled @@ -394,9 +396,13 @@ const FileSelector = ({ cursor: 'pointer', _hover: { bg: 'primary.50', - borderColor: 'primary.600' + borderColor: isInvalid ? 'red.500' : 'primary.600' }, - borderColor: isDragging ? 'primary.600' : 'borderColor.high', + borderColor: isInvalid + ? 'red.500' + : isDragging + ? 'primary.600' + : 'borderColor.high', onDragEnter: handleDragEnter, onDragOver: (e) => e.preventDefault(), onDragLeave: handleDragLeave, @@ -436,17 +442,25 @@ const FileSelector = ({ /> setUrlInput(e.target.value)} onBlur={(e) => handleAddUrl(e.target.value)} border={'1.5px dashed'} - borderColor={'myGray.250'} + borderColor={isInvalid ? 'red.500' : 'myGray.250'} borderRadius={'md'} pl={8} py={1.5} placeholder={ isMaxSelected ? t('file:reached_max_file_count') : t('chat:click_to_add_url') } + _hover={{ + borderColor: isInvalid ? 'red.500' : 'myGray.300' + }} + _focus={{ + borderColor: isInvalid ? 'red.500' : 'primary.600', + boxShadow: isInvalid ? '0 0 0 1px var(--chakra-colors-red-500)' : undefined + }} /> @@ -480,15 +494,29 @@ const FileSelector = ({ {/* Status icon */} <> {!!file?.url || !!file?.error || file.process === undefined ? ( - } - onClick={() => handleDeleteFile(file?.id)} - isDisabled={disabled} - /> + + {/* View button - 查看文件 */} + {file?.url && ( + } + onClick={() => window.open(file.url, '_blank')} + /> + )} + {/* Delete button - 只在未禁用时显示 */} + {!disabled && ( + } + onClick={() => handleDeleteFile(file?.id)} + /> + )} + ) : ( { value={files} onChange={(e) => onChange?.(e)} isDisabled={isDisabled} + isInvalid={isInvalid} maxFiles={props.maxFiles} canSelectFile={props.canSelectFile} canSelectImg={props.canSelectImg} diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts b/projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts index ff5dd68272..38d80b8b16 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts @@ -155,12 +155,27 @@ export const rewriteHistoriesByInteractiveResponse = ({ finalInteractive.type === 'userInput' || finalInteractive.type === 'agentPlanAskUserForm' ) { + const submittedData: Record = (() => { + try { + return JSON.parse(interactiveVal); + } catch (error) { + return {}; + } + })(); + + // 更新 inputForm 中的 value + const updatedInputForm = finalInteractive.params.inputForm.map((item) => ({ + ...item, + value: submittedData[item.key] ?? item.value + })); + return { ...val, interactive: { ...finalInteractive, params: { ...finalInteractive.params, + inputForm: updatedInputForm, submitted: true } } diff --git a/projects/app/src/components/core/chat/components/AIResponseBox.tsx b/projects/app/src/components/core/chat/components/AIResponseBox.tsx index ad91a1eadb..399aabb056 100644 --- a/projects/app/src/components/core/chat/components/AIResponseBox.tsx +++ b/projects/app/src/components/core/chat/components/AIResponseBox.tsx @@ -245,34 +245,9 @@ const RenderUserFormInteractive = React.memo(function RenderFormInput({ } }); - if (typeof window !== 'undefined') { - const dataToSave = { ...data }; - interactive.params.inputForm?.forEach((item) => { - // 这是干啥的? - if ( - item.type === 'fileSelect' && - Array.isArray(dataToSave[item.key]) && - dataToSave[item.key].length > 0 - ) { - const files = dataToSave[item.key]; - if (files[0]?.url !== undefined) { - dataToSave[item.key] = files - .map((file: any) => ({ - url: file.url, - key: file.key, - name: file.name, - type: file.type - })) - .filter((file: any) => file.url); - } - } - }); - sessionStorage.setItem(`interactiveForm_${chatItemDataId}`, JSON.stringify(dataToSave)); - } - onSendPrompt(JSON.stringify(finalData)); }, - [chatItemDataId, interactive.params.inputForm] + [interactive.params.inputForm] ); return ( diff --git a/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx b/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx index b5b29eba6f..22358a2f64 100644 --- a/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx +++ b/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx @@ -157,7 +157,7 @@ export const FormInputComponent = React.memo(function FormInputComponent({ validate: (value) => { if (input.type === 'password' && input.minLength) { if (!value || typeof value !== 'object' || !value.value) { - return false; + return t('common:required'); } if (value.value.length < input.minLength) { return t('common:min_length', { minLenth: input.minLength }); @@ -188,7 +188,7 @@ export const FormInputComponent = React.memo(function FormInputComponent({ isInvalid={!!error} isRichText={false} /> - {error && {error.message}} + {error && error.message && {error.message}} ); }} diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx index a3b03c513b..23fc994225 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx @@ -525,8 +525,9 @@ const NodeIntro = React.memo(function NodeIntro({ intro?: string; }) { const { t } = useTranslation(); - const nodeIsTool = useContextSelector(WorkflowUtilsContext, (ctx) => - ctx.splitToolInputs([], nodeId) + const nodeIsTool = useContextSelector( + WorkflowUtilsContext, + (ctx) => ctx.splitToolInputs([], nodeId)?.isTool ); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); @@ -544,32 +545,32 @@ const NodeIntro = React.memo(function NodeIntro({ {t(intro as any) || t('app:node_not_intro')} - {nodeIsTool && ( - { - onOpenIntroModal({ - defaultVal: intro, - onSuccess(e) { - onChangeNode({ - nodeId, - type: 'attr', - key: 'intro', - value: e - }); - } - }); - }} - > - - - )} + { + onOpenIntroModal({ + defaultVal: intro, + onSuccess(e) { + onChangeNode({ + nodeId, + type: 'attr', + key: 'intro', + value: e + }); + } + }); + }} + > + + diff --git a/test/cases/global/core/app/jsonschema.test.ts b/test/cases/global/core/app/jsonschema.test.ts index f4d10994d9..e9a8fe6158 100644 --- a/test/cases/global/core/app/jsonschema.test.ts +++ b/test/cases/global/core/app/jsonschema.test.ts @@ -253,6 +253,124 @@ describe('getNodeInputTypeFromSchemaInputType', () => { }); expect(result).toBe(WorkflowIOValueTypeEnum.arrayAny); }); + + it('should return any when type is undefined (for anyOf/oneOf)', () => { + const result = getNodeInputTypeFromSchemaInputType({ + type: undefined, + arrayItems: undefined + }); + expect(result).toBe(WorkflowIOValueTypeEnum.any); + }); +}); + +describe('jsonSchema2NodeInput with anyOf/oneOf (union types)', () => { + it('should handle Optional[str] with anyOf as any type', () => { + const jsonSchema: JSONSchemaInputType = { + type: 'object', + properties: { + optional_field: { + anyOf: [{ type: 'string' }, { type: 'null' }], + description: 'An optional string field' + } + }, + required: [] + }; + + const result = jsonSchema2NodeInput({ jsonSchema, schemaType: 'mcp' }); + + expect(result).toHaveLength(1); + expect(result[0].key).toBe('optional_field'); + expect(result[0].valueType).toBe(WorkflowIOValueTypeEnum.any); + expect(result[0].required).toBe(false); + expect(result[0].description).toBe('An optional string field'); + }); + + it('should handle oneOf with null as any type', () => { + const jsonSchema: JSONSchemaInputType = { + type: 'object', + properties: { + optional_number: { + oneOf: [{ type: 'number' }, { type: 'null' }] + } + } + }; + + const result = jsonSchema2NodeInput({ jsonSchema, schemaType: 'mcp' }); + + expect(result[0].valueType).toBe(WorkflowIOValueTypeEnum.any); + }); + + it('should handle mixed schema with anyOf and regular types', () => { + const jsonSchema: JSONSchemaInputType = { + type: 'object', + properties: { + required_field: { + type: 'string', + description: 'A required string' + }, + optional_field: { + anyOf: [{ type: 'string' }, { type: 'null' }], + description: 'An optional string' + }, + number_field: { + type: 'number' + } + }, + required: ['required_field'] + }; + + const result = jsonSchema2NodeInput({ jsonSchema, schemaType: 'mcp' }); + + expect(result).toHaveLength(3); + + const requiredField = result.find((i) => i.key === 'required_field'); + expect(requiredField?.valueType).toBe(WorkflowIOValueTypeEnum.string); + expect(requiredField?.required).toBe(true); + + const optionalField = result.find((i) => i.key === 'optional_field'); + expect(optionalField?.valueType).toBe(WorkflowIOValueTypeEnum.any); + expect(optionalField?.required).toBe(false); + + const numberField = result.find((i) => i.key === 'number_field'); + expect(numberField?.valueType).toBe(WorkflowIOValueTypeEnum.number); + }); + + it('should handle weather API real-world example', () => { + const jsonSchema: JSONSchemaInputType = { + type: 'object', + properties: { + location: { + type: 'string', + description: '地点名称' + }, + date: { + anyOf: [{ type: 'string' }, { type: 'null' }], + description: '日期(可选)' + }, + forecast_type: { + type: 'string', + enum: ['daily', 'hourly', 'weekly'] + } + }, + required: ['location'] + }; + + const result = jsonSchema2NodeInput({ jsonSchema, schemaType: 'mcp' }); + + expect(result).toHaveLength(3); + + const location = result.find((i) => i.key === 'location'); + expect(location?.valueType).toBe(WorkflowIOValueTypeEnum.string); + expect(location?.required).toBe(true); + + const date = result.find((i) => i.key === 'date'); + expect(date?.valueType).toBe(WorkflowIOValueTypeEnum.any); + expect(date?.required).toBe(false); + + const forecastType = result.find((i) => i.key === 'forecast_type'); + expect(forecastType?.valueType).toBe(WorkflowIOValueTypeEnum.string); + expect(forecastType?.list).toHaveLength(3); + }); }); describe('jsonSchema2NodeOutput', () => {