diff --git a/.claude/design/lexical-consistency-analysis.md b/.claude/design/lexical-consistency-analysis.md new file mode 100644 index 000000000..bce6966f8 --- /dev/null +++ b/.claude/design/lexical-consistency-analysis.md @@ -0,0 +1,384 @@ +# Lexical Editor 文本解析一致性分析报告 + +## 执行摘要 + +通过对 `textToEditorState` 和 `editorStateToText` 函数的全面分析,发现了 **3 个确认的不一致性问题**,会导致用户保存后重新加载时看到与编辑器显示不同的内容。 + +### 严重问题总览 + +| 问题 | 严重性 | 影响 | 位置 | +|------|--------|------|------| +| 列表项尾部空格丢失 | 🔴 高 | 用户有意添加的空格被删除 | utils.ts:255 | +| 有序列表序号重置 | 🔴 高 | 自定义序号变成连续序号 | utils.ts:257 | +| 列表项内换行不对称 | 🟡 中 | 编辑器支持但无法往返 | processListItem | + +--- + +## 问题 1: 列表项尾部空格丢失 🔴(已处理) + +### 问题描述 + +在 `processListItem` 函数中使用了 `trim()` 处理列表项文本: + +```typescript +// utils.ts:255 +const itemTextString = itemText.join('').trim(); +``` + +### 不一致性演示 + +**用户输入:** +``` +- hello world +``` +(注意 "world" 后面有 2 个空格) + +**EditorState:** +```json +{ + "type": "listitem", + "children": [ + { "type": "text", "text": "hello world " } + ] +} +``` + +**输出文本:** +``` +- hello world +``` +(尾部空格被 trim 删除) + +**重新加载:** +``` +- hello world +``` +(用户的空格永久丢失) + +### 影响分析 + +- **用户体验**: 用户有意添加的尾部空格(可能用于格式对齐)会丢失 +- **数据完整性**: 每次保存/加载循环都会丢失尾部空格 +- **严重程度**: 高 - 直接影响用户输入的完整性 + +### 解决方案 + +**方案 1: 移除 trim()** +```typescript +const itemTextString = itemText.join(''); // 不使用 trim +``` + +**方案 2: 只移除前导空格** +```typescript +const itemTextString = itemText.join('').trimStart(); // 只移除开头空格 +``` + +**推荐**: 方案 1,完全保留用户输入的空格 + +--- + +## 问题 2: 有序列表序号重置 🔴 + +### 问题描述 + +在输出有序列表时,使用 `index + 1` 而不是列表项自身的 `value`: + +```typescript +// utils.ts:257 +const prefix = listType === 'bullet' ? '- ' : `${index + 1}. `; +``` + +但在解析时,`numberValue` 被正确提取并存储到 `listItem.value`。 + +### 不一致性演示 + +**用户输入:** +``` +1. first +2. second +5. fifth +10. tenth +``` + +**解析 (textToEditorState):** +```javascript +items = [ + { numberValue: 1, text: "first" }, + { numberValue: 2, text: "second" }, + { numberValue: 5, text: "fifth" }, + { numberValue: 10, text: "tenth" } +] +``` + +**EditorState:** +```json +[ + { "value": 1, "text": "first" }, + { "value": 2, "text": "second" }, + { "value": 5, "text": "fifth" }, + { "value": 10, "text": "tenth" } +] +``` + +**输出文本 (editorStateToText):** +``` +1. first (index=0, 0+1=1) ✓ +2. second (index=1, 1+1=2) ✓ +3. fifth (index=2, 2+1=3) ✗ 应该是 5 +4. tenth (index=3, 3+1=4) ✗ 应该是 10 +``` + +**重新加载:** +用户的自定义序号 5 和 10 永久丢失,变成连续的 3 和 4。 + +### 影响分析 + +- **用户体验**: 用户有意设置的序号被强制改为连续序号 +- **数据完整性**: 有序列表的语义丢失(如章节编号 1.1, 1.2, 2.1) +- **严重程度**: 高 - 改变了用户的语义表达 + +### 解决方案 + +```typescript +// utils.ts:257 +const prefix = listType === 'bullet' + ? '- ' + : `${listItem.value || index + 1}. `; +``` + +使用 `listItem.value` 而不是 `index + 1`,保留原始序号。 + +--- + +## 问题 3: 列表项内换行不对称 🟡 + +### 问题描述 + +Lexical 编辑器允许在列表项内插入换行符 (`linebreak` 节点),但 `textToEditorState` 无法将包含换行的文本重新解析为列表项内换行。 + +### 不一致性演示 + +**用户在编辑器中操作:** +``` +1. 输入: "- item1" +2. 按 Shift+Enter (插入软换行) +3. 继续输入: "continued content" +``` + +**EditorState:** +```json +{ + "type": "listitem", + "children": [ + { "type": "text", "text": "item1" }, + { "type": "linebreak" }, + { "type": "text", "text": "continued content" } + ] +} +``` + +**输出文本 (editorStateToText):** +``` +- item1 +continued content +``` + +**重新加载 (textToEditorState):** +``` +行1: "- item1" → 列表项 +行2: "continued content" → 段落 (不再是列表项的一部分!) +``` + +**最终结构变化:** +``` +原来: 1个列表项(包含换行) +现在: 1个列表项 + 1个段落 +``` + +### 影响分析 + +- **结构完整性**: 列表项的内部结构在保存/加载后改变 +- **语义丢失**: 原本属于列表项的内容变成了独立段落 +- **严重程度**: 中 - 影响文档结构,但可能符合 Markdown 语义 + +### 解决方案 + +**方案 1: 在输出时将换行转为空格** +```typescript +if (child.type === 'linebreak') { + itemText.push(' '); // 使用空格而不是 \n +} +``` + +**方案 2: 在编辑器中禁止列表项内换行** +- 配置 Lexical 不允许在列表项内插入 linebreak +- 用户只能通过创建新列表项来换行 + +**方案 3: 支持 Markdown 风格的列表项多行** +```typescript +// 识别缩进的行为列表项的继续内容 +parseTextLine: +if (line.startsWith(' ') && prevLine.wasListItem) { + // 作为列表项的继续内容 +} +``` + +**推荐**: 方案 1 (最简单) 或方案 2 (最明确) + +--- + +## 其他潜在问题 + +### 问题 4: 变量节点保存后变成普通文本 🟡 + +**现象**: +``` +EditorState: { type: 'variableLabel', variableKey: '{{var1}}' } + ↓ +输出文本: "{{var1}}" + ↓ +重新加载: { type: 'text', text: "{{var1}}" } +``` + +**影响**: 变量节点的功能性丢失 + +**分析**: 这可能是设计决策 - 变量只在编辑会话中有效,保存到文本后变成普通占位符。如果需要保持变量功能,应该使用其他存储格式(如 JSON)而不是纯文本。 + +### 问题 5: 非连续缩进级别可能导致结构错误 🟡 + +**现象**: +``` +输入: +- level 0 + - level 2 (跳过 level 1) + - level 1 +``` + +**问题**: `buildListStructure` 可能无法正确处理非连续的缩进级别 + +**影响**: 列表嵌套结构可能不符合预期 + +**建议**: 规范化缩进级别,或在文档中说明只支持连续缩进 + +--- + +## 正常运作的部分 ✅ + +经过分析,以下功能**正常运作**,不存在一致性问题: + +1. **空行处理** - 空行被正确保留和还原 +2. **段落前导空格** - 修复后完全保留 +3. **列表和段落边界** - 正确识别和分离 +4. **特殊字符在段落中** - 只有行首的 `- ` 和 `\d+. ` 被识别为列表 +5. **混合列表类型** - bullet 和 number 列表正确分离 +6. **列表缩进** - 使用 TabStr 统一为 2 个空格 + +--- + +## 测试用例建议 + +### 测试用例 1: 列表项尾部空格 +```typescript +const input = "- hello world "; // 2个尾部空格 +const state = textToEditorState(input, true); +const editor = createEditorWithState(state); +const output = editorStateToText(editor); +expect(output).toBe("- hello world "); // 应保留空格 +``` + +### 测试用例 2: 自定义列表序号 +```typescript +const input = "1. first\n5. fifth\n10. tenth"; +const state = textToEditorState(input, true); +const editor = createEditorWithState(state); +const output = editorStateToText(editor); +expect(output).toBe("1. first\n5. fifth\n10. tenth"); // 应保留序号 +``` + +### 测试用例 3: 列表项换行 +```typescript +// 在编辑器中创建列表项并插入 linebreak +const editor = createEditor(); +// ... 创建列表项 +// ... 插入 linebreak +const output = editorStateToText(editor); +const reloadedState = textToEditorState(output, true); +const reloadedEditor = createEditorWithState(reloadedState); +// 验证结构是否一致 +expect(getStructure(editor)).toEqual(getStructure(reloadedEditor)); +``` + +### 测试用例 4: 往返对称性 +```typescript +const testCases = [ + "simple text", + " indented text", + "- bullet list\n - nested", + "1. first\n2. second\n5. fifth", + "text\n\n\nwith\n\nempty\n\nlines", + "- item ", // 尾部空格 +]; + +testCases.forEach(input => { + const state = textToEditorState(input, true); + const editor = createEditorWithState(state); + const output = editorStateToText(editor); + expect(output).toBe(input); // 应完全一致 +}); +``` + +--- + +## 修复优先级建议 + +### P0 - 立即修复 +1. ✅ **列表项尾部空格丢失** - 影响数据完整性 +2. ✅ **有序列表序号重置** - 影响语义表达 + +### P1 - 高优先级 +3. ⚠️ **列表项内换行不对称** - 影响结构一致性 + +### P2 - 按需修复 +4. 📝 **变量节点** - 根据产品需求决定 +5. 📝 **非连续缩进** - 文档说明或规范化处理 + +--- + +## 代码修改建议 + +### 修改 1: 保留列表项空格 +```diff +// utils.ts:255 +- const itemTextString = itemText.join('').trim(); ++ const itemTextString = itemText.join(''); +``` + +### 修改 2: 使用原始列表序号 +```diff +// utils.ts:257 +- const prefix = listType === 'bullet' ? '- ' : `${index + 1}. `; ++ const prefix = listType === 'bullet' ? '- ' : `${listItem.value || index + 1}. `; +``` + +### 修改 3: 处理列表项换行(方案1) +```diff +// utils.ts:242 + if (child.type === 'linebreak') { +- itemText.push('\n'); ++ itemText.push(' '); // 转为空格而不是换行 + } +``` + +--- + +## 总结 + +通过全面分析,确认了 **3 个会导致编辑器显示与解析文本不一致的问题**: + +1. 🔴 列表项尾部空格丢失 → 修复: 移除 trim() +2. 🔴 有序列表序号重置 → 修复: 使用 listItem.value +3. 🟡 列表项内换行不对称 → 修复: 转换为空格或禁止 + +其他方面(空行、前导空格、边界处理)都运作正常。 + +建议优先修复前两个 P0 问题,确保用户数据的完整性和语义准确性。 diff --git a/document/content/docs/introduction/development/design/design_plugin.mdx b/document/content/docs/introduction/development/design/design_plugin.mdx index 045362930..f0996dae4 100644 --- a/document/content/docs/introduction/development/design/design_plugin.mdx +++ b/document/content/docs/introduction/development/design/design_plugin.mdx @@ -52,7 +52,7 @@ description: FastGPT 系统插件设计方案 - **lib**: 库文件,提供工具函数和类库 - **test**: 测试相关 -系统工具的结构可以参考 [如何开发系统工具](/docs/introduction/guide/plugins/dev_system_tool)。 +系统工具的结构可以参考 [如何开发系统插件](/docs/introduction/guide/plugins/dev_system_tool)。 ## 技术细节 diff --git a/document/content/docs/introduction/guide/plugins/dev_system_tool.mdx b/document/content/docs/introduction/guide/plugins/dev_system_tool.mdx index df4cbdeb5..e8267557a 100644 --- a/document/content/docs/introduction/guide/plugins/dev_system_tool.mdx +++ b/document/content/docs/introduction/guide/plugins/dev_system_tool.mdx @@ -1,14 +1,17 @@ --- -title: 如何开发系统工具 -description: FastGPT 系统工具开发指南 +title: 如何开发系统插件 +description: FastGPT 系统插件开发指南(工具篇) --- ## 介绍 -FastGPT 系统工具项目从 4.10.0 版本后移动到独立的`fastgpt-plugin`项目中,采用纯代码的模式进行工具编写。 +FastGPT 系统插件项目从 4.10.0 版本后移动到独立的`fastgpt-plugin`项目中,采用纯代码的模式进行工具编写。 在 4.14.0 版本插件市场更新后,系统工具开发流程有所改变,请依照最新文档贡献代码。 + 你可以在`fastgpt-plugin`项目中进行独立开发和调试好插件后,直接向 FastGPT 官方提交 PR 即可,无需运行 FastGPT 主服务。 +目前系统插件仅支持“工具”这一种类型。 + ## 概念 - 工具(Tool):最小的运行单元,每个工具都有唯一 ID 和特定的输入和输出。 diff --git a/document/content/docs/toc.mdx b/document/content/docs/toc.mdx index d1591c83d..98cb49863 100644 --- a/document/content/docs/toc.mdx +++ b/document/content/docs/toc.mdx @@ -111,6 +111,7 @@ description: FastGPT 文档目录 - [/docs/upgrading/4-13/4131](/docs/upgrading/4-13/4131) - [/docs/upgrading/4-13/4132](/docs/upgrading/4-13/4132) - [/docs/upgrading/4-14/4140](/docs/upgrading/4-14/4140) +- [/docs/upgrading/4-14/4141](/docs/upgrading/4-14/4141) - [/docs/upgrading/4-8/40](/docs/upgrading/4-8/40) - [/docs/upgrading/4-8/41](/docs/upgrading/4-8/41) - [/docs/upgrading/4-8/42](/docs/upgrading/4-8/42) diff --git a/document/content/docs/upgrading/4-14/4141.mdx b/document/content/docs/upgrading/4-14/4141.mdx new file mode 100644 index 000000000..38e1ec635 --- /dev/null +++ b/document/content/docs/upgrading/4-14/4141.mdx @@ -0,0 +1,18 @@ +--- +title: 'V4.14.0(包含升级脚本)' +description: 'FastGPT V4.14.0 更新说明' +--- + + + +## 🚀 新增内容 + + +## ⚙️ 优化 + +1. 在同一轮对话中,MCP Client 会持久化实例,不会销毁。 + +## 🐛 修复 + +1. Debug 模式下,交互节点无法正常使用。 +2. 富文本编辑器 tab 空格未对齐。 diff --git a/document/content/docs/upgrading/4-14/meta.json b/document/content/docs/upgrading/4-14/meta.json new file mode 100644 index 000000000..7e7bfc15e --- /dev/null +++ b/document/content/docs/upgrading/4-14/meta.json @@ -0,0 +1,5 @@ +{ + "title": "4.14.x", + "description": "", + "pages": ["4141", "4140"] +} diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index bc105b80c..a714e1401 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -20,7 +20,7 @@ "document/content/docs/introduction/development/custom-models/xinference.mdx": "2025-08-05T23:20:39+08:00", "document/content/docs/introduction/development/design/dataset.mdx": "2025-07-23T21:35:03+08:00", "document/content/docs/introduction/development/design/design_plugin.mdx": "2025-11-04T16:58:12+08:00", - "document/content/docs/introduction/development/docker.mdx": "2025-11-05T13:57:12+08:00", + "document/content/docs/introduction/development/docker.mdx": "2025-11-05T14:16:14+08:00", "document/content/docs/introduction/development/faq.mdx": "2025-08-12T22:22:18+08:00", "document/content/docs/introduction/development/intro.mdx": "2025-09-29T11:34:11+08:00", "document/content/docs/introduction/development/migration/docker_db.mdx": "2025-07-23T21:35:03+08:00", @@ -101,7 +101,7 @@ "document/content/docs/protocol/terms.en.mdx": "2025-08-03T22:37:45+08:00", "document/content/docs/protocol/terms.mdx": "2025-08-03T22:37:45+08:00", "document/content/docs/toc.en.mdx": "2025-08-04T13:42:36+08:00", - "document/content/docs/toc.mdx": "2025-11-04T16:58:12+08:00", + "document/content/docs/toc.mdx": "2025-11-05T21:56:32+08:00", "document/content/docs/upgrading/4-10/4100.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-10/4101.mdx": "2025-09-08T20:07:20+08:00", "document/content/docs/upgrading/4-11/4110.mdx": "2025-08-05T23:20:39+08:00", @@ -114,7 +114,8 @@ "document/content/docs/upgrading/4-13/4130.mdx": "2025-11-04T15:06:39+08:00", "document/content/docs/upgrading/4-13/4131.mdx": "2025-09-30T15:47:06+08:00", "document/content/docs/upgrading/4-13/4132.mdx": "2025-10-21T11:46:53+08:00", - "document/content/docs/upgrading/4-14/4140.mdx": "2025-11-05T13:57:12+08:00", + "document/content/docs/upgrading/4-14/4140.mdx": "2025-11-05T14:16:14+08:00", + "document/content/docs/upgrading/4-14/4141.mdx": "2025-11-06T13:25:49+08:00", "document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00", diff --git a/packages/global/common/string/textSplitter.ts b/packages/global/common/string/textSplitter.ts index 634f0ef72..5563562c6 100644 --- a/packages/global/common/string/textSplitter.ts +++ b/packages/global/common/string/textSplitter.ts @@ -97,7 +97,7 @@ ${mdSplitString} if (chunkLength > maxSize) { const newChunks = commonSplit({ ...props, - text: chunk + text: chunk.replace(defaultChunk, '').trim() }).chunks; chunks.push(...newChunks); } else { diff --git a/packages/global/core/workflow/runtime/type.d.ts b/packages/global/core/workflow/runtime/type.d.ts index 7702817f7..0909c98a5 100644 --- a/packages/global/core/workflow/runtime/type.d.ts +++ b/packages/global/core/workflow/runtime/type.d.ts @@ -89,6 +89,8 @@ export type ModuleDispatchProps = ChatDispatchProps & { runtimeNodes: RuntimeNodeItemType[]; runtimeEdges: RuntimeEdgeItemType[]; params: T; + + mcpClientMemory: Record; // key: url }; export type SystemVariablesType = { diff --git a/packages/service/core/app/mcp.ts b/packages/service/core/app/mcp.ts index 220a32b43..4ea6c544e 100644 --- a/packages/service/core/app/mcp.ts +++ b/packages/service/core/app/mcp.ts @@ -59,9 +59,10 @@ export class MCPClient { } // 内部方法:关闭连接 - private async closeConnection() { + async closeConnection() { try { await retryFn(() => this.client.close(), 3); + addLog.debug(`[MCP Client] Closed connection:${this.url}`); } catch (error) { addLog.error('[MCP Client] Failed to close connection:', error); } @@ -110,7 +111,15 @@ export class MCPClient { * @param params Parameters * @returns Tool execution result */ - public async toolCall(toolName: string, params: Record): Promise { + public async toolCall({ + toolName, + params, + closeConnection = true + }: { + toolName: string; + params: Record; + closeConnection?: boolean; + }): Promise { try { const client = await this.getConnection(); addLog.debug(`[MCP Client] Call tool: ${toolName}`, params); @@ -129,7 +138,9 @@ export class MCPClient { addLog.error(`[MCP Client] Failed to call tool ${toolName}:`, error); return Promise.reject(error); } finally { - await this.closeConnection(); + if (closeConnection) { + await this.closeConnection(); + } } } } diff --git a/packages/service/core/workflow/dispatch/child/runTool.ts b/packages/service/core/workflow/dispatch/child/runTool.ts index 16fc45ed6..8eca18130 100644 --- a/packages/service/core/workflow/dispatch/child/runTool.ts +++ b/packages/service/core/workflow/dispatch/child/runTool.ts @@ -22,7 +22,6 @@ import { getNodeErrResponse } from '../utils'; import { splitCombineToolId } from '@fastgpt/global/core/app/tool/utils'; import { getAppVersionById } from '../../../../core/app/version/controller'; import { runHTTPTool } from '../../../app/http'; -import { i18nT } from '../../../../../web/i18n/utils'; type SystemInputConfigType = { type: SystemToolSecretInputTypeEnum; @@ -50,6 +49,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise & { runtimeNodes: RuntimeNodeItemType[]; @@ -152,6 +153,8 @@ export async function dispatchWorkFlow({ })) }; + let mcpClientMemory = {} as Record; + // Init some props return runWorkflow({ ...data, @@ -163,17 +166,24 @@ export async function dispatchWorkFlow({ variables: defaultVariables, workflowDispatchDeep: 0, usageId: newUsageId, - concatUsage + concatUsage, + mcpClientMemory }).finally(() => { if (streamCheckTimer) { clearInterval(streamCheckTimer); } + + // Close mcpClient connections + Object.values(mcpClientMemory).forEach((client) => { + client.closeConnection(); + }); }); } type RunWorkflowProps = ChatDispatchProps & { runtimeNodes: RuntimeNodeItemType[]; runtimeEdges: RuntimeEdgeItemType[]; + mcpClientMemory: Record; defaultSkipNodeQueue?: WorkflowDebugResponse['skipNodeQueue']; concatUsage?: (points: number) => any; }; @@ -192,7 +202,8 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise> = { ...data, - lastInteractive: data.lastInteractive?.entryNodeIds.includes(node.nodeId) + mcpClientMemory, + lastInteractive: data.lastInteractive?.entryNodeIds?.includes(node.nodeId) ? data.lastInteractive : undefined, variables, diff --git a/packages/web/components/common/Input/HttpInput/Editor.tsx b/packages/web/components/common/Input/HttpInput/Editor.tsx index 4fb3798b6..44840102d 100644 --- a/packages/web/components/common/Input/HttpInput/Editor.tsx +++ b/packages/web/components/common/Input/HttpInput/Editor.tsx @@ -30,6 +30,7 @@ import FocusPlugin from '../../Textarea/PromptEditor/plugins/FocusPlugin'; import VariableLabelPlugin from '../../Textarea/PromptEditor/plugins/VariableLabelPlugin'; import { VariableLabelNode } from '../../Textarea/PromptEditor/plugins/VariableLabelPlugin/node'; import VariableLabelPickerPlugin from '../../Textarea/PromptEditor/plugins/VariableLabelPickerPlugin'; +import { useDeepCompareEffect } from 'ahooks'; export default function Editor({ h = 40, @@ -45,7 +46,7 @@ export default function Editor({ h?: number; variables: EditorVariablePickerType[]; variableLabels: EditorVariableLabelPickerType[]; - onChange?: (editorState: EditorState, editor: LexicalEditor) => void; + onChange?: (editor: LexicalEditor) => void; onBlur?: (editor: LexicalEditor) => void; value?: string; currentValue?: string; @@ -61,11 +62,11 @@ export default function Editor({ nodes: [VariableNode, VariableLabelNode], editorState: textToEditorState(value), onError: (error: Error) => { - throw error; + console.error('Lexical errror', error); } }; - useEffect(() => { + useDeepCompareEffect(() => { if (focus) return; setKey(getNanoid(6)); }, [value, variables.length]); @@ -119,7 +120,7 @@ export default function Editor({ { startSts(() => { - onChange?.(editorState, editor); + onChange?.(editor); }); }} /> diff --git a/packages/web/components/common/Input/HttpInput/index.tsx b/packages/web/components/common/Input/HttpInput/index.tsx index ad56f088f..f2f422f46 100644 --- a/packages/web/components/common/Input/HttpInput/index.tsx +++ b/packages/web/components/common/Input/HttpInput/index.tsx @@ -30,7 +30,7 @@ const HttpInput = ({ const [currentValue, setCurrentValue] = React.useState(value); const onChangeInput = useCallback( - (editorState: EditorState, editor: LexicalEditor) => { + (editor: LexicalEditor) => { const text = editorStateToText(editor); setCurrentValue(text); onChange?.(text); diff --git a/packages/web/components/common/Textarea/PromptEditor/Editor.tsx b/packages/web/components/common/Textarea/PromptEditor/Editor.tsx index 606d9b54c..40c8b4c5b 100644 --- a/packages/web/components/common/Textarea/PromptEditor/Editor.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/Editor.tsx @@ -7,7 +7,7 @@ */ import type { CSSProperties } from 'react'; -import { useMemo, useState, useTransition } from 'react'; +import { useEffect, useMemo, useState, useTransition } from 'react'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; @@ -72,7 +72,7 @@ export type EditorProps = { isRichText?: boolean; variables?: EditorVariablePickerType[]; variableLabels?: EditorVariableLabelPickerType[]; - value?: string; + value: string; showOpenModal?: boolean; minH?: number; maxH?: number; @@ -100,7 +100,7 @@ export default function Editor({ onChange, onChangeText, onBlur, - value, + value = '', placeholder = '', placeholderPadding = '12px 14px', bg = 'white', @@ -111,7 +111,7 @@ export default function Editor({ }: EditorProps & FormPropsType & { onOpenModal?: () => void; - onChange: (editorState: EditorState, editor: LexicalEditor) => void; + onChange: (editor: LexicalEditor) => void; onChangeText?: ((text: string) => void) | undefined; onBlur?: (editor: LexicalEditor) => void; }) { @@ -132,7 +132,7 @@ export default function Editor({ ], editorState: textToEditorState(value, isRichText), onError: (error: Error) => { - throw error; + console.error('Lexical errror', error); } }; @@ -185,8 +185,6 @@ export default function Editor({ maxHeight: `${maxH}px`, ...boxStyle }} - onFocus={() => setFocus(true)} - onBlur={() => setFocus(false)} /> } placeholder={{placeholder}} @@ -234,7 +232,7 @@ export default function Editor({ const rootElement = editor.getRootElement(); setScrollHeight(rootElement?.scrollHeight || 0); startSts(() => { - onChange?.(editorState, editor); + onChange?.(editor); }); }} /> diff --git a/packages/web/components/common/Textarea/PromptEditor/constants.ts b/packages/web/components/common/Textarea/PromptEditor/constants.ts new file mode 100644 index 000000000..67dca7b41 --- /dev/null +++ b/packages/web/components/common/Textarea/PromptEditor/constants.ts @@ -0,0 +1 @@ +export const TabStr = ' '; diff --git a/packages/web/components/common/Textarea/PromptEditor/index.tsx b/packages/web/components/common/Textarea/PromptEditor/index.tsx index 9e1eed255..d60028f55 100644 --- a/packages/web/components/common/Textarea/PromptEditor/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/index.tsx @@ -5,7 +5,7 @@ import type { EditorProps } from './Editor'; import Editor from './Editor'; import MyModal from '../../MyModal'; import { useTranslation } from 'next-i18next'; -import type { EditorState, LexicalEditor } from 'lexical'; +import type { LexicalEditor } from 'lexical'; import type { FormPropsType } from './type'; const PromptEditor = ({ @@ -18,7 +18,8 @@ const PromptEditor = ({ isDisabled, ...props }: FormPropsType & - EditorProps & { + Omit & { + value?: string; title?: string; isDisabled?: boolean; onChange?: (text: string) => void; @@ -28,7 +29,7 @@ const PromptEditor = ({ const { t } = useTranslation(); const onChangeInput = useCallback( - (editorState: EditorState, editor: LexicalEditor) => { + (editor: LexicalEditor) => { const text = editorStateToText(editor); onChange?.(text); }, @@ -37,10 +38,8 @@ const PromptEditor = ({ const onBlurInput = useCallback( (editor: LexicalEditor) => { - if (onBlur) { - const text = editorStateToText(editor); - onBlur(text); - } + const text = editorStateToText(editor); + onBlur?.(text); }, [onBlur] ); diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/TabToSpacesPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/TabToSpacesPlugin/index.tsx index 9fe40f151..22448d617 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/TabToSpacesPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/TabToSpacesPlugin/index.tsx @@ -1,7 +1,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { KEY_TAB_COMMAND, - COMMAND_PRIORITY_EDITOR, + COMMAND_PRIORITY_HIGH, $getSelection, $isRangeSelection, $isTextNode @@ -62,19 +62,15 @@ export default function TabToSpacesPlugin(): null { // Handle Shift+Tab (outdent) if (isShiftTab) { if (!selection.isCollapsed()) { - // For selected text, remove 4 spaces from the beginning of each line + // For selected text, remove 2 spaces from the beginning of each line try { const selectedText = selection.getTextContent(); const lines = selectedText.split('\n'); const outdentedText = lines .map((line) => { - // Remove up to 4 spaces from the beginning of the line - if (line.startsWith(' ')) { - return line.slice(4); - } else if (line.startsWith(' ')) { - return line.slice(3); - } else if (line.startsWith(' ')) { + // Remove up to 2 spaces from the beginning of the line + if (line.startsWith(' ')) { return line.slice(2); } else if (line.startsWith(' ')) { return line.slice(1); @@ -128,7 +124,7 @@ export default function TabToSpacesPlugin(): null { // Check if there are spaces before cursor to remove let spacesToRemove = 0; - for (let i = beforeCursor.length - 1; i >= 0 && spacesToRemove < 4; i--) { + for (let i = beforeCursor.length - 1; i >= 0 && spacesToRemove < 2; i--) { if (beforeCursor[i] === ' ') { spacesToRemove++; } else { @@ -159,7 +155,7 @@ export default function TabToSpacesPlugin(): null { try { const selectedText = selection.getTextContent(); const lines = selectedText.split('\n'); - const indentedText = lines.map((line) => ' ' + line).join('\n'); + const indentedText = lines.map((line) => ' ' + line).join('\n'); // Insert the indented text and let Lexical handle cursor positioning selection.insertText(indentedText); @@ -191,13 +187,13 @@ export default function TabToSpacesPlugin(): null { return true; } catch (e) { // If selection operation fails, fall back to simple space insertion - const textNode = $createTextNode(' '); + const textNode = $createTextNode(' '); selection.insertNodes([textNode]); return true; } } else { - // For cursor position (no selection), insert 4 spaces - const textNode = $createTextNode(' '); // 4 spaces + // For cursor position (no selection), insert 2 spaces + const textNode = $createTextNode(' '); // 2 spaces selection.insertNodes([textNode]); return true; } @@ -208,7 +204,7 @@ export default function TabToSpacesPlugin(): null { return false; } }, - COMMAND_PRIORITY_EDITOR + COMMAND_PRIORITY_HIGH ); }, [editor]); diff --git a/packages/web/components/common/Textarea/PromptEditor/utils.ts b/packages/web/components/common/Textarea/PromptEditor/utils.ts index 3405e423f..244521089 100644 --- a/packages/web/components/common/Textarea/PromptEditor/utils.ts +++ b/packages/web/components/common/Textarea/PromptEditor/utils.ts @@ -20,6 +20,7 @@ import type { ListItemInfo, ChildEditorNode } from './type'; +import { TabStr } from './constants'; export function registerLexicalTextEntity( editor: LexicalEditor, @@ -186,7 +187,8 @@ export function registerLexicalTextEntity { const trimmed = line.trimStart(); - const indentLevel = Math.floor((line.length - trimmed.length) / 2); + const leadingSpaces = line.length - trimmed.length; + const indentLevel = Math.floor(leadingSpaces / TabStr.length); const bulletMatch = trimmed.match(/^- (.*)$/); if (bulletMatch) { @@ -203,7 +205,8 @@ const parseTextLine = (line: string) => { }; } - return { type: 'paragraph', text: trimmed, indent: indentLevel }; + // For paragraphs, preserve original leading spaces in text (don't use indent) + return { type: 'paragraph', text: line, indent: 0 }; }; const buildListStructure = (items: ListItemInfo[]) => { @@ -326,7 +329,7 @@ export const textToEditorState = (text = '', isRichText = false) => { ], direction: 'ltr', format: '', - indent: parsed.indent, + indent: 0, // Always use 0 for paragraphs, spaces are in text content type: 'paragraph', version: 1 }); @@ -426,7 +429,7 @@ const processListItem = ({ } else if (child.type === 'text') { itemText.push(child.text); } else if (child.type === 'tab') { - itemText.push(' '); + itemText.push(TabStr); } else if (child.type === 'variableLabel' || child.type === 'Variable') { itemText.push(child.variableKey); } else if (child.type === 'list') { @@ -434,9 +437,9 @@ const processListItem = ({ } }); - // Add prefix and indent - const itemTextString = itemText.join('').trim(); - const indent = ' '.repeat(indentLevel); + // Add prefix and indent (using TabStr for consistency) + const itemTextString = itemText.join(''); + const indent = TabStr.repeat(indentLevel); const prefix = listType === 'bullet' ? '- ' : `${index + 1}. `; results.push(indent + prefix + itemTextString); @@ -483,7 +486,7 @@ export const editorStateToText = (editor: LexicalEditor) => { // Handle tab nodes if (node.type === 'tab') { - return ' '; + return TabStr; } // Handle text nodes @@ -549,15 +552,14 @@ export const editorStateToText = (editor: LexicalEditor) => { const children = paragraph.children; const paragraphText: string[] = []; - const indentSpaces = ' '.repeat(paragraph.indent || 0); - + // Don't add indent prefix for paragraphs, spaces are already in text content children.forEach((child) => { const val = extractText(child); paragraphText.push(val); }); const finalText = paragraphText.join(''); - editorStateTextString.push(indentSpaces + finalText); + editorStateTextString.push(finalText); } else { const text = extractText(paragraph); editorStateTextString.push(text); diff --git a/projects/app/src/components/core/app/formRender/index.tsx b/projects/app/src/components/core/app/formRender/index.tsx index 58f83f28d..c7ec86917 100644 --- a/projects/app/src/components/core/app/formRender/index.tsx +++ b/projects/app/src/components/core/app/formRender/index.tsx @@ -70,6 +70,7 @@ const InputRender = (props: InputRenderProps) => { maxLength={props.maxLength} minH={40} maxH={120} + isRichText={props.isRichText} /> ); } diff --git a/projects/app/src/components/core/chat/components/AIResponseBox.tsx b/projects/app/src/components/core/chat/components/AIResponseBox.tsx index 7bfe7d91d..6fec8354a 100644 --- a/projects/app/src/components/core/chat/components/AIResponseBox.tsx +++ b/projects/app/src/components/core/chat/components/AIResponseBox.tsx @@ -218,7 +218,7 @@ const RenderUserFormInteractive = React.memo(function RenderFormInput({ const defaultValues = useMemo(() => { if (interactive.type === 'userInput') { return interactive.params.inputForm?.reduce((acc: Record, item, index) => { - acc[`field_${index}`] = !!item.value ? item.value : item.defaultValue; + acc[item.key] = !!item.value ? item.value : item.defaultValue; return acc; }, {}); } @@ -229,9 +229,8 @@ const RenderUserFormInteractive = React.memo(function RenderFormInput({ (data: Record) => { const finalData: Record = {}; interactive.params.inputForm?.forEach((item, index) => { - const fieldName = `field_${index}`; - if (fieldName in data) { - finalData[item.label] = data[fieldName]; + if (item.key in data) { + finalData[item.key] = data[item.key]; } }); 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 422b4c381..a6c679e72 100644 --- a/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx +++ b/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx @@ -1,10 +1,9 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { Box, Flex } from '@chakra-ui/react'; import { Controller, useForm, type UseFormHandleSubmit } from 'react-hook-form'; import Markdown from '@/components/Markdown'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; import { - type UserInputFormItemType, type UserInputInteractive, type UserSelectInteractive, type UserSelectOptionItemType @@ -64,7 +63,7 @@ export const SelectOptionsComponent = React.memo(function SelectOptionsComponent }); export const FormInputComponent = React.memo(function FormInputComponent({ - interactiveParams, + interactiveParams: { description, inputForm, submitted }, defaultValues = {}, SubmitButton }: { @@ -72,58 +71,49 @@ export const FormInputComponent = React.memo(function FormInputComponent({ defaultValues?: Record; SubmitButton: (e: { onSubmit: UseFormHandleSubmit> }) => React.JSX.Element; }) { - const { description, inputForm, submitted } = interactiveParams; - const { handleSubmit, control } = useForm({ defaultValues }); - const RenderFormInput = useCallback( - ({ input, index }: { input: UserInputFormItemType; index: number }) => { - return ( - { - const inputType = nodeInputTypeToInputType([input.type]); - - return ( - - ); - }} - /> - ); - }, - [control, submitted] - ); - return ( - {inputForm.map((input, index) => ( - - - {input.required && *} - {input.label} - {input.description && } - - - - ))} + {inputForm.map((input) => { + const inputType = nodeInputTypeToInputType([input.type]); + + return ( + + + {input.required && *} + {input.label} + {input.description && } + + { + return ( + + ); + }} + /> + + ); + })} {!submitted && ( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderDebug/NodeDebugResponse.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderDebug/NodeDebugResponse.tsx index 60e209e1c..1ea956b5a 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderDebug/NodeDebugResponse.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderDebug/NodeDebugResponse.tsx @@ -11,14 +11,9 @@ import { SelectOptionsComponent } from '@/components/core/chat/components/Interactive/InteractiveComponents'; import { type UserInputInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type'; -import { - getLastInteractiveValue, - storeEdges2RuntimeEdges -} from '@fastgpt/global/core/workflow/runtime/utils'; import { type ChatItemType, type UserChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import { ChatItemValueTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm'; -import MyIconButton from '@fastgpt/web/components/common/Icon/button'; import { WorkflowActionsContext } from '../../../../context/workflowActionsContext'; import { WorkflowDebugContext } from '../../../../context/workflowDebugContext'; @@ -27,7 +22,7 @@ type NodeDebugResponseProps = { debugResult: FlowNodeItemType['debugResult']; }; -const RenderUserFormInteractive = React.memo(function RenderFormInput({ +const RenderUserFormInteractive = function RenderFormInput({ interactive, onNext }: { @@ -38,13 +33,13 @@ const RenderUserFormInteractive = React.memo(function RenderFormInput({ const defaultValues = useMemo(() => { return interactive.params.inputForm?.reduce((acc: Record, item) => { - acc[item.label] = !!item.value ? item.value : item.defaultValue; + acc[item.key] = item.value !== undefined ? item.value : item.defaultValue; return acc; }, {}); }, [interactive.params.inputForm]); return ( - + ); -}); +}; const NodeDebugResponse = ({ nodeId, debugResult }: NodeDebugResponseProps) => { const { t } = useTranslation(); @@ -120,8 +115,8 @@ const NodeDebugResponse = ({ nodeId, debugResult }: NodeDebugResponseProps) => { type: ChatItemValueTypeEnum.interactive, interactive: { ...interactive, + entryNodeIds: workflowDebugData.entryNodeIds || [], memoryEdges: interactive.memoryEdges || [], - entryNodeIds: interactive.entryNodeIds || [], nodeOutputs: interactive.nodeOutputs || [] } } diff --git a/projects/app/src/pages/api/core/app/create.ts b/projects/app/src/pages/api/core/app/create.ts index b7e6de213..46bcfb6db 100644 --- a/projects/app/src/pages/api/core/app/create.ts +++ b/projects/app/src/pages/api/core/app/create.ts @@ -94,7 +94,8 @@ async function handler(req: ApiRequestProps) { teamId, tmbId, userAvatar: tmb?.avatar, - username: tmb?.user?.username + username: tmb?.user?.username, + templateId }); pushTrack.createApp({ diff --git a/projects/app/src/pages/api/support/mcp/client/runTool.ts b/projects/app/src/pages/api/support/mcp/client/runTool.ts index bb94126ff..bb7ac179e 100644 --- a/projects/app/src/pages/api/support/mcp/client/runTool.ts +++ b/projects/app/src/pages/api/support/mcp/client/runTool.ts @@ -27,7 +27,7 @@ async function handler( }) }); - return mcpClient.toolCall(toolName, params); + return mcpClient.toolCall({ toolName, params }); } export default NextAPI(handler);