Commit 6ea65f64 by Archer Committed by GitHub

Fix issue (#6560)

* perf: mcp json schema type

* fix: workflow form value reset

* fix: ts

* fix: test
parent dbc443a7
...@@ -5,10 +5,11 @@ ...@@ -5,10 +5,11 @@
## 输出要求 ## 输出要求
1. 输出语言:中文 1. 输出语言:中文
2. 输出的设计文档位置:.claude/design,以 Markdown 文件为主。 2. 输出的设计文档位置:.claude/design,问题分析文档位置: .claude/issue,以 Markdown 文件为主。
3. 输出 Plan 时,均需写入 .claude/plan 目录下,以 Markdown 文件为主。 3. 输出 Plan 时,均需写入 .claude/plan 目录下,以 Markdown 文件为主。
4. 文件输出,使用正确的编码格式,例如UTF-8。 4. 相同需求文档,尽量写在一起,或者创建要给目录一起管理,不要随意平铺一堆不同版本的相同问题的文档。
5. 如果用户未指明,不要随意编写总结报告。 5. 文件输出,使用正确的编码格式,例如UTF-8。
6. 如果用户未指明,不要随意编写总结报告。
## 项目概述 ## 项目概述
......
# 工作流调试弹窗表单输入内容清空问题分析
## 问题描述
**位置**: 工作流画布右侧的 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<string, any>, 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<string, any>, 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<string, any> = {};
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<string, any>, item, index) => {
// 优先使用 item.value,其次使用 sessionStorage,最后使用 defaultValue
acc[item.key] = item.value ?? savedData[item.key] ?? item.defaultValue;
return acc;
}, {});
}, [interactive, chatItemDataId]);
```
但这只是临时方案,根本问题还是需要确保数据库正确保存了用户提交的值。
## 下一步
1. 添加日志验证数据流
2. 检查数据库记录
3. 根据调试结果确定具体原因
4. 实施修复方案
...@@ -235,7 +235,7 @@ ...@@ -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/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.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/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.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/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", "document/content/docs/self-host/upgrading/outdated/41.en.mdx": "2026-03-03T17:39:47+08:00",
......
...@@ -6,5 +6,6 @@ export const DEFAULT_TEAM_AVATAR = `/imgs/avatar/defaultTeamAvatar.svg`; ...@@ -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_ORG_AVATAR = '/imgs/avatar/defaultOrgAvatar.svg';
export const DEFAULT_USER_AVATAR = '/imgs/avatar/BlueAvatar.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 isProduction = process.env.NODE_ENV === 'production';
export const isTestEnv = process.env.NODE_ENV === 'test'; export const isTestEnv = process.env.NODE_ENV === 'test';
...@@ -9,13 +9,51 @@ import { i18nT } from '../../../web/i18n/utils'; ...@@ -9,13 +9,51 @@ import { i18nT } from '../../../web/i18n/utils';
import z from 'zod'; import z from 'zod';
export const JsonSchemaPropertiesItemSchema = z.object({ export const JsonSchemaPropertiesItemSchema = z.object({
description: z.string().optional(), // 基本类型定义
'x-tool-description': z.string().optional(), type: z.any().optional(), // 可能不存在(使用 anyOf/oneOf 时)
type: z.any(),
enum: z.array(z.string()).optional(), // 组合类型(JSON Schema 规范)
minimum: z.number().optional(), anyOf: z.array(z.any()).optional(), // 任意一个匹配(联合类型,如 Optional[T])
maximum: z.number().optional(), oneOf: z.array(z.any()).optional(), // 只能匹配一个
items: z.any().optional() // Array 时候有 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<typeof JsonSchemaPropertiesItemSchema>; export type JsonSchemaPropertiesItemType = z.infer<typeof JsonSchemaPropertiesItemSchema>;
...@@ -37,16 +75,19 @@ export const getNodeInputTypeFromSchemaInputType = ({ ...@@ -37,16 +75,19 @@ export const getNodeInputTypeFromSchemaInputType = ({
type, type,
arrayItems arrayItems
}: { }: {
type: string; type: string | undefined;
arrayItems?: { type: string }; arrayItems?: { type: string };
}) => { }) => {
// 如果 type 为 undefined,返回 any 类型(处理 anyOf/oneOf 等联合类型)
if (!type) return WorkflowIOValueTypeEnum.any;
if (type === 'string') return WorkflowIOValueTypeEnum.string; if (type === 'string') return WorkflowIOValueTypeEnum.string;
if (type === 'number' || type === 'integer') return WorkflowIOValueTypeEnum.number; if (type === 'number' || type === 'integer') return WorkflowIOValueTypeEnum.number;
if (type === 'boolean') return WorkflowIOValueTypeEnum.boolean; if (type === 'boolean') return WorkflowIOValueTypeEnum.boolean;
if (type === 'object') return WorkflowIOValueTypeEnum.object; if (type === 'object') return WorkflowIOValueTypeEnum.object;
// Array
if (type !== 'array') return WorkflowIOValueTypeEnum.any; if (type !== 'array') return WorkflowIOValueTypeEnum.any;
if (!arrayItems) return WorkflowIOValueTypeEnum.arrayAny; if (!arrayItems) return WorkflowIOValueTypeEnum.arrayAny;
const itemType = arrayItems.type; const itemType = arrayItems.type;
......
import { isIP } from 'net'; import { isIP } from 'net';
import * as dns from 'node:dns/promises'; import * as dns from 'node:dns/promises';
import { SERVICE_LOCAL_HOST } from './tools'; import { SERVICE_LOCAL_HOST } from './tools';
import { isDevEnv } from '@fastgpt/global/common/system/constants';
export const isInternalAddress = async (url: string): Promise<boolean> => { export const isInternalAddress = async (url: string): Promise<boolean> => {
if (isDevEnv) return false;
const isInternalIPv6 = (ip: string): boolean => { const isInternalIPv6 = (ip: string): boolean => {
// 移除 IPv6 地址中的方括号(如果有) // 移除 IPv6 地址中的方括号(如果有)
const cleanIp = ip.replace(/^\[|\]$/g, ''); const cleanIp = ip.replace(/^\[|\]$/g, '');
......
...@@ -120,24 +120,24 @@ export class MCPClient { ...@@ -120,24 +120,24 @@ export class MCPClient {
const tools = await Promise.all( const tools = await Promise.all(
response.tools.map(async (tool) => { response.tools.map(async (tool) => {
let processedSchema; const processedSchema = await (async () => {
if (tool.inputSchema) {
if (tool.inputSchema) { try {
try { // Deep clone to avoid dereference() mutating the original object
// Deep clone to avoid dereference() mutating the original object const schemaClone = JSON.parse(JSON.stringify(tool.inputSchema));
const schemaClone = JSON.parse(JSON.stringify(tool.inputSchema)); return await $RefParser.dereference(schemaClone, {
processedSchema = await $RefParser.dereference(schemaClone, { resolve: {
resolve: { // Disable file and HTTP $ref resolution to prevent SSRF
// Disable file and HTTP $ref resolution to prevent SSRF file: false,
file: false, http: false
http: false }
} });
}); } catch (error) {
} catch (error) { logger.error(`Failed to dereference schema for tool "${tool.name}":`, { error });
logger.error(`Failed to dereference schema for tool "${tool.name}":`, { error }); return tool.inputSchema;
processedSchema = tool.inputSchema; }
} }
} })();
return { return {
name: tool.name, name: tool.name,
......
...@@ -31,6 +31,7 @@ import { ...@@ -31,6 +31,7 @@ import {
getExtractJsonToolPrompt getExtractJsonToolPrompt
} from '@fastgpt/global/core/ai/prompt/agent'; } from '@fastgpt/global/core/ai/prompt/agent';
import { createLLMResponse } from '../../../ai/llm/request'; import { createLLMResponse } from '../../../ai/llm/request';
import type { JsonSchemaPropertiesItemType } from '@fastgpt/global/core/app/jsonschema';
type Props = ModuleDispatchProps<{ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.history]?: ChatItemType[]; [NodeInputKeyEnum.history]?: ChatItemType[];
...@@ -162,13 +163,7 @@ export async function dispatchContentExtract(props: Props): Promise<Response> { ...@@ -162,13 +163,7 @@ export async function dispatchContentExtract(props: Props): Promise<Response> {
} }
const getJsonSchema = ({ params: { extractKeys } }: ActionProps) => { const getJsonSchema = ({ params: { extractKeys } }: ActionProps) => {
const properties: Record< const properties: Record<string, JsonSchemaPropertiesItemType> = {};
string,
{
type: string;
description: string;
}
> = {};
extractKeys.forEach((item) => { extractKeys.forEach((item) => {
const jsonSchema = item.valueType const jsonSchema = item.valueType
? valueTypeJsonSchemaMap[item.valueType] || toolValueTypeList[0].jsonSchema ? valueTypeJsonSchemaMap[item.valueType] || toolValueTypeList[0].jsonSchema
......
...@@ -17,6 +17,7 @@ import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; ...@@ -17,6 +17,7 @@ import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { toolValueTypeList, valueTypeJsonSchemaMap } from '@fastgpt/global/core/workflow/constants'; import { toolValueTypeList, valueTypeJsonSchemaMap } from '@fastgpt/global/core/workflow/constants';
import { runAgentCall } from '../../../../ai/llm/agentCall'; import { runAgentCall } from '../../../../ai/llm/agentCall';
import type { ToolCallChildrenInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import type { ToolCallChildrenInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type { JsonSchemaPropertiesItemType } from '@fastgpt/global/core/app/jsonschema';
type ResponseType = { type ResponseType = {
requestIds: string[]; requestIds: string[];
...@@ -70,18 +71,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo ...@@ -70,18 +71,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
}; };
} }
const properties: Record< const properties: Record<string, JsonSchemaPropertiesItemType> = {};
string,
{
type: string;
description: string;
enum?: string[];
required?: boolean;
items?: {
type: string;
};
}
> = {};
item.toolParams.forEach((item) => { item.toolParams.forEach((item) => {
const jsonSchema = item.valueType const jsonSchema = item.valueType
? valueTypeJsonSchemaMap[item.valueType] || toolValueTypeList[0].jsonSchema ? valueTypeJsonSchemaMap[item.valueType] || toolValueTypeList[0].jsonSchema
......
...@@ -242,7 +242,7 @@ export const getTeamPlanStatus = async ({ ...@@ -242,7 +242,7 @@ export const getTeamPlanStatus = async ({
teamPoint.updateTeamPointsCache({ teamId, totalPoints, surplusPoints }); teamPoint.updateTeamPointsCache({ teamId, totalPoints, surplusPoints });
return { return {
[SubTypeEnum.standard]: standard:
standardPlan.currentSubLevel === StandardSubLevelEnum.custom && standardConstants standardPlan.currentSubLevel === StandardSubLevelEnum.custom && standardConstants
? { ? {
...standardPlan, ...standardPlan,
......
...@@ -124,7 +124,8 @@ export const useConfirm = (props?: { ...@@ -124,7 +124,8 @@ export const useConfirm = (props?: {
const isInputDeleteConfirmValid = !isInputDelete const isInputDeleteConfirmValid = !isInputDelete
? true ? true
: !!customContentInputConfirmText && inputValue.trim() === customContentInputConfirmText; : !!customContentInputConfirmText &&
inputValue.trim() === customContentInputConfirmText.trim();
return ( return (
<MyModal <MyModal
...@@ -150,6 +151,7 @@ export const useConfirm = (props?: { ...@@ -150,6 +151,7 @@ export const useConfirm = (props?: {
<Input <Input
size={'sm'} size={'sm'}
value={inputValue} value={inputValue}
autoFocus
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
placeholder={t('common:confirm_input_delete_placeholder', { placeholder={t('common:confirm_input_delete_placeholder', {
confirmText: customContentInputConfirmText confirmText: customContentInputConfirmText
......
...@@ -44,13 +44,15 @@ const FileSelector = ({ ...@@ -44,13 +44,15 @@ const FileSelector = ({
customFileExtensionList, customFileExtensionList,
canLocalUpload, canLocalUpload,
canUrlUpload, canUrlUpload,
isDisabled = false isDisabled = false,
isInvalid = false
}: AppFileSelectConfigType & { }: AppFileSelectConfigType & {
value: UserInputFileItemType[]; value: UserInputFileItemType[];
onChange: (e: any[]) => void; onChange: (e: any[]) => void;
canLocalUpload?: boolean; canLocalUpload?: boolean;
canUrlUpload?: boolean; canUrlUpload?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
isInvalid?: boolean;
}) => { }) => {
const { feConfigs } = useSystemStore(); const { feConfigs } = useSystemStore();
const { teamPlanStatus } = useUserStore(); const { teamPlanStatus } = useUserStore();
...@@ -382,7 +384,7 @@ const FileSelector = ({ ...@@ -382,7 +384,7 @@ const FileSelector = ({
px={3} px={3}
py={[4, 7]} py={[4, 7]}
border={'1.5px dashed'} border={'1.5px dashed'}
borderColor={'myGray.250'} borderColor={isInvalid ? 'red.500' : 'myGray.250'}
borderRadius={'md'} borderRadius={'md'}
userSelect={'none'} userSelect={'none'}
{...(isMaxSelected || disabled {...(isMaxSelected || disabled
...@@ -394,9 +396,13 @@ const FileSelector = ({ ...@@ -394,9 +396,13 @@ const FileSelector = ({
cursor: 'pointer', cursor: 'pointer',
_hover: { _hover: {
bg: 'primary.50', 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, onDragEnter: handleDragEnter,
onDragOver: (e) => e.preventDefault(), onDragOver: (e) => e.preventDefault(),
onDragLeave: handleDragLeave, onDragLeave: handleDragLeave,
...@@ -436,17 +442,25 @@ const FileSelector = ({ ...@@ -436,17 +442,25 @@ const FileSelector = ({
/> />
<Input <Input
isDisabled={isMaxSelected || disabled} isDisabled={isMaxSelected || disabled}
isInvalid={isInvalid}
value={urlInput} value={urlInput}
onChange={(e) => setUrlInput(e.target.value)} onChange={(e) => setUrlInput(e.target.value)}
onBlur={(e) => handleAddUrl(e.target.value)} onBlur={(e) => handleAddUrl(e.target.value)}
border={'1.5px dashed'} border={'1.5px dashed'}
borderColor={'myGray.250'} borderColor={isInvalid ? 'red.500' : 'myGray.250'}
borderRadius={'md'} borderRadius={'md'}
pl={8} pl={8}
py={1.5} py={1.5}
placeholder={ placeholder={
isMaxSelected ? t('file:reached_max_file_count') : t('chat:click_to_add_url') 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
}}
/> />
</InputGroup> </InputGroup>
</Box> </Box>
...@@ -480,15 +494,29 @@ const FileSelector = ({ ...@@ -480,15 +494,29 @@ const FileSelector = ({
{/* Status icon */} {/* Status icon */}
<> <>
{!!file?.url || !!file?.error || file.process === undefined ? ( {!!file?.url || !!file?.error || file.process === undefined ? (
<IconButton <HStack spacing={1}>
size={'xsSquare'} {/* View button - 查看文件 */}
borderRadius={'xs'} {file?.url && (
variant={'transparentDanger'} <IconButton
aria-label={'Delete file'} size={'xsSquare'}
icon={<MyIcon name={'close'} w={'1rem'} />} variant={'grayGhost'}
onClick={() => handleDeleteFile(file?.id)} aria-label={'View file'}
isDisabled={disabled} icon={<MyIcon name={'common/viewLight'} w={'1rem'} />}
/> onClick={() => window.open(file.url, '_blank')}
/>
)}
{/* Delete button - 只在未禁用时显示 */}
{!disabled && (
<IconButton
size={'xsSquare'}
borderRadius={'xs'}
variant={'transparentDanger'}
aria-label={'Delete file'}
icon={<MyIcon name={'close'} w={'1rem'} />}
onClick={() => handleDeleteFile(file?.id)}
/>
)}
</HStack>
) : ( ) : (
<HStack w={'24px'} h={'24px'} justifyContent={'center'}> <HStack w={'24px'} h={'24px'} justifyContent={'center'}>
<CircularProgress <CircularProgress
......
...@@ -219,6 +219,7 @@ const InputRender = (props: InputRenderProps) => { ...@@ -219,6 +219,7 @@ const InputRender = (props: InputRenderProps) => {
value={files} value={files}
onChange={(e) => onChange?.(e)} onChange={(e) => onChange?.(e)}
isDisabled={isDisabled} isDisabled={isDisabled}
isInvalid={isInvalid}
maxFiles={props.maxFiles} maxFiles={props.maxFiles}
canSelectFile={props.canSelectFile} canSelectFile={props.canSelectFile}
canSelectImg={props.canSelectImg} canSelectImg={props.canSelectImg}
......
...@@ -155,12 +155,27 @@ export const rewriteHistoriesByInteractiveResponse = ({ ...@@ -155,12 +155,27 @@ export const rewriteHistoriesByInteractiveResponse = ({
finalInteractive.type === 'userInput' || finalInteractive.type === 'userInput' ||
finalInteractive.type === 'agentPlanAskUserForm' finalInteractive.type === 'agentPlanAskUserForm'
) { ) {
const submittedData: Record<string, any> = (() => {
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 { return {
...val, ...val,
interactive: { interactive: {
...finalInteractive, ...finalInteractive,
params: { params: {
...finalInteractive.params, ...finalInteractive.params,
inputForm: updatedInputForm,
submitted: true submitted: true
} }
} }
......
...@@ -245,34 +245,9 @@ const RenderUserFormInteractive = React.memo(function RenderFormInput({ ...@@ -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)); onSendPrompt(JSON.stringify(finalData));
}, },
[chatItemDataId, interactive.params.inputForm] [interactive.params.inputForm]
); );
return ( return (
......
...@@ -157,7 +157,7 @@ export const FormInputComponent = React.memo(function FormInputComponent({ ...@@ -157,7 +157,7 @@ export const FormInputComponent = React.memo(function FormInputComponent({
validate: (value) => { validate: (value) => {
if (input.type === 'password' && input.minLength) { if (input.type === 'password' && input.minLength) {
if (!value || typeof value !== 'object' || !value.value) { if (!value || typeof value !== 'object' || !value.value) {
return false; return t('common:required');
} }
if (value.value.length < input.minLength) { if (value.value.length < input.minLength) {
return t('common:min_length', { minLenth: input.minLength }); return t('common:min_length', { minLenth: input.minLength });
...@@ -188,7 +188,7 @@ export const FormInputComponent = React.memo(function FormInputComponent({ ...@@ -188,7 +188,7 @@ export const FormInputComponent = React.memo(function FormInputComponent({
isInvalid={!!error} isInvalid={!!error}
isRichText={false} isRichText={false}
/> />
{error && <FormErrorMessage>{error.message}</FormErrorMessage>} {error && error.message && <FormErrorMessage>{error.message}</FormErrorMessage>}
</FormControl> </FormControl>
); );
}} }}
......
...@@ -525,8 +525,9 @@ const NodeIntro = React.memo(function NodeIntro({ ...@@ -525,8 +525,9 @@ const NodeIntro = React.memo(function NodeIntro({
intro?: string; intro?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const nodeIsTool = useContextSelector(WorkflowUtilsContext, (ctx) => const nodeIsTool = useContextSelector(
ctx.splitToolInputs([], nodeId) WorkflowUtilsContext,
(ctx) => ctx.splitToolInputs([], nodeId)?.isTool
); );
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
...@@ -544,32 +545,32 @@ const NodeIntro = React.memo(function NodeIntro({ ...@@ -544,32 +545,32 @@ const NodeIntro = React.memo(function NodeIntro({
<Box fontSize={'sm'} color={'myGray.500'} flex={'1 0 0'}> <Box fontSize={'sm'} color={'myGray.500'} flex={'1 0 0'}>
{t(intro as any) || t('app:node_not_intro')} {t(intro as any) || t('app:node_not_intro')}
</Box> </Box>
{nodeIsTool && ( <Flex
<Flex className="node-hover-controller"
p={'7px'} visibility={nodeIsTool ? 'visible' : 'hidden'}
rounded={'sm'} p={'7px'}
alignItems={'center'} rounded={'sm'}
_hover={{ alignItems={'center'}
bg: 'myGray.100' _hover={{
}} bg: 'myGray.100'
cursor={'pointer'} }}
onClick={() => { cursor={'pointer'}
onOpenIntroModal({ onClick={() => {
defaultVal: intro, onOpenIntroModal({
onSuccess(e) { defaultVal: intro,
onChangeNode({ onSuccess(e) {
nodeId, onChangeNode({
type: 'attr', nodeId,
key: 'intro', type: 'attr',
value: e key: 'intro',
}); value: e
} });
}); }
}} });
> }}
<MyIcon name={'edit'} w={'18px'} /> >
</Flex> <MyIcon name={'edit'} w={'18px'} />
)} </Flex>
</Flex> </Flex>
<EditIntroModal maxLength={500} /> <EditIntroModal maxLength={500} />
</> </>
......
...@@ -253,6 +253,124 @@ describe('getNodeInputTypeFromSchemaInputType', () => { ...@@ -253,6 +253,124 @@ describe('getNodeInputTypeFromSchemaInputType', () => {
}); });
expect(result).toBe(WorkflowIOValueTypeEnum.arrayAny); 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', () => { describe('jsonSchema2NodeOutput', () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment