FastGPT 工作流表单输入节点的值持久化复盘:预览页重开后表单被重置为默认值的根因与修复
【免费下载链接】FastGPTFastGPT is a knowledge-based platform built on the LLMs, offers a comprehensive suite of out-of-the-box capabilities such as data processing, RAG retrieval, and visual AI workflow orchestration, letting you easily develop and deploy complex question-answering systems without the need for extensive setup or configuration.项目地址: https://gitcode.com/GitHub_Trending/fa/FastGPT
本文围绕 FastGPT 工作流中「表单输入(userInput)交互节点」的一个真实缺陷展开:用户提交表单后关闭预览页面再重新打开,表单内容被恢复为默认值,而不是用户已填写的值。文章完整继承缺陷分析文档的复现路径、数据结构、根因定位与候选方案,并结合当前仓库源码,说明该问题在前端提交改写、流恢复(stream resume)与后端节点持久化三条链路上的最终实现,读完你可以掌握 FastGPT 交互式工作流(interactive)的值存储模型与跨会话恢复机制。
一、问题描述:表单内容在重开预览页后被重置
复现场景位于工作流编辑器的运行预览页面:
- 在工作流中添加「表单输入」节点;
- 在运行预览页面发起对话测试,触发表单输入交互;
- 正常填写表单并提交,任务继续运行成功;
- 关闭预览页面;
- 重新打开预览页面;
- 问题:表单内容被恢复为默认值(
defaultValue),而不是用户之前填写的值。
该缺陷的完整分析记录见 workflow-form-input-restore-bug.md。它的本质是一个「已提交数据未写回聊天记录」的持久化缺陷:表单渲染时优先读取inputForm[].value,value为空才回退到defaultValue;而提交链路只标记了submitted: true,没有把用户填写的值写进value字段,于是聊天记录重新加载后所有字段都退化为默认值。
二、交互数据结构:表单值应该存放在哪里
表单输入交互的类型定义位于 type.ts。文档中引用的核心片段在仓库中的完整形态如下(UserInputFormItemSchema,type.ts#L144-L172):
export const UserInputFormItemSchema = AppFileSelectConfigTypeSchema.extend({ type: z.enum(FlowNodeInputTypeEnum), // 控件类型:文本/密码/数字/下拉/文件等 key: string, // 字段键名,也是提交 JSON 中的 key label: string, // 展示标签 value: z.any(), // 用户填写的值(持久化真源) valueType: z.enum(WorkflowIOValueTypeEnum), // 工作流变量类型 description: z.string().optional(), defaultValue: z.any().optional(), // 设计器中配置的默认值 required: z.boolean(), maxLength: z.number().optional(), // input & textarea minLength: z.number().optional(), // password max: z.number().optional(), // numberInput min: z.number().optional(), // numberInput list: z.array(z.object({ label: z.string(), value: z.string() })).optional(), // select 选项 canLocalUpload: z.boolean().optional(), // 文件选择是否允许本地上传 canUrlUpload: z.boolean().optional() // 文件选择是否允许 URL 上传 }); export const UserInputInteractiveSchema = z.object({ type: z.literal('userInput'), params: z.object({ description: z.string(), inputForm: z.array(UserInputFormItemSchema), submitted: z.boolean().optional() // 表单是否已提交 }) });与文档摘要相比,仓库中的实际 Schema 还包含maxLength/minLength/max/min(按控件类型生效的长度与数值边界)、list(select 选项)与文件上传开关等字段,这些约束决定了前端表单控件的校验行为。
设计意图是明确的:
value字段存储用户填写的值,随聊天记录的interactive对象一起持久化;submitted标记表单是否已提交,用于控制是否还允许重复提交;- 重新打开预览页时,前端从聊天记录恢复
interactive,defaultValues从item.value读取即可还原用户填写内容。
三、根因分析
3.1 defaultValues 的计算逻辑:value 为空即回退默认值
表单渲染组件中defaultValues的计算逻辑(缺陷版本)为:
const defaultValues = useMemo(() => { return interactive.params.inputForm?.reduce((acc: Record<string, any>, item) => { acc[item.key] = item.value ?? item.defaultValue; return acc; }, {}); }, [interactive]);逻辑本身是合理的:item.value优先于item.defaultValue。问题在于当页面重新打开、interactive.params从聊天记录恢复时,如果提交链路从未把用户填写的值写入item.value,所有字段都会命中??分支,回退到defaultValue——这正是「表单被重置」的直接表现。
3.2 sessionStorage 的冗余使用:只写不读
缺陷版本的前端代码(文档引用时位于单体文件AIResponseBox.tsx的RenderUserFormInteractive组件,第 248–271 行)在表单提交时执行:
if (typeof window !== 'undefined') { const dataToSave = { ...data }; // ... 处理文件数据 sessionStorage.setItem(`interactiveForm_${chatItemDataId}`, JSON.stringify(dataToSave)); }通过全局搜索可以确认:这段代码只有写入,没有任何读取操作。它是一段无效代码,增加了复杂度但没有实际作用。当前仓库中该写入逻辑已被移除,chat 相关目录下仅保留了聊天输入框草稿对 sessionStorage 的使用(useChatInputForm.ts),表单交互不再依赖它。
3.3 核心问题定位:提交改写函数只标记了 submitted
文档定位的根源在提交后的历史改写函数rewriteHistoriesByInteractiveResponse(文档引用时位于ChatBox/utils.ts第 154–168 行)。缺陷版本对userInput交互的处理是:
if ( finalInteractive.type === 'userInput' || finalInteractive.type === 'agentPlanAskUserForm' ) { return { ...val, interactive: { ...finalInteractive, params: { ...finalInteractive.params, submitted: true // 只设置了 submitted // 但没有更新 inputForm[].value } } }; }用户提交的表单数据以 JSON 字符串形式放在interactiveVal参数中,函数只是简单标记submitted: true,没有解析interactiveVal并回填params.inputForm[].value。其后果链条是:
期望流程(应该是这样):
用户填写表单 → 提交时发送到后端 → 更新 interactive.params.inputForm[].value → 保存到聊天记录 → 关闭预览页面 → 重新打开预览页面 → 从聊天记录恢复 interactive → defaultValues 从 item.value 读取 → 表单显示用户填写的值实际流程(出问题时):
用户填写表单 → 提交时发送到后端 → 前端改写历史时只标记 submitted: true,value 未更新 → 关闭预览页面 → 重新打开预览页面 → 从聊天记录恢复 interactive → interactive.params.inputForm[].value 为空 → defaultValues 回退到 item.defaultValue → 表单显示默认值3.4 一个补充事实:提交值最终也会写入后端历史
值得说明的是,表单提交值在后端侧并非完全丢失。工作流引擎处理表单提交时,会把用户输入解析并输出为formInputResult(见第七节),随节点运行结果进入同一条 AI 消息的responseData。因此缺陷版本并非「值完全没有落库」,而是交互对象(interactive)上的value字段没有被 hydrate——渲染层只读interactive.params.inputForm[].value,于是出现了「后端有、交互对象没有」的数据不一致。这一事实直接催生了当前仓库中「渲染层兜底」与「流恢复回填」两条补偿链路(第六节)。
四、sessionStorage 的设计意图复盘
文档在深入分析后指出,sessionStorage 的使用「可能有其合理性」,这些场景分析对理解最终方案有参考价值。
chatItemDataId 的含义
chatItemDataId是每条聊天消息的唯一标识(不是 chatId);- 一个对话(chatId)中可能有多条消息,每条消息有不同的
dataId; - 一个工作流中可能有多个表单输入节点,每个节点触发时会创建新的消息。
可能的场景
场景 1:同一对话中多个表单输入
对话开始 → 触发表单输入节点 A (dataId: xxx-1) → 用户填写表单 A,提交,继续执行 → 触发表单输入节点 B (dataId: xxx-2) → 用户填写表单 B → 关闭预览页面,重新打开 → 需要恢复两个表单的数据场景 2:表单数据的临时性
用户可能在填写过程中关闭页面(未提交),sessionStorage 可以保存未提交的草稿,重新打开时恢复草稿,避免用户重新填写。
为什么单纯靠后端保存不够
- 未提交的数据:用户填写了一半但未提交,后端没有这些数据;
- 多个表单实例:同一对话中可能有多个表单输入节点,需要按消息粒度分别保存;
- 临时状态:表单的临时编辑状态(如文件上传中)不应该保存到后端。
五、修复方案:文档提出的两个候选
方案 1:双重保存机制(sessionStorage + interactive.params,文档推荐)
结合两种机制的优点:sessionStorage 保存未提交的草稿和临时状态,interactive.params 保存已提交的最终数据。
步骤 1:修复rewriteHistoriesByInteractiveResponse(已提交数据)
if ( finalInteractive.type === 'userInput' || finalInteractive.type === 'agentPlanAskUserForm' ) { // 解析用户提交的表单数据 let submittedData: Record<string, any> = {}; 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计算逻辑(恢复草稿)
const defaultValues = useMemo(() => { // 1. 优先从 sessionStorage 恢复数据(包括未提交的草稿) let savedData: Record<string, any> | 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. 优先级: sessionStorage(草稿) > item.value(已提交) > item.defaultValue(默认) return interactive.params.inputForm?.reduce((acc: Record<string, any>, item) => { if (savedData && item.key in savedData) { acc[item.key] = savedData[item.key]; } else { acc[item.key] = item.value ?? item.defaultValue; } return acc; }, {}); }, [interactive, chatItemDataId]);步骤 3:清理 sessionStorage(可选优化)——在表单提交成功后sessionStorage.removeItem(interactiveForm_${chatItemDataId})。
方案 1 的优点:保留草稿保存能力、修复已提交数据的持久化、支持多表单场景、向后兼容;缺点是需要改动两处,逻辑稍复杂。
方案 2:仅修复 interactive.params(简化方案)
如果不需要草稿保存功能,只修复rewriteHistoriesByInteractiveResponse并删除 sessionStorage 相关代码。优点是简单清晰,缺点是失去草稿保存能力。
文档最终推荐方案 1,理由:保留 sessionStorage 的设计意图、修复持久化问题、覆盖复杂场景(多表单、未提交草稿)、向后兼容。
六、当前仓库的最终实现:聊天记录为单一真源,formInputResult 做兜底
从当前仓库源码看,最终落地的是方案 2 的思路并做了两处增强:表单交互彻底移除了 sessionStorage 依赖,已提交值的唯一真源是聊天历史(interactive + 节点运行结果),渲染层与流恢复层通过formInputResult对旧数据做兜底。相关代码已从文档引用时的单体文件重组为独立模块:
| 文档引用位置 | 当前仓库位置 |
|---|---|
AIResponseBox.tsx内RenderUserFormInteractive | RenderUserFormInteractive.tsx |
ChatBox/utils.ts内rewriteHistoriesByInteractiveResponse | interactive.ts |
表单组件FormInputComponent | InteractiveComponents.tsx |
6.1 提交链路:rewriteHistoriesByInteractiveResponse 写回 value
当前实现(interactive.ts#L384-L410)正是文档方案 1 步骤 1 的落地——解析interactiveVal并回填每个字段的value:
if (finalInteractive.type === 'userInput') { const submittedData: Record<string, any> = (() => { try { return JSON.parse(interactiveVal); } catch { 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 } } }; }该函数由 useChatGenerate.ts 在用户提交交互时被调用(约第 772 行),是「关闭预览页再打开」场景下值能够恢复的第一道保障:提交瞬间value已写进前端历史,随消息持久化到后端。
6.2 流恢复链路:refreshSubmittedFormInteractiveValues 回填节点运行结果
针对「恢复流中 interactive 上的inputForm.value可能仍为空(持久化时未 hydrate)」的旧数据,仓库新增了 refreshSubmittedFormInteractiveValues:当流恢复收到携带formInputResult的flowNodeResponse时,把节点运行结果写回已提交的表单交互节点。其匹配策略有两级:
- 精确匹配:交互的
entryNodeIds包含nodeResponse.nodeId; - 兜底匹配:全历史中仅有一个 submitted 表单交互,且其字段 key 与
formInputResult有交集(覆盖 dataId 漂移场景)。
对fileSelect字段,回填时优先保留历史中持久化的原始 key/url + name/type,URL 运行结果仅在原始值缺失时兜底(通过resolveFormInputFileValues实现,见第六节末)。无任何字段更新时函数返回原histories引用,避免触发多余渲染。该函数在 useChatGenerate.ts 约第 201 行被调用。
6.3 渲染层兜底:getInputFormValueFromResponseData
RenderUserFormInteractive.tsx#L20-L45 从同条 AI 消息的responseData中反向查找最近一条匹配的formInputResult(nodeId在entryNodeIds内,或未指定 nodeId 时取最近一条),作为defaultValues的来源。因此当前defaultValues的优先级为:
- fileSelect:
inputForm.value中持久化的原始文件信息优先,responseData.formInputResult仅对旧历史兜底(由 FormInputResult.tsx 中的resolveFormInputFileValues归一化); - 其他字段:
responseData.formInputResult中最近一次运行值优先; - 最后回退
item.value ?? item.defaultValue。
此外,isUserInputInteractiveSubmitted 处理了「旧聊天记录未持久化submitted」的兼容:只要submitted为真、或不是最后一条消息(isLastChild=false)、或responseData中存在formInputResult且字段 key 有交集,即判定已提交。这与渲染组件中「非最后一条子消息时强制submitted: true,禁止重复提交历史表单」(RenderUserFormInteractive.tsx#L107-L129)共同防止了重开页面后历史表单可被二次提交的问题。
6.4 文件值的归一化
FormInputResult.tsx 承担文件类表单值的兼容工作:
getFilenameFromFormInputFileUrl:从签名下载 URL 的filenamequery 参数解析展示用文件名(path 段往往只是 token,不可读);normalizeFormInputResultFile:兼容「纯 URL 字符串」与{ name, url }对象两种历史形态,统一为{ name, url };resolveFormInputFileValues:确立「首次提交时保存的 key/url + name/type 是唯一真源,节点生成的签名 URL 仅兜底」的原则,避免短链接覆盖文件名和类型。
该函数被流恢复(ChatBox/utils)与表单交互回填(RenderUserFormInteractive)两处复用,是文档测试建议中「文件上传场景」能够正确恢复的关键。
七、后端持久化链路:dispatchFormInput 如何落地表单值
前端修复保证的是交互对象的值被写回,而表单值进入工作流引擎与聊天历史的入口是 formInput.ts 中的dispatchFormInput(formInput.ts#L83-L161):
- 入口判定:节点不是入口节点(
!isEntry)或上一轮交互不是userInput时,返回一个未提交的userInput交互,等待用户填写; - JSON 解析:用户提交内容以 JSON 字符串形式进入工作流(「用户输入都内容,将会以 JSON 字符串格式进入工作流,可以从 query 的 text 中获取」),通过
chatValue2RuntimePrompt(query)取出文本后JSON.parse,解析失败则记录告警并返回空对象; - 字段级处理:
password类型字段执行anyValueDecrypt解密;fileSelect字段经formatFileSelectRuntimeValue处理:优先通过fileRegistrar.registerInputFile登记并生成modelUrl,否则将url或key转换为预览 URL(S3 预签名,默认有效期 1 小时),文件数量受maxFileAmount限制(模块级上限优先,默认 5 个);
- 结果输出:返回值中同时携带
data:展开后的用户输入 +formInputResult(表单结果的输出键,供下游节点引用);rewriteHistories:截去当前会话记录;nodeResponse: { formInputResult: userInputVal }——这正是第六节前端兜底链路读取的数据来源。
对应测试 formInput.test.ts 验证了 fileSelect 值在输出前被预签名为可访问 URL 的行为(presigns key-only fileSelect values before exposing form outputs),与源码实现一致。
八、影响范围与回归测试清单
影响文件与组件:
- AIResponseBox/RenderUserFormInteractive.tsx——表单渲染与提交逻辑;
- ChatBox/utils/interactive.ts——提交改写与流恢复回填;
- formInput.ts——后端表单输入处理。
影响场景:所有使用表单输入节点的工作流;预览页面关闭后重新打开;同一对话中存在多个表单输入节点(按dataId粒度各自独立保存和恢复)。
回归测试清单(继承文档的测试建议):
- 基本场景:填写表单 → 提交 → 关闭预览 → 重新打开 → 验证表单内容保持;
- 多次提交:填写 → 提交 → 修改 → 再次提交 → 关闭 → 重新打开 → 验证显示最后一次提交的内容;
- 文件上传:包含文件选择的表单,验证文件名与文件信息正确恢复(重点覆盖 key/url 真源与签名 URL 兜底两条路径);
- 必填项验证:验证
required、maxLength/minLength、max/min等约束逻辑不受影响; - 多个表单:同一对话中多个表单输入节点,验证按
entryNodeIds/dataId 各自独立匹配与恢复,不串数据; - 清空对话:点击「重新开始」后,验证表单数据被正确清空。
九、小结
这个缺陷的演进过程呈现了一个清晰的工程脉络:
- 缺陷期:提交链路只标记
submitted: true而不回填inputForm[].value,叠加一段只写不读的 sessionStorage 死代码,导致重开预览页后表单值退化为默认值; - 方案期:文档给出「双重保存(sessionStorage 草稿 + interactive.params 已提交值)」与「仅修复 interactive.params」两个候选,推荐前者以保留草稿能力;
- 落地期(从当前仓库源码看):最终收敛为「聊天历史单一真源」——提交时解析
interactiveVal写回value,formInputResult随节点运行结果进入responseData;旧数据则通过流恢复回填(refreshSubmittedFormInteractiveValues)与渲染层兜底(getInputFormValueFromResponseData)两条链路补偿;sessionStorage 死代码被移除,文件类字段以「原始 key/url 为真源、签名 URL 仅兜底」的原则保证恢复展示的一致性。
对在其他交互式工作流平台做类似设计的读者,本文有三个可迁移的结论:提交回写必须发生在「改写历史」这一步而不是只改状态标志;恢复优先级应当显式定义为「持久化真源 > 运行结果兜底 > 默认值」三级;任何临时存储(sessionStorage 之类)如果只有写入没有读取路径,就是在为未来的恢复逻辑埋下不一致的隐患。
【免费下载链接】FastGPTFastGPT is a knowledge-based platform built on the LLMs, offers a comprehensive suite of out-of-the-box capabilities such as data processing, RAG retrieval, and visual AI workflow orchestration, letting you easily develop and deploy complex question-answering systems without the need for extensive setup or configuration.项目地址: https://gitcode.com/GitHub_Trending/fa/FastGPT
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考