Commit b9b6e230 by Archer Committed by GitHub

feat: configure auth cookies, simplify form results, and track referrals (#7320)

* feat: configure auth cookies and simplify form results

* chore: add referral tracking to website links

* ci: propagate docs referral tracking

* submodule

* feat: custom tool params

* fix: sync pro changes with upstream main

* perf: sse response

* api doc

* doc

* perf: node response

* perf: output ui

* remove invalid env
parent c21783ed
......@@ -92,6 +92,54 @@ ChatItemSchema.index({ appId: 1, chatId: 1, obj: 1, _id: -1 });
7. 普通写入失败重试 3 次;仍失败则写 slim rows;slim 仍失败时丢弃本批详情 rows 并记录日志,不阻断主 workflow。
8. `saveChat` 需要的引用、错误数和根节点积分由 writer 在运行期维护 summary;详情 rows 写库失败不影响这些摘要。
## 实时发布路径
NodeResponse 的持久化和实时发布统一由请求级 `WorkflowNodeResponseSink` 协调:
1. 一个 workflow 请求只创建一个 sink,内部复用同一个 `WorkflowNodeResponseWriter`
2. root workflow、child workflow、Agent、ToolCall、LoopRun、ParallelRun 共享该 sink。
3. 节点和 Agent adapter 只上交标准 nodeResponse,不直接操作 writer,也不直接发送
`flowNodeResponse` SSE。
4. sink 为缺少 parentId 的响应补调用方显式传入的 parentId,调用 writer 规范化并写入,
再按请求可见性配置发布本次响应。
5. writer 仍按 `batchSize` 批量物理写 Mongo;“接收一个、返回一个”指每个逻辑
nodeResponse 都产生独立 SSE 事件,不要求每条 response 单独执行 Mongo create。
6. sink 不负责 `RuntimeNodeResponseSummary`、usage、计费、child count 或控制流判断;这些仍由
当前 WorkflowQueue/Agent collector 在各自运行作用域内计算,避免跨作用域重复累计。
7.`(id, parentId)` 的多条响应仍是 append-only 增量,sink 不去重、不覆盖、不改变数值字段
的增量语义。
输出协议:
- V2 `stream=true, detail=true`:可见 nodeResponse 逐条发送 `flowNodeResponse`,客户端按
`(id, parentId)` 拼树;结束时不再发送完整 nodeResponse 数组。
- V1 `stream=true, detail=true`:运行期不发送单个 nodeResponse,结束时一次性发送
`flowResponses`
- V1/V2 `stream=false, detail=true`:结束时在 JSON `responseData` 中一次性返回。
- V2 Share 流式:完整 nodeResponse 逐条写库,对外先按 public node/field 规则过滤,再逐条
发送;为保持 `pushResult2Remote` 原有回调契约,运行期间仍保留最终详情数组。
Share 可见性必须分层处理,不能只依赖一个字段过滤函数:
- `responseAllData=false`:sink 只发布 public node 类型和字段,并保留客户端拼树需要的
`id/parentId`
- Share workflow 内部始终保留回答中的引用 ID,writer 始终接收完整 nodeResponse;普通 API
保持 `retainDatasetCite` 的原有语义。dataset `quoteList` 入库时继续移除 q/a,只保留引用关联、
来源和分数等元信息。
- `showCite`:控制公开 nodeResponse 是否包含 `quoteList`;关闭时 SSE 与非流式 JSON 都不返回
`quoteList`,但不改写 SSE 回答文本,也不能改变上述持久化数据。客户端没有 quoteList 时不展示
引用;之后重新开启配置并刷新 Share,可以根据已保存的引用 ID 和 quote 元信息恢复展示。
- `showRunningStatus`:控制 `flowNodeStatus/toolCall/toolParams/toolResponse` 等过程事件,不直接
禁止引用展示依赖的 public `flowNodeResponse`
- `showSkillReferences`:继续由 Agent 输出链路控制,并受 `showRunningStatus` 约束。
- `showWholeResponse/showFullText/canDownloadSource`:继续由前端能力和详情/引用/文件接口鉴权,
sink 不替代这些权限检查。
- 明确隐藏内部 workflow 的系统插件继续既不写入 child rows,也不发布 child 事件,只保留外层
工具节点响应。
`pushResult2Remote` 不属于本次 SSE 改造范围,继续使用运行期 `finalResponseData` 调用
`/shareAuth/finish`,不增加延迟读库或回调协议变化。
运行期明确删除的行为:
- 不按 `data.id` delete 旧 rows。
......@@ -318,4 +366,17 @@ nodeResponse append-only:
- append-only 会增加 rows 数量,需要依赖对话删除、应用删除和过期清理控制表规模。
- 历史 `mergeSignId` 数据不迁移,异常展示风险已接受。
- 如果线上 AI dataId 冲突检查成为热点,再评估普通索引 `{ appId, chatId, dataId, obj }`
## 本次实施 TODO
- [x] 新增请求级 `WorkflowNodeResponseSink`,统一 writer 和 V2 SSE 发布。
- [x] WorkflowQueue 的 root/child runtime 都通过 sink 逐条发布 nodeResponse。
- [x] Agent collector 移除 writer 依赖,改为上交 sink,同时保留局部 runtime summary。
- [x] LoopRun/ParallelRun 虚拟任务节点移除直接 writer/SSE 调用,改走 sink。
- [x] 系统插件内部 workflow 使用禁用 sink 的作用域,保持隐藏语义。
- [x] 保持 `pushResult2Remote` 和 Share 完成回调原有行为,只改造 SSE 发布路径。
- [x] V1、非流式 JSON、Share public 过滤和引用/文件权限保持兼容。
- [x] Share 引用的内部持久化与 SSE/JSON 公开过滤解耦,quote q/a 继续只在入库边界裁剪。
- [x] 补充 sink、Agent child、Loop/Parallel child、Share public 过滤和返回策略测试。
- [x] 运行最终全量测试;全仓并发出现 4 个 20 秒超时,相关文件单独复跑全部通过。
- LoopRun、ToolCall 等恢复场景必须持续保证写入的是本次运行片段增量,而不是累计值。
# 工具参数自定义 JSON Schema
## 需求分析
- 工具参数的数据类型增加“自定义”选项。
- 自定义模式保留独立参数名输入,只输入该参数的 property JSON Schema。
- 参数描述取参数 Schema 的 `description`,不再单独输入。
- 必填状态只由现有开关决定,不读取输入 Schema 的 `required`
- 提交时使用专用递归 Zod Schema,严格校验每层 type、properties、items 和 required 关系。
## 开发设计
1. 在节点输入结构中保存 property 级 `customJsonSchema`
2. 在 global JSON Schema 工具中提供 property Schema 解析函数;保留外部工具使用的宽松 Schema,手工入口使用递归严格 Schema。
3. `nodeInput2JsonSchemaProperty` 优先输出 `customJsonSchema`,外层 `nodeInputs2JsonSchema` 继续根据节点输入的 `required` 开关生成必填数组。
4. 工具参数弹窗使用本地“自定义”选择态,提交后将 Schema 自动提取为标准节点输入字段。
## TODO
- [x] 增加节点输入存储字段和解析/转换逻辑。
- [x] 覆盖正常、边界和异常转换测试。
- [x] 增加自定义类型 UI、JSON 编辑器和国际化文案。
- [x] 完成格式、Lint、测试和类型检查。
<div align="center">
<a href="https://fastgpt.io/"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=logo"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
# FastGPT
......@@ -18,7 +18,7 @@ FastGPT 是一个 AI Agent 构建平台,提供开箱即用的数据处理、
</div>
<p align="center">
<a href="https://fastgpt.io/">
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_badge">
<img height="21" src="https://img.shields.io/badge/在线使用-d4eaf7?style=flat-square&logo=spoj&logoColor=7d09f1" alt="cloud">
</a>
<a href="https://doc.fastgpt.io/guide/getting-started">
......@@ -52,7 +52,7 @@ docker compose up -d
## 🛸 使用方式
- **云服务版本**
如果你不需要私有化部署,可以直接使用我们提供的云服务版本,地址为:[fastgpt.io](https://fastgpt.io/)
如果你不需要私有化部署,可以直接使用我们提供的云服务版本,地址为:[fastgpt.io](https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_service_link)
- **社区自托管版本**
可以使用[Docker](https://doc.fastgpt.io/self-host/deploy/docker)快速部署,也可以使用[Sealos Cloud](https://doc.fastgpt.io/self-host/deploy/sealos) 来一键部署FastGPT。
......
<div align="center">
<a href="https://fastgpt.io/"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=logo"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
# FastGPT
......@@ -18,7 +18,7 @@ FastGPT is an AI Agent building platform that provides out-of-the-box capabiliti
</div>
<p align="center">
<a href="https://fastgpt.io/">
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_badge">
<img height="21" src="https://img.shields.io/badge/Online_Usage-d4eaf7?style=flat-square&logo=spoj&logoColor=7d09f1" alt="cloud">
</a>
<a href="https://doc.fastgpt.io/guide/getting-started">
......@@ -52,7 +52,7 @@ If you encounter any issues, you can [view the complete Docker deployment tutori
## 🛸 Usage
- **Cloud Version**
If you don't need private deployment, you can directly use our cloud service at: [fastgpt.io](https://fastgpt.io/)
If you don't need private deployment, you can directly use our cloud service at: [fastgpt.io](https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_service_link)
- **Community Self-Hosted Version**
You can quickly deploy using [Docker](https://doc.fastgpt.io/self-host/deploy/docker) or use [Sealos Cloud](https://doc.fastgpt.io/self-host/deploy/sealos) to deploy FastGPT with one click.
......
<div align="center">
<a href="https://fastgpt.io/"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=logo"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
# FastGPT
......@@ -18,7 +18,7 @@ FastGPT adalah platform pembangunan AI Agent yang menyediakan kemampuan siap pak
</div>
<p align="center">
<a href="https://fastgpt.io/">
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_badge">
<img height="21" src="https://img.shields.io/badge/Penggunaan_Online-d4eaf7?style=flat-square&logo=spoj&logoColor=7d09f1" alt="cloud">
</a>
<a href="https://doc.fastgpt.io/guide/getting-started">
......@@ -52,7 +52,7 @@ Jika Anda menghadapi masalah, Anda dapat [melihat tutorial penyebaran Docker len
## 🛸 Cara Penggunaan
- **Versi Cloud**
Jika Anda tidak memerlukan penyebaran privat, Anda dapat langsung menggunakan layanan cloud kami di: [fastgpt.io](https://fastgpt.io/)
Jika Anda tidak memerlukan penyebaran privat, Anda dapat langsung menggunakan layanan cloud kami di: [fastgpt.io](https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_service_link)
- **Versi Self-Hosted Komunitas**
Anda dapat menyebarkan dengan cepat menggunakan [Docker](https://doc.fastgpt.io/self-host/deploy/docker) atau menggunakan [Sealos Cloud](https://doc.fastgpt.io/self-host/deploy/sealos) untuk menyebarkan FastGPT dengan satu klik.
......
<div align="center">
<a href="https://fastgpt.io/"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=logo"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
# FastGPT
......@@ -18,7 +18,7 @@ FastGPT 縺ッ AI Agent 讒狗ッ峨繝ゥ繝ヨ繝輔か繝シ繝縺ァ縺ゅj縲√☆縺舌↓菴ソ縺医
</div>
<p align="center">
<a href="https://fastgpt.io/">
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_badge">
<img height="21" src="https://img.shields.io/badge/オンライン利用-d4eaf7?style=flat-square&logo=spoj&logoColor=7d09f1" alt="cloud">
</a>
<a href="https://doc.fastgpt.io/guide/getting-started">
......@@ -52,7 +52,7 @@ docker compose up -d
## 🛸 利用方法
- **クラウド版**
プライベートデプロイが不要な場合は、クラウドサービスを直接ご利用いただけます:[fastgpt.io](https://fastgpt.io/)
プライベートデプロイが不要な場合は、クラウドサービスを直接ご利用いただけます:[fastgpt.io](https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_service_link)
- **コミュニティセルフホスト版**
[Docker](https://doc.fastgpt.io/self-host/deploy/docker) で素早くデプロイするか、[Sealos Cloud](https://doc.fastgpt.io/self-host/deploy/sealos) でワンクリックデプロイが可能です。
......
<div align="center">
<a href="https://fastgpt.io/"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=logo"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
# FastGPT
......@@ -18,7 +18,7 @@ FastGPT เป็นแพลตฟอร์มสำหรับสร้าง
</div>
<p align="center">
<a href="https://fastgpt.io/">
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_badge">
<img height="21" src="https://img.shields.io/badge/ใช้งานออนไลน์-d4eaf7?style=flat-square&logo=spoj&logoColor=7d09f1" alt="cloud">
</a>
<a href="https://doc.fastgpt.io/guide/getting-started">
......@@ -52,7 +52,7 @@ docker compose up -d
## 🛸 วิธีการใช้งาน
- **เวอร์ชันคลาวด์**
หากคุณไม่ต้องการติดตั้งแบบส่วนตัว คุณสามารถใช้บริการคลาวด์ของเราได้โดยตรงที่: [fastgpt.io](https://fastgpt.io/)
หากคุณไม่ต้องการติดตั้งแบบส่วนตัว คุณสามารถใช้บริการคลาวด์ของเราได้โดยตรงที่: [fastgpt.io](https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_service_link)
- **เวอร์ชันโฮสต์ตัวเองของชุมชน**
คุณสามารถติดตั้งได้อย่างรวดเร็วโดยใช้ [Docker](https://doc.fastgpt.io/self-host/deploy/docker) หรือใช้ [Sealos Cloud](https://doc.fastgpt.io/self-host/deploy/sealos) เพื่อติดตั้ง FastGPT ด้วยคลิกเดียว
......
<div align="center">
<a href="https://fastgpt.io/"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=logo"><img src="/.github/imgs/logo.svg" width="120" height="120" alt="fastgpt logo"></a>
# FastGPT
......@@ -18,7 +18,7 @@ FastGPT là nền tảng xây dựng AI Agent cung cấp khả năng sẵn sàng
</div>
<p align="center">
<a href="https://fastgpt.io/">
<a href="https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_badge">
<img height="21" src="https://img.shields.io/badge/Sử_Dụng_Online-d4eaf7?style=flat-square&logo=spoj&logoColor=7d09f1" alt="cloud">
</a>
<a href="https://doc.fastgpt.io/guide/getting-started">
......@@ -52,7 +52,7 @@ Nếu bạn gặp vấn đề, bạn có thể [xem hướng dẫn triển khai
## 🛸 Cách Sử Dụng
- **Phiên Bản Đám Mây**
Nếu bạn không cần triển khai riêng, bạn có thể sử dụng trực tiếp dịch vụ đám mây của chúng tôi tại: [fastgpt.io](https://fastgpt.io/)
Nếu bạn không cần triển khai riêng, bạn có thể sử dụng trực tiếp dịch vụ đám mây của chúng tôi tại: [fastgpt.io](https://fastgpt.io/?utm_source=github&utm_medium=referral&utm_campaign=github_home&utm_content=cloud_service_link)
- **Phiên Bản Tự Host Cộng Đồng**
Bạn có thể triển khai nhanh chóng bằng [Docker](https://doc.fastgpt.io/self-host/deploy/docker) hoặc sử dụng [Sealos Cloud](https://doc.fastgpt.io/self-host/deploy/sealos) để triển khai FastGPT bằng một cú nhấp chuột.
......
......@@ -123,13 +123,13 @@ x-service-env-config: &x-service-env-config
MULTIPLE_DATA_TO_BASE64: true
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
AUTH_COOKIE_SECURE: false
TRUSTED_PROXY_ENABLE: false
TRUSTED_PROXY_IPS:
PASSWORD_LOGIN_LOCK_SECONDS:
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -24,6 +24,7 @@ ARG DOC_TRACK_SRC
ARG DOC_TRACK_SITE_ID
ENV FASTGPT_HOME_DOMAIN=$FASTGPT_HOME_DOMAIN
ENV NEXT_PUBLIC_FASTGPT_HOME_DOMAIN=$FASTGPT_HOME_DOMAIN
ENV NEXT_PUBLIC_DOC_TRACK_SRC=$DOC_TRACK_SRC
ENV NEXT_PUBLIC_DOC_TRACK_SITE_ID=$DOC_TRACK_SITE_ID
......
......@@ -6,7 +6,7 @@
要运行文档,首先需要进行环境变量配置,在文档的根目录下创建`.env.local`文件,填写以下环境变量:
```bash
FASTGPT_HOME_DOMAIN = #要跳转的FastGPT项目的域名,默认海外版
FASTGPT_HOME_DOMAIN=https://fastgpt.io # 只填写 origin,不携带路径或查询参数
```
你可以在FastGPT项目根目录下执行以下命令来运行文档。
......@@ -29,11 +29,12 @@ icon: menu #icon采用`lucide-react`第三方库。
---
import { Alert } from '@/components/docs/Alert'; #高亮块组件
import FastGPTLink from '@/components/docs/linkFastGPT';
<Alert icon="🤖" context="success">
快速开始体验
- 海外版:[https://fastgpt.io](https://fastgpt.io)
- 中国大陆:[https://fastgpt.cn](https://fastgpt.cn)
- 海外版:<FastGPTLink campaign="docs_getting_started" content="cloud_entry_io" site="io">{'https://fastgpt.io'}</FastGPTLink>
- 中国大陆:<FastGPTLink campaign="docs_getting_started" content="cloud_entry_cn" site="cn">{'https://fastgpt.cn'}</FastGPTLink>
</Alert>
import {Redirect} from '@/components/docs/Redirect' #重定向组件,如果你希望用户点击这个文件跳转到别的文件的话,详情参考 `FAQ`的`Docker 部署问题`文档。
......@@ -45,13 +46,15 @@ import {Redirect} from '@/components/docs/Redirect' #重定向组件,如果你
<Tab value="Rust">Rust is fast</Tab>
import FastGPTLink from '@/components/docs/linkFastGPT'; #FastGPT跳转链接组件,通过接收一个域名环境变量,来实现跳转到海外或者国内
import FastGPTLink from '@/components/docs/linkFastGPT'; #FastGPT跳转链接组件,根据域名环境变量和传入的归因参数生成链接
本文档介绍了如何设置开发环境以构建和测试 <FastGPTLink>FastGPT</FastGPTLink>。
本文档介绍了如何设置开发环境以构建和测试 <FastGPTLink campaign="docs_self_host_dev" content="intro_product_link">FastGPT</FastGPTLink>。
</Tabs>
```
新增跳转 FastGPT 官网的链接时,请同步登记并复用 [UTM 归因规范](./UTM_ATTRIBUTION.md) 中的 `utm_campaign``utm_content`
在书写完文档后,需要在对应的目录下的`meta.json`文件的`pages`字段合适位置添加自己的文件名。例如在`content/docs`(默认这是所有文档的根目录)的`introduction`目录下书写了一个`hello.mdx`文件。则需要去`introduction`目录下的`meta.json`添加以下内容:
```bash
......
# UTM 归因规范
`FASTGPT_HOME_DOMAIN` 只配置 origin,例如 `https://fastgpt.io``https://fastgpt.cn`,不能携带路径或查询参数。
文档内跳转 FastGPT 官网统一使用 `FastGPTLink`。组件会固定添加:
- `utm_source=docs`
- `utm_medium=referral`
页面和链接位置使用以下参数:
| 页面 | `utm_campaign` | 链接位置 | `utm_content` |
| --- | --- | --- | --- |
| 快速了解 FastGPT | `docs_getting_started` | 国际版入口 | `cloud_entry_io` |
| 快速了解 FastGPT | `docs_getting_started` | 中国大陆版入口 | `cloud_entry_cn` |
| 云服务介绍 | `docs_cloud_intro` | 国际版入口 | `cloud_entry_io` |
| 云服务介绍 | `docs_cloud_intro` | 中国大陆版入口 | `cloud_entry_cn` |
| 云服务 FAQ | `docs_cloud_faq` | 国际版登录帮助 | `login_help_io` |
| 云服务 FAQ | `docs_cloud_faq` | 中国大陆版登录帮助 | `login_help_cn` |
| 本地开发 | `docs_self_host_dev` | 文档开头产品链接 | `intro_product_link` |
| 本地开发 | `docs_self_host_dev` | 前置环境产品链接 | `prerequisites_product_link` |
GitHub README 中的链接固定使用 `utm_source=github``utm_medium=referral`,并使用以下参数:
| 位置 | `utm_campaign` | `utm_content` |
| --- | --- | --- |
| 顶部 Logo | `github_home` | `logo` |
| Cloud Service 徽章 | `github_home` | `cloud_badge` |
| 云服务正文链接 | `github_home` | `cloud_service_link` |
同一页面或同一推广主题复用同一个 `utm_campaign`,使用不同的 `utm_content` 区分具体链接位置。新增页面时使用稳定、可读的小写下划线命名,避免把文案或时间写入参数。
......@@ -6,6 +6,7 @@ import { createRelativeLink } from 'fumadocs-ui/mdx';
import { getMDXComponents } from '@/mdx-components';
import { i18n } from '@/lib/i18n';
import { generateArticleSchema, generateBreadcrumbSchema } from '@/lib/schema';
import { getFastGPTDocsOrigin } from '@/lib/fastgpt-home-url';
// 在构建时导入静态数据
import docLastModifiedData from '@/data/doc-last-modified.json';
......@@ -37,8 +38,7 @@ export default async function Page({
// @ts-ignore
const lastModified = docLastModifiedData[filePath] || page.data.lastModified;
const homeDomain = process.env.FASTGPT_HOME_DOMAIN ?? 'https://fastgpt.io';
const domain = homeDomain.replace('https://', 'https://doc.');
const domain = getFastGPTDocsOrigin();
const url = `${domain}${page.url}`;
// 生成面包屑导航
......@@ -115,8 +115,7 @@ export async function generateMetadata(props: {
const page = source.getPage(slug, lang);
if (!page || !page.data) notFound();
const homeDomain = process.env.FASTGPT_HOME_DOMAIN ?? 'https://fastgpt.io';
const domain = homeDomain.replace('https://', 'https://doc.');
const domain = getFastGPTDocsOrigin();
const url = `${domain}${page.url}`;
// 构建多语言 alternates
......
......@@ -5,6 +5,7 @@ import type { Translations } from 'fumadocs-ui/i18n';
import CustomSearchDialog from '@/components/CustomSearchDialog';
import Script from 'next/script';
import type { Metadata } from 'next';
import { getFastGPTDocsOrigin } from '@/lib/fastgpt-home-url';
const zh_CN: Partial<Translations> = {
search: '搜索',
......@@ -46,8 +47,7 @@ export async function generateMetadata({
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
const homeDomain = process.env.FASTGPT_HOME_DOMAIN ?? 'https://fastgpt.io';
const domain = homeDomain.replace('https://', 'https://doc.');
const domain = getFastGPTDocsOrigin();
const title = lang === 'zh-CN' ? 'FastGPT 文档 - 快速开始' : 'FastGPT Documentation - Getting Started';
const description =
......
import { NextResponse } from 'next/server';
import { getFastGPTHomeOrigin, getFastGPTDocsOrigin } from '@/lib/fastgpt-home-url';
export const dynamic = 'force-static';
export function GET() {
const homeDomain = process.env.FASTGPT_HOME_DOMAIN ?? 'https://fastgpt.io';
const domain = homeDomain.replace('https://', 'https://doc.');
const homeDomain = getFastGPTHomeOrigin();
const domain = getFastGPTDocsOrigin();
const isCN = homeDomain.includes('.cn');
let content: string;
......
import { source } from '@/lib/source';
import { NextResponse } from 'next/server';
import docLastModifiedData from '@/data/doc-last-modified.json';
import { getFastGPTDocsOrigin } from '@/lib/fastgpt-home-url';
export const dynamic = 'force-static';
export function GET() {
const homeDomain = process.env.FASTGPT_HOME_DOMAIN ?? 'https://fastgpt.io';
const domain = homeDomain.replace('https://', 'https://doc.');
const domain = getFastGPTDocsOrigin();
const pages = source.getPages();
......
'use client';
import React, { useMemo } from 'react';
import {
buildFastGPTHomeUrl,
type DocsUtmCampaign,
type FastGPTSite
} from '@/lib/fastgpt-home-url';
type FastGPTLinkProps = {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
campaign: DocsUtmCampaign;
content: string;
site?: FastGPTSite;
};
const defaultStyles: React.CSSProperties = {
......@@ -20,10 +28,20 @@ const hoverStyles: React.CSSProperties = {
textDecoration: 'underline'
};
const FastGPTLink = ({ children, className, style, onClick, ...props }: FastGPTLinkProps) => {
const href = useMemo(() => {
return process.env.FASTGPT_HOME_DOMAIN ?? 'https://fastgpt.io';
}, []);
const FastGPTLink = ({
children,
className,
style,
onClick,
campaign,
content,
site = 'configured',
...props
}: FastGPTLinkProps) => {
const href = useMemo(
() => buildFastGPTHomeUrl({ campaign, content, site }),
[campaign, content, site]
);
const [isHovered, setIsHovered] = React.useState(false);
......
......@@ -4,6 +4,7 @@ description: FastGPT's capabilities and advantages
---
import { Alert } from '@/components/docs/Alert';
import FastGPTLink from '@/components/docs/linkFastGPT';
FastGPT is an AI Agent application development platform built on large language models. It combines Knowledge Base Q&A, visual Workflows, Agent orchestration, tool calling, and skill extensions so developers and business users can quickly build custom AI applications.
......@@ -11,8 +12,8 @@ FastGPT is an AI Agent application development platform built on large language
Try FastGPT now
- International: [https://fastgpt.io](https://fastgpt.io)
- China Mainland: [https://fastgpt.cn](https://fastgpt.cn)
- International: <FastGPTLink campaign="docs_getting_started" content="cloud_entry_io" site="io">{'https://fastgpt.io'}</FastGPTLink>
- China Mainland: <FastGPTLink campaign="docs_getting_started" content="cloud_entry_cn" site="cn">{'https://fastgpt.cn'}</FastGPTLink>
</Alert>
......
......@@ -4,6 +4,7 @@ description: FastGPT 的能力与优势
---
import { Alert } from '@/components/docs/Alert';
import FastGPTLink from '@/components/docs/linkFastGPT';
FastGPT 是一个基于大语言模型的 AI Agent 应用开发平台,集知识库问答、可视化工作流、Agent 编排、工具调用和技能扩展于一体,让开发者和业务人员都能快速构建专属 AI 应用。
......@@ -11,8 +12,8 @@ FastGPT 是一个基于大语言模型的 AI Agent 应用开发平台,集知
快速开始体验
- 国际版:[https://fastgpt.io](https://fastgpt.io)
- 中国大陆版:[https://fastgpt.cn](https://fastgpt.cn)
- 国际版:<FastGPTLink campaign="docs_getting_started" content="cloud_entry_io" site="io">{'https://fastgpt.io'}</FastGPTLink>
- 中国大陆版:<FastGPTLink campaign="docs_getting_started" content="cloud_entry_cn" site="cn">{'https://fastgpt.cn'}</FastGPTLink>
</Alert>
......
......@@ -3,13 +3,15 @@ title: FAQ
description: FastGPT Cloud FAQ
---
import FastGPTLink from '@/components/docs/linkFastGPT';
## Account and Login Issues
FastGPT has two versions, and accounts are not shared between them:
China Mainland: [https://fastgpt.cn](https://fastgpt.cn) (supports WeChat and phone number login)
China Mainland: <FastGPTLink campaign="docs_cloud_faq" content="login_help_cn" site="cn">{'https://fastgpt.cn'}</FastGPTLink> (supports WeChat and phone number login)
International: [https://fastgpt.io](https://fastgpt.io) (supports email, Google, and GitHub login; phone number sign-up was available before September 2024)
International: <FastGPTLink campaign="docs_cloud_faq" content="login_help_io" site="io">{'https://fastgpt.io'}</FastGPTLink> (supports email, Google, and GitHub login; phone number sign-up was available before September 2024)
If you have used FastGPT before but cannot log in, try switching between the two versions.
......
......@@ -3,11 +3,13 @@ title: 常见问题
description: FastGPT 云服务常见问题
---
import FastGPTLink from '@/components/docs/linkFastGPT';
## 账号/登录问题
FastGPT 有两个版本,账号不互通:
中国大陆版:[https://fastgpt.cn](https://fastgpt.cn) (支持微信/手机号登录)
国际版:[https://fastgpt.io](https://fastgpt.io) (支持邮箱/google/github/ 登录,2024 年 9 月前可手机号注册)
中国大陆版:<FastGPTLink campaign="docs_cloud_faq" content="login_help_cn" site="cn">{'https://fastgpt.cn'}</FastGPTLink> (支持微信/手机号登录)<br />
国际版:<FastGPTLink campaign="docs_cloud_faq" content="login_help_io" site="io">{'https://fastgpt.io'}</FastGPTLink> (支持邮箱/google/github/ 登录,2024 年 9 月前可手机号注册)
如果使用过,但是发现登录不了,可以尝试切换不同版本进行尝试。
......
......@@ -3,9 +3,11 @@ title: FastGPT Cloud Service
description: FastGPT Cloud Service
---
import FastGPTLink from '@/components/docs/linkFastGPT';
## Service URLs
- [China Mainland: https://fastgpt.cn](https://fastgpt.cn)
- [International: https://fastgpt.io](https://fastgpt.io)
- China Mainland: <FastGPTLink campaign="docs_cloud_intro" content="cloud_entry_cn" site="cn">{'https://fastgpt.cn'}</FastGPTLink>
- International: <FastGPTLink campaign="docs_cloud_intro" content="cloud_entry_io" site="io">{'https://fastgpt.io'}</FastGPTLink>
Register based on your needs. Accounts are not shared between the two versions.
......@@ -3,9 +3,11 @@ title: 介绍
description: FastGPT 云服务介绍
---
import FastGPTLink from '@/components/docs/linkFastGPT';
## 服务地址
- [中国大陆版: https://fastgpt.cn](https://fastgpt.cn)
- [国际版: https://fastgpt.io](https://fastgpt.io)
- 中国大陆版:<FastGPTLink campaign="docs_cloud_intro" content="cloud_entry_cn" site="cn">{'https://fastgpt.cn'}</FastGPTLink>
- 国际版:<FastGPTLink campaign="docs_cloud_intro" content="cloud_entry_io" site="io">{'https://fastgpt.io'}</FastGPTLink>
请按需注册,两个版本账号不互通。
......@@ -165,6 +165,7 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
| ----------------------------- | ------- | --------------------------------------------------------------------------------------------------- |
| `USE_IP_LIMIT` | `false` | Whether IP rate limiting is enabled for selected APIs. |
| `CHECK_INTERNAL_IP` | `false` | Whether internal IP checks are enabled to reduce SSRF risk. |
| `AUTH_COOKIE_SECURE` | `false` | Whether login cookies use the `Secure` attribute. Enable only when the site is HTTPS-only. |
| `TRUSTED_PROXY_ENABLE` | `false` | Whether trusted reverse proxy client IP validation is enabled. Disabled keeps legacy behavior. |
| `TRUSTED_PROXY_IPS` | Empty | Trusted reverse proxy IP/CIDR list, separated by commas or whitespace. |
| `PASSWORD_LOGIN_LOCK_SECONDS` | `120` | Lock duration after failed password login attempts, in seconds. |
......
......@@ -165,6 +165,7 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
| ----------------------------- | ------- | -------------------------------------------------------------- |
| `USE_IP_LIMIT` | `false` | 是否启用部分接口的 IP 限流。 |
| `CHECK_INTERNAL_IP` | `false` | 是否启用内网 IP 检查,用于降低 SSRF 风险。 |
| `AUTH_COOKIE_SECURE` | `false` | 是否为登录 Cookie 添加 `Secure` 属性;仅在全站 HTTPS 时启用。 |
| `TRUSTED_PROXY_ENABLE` | `false` | 是否启用可信反向代理客户端 IP 校验;关闭时兼容旧逻辑。 |
| `TRUSTED_PROXY_IPS` | 空 | 可信反向代理 IP/CIDR 列表,逗号或空白分隔。 |
| `PASSWORD_LOGIN_LOCK_SECONDS` | `120` | 密码登录错误后的锁定时长,单位秒。 |
......
......@@ -6,18 +6,18 @@ description: Develop and debug FastGPT locally
import { Alert } from '@/components/docs/Alert';
import FastGPTLink from '@/components/docs/linkFastGPT';
This guide covers how to set up your development environment to build and test <FastGPTLink>FastGPT</FastGPTLink>.
This guide covers how to set up your development environment to build and test <FastGPTLink campaign="docs_self_host_dev" content="intro_product_link">FastGPT</FastGPTLink>.
## Prerequisites
Install and configure these dependencies on your machine to build <FastGPTLink>FastGPT</FastGPTLink>:
Install and configure these dependencies on your machine to build <FastGPTLink campaign="docs_self_host_dev" content="prerequisites_product_link">FastGPT</FastGPTLink>:
- [Git](https://git-scm.com/)
- [Docker](https://www.docker.com/)
- [Node.js v20.14.0](https://nodejs.org) (match this version closely; use [nvm](https://github.com/nvm-sh/nvm) to manage Node versions)
- [pnpm](https://pnpm.io/) recommended version 9.4.0 (current official dev environment)
We recommend developing on *nix environments (Linux, macOS, Windows WSL).
We recommend developing on \*nix environments (Linux, macOS, Windows WSL).
## Local Development
......@@ -33,7 +33,6 @@ Clone your forked repository from GitHub:
git clone git@github.com:<your_github_username>/FastGPT.git
```
### 3. Start the Development Environment with Docker
If you're already running FastGPT locally via Docker, stop it first to avoid port conflicts.
......@@ -46,8 +45,9 @@ docker compose up -d
```
<Alert context="warning">
1. If you can't pull images, use the China mirror version: `docker compose -f docker-compose.cn.yml up -d`
2. For MongoDB, add the `directConnection=true` parameter to your connection string to connect to the replica set.
1. If you can't pull images, use the China mirror version: `docker compose -f
docker-compose.cn.yml up -d` 2. For MongoDB, add the `directConnection=true` parameter to your
connection string to connect to the replica set.
</Alert>
### 4. Initial Configuration
......@@ -103,6 +103,7 @@ Next.js runs on port 3000 by default. Visit http://localhost:3000
### 6. Build
We recommend using Docker for builds.
```bash
# Without proxy
docker build -f ./projects/app/Dockerfile -t fastgpt . --build-arg name=app
......
......@@ -6,18 +6,18 @@ description: 对 FastGPT 进行开发调试
import { Alert } from '@/components/docs/Alert';
import FastGPTLink from '@/components/docs/linkFastGPT';
本文档介绍了如何设置开发环境以构建和测试 <FastGPTLink>FastGPT</FastGPTLink>。
本文档介绍了如何设置开发环境以构建和测试 <FastGPTLink campaign="docs_self_host_dev" content="intro_product_link">FastGPT</FastGPTLink>。
## 前置开发环境
您需要在计算机上安装和配置以下依赖项才能构建 <FastGPTLink>FastGPT</FastGPTLink>:
您需要在计算机上安装和配置以下依赖项才能构建 <FastGPTLink campaign="docs_self_host_dev" content="prerequisites_product_link">FastGPT</FastGPTLink>:
- [Git](https://git-scm.com/)
- [Docker](https://www.docker.com/)
- [Node.js >=20](https://nodejs.org)(版本尽量一样,可以使用 [nvm](https://github.com/nvm-sh/nvm) 管理 node 版本)
- [Node.js >=20](https://nodejs.org)(版本尽量一样,可以使用 [nvm](https://github.com/nvm-sh/nvm) 管理 Node.js 版本)
- [pnpm](https://pnpm.io/) 需要使用 10.x
建议在 *nix 环境进行开发 (Linux, MacOS, Windows WSL)
建议在 \*nix 环境进行开发 (Linux, MacOS, Windows WSL)
## 开始本地开发
......@@ -33,7 +33,6 @@ import FastGPTLink from '@/components/docs/linkFastGPT';
git clone git@github.com:<your_github_username>/FastGPT.git
```
### 3. 通过 docker 启动开发环境
若您本地已经通过 docker 启动了 FastGPT,则需要先关闭,否则会有端口冲突。
......@@ -46,9 +45,9 @@ docker compose up -d
```
<Alert context="warning">
1. 如果无法获取镜像,可以选择国内镜像版本的 docker-compose.yml 文件:`docker compose -f docker-compose.cn.yml up -d`
2. Mongo 数据库需要注意,需要注意在连接地址中增加 `directConnection=true`
参数,才能连接上副本集的数据库。
1. 如果无法获取镜像,可以选择国内镜像版本的 docker-compose.yml 文件:`docker compose -f
docker-compose.cn.yml up -d` 2. Mongo 数据库需要注意,需要注意在连接地址中增加
`directConnection=true` 参数,才能连接上副本集的数据库。
</Alert>
### 4. 初始配置
......@@ -63,8 +62,7 @@ pwd
**1. 环境变量**
复制 `.env.template` 文件,在同级目录下生成一个`.env.local` 文件,修改`.env.local` 里内容才是有效的变量。
变量说明见 `.env.template`
复制 `.env.template` 文件,在同级目录下生成一个 `.env.local` 文件,修改 `.env.local` 里内容才是有效的变量。变量说明见 `.env.template`
如果没有修改 docker-compose.yaml 中的变量,`.env.template` 中的默认值就可以,不需要进行修改,否则需要和 `yml` 中的变量一致。
```bash
......@@ -81,10 +79,10 @@ cp data/config.json data/config.local.json
这个文件大部分时候不需要修改。只需要关注 `systemEnv` 里的参数:
- `vectorMaxProcess`: 向量生成最大进程,根据数据库和 key 的并发数来决定,通常单个 120 号,2c4g 服务器设置 10~15。
- `qaMaxProcess`: QA 生成最大进程
- `vlmMaxProcess`: 图片理解模型最大进程
- `hnswEfSearch`: 向量搜索参数,仅对 PG 和 OB 生效,越大搜索精度越高但是速度越慢。
- `vectorMaxProcess` : 向量生成最大进程,根据数据库和 key 的并发数来决定,通常单个 120 号,2c4g 服务器设置 10~15。
- `qaMaxProcess` : QA 生成最大进程
- `vlmMaxProcess` : 图片理解模型最大进程
- `hnswEfSearch` : 向量搜索参数,仅对 PG 和 OB 生效,越大搜索精度越高但是速度越慢。
### 5. 运行
......@@ -104,6 +102,7 @@ pnpm dev
### 6. 打包
建议直接使用 Docker 进行打包。
```bash
# 没有 Proxy
docker build -f ./projects/app/Dockerfile -t fastgpt . --build-arg name=app
......@@ -125,26 +124,25 @@ docker build -f ./projects/app/Dockerfile -t fastgpt. --build-arg name=app --bui
### 获取系统时间异常
如果用户默认的时区为 `Asia/Shanghai`, 非 linux 环境时,获取系统时间会异常,本地开发时,可以将用户的时区调整成 UTC(+0)。
如果用户默认的时区为 `Asia/Shanghai` , 非 linux 环境时,获取系统时间会异常,本地开发时,可以将用户的时区调整成 UTC(+0)。
### 本地数据库无法连接
1. 如果你是连接远程的数据库,先检查对应的端口是否开放。
2. 如果是本地运行的数据库,可尝试`host`改成`localhost`或`127.0.0.1`
2. 如果是本地运行的数据库,可尝试 `host` 改成 `localhost` 或 `127.0.0.1`
3. 本地连接远程的 Mongo,需要增加 `directConnection=true` 参数,才能连接上副本集的数据库。
4. mongo使用`mongocompass`客户端进行连接测试和可视化管理。
5. pg使用`navicat`进行连接和管理。
4. mongo 使用 `mongocompass` 客户端进行连接测试和可视化管理。
5. pg 使用 `navicat` 进行连接和管理。
### sh ./scripts/postinstall.sh 没权限
FastGPT 在`pnpm i`后会执行`postinstall`脚本,用于自动生成`ChakraUI`的`Type`。如果没有权限,可以先执行`chmod -R +x ./scripts/`,再执行`pnpm i`。
FastGPT 在 `pnpm i` 后会执行 `postinstall` 脚本,用于自动生成 `ChakraUI` 的 `Type`。如果没有权限,可以先执行 `chmod -R +x ./scripts/`,再执行 `pnpm i`。
仍不可行的话,可以手动执行`./scripts/postinstall.sh`里的内容。
_如果是Windows下的话,可以使用git bash给`postinstall`脚本添加执行权限并执行sh脚本_
仍不可行的话,可以手动执行 `./scripts/postinstall.sh` 里的内容。_如果是 Windows 下的话,可以使用 git bash 给 `postinstall` 脚本添加执行权限并执行 sh 脚本_
### TypeError: Cannot read properties of null (reading 'useMemo' )
删除所有的`node_modules`,用 Node18 重新 install 试试,可能最新的 Node 有问题。 本地开发流程:
删除所有的 `node_modules`,用 Node18 重新 install 试试,可能最新的 Node.js 有问题。本地开发流程:
1. 根目录: `pnpm i`
2. 复制 `config.json` -> `config.local.json`
......@@ -154,7 +152,7 @@ _如果是Windows下的话,可以使用git bash给`postinstall`脚本添加执
### Error response from daemon: error while creating mount source path 'XXX': mkdir XXX: file exists
这个错误可能是之前停止容器时有文件残留导致的,首先需要确认相关镜像都全部关闭,然后手动删除相关文件或者重启docker即可
这个错误可能是之前停止容器时有文件残留导致的,首先需要确认相关镜像都全部关闭,然后手动删除相关文件或者重启 docker 即可
## 加入社区
......@@ -170,7 +168,7 @@ _如果是Windows下的话,可以使用git bash给`postinstall`脚本添加执
### nextjs
FastGPT 使用了 nextjs 的 page route 作为框架。为了区分好前后端代码,在目录分配上会分成 global, service, web 3个自目录,分别对应着 `前后端共用`、`后端专用`、`前端专用`的代码。
FastGPT 使用了 nextjs 的 page route 作为框架。为了区分好前后端代码,在目录分配上会分成 global, service, web 3 个自目录,分别对应着 `前后端共用`、`后端专用`、`前端专用` 的代码。
### monorepo
......@@ -185,7 +183,7 @@ FastGPT 采用 pnpm workspace 方式构建 monorepo 项目,主要分为两个
### 领域驱动模式(DDD)
FastGPT 在代码模块划分时,按DDD的思想进行划分,主要分为以下几个领域:
FastGPT 在代码模块划分时,按 DDD 的思想进行划分,主要分为以下几个领域:
- core - 核心功能(知识库,工作流,应用,对话)
- support - 支撑功能(用户体系,计费,鉴权等)
......
......@@ -45,6 +45,7 @@ When using `short-redirect`, you must configure `STORAGE_EXTERNAL_ENDPOINT`.
2. The portal page now supports selecting Agent V2 apps for conversations.
3. File upload and download URLs now use short access URLs, reducing the context consumed by long URLs and the risk of malformed model output. Previously issued URLs remain supported.
4. For file URLs without a recognizable extension, FastGPT now infers the file type from the buffer to improve parsing success rates.
5. The Custom Tool Parameters node now supports manually entering a JSON Schema and marking parameters as required.
## ⚙️ Improvements
......@@ -59,6 +60,7 @@ When using `short-redirect`, you must configure `STORAGE_EXTERNAL_ENDPOINT`.
7. Improved the performance of the fade-in effect for streaming output in chat dialogs.
8. Upgraded `LiteParse` to fix PDF parsing errors under concurrent workloads. The default file parsing worker count is now 5 instead of 10 and remains configurable through `PARSE_FILE_WORKERS`.
9. Added in-flight request deduplication for the model list and sandbox package endpoints. Identical concurrent requests now share the same result, reducing duplicate requests triggered by workflow nodes and selectors.
10. Workflow node responses are now included in SSE streams.
### Streaming Markdown Rendering Improvements
......@@ -97,6 +99,7 @@ When using `short-redirect`, you must configure `STORAGE_EXTERNAL_ENDPOINT`.
1. Refactored Agent V2 assisted generation / ChatAgentHelper to reuse the dialog.
2. AI request records that contain very long base64/data URLs are truncated before saving to prevent possible stack overflows.
3. Unified SSE event wrapping for stronger type hints.
4. Added the `AUTH_COOKIE_SECURE` environment variable. When enabled, login cookies use the `Secure` attribute and are sent only over HTTPS.
### Agent Loop Refactor
......
......@@ -41,10 +41,12 @@ V4.15.2 新增 `STORAGE_DOWNLOAD_URL_MODE` 环境变量,默认值为 `short-pr
## 🚀 新增内容
1. 企业认证/公司认证能力
2. 门户页支持选择 AgentV2 应用进行对话
1. 工作流节点增加实时错误提示
2. 自定义工具参数节点,支持手动输入 jsonschema,同时支持必填选项
3. 文件上传、下载链接改用短访问链接,减少长链接占用上下文及模型输出异常;已签发的旧版链接仍保持兼容。
4. 针对无明确后缀的文件链接,进行 buffer 推测后缀,提高文件解析成功率。
5. 企业认证/公司认证能力。
6. 门户页支持选择 AgentV2 应用进行对话。
## ⚙️ 优化
......@@ -57,6 +59,7 @@ V4.15.2 新增 `STORAGE_DOWNLOAD_URL_MODE` 环境变量,默认值为 `short-pr
7. 对话框流输出,淡入效果性能优化。
8. 升级 `LiteParse` 版本,解决并发解析 PDF 报错问题;文件解析 worker 默认数量由 10 调整为 5,仍可通过 `PARSE_FILE_WORKERS` 配置。
9. 前端请求增加并发去重能力,模型列表和沙盒依赖接口的相同请求会复用进行中的结果,减少工作流节点和选择器重复触发的请求。
10. 工作流 SSE 返回 nodeResponse。
## 🐛 修复
......@@ -78,6 +81,7 @@ V4.15.2 新增 `STORAGE_DOWNLOAD_URL_MODE` 环境变量,默认值为 `short-pr
2. 保存包含超长 base64/data URL 的 AI 请求记录时可能触发栈溢出,提前进行截断。
3. SSE 事件统一封装,强化类型提示。
4. packages/service 和 packages/global 移除 next 依赖。
5. 新增 `AUTH_COOKIE_SECURE` 环境变量,启用后登录 Cookie 将添加 `Secure` 属性,仅通过 HTTPS 传输。
### Agent Loop 重构
......
......@@ -123,16 +123,16 @@
"content/guide/dataset/third-party/yuque_dataset.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/dataset/websync.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/dataset/websync.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/getting-started/index.en.mdx": "2026-07-02T11:56:02+08:00",
"content/guide/getting-started/index.mdx": "2026-07-02T11:56:02+08:00",
"content/guide/getting-started/index.en.mdx": "2026-07-17T13:35:26+08:00",
"content/guide/getting-started/index.mdx": "2026-07-17T13:35:26+08:00",
"content/guide/getting-started/quick-start.en.mdx": "2026-07-01T17:20:32+08:00",
"content/guide/getting-started/quick-start.mdx": "2026-07-01T17:20:32+08:00",
"content/guide/index.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/index.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/faq.en.mdx": "2026-05-25T18:16:39+08:00",
"content/guide/version/cloud/faq.mdx": "2026-05-25T18:16:39+08:00",
"content/guide/version/cloud/intro.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/intro.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/faq.en.mdx": "2026-07-17T13:35:26+08:00",
"content/guide/version/cloud/faq.mdx": "2026-07-17T13:35:26+08:00",
"content/guide/version/cloud/intro.en.mdx": "2026-07-17T13:35:26+08:00",
"content/guide/version/cloud/intro.mdx": "2026-07-17T13:35:26+08:00",
"content/guide/version/cloud/privacy.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/privacy.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/terms.en.mdx": "2026-05-07T15:06:40+08:00",
......@@ -167,16 +167,16 @@
"content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/system-tool-development.en.mdx": "2026-07-02T11:54:55+08:00",
"content/plugin/system-tool-development.mdx": "2026-07-02T11:54:55+08:00",
"content/self-host/config/env.en.mdx": "2026-07-16T23:04:39+08:00",
"content/self-host/config/env.mdx": "2026-07-16T23:04:39+08:00",
"content/self-host/config/env.en.mdx": "2026-07-17T11:33:16+08:00",
"content/self-host/config/env.mdx": "2026-07-17T11:33:16+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/intro.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/minimax.en.mdx": "2026-06-03T10:40:17+08:00",
"content/self-host/config/model/minimax.mdx": "2026-06-03T10:40:17+08:00",
"content/self-host/config/model/siliconCloud.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/config/model/siliconCloud.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/config/object-storage.en.mdx": "2026-05-21T11:24:48+08:00",
"content/self-host/config/object-storage.mdx": "2026-05-21T11:24:48+08:00",
"content/self-host/config/object-storage.en.mdx": "2026-07-17T14:00:26+08:00",
"content/self-host/config/object-storage.mdx": "2026-07-17T14:00:26+08:00",
"content/self-host/config/remote-debug-suite.en.mdx": "2026-06-27T22:05:51+08:00",
"content/self-host/config/remote-debug-suite.mdx": "2026-06-27T22:05:51+08:00",
"content/self-host/config/sandbox/common.en.mdx": "2026-07-02T15:38:53+08:00",
......@@ -209,8 +209,8 @@
"content/self-host/deploy/sealos.mdx": "2026-06-30T22:10:03+08:00",
"content/self-host/design/dataset.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/design/dataset.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/dev.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/dev.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/dev.en.mdx": "2026-07-17T13:35:26+08:00",
"content/self-host/dev.mdx": "2026-07-17T13:35:26+08:00",
"content/self-host/index.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/index.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/migration/docker_db.en.mdx": "2026-04-26T21:08:47+08:00",
......@@ -320,8 +320,8 @@
"content/self-host/upgrading/4-15/41507.mdx": "2026-06-30T17:31:43+08:00",
"content/self-host/upgrading/4-15/4151.en.mdx": "2026-07-07T21:14:28+08:00",
"content/self-host/upgrading/4-15/4151.mdx": "2026-07-07T21:14:28+08:00",
"content/self-host/upgrading/4-15/4152.en.mdx": "2026-07-16T23:04:39+08:00",
"content/self-host/upgrading/4-15/4152.mdx": "2026-07-16T23:04:39+08:00",
"content/self-host/upgrading/4-15/4152.en.mdx": "2026-07-17T16:16:34+08:00",
"content/self-host/upgrading/4-15/4152.mdx": "2026-07-17T16:16:34+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
const DEFAULT_FASTGPT_HOME_ORIGIN = 'https://fastgpt.io';
export const DOCS_UTM_CAMPAIGNS = {
gettingStarted: 'docs_getting_started',
cloudIntro: 'docs_cloud_intro',
cloudFaq: 'docs_cloud_faq',
selfHostDev: 'docs_self_host_dev'
} as const;
export type DocsUtmCampaign = (typeof DOCS_UTM_CAMPAIGNS)[keyof typeof DOCS_UTM_CAMPAIGNS];
export type FastGPTSite = 'configured' | 'cn' | 'io';
const normalizeOrigin = (value?: string): string => {
try {
return new URL(value || DEFAULT_FASTGPT_HOME_ORIGIN).origin;
} catch {
return DEFAULT_FASTGPT_HOME_ORIGIN;
}
};
export const getFastGPTHomeOrigin = (): string =>
normalizeOrigin(
process.env.NEXT_PUBLIC_FASTGPT_HOME_DOMAIN || process.env.FASTGPT_HOME_DOMAIN
);
export const getFastGPTDocsOrigin = (): string => {
const homeUrl = new URL(getFastGPTHomeOrigin());
homeUrl.hostname = `doc.${homeUrl.hostname}`;
return homeUrl.origin;
};
export const buildFastGPTHomeUrl = ({
campaign,
content,
site = 'configured'
}: {
campaign: DocsUtmCampaign;
content: string;
site?: FastGPTSite;
}): string => {
const origin =
site === 'cn'
? 'https://fastgpt.cn'
: site === 'io'
? 'https://fastgpt.io'
: getFastGPTHomeOrigin();
const url = new URL('/', origin);
url.searchParams.set('utm_source', 'docs');
url.searchParams.set('utm_medium', 'referral');
url.searchParams.set('utm_campaign', campaign);
url.searchParams.set('utm_content', content);
return url.toString();
};
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -130,7 +130,6 @@ x-service-env-config: &x-service-env-config
MAX_LOGIN_SESSION:
ALLOWED_ORIGINS:
AGENT_ENGINE: default
CHAT_TITLE_MODEL:
SKIP_FILE_TYPE_CHECK: false
WECHAT_CHANNEL_CONCURRENCY: 1000
PARSE_FILE_WORKERS: 5
......
......@@ -70,6 +70,65 @@ export const JsonSchemaPropertiesItemSchema = z
.catchall(z.any());
export type JsonSchemaPropertiesItemType = z.infer<typeof JsonSchemaPropertiesItemSchema>;
const ToolParamJsonSchemaTypeSchema = z.enum([
'string',
'number',
'integer',
'boolean',
'object',
'array',
'null'
]);
/** 手工工具参数使用的严格 JSON Schema,递归校验每一层的 type 和结构关系。 */
export const ToolParamJsonSchemaSchema: z.ZodType<JsonSchemaPropertiesItemType> = z.lazy(() =>
JsonSchemaPropertiesItemSchema.extend({
type: ToolParamJsonSchemaTypeSchema,
properties: z.record(z.string(), ToolParamJsonSchemaSchema).optional(),
items: ToolParamJsonSchemaSchema.optional()
}).superRefine((schema, ctx) => {
if (schema.properties && schema.type !== 'object') {
ctx.addIssue({
code: 'custom',
path: ['properties'],
message: 'properties is only allowed when type is object'
});
}
if (schema.required && schema.type !== 'object') {
ctx.addIssue({
code: 'custom',
path: ['required'],
message: 'required is only allowed when type is object'
});
}
if (schema.items && schema.type !== 'array') {
ctx.addIssue({
code: 'custom',
path: ['items'],
message: 'items is only allowed when type is array'
});
}
if (schema.type === 'array' && !schema.items) {
ctx.addIssue({
code: 'custom',
path: ['items'],
message: 'items is required when type is array'
});
}
const propertyKeys = new Set(Object.keys(schema.properties ?? {}));
schema.required?.forEach((key, index) => {
if (!propertyKeys.has(key)) {
ctx.addIssue({
code: 'custom',
path: ['required', index],
message: `required field ${key} is not defined in properties`
});
}
});
})
);
export const JSONSchemaInputTypeSchema = z
.object({
type: z.any().optional(),
......@@ -115,6 +174,25 @@ export const getNodeInputTypeFromSchemaInputType = ({
return WorkflowIOValueTypeEnum.arrayAny;
};
/** 解析并严格校验手工工具参数 Schema,同时提取参数描述和工作流值类型。 */
export const parseToolParamJsonSchema = (schemaString: string) => {
const schema = ToolParamJsonSchemaSchema.parse(JSON.parse(schemaString));
const description = schema.description?.trim();
if (!description) {
throw new Error('JSON Schema property description is required');
}
return {
description,
schema,
valueType: getNodeInputTypeFromSchemaInputType({
type: schema.type,
arrayItems: schema.items
})
};
};
const getNodeInputRenderTypeFromSchemaInputType = ({
type,
items,
......@@ -436,6 +514,10 @@ const setEnumValuesToJsonSchemaProperty = ({
export const nodeInput2JsonSchemaProperty = (
input: FlowNodeInputItemType
): JsonSchemaPropertiesItemType => {
if (input.customJsonSchema) {
return cloneJsonSchemaProperty(input.customJsonSchema);
}
const schema = setEnumValuesToJsonSchemaProperty({
schema: getJsonSchemaPropertyFromValueType(input.valueType),
enumValues: getEnumValuesFromNodeInput(input)
......
......@@ -112,12 +112,8 @@ function attachNodeResponsesByParent(responses: ChatHistoryItemResType[]) {
: undefined;
if (!parent) return [...roots, response];
parent.childrenResponses = mergeChildResponseList(parent.childrenResponses || [], [
{
...response,
parentId: response.parentId
}
]);
// 中间父节点挂到祖先后仍可能继续收到恢复执行产生的 child,必须保留同一对象引用。
parent.childrenResponses = mergeChildResponseList(parent.childrenResponses || [], [response]);
return roots;
}, []);
}
......
......@@ -281,6 +281,9 @@ export const FlowNodeInputItemTypeSchema = InputComponentPropsTypeSchema.extend(
toolDescription: z.string().optional().meta({
description: '作为工具调用参数时的语义说明'
}), // If this field is not empty, it is entered as a tool
customJsonSchema: z.record(z.string(), z.any()).optional().meta({
description: '工具参数自定义 JSON Schema 的 property 定义'
}),
enum: z.string().optional().meta({
description: '已废弃:旧版枚举配置'
......
......@@ -66,7 +66,7 @@ export const AppLogPath: OpenAPIPath = {
post: {
summary: '获取应用日志列表',
description: '分页获取应用的对话日志列表,支持按时间范围、来源、用户等条件筛选',
tags: [DevApiTagsMap.appLog],
tags: [DevApiTagsMap.appLog, SystemOpenApiTagMap.appLog],
requestBody: {
content: {
'application/json': {
......@@ -158,7 +158,7 @@ export const AppLogPath: OpenAPIPath = {
post: {
summary: '获取日志用户列表',
description: '获取应用日志中的用户列表,包括外链用户和团队成员,按对话数量排序',
tags: [DevApiTagsMap.appLog],
tags: [DevApiTagsMap.appLog, SystemOpenApiTagMap.appLog],
requestBody: {
content: {
'application/json': {
......
......@@ -502,7 +502,7 @@ ${interactiveStreamExample}
- \`fastAnswer\`:指定回复返回给客户端的文本(最终会算作回答)。
- \`toolCall\` / \`toolParams\` / \`toolResponse\`:工具相关。
- \`flowNodeStatus\`:运行到的节点状态。
- \`flowNodeResponse\`:v2 节点响应详情。与 v1 的 \`flowResponses\` 不同,v2 会按节点逐条推送,而不是最后一次性返回数组。
- \`flowNodeResponse\`:v2 节点响应详情。与 v1 的 \`flowResponses\` 不同,v2 会按节点逐条推送(包括 Agent、工具、Loop、Parallel 等内部节点),客户端应按 \`id + parentId\` 追加或合并,而不是等待最后一次性数组。
- \`workflowDuration\`:工作流本轮运行耗时,payload 为 \`{"durationSeconds": number}\`。
- \`updateVariables\`:更新变量。
- \`interactive\`:交互节点配置。
......@@ -510,6 +510,8 @@ ${interactiveStreamExample}
- \`skillCall\` / \`sandboxStatus\`:技能调用和沙盒状态(仅相关能力启用时可能返回)。
- \`error\`:报错。
Share 调用沿用相同的逐条事件协议,但会先按分享配置过滤公共字段;引用、运行状态和技能引用分别受 \`showCite\`、\`showRunningStatus\`、\`showSkillReferences\` 控制,知识库源文件下载仍受分享下载权限控制。
**交互节点**
如果工作流中包含交互节点,需要设置 \`detail=true\`:
......
......@@ -12,6 +12,7 @@ import {
getSchemaValueType,
nodeInputs2JsonSchema,
nodeOutputs2JsonSchema,
parseToolParamJsonSchema,
str2OpenApiSchema
} from '@fastgpt/global/core/app/jsonschema';
import { bundleOpenAPISchema } from '@fastgpt/global/common/string/swagger';
......@@ -21,6 +22,68 @@ import {
InputConfigInputTypeEnum
} from '@fastgpt/global/core/workflow/type/io';
describe('parseToolParamJsonSchema', () => {
it('should parse a recursively valid property schema', () => {
const result = parseToolParamJsonSchema(
JSON.stringify({
type: 'object',
description: ' User information ',
properties: {
name: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } }
},
required: ['name']
})
);
expect(result.description).toBe('User information');
expect(result.valueType).toBe(WorkflowIOValueTypeEnum.object);
expect(result.schema.properties?.tags).toEqual({
type: 'array',
items: { type: 'string' }
});
});
it.each([
['invalid JSON', '{'],
['missing root type', JSON.stringify({ description: 'Missing type' })],
['invalid root type', JSON.stringify({ type: 'invalid', description: 'Invalid type' })],
[
'missing nested type',
JSON.stringify({
type: 'object',
description: 'Object',
properties: { name: {} }
})
],
[
'invalid nested type',
JSON.stringify({
type: 'object',
description: 'Object',
properties: { name: { type: 'invalid' } }
})
],
[
'properties on a non-object',
JSON.stringify({ type: 'string', description: 'String', properties: {} })
],
['array without items', JSON.stringify({ type: 'array', description: 'Array' })],
[
'undefined required field',
JSON.stringify({
type: 'object',
description: 'Object',
properties: { name: { type: 'string' } },
required: ['missing']
})
],
['missing description', JSON.stringify({ type: 'string' })]
])('should reject %s', (_case, schema) => {
expect(() => parseToolParamJsonSchema(schema)).toThrow();
});
});
describe('jsonSchema2NodeInput', () => {
it('should return correct node input for http schema', () => {
const jsonSchema: JSONSchemaInputType = {
......@@ -574,6 +637,43 @@ describe('jsonSchema2NodeOutput', () => {
});
describe('nodeInputs2JsonSchema', () => {
it('should preserve a custom property schema and use the input required switch', () => {
const result = nodeInputs2JsonSchema({
inputs: [
{
key: 'userInfo',
label: 'userInfo',
valueType: WorkflowIOValueTypeEnum.object,
toolDescription: 'User information',
required: false,
renderTypeList: ['reference'],
customJsonSchema: {
type: 'object',
description: 'User information',
properties: {
name: { type: 'string' }
},
additionalProperties: false
}
}
]
});
expect(result).toEqual({
type: 'object',
properties: {
userInfo: {
type: 'object',
description: 'User information',
properties: {
name: { type: 'string' }
},
additionalProperties: false
}
}
});
});
it('should convert node inputs to json schema properties', () => {
const result = nodeInputs2JsonSchema({
inputs: [
......
......@@ -619,6 +619,52 @@ describe('mergeNodeResponseDataByIdAndParent', () => {
]);
});
it('should keep resumed children added after their parent was attached to a grandparent', () => {
const responseDataList: ChatHistoryItemResType[] = [
createNodeResponse({
id: 'before-interactive',
parentId: 'iteration-1',
moduleName: 'Before interactive'
}),
createNodeResponse({
id: 'iteration-1',
parentId: 'loop-run',
moduleType: FlowNodeTypeEnum.loopRun,
childResponseCount: 1
}),
createNodeResponse({
id: 'loop-run',
moduleType: FlowNodeTypeEnum.loopRun,
childResponseCount: 2
}),
createNodeResponse({
id: 'after-interactive',
parentId: 'iteration-1',
moduleName: 'After interactive'
}),
createNodeResponse({
id: 'iteration-1',
parentId: 'loop-run',
moduleType: FlowNodeTypeEnum.loopRun,
childResponseCount: 1
})
];
const result = mergeNodeResponseDataByIdAndParent(responseDataList);
expect(result.map((item) => item.id)).toEqual(['loop-run']);
expect(result[0].childrenResponses?.map((item) => item.id)).toEqual(['iteration-1']);
expect(result[0].childrenResponses?.[0]).toEqual(
expect.objectContaining({
childResponseCount: 2
})
);
expect(result[0].childrenResponses?.[0].childrenResponses?.map((item) => item.id)).toEqual([
'before-interactive',
'after-interactive'
]);
});
it('should merge legacy childrenResponses and childResponseCount', () => {
const responseDataList: ChatHistoryItemResType[] = [
{
......
......@@ -24,7 +24,6 @@ import {
streamAgentSandboxInitStatus,
type AgentSandboxPrepareAction
} from './sub/sandbox';
import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage';
import type { RuntimeNodeResponseSummary } from '../../type';
import { createAgentNodeResponseCollector } from './nodeResponseCollector';
import { createAgentSandboxPermissionDeniedError } from '../../../../ai/sandbox/interface/runtime';
......@@ -78,7 +77,6 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.useAgentSandbox]?: boolean;
[NodeInputKeyEnum.sandboxEntrypoint]?: string;
}> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
agentSandboxPrepareActions?: AgentSandboxPrepareAction[];
};
......@@ -98,8 +96,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
const assistantResponses: AIChatItemValueItemType[] = [];
const childNodeResponses: ChatHistoryItemResType[] = [];
const nodeResponseCollector = createAgentNodeResponseCollector({
nodeResponseWriter: props.nodeResponseWriter,
nodeResponseParentId: undefined,
nodeResponseSink: props.nodeResponseSink,
nodeResponses: childNodeResponses
});
......
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage';
import type { WorkflowNodeResponseSinkLike } from '../../nodeResponseSink';
import type { RuntimeNodeResponseSummary } from '../../type';
import { createRuntimeNodeResponseSummary, summarizeRuntimeNodeResponses } from '../../utils';
/**
* 收集 Agent 内部持续产生的 nodeResponse。
*
* 普通 Agent 和 PiAgent 都会在一次节点运行中产生多条内部详情。业务链路存在 root writer
* 时,这些详情应立即写库并释放,只向父 workflow 返回运行期 summary;无 writer 只保留给
* 普通 Agent 和 PiAgent 都会在一次节点运行中产生多条内部详情。业务链路存在请求级 sink
* 时,这些详情应逐条发布并释放,只向父 workflow 返回运行期 summary;无 sink 只保留给
* 不落库的调试/单测路径,继续返回旧的内存数组。
*/
export const createAgentNodeResponseCollector = ({
nodeResponseWriter,
nodeResponseParentId,
nodeResponseSink,
nodeResponses
}: {
nodeResponseWriter?: WorkflowNodeResponseWriter;
nodeResponseParentId?: string;
nodeResponseSink?: WorkflowNodeResponseSinkLike;
nodeResponses: ChatHistoryItemResType[];
}) => {
let runtimeNodeResponseSummary: RuntimeNodeResponseSummary = createRuntimeNodeResponseSummary();
let writeQueue = Promise.resolve();
const appendNodeResponse = (nodeResponse: ChatHistoryItemResType) => {
if (!nodeResponseWriter) {
if (!nodeResponseSink) {
nodeResponses.push(nodeResponse);
return;
}
......@@ -31,9 +29,9 @@ export const createAgentNodeResponseCollector = ({
runtimeNodeResponseSummary = summarizeRuntimeNodeResponses(runtimeNodeResponseSummary, [
nodeResponse
]);
// Agent runtime 可能连续同步 append 多条详情,这里串行交给共享 writer,避免乱序。
// Agent runtime 可能连续同步 append 多条详情,这里串行交给共享 sink,避免乱序。
writeQueue = writeQueue
.then(() => nodeResponseWriter.recordWithParent([nodeResponse], nodeResponseParentId))
.then(() => nodeResponseSink.publish([{ response: nodeResponse }]))
.then(
() => undefined,
() => undefined
......@@ -43,8 +41,7 @@ export const createAgentNodeResponseCollector = ({
return {
appendNodeResponse,
flush: () => writeQueue,
getNodeResponses: () => (nodeResponseWriter ? undefined : nodeResponses),
getRuntimeNodeResponseSummary: () =>
nodeResponseWriter ? runtimeNodeResponseSummary : undefined
getNodeResponses: () => (nodeResponseSink ? undefined : nodeResponses),
getRuntimeNodeResponseSummary: () => (nodeResponseSink ? runtimeNodeResponseSummary : undefined)
};
};
......@@ -41,7 +41,7 @@ type Props = Pick<
| 'workflowDispatchDeep'
| 'responseAllData'
| 'responseDetail'
| 'nodeResponseWriter'
| 'nodeResponseSink'
| 'nodeResponseParentId'
| 'variableState'
| 'lastInteractive'
......
......@@ -13,7 +13,6 @@ import type { WorkflowResponseItemType } from '../../../type';
import { dispatchApp, dispatchPlugin } from './app';
import { SystemToolRepo } from '../../../../../app/tool/systemTool/systemTool.repo';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { WorkflowNodeResponseWriter } from '../../../../../chat/nodeResponseStorage';
import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type';
import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants';
......@@ -88,8 +87,8 @@ export type ToolDispatchContext = Pick<
| 'workflowDispatchDeep'
| 'params'
| 'stream'
| 'nodeResponseSink'
> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
nodeResponseParentId?: string;
systemPrompt?: string;
getSubAppInfo: GetSubAppInfoFnType;
......@@ -174,7 +173,7 @@ export const getExecuteTool = ({
retainDatasetCite,
maxRunTimes,
workflowDispatchDeep,
nodeResponseWriter
nodeResponseSink
}: ToolDispatchContext) => {
/**
* 执行单次工具调用,并补齐节点响应的 id、运行时间和计费信息。
......@@ -280,7 +279,7 @@ export const getExecuteTool = ({
retainDatasetCite,
maxRunTimes,
workflowDispatchDeep,
nodeResponseWriter,
nodeResponseSink,
nodeResponseParentId: callId,
variableState,
lastInteractive
......@@ -335,7 +334,7 @@ export const getExecuteTool = ({
retainDatasetCite,
maxRunTimes,
workflowDispatchDeep,
nodeResponseWriter,
nodeResponseSink,
nodeResponseParentId: callId,
variableState,
lastInteractive
......
......@@ -22,7 +22,6 @@ import { getAppVersionById } from '../../../app/version/controller';
import { parseUrlToFileType } from '../../utils/context';
import { getUserChatInfo } from '../../../../support/user/team/utils';
import { getRunningUserInfoByTmbId } from '../../../../support/user/team/utils';
import type { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import { getRuntimeNodeResponseSummary } from '../utils';
type Props = ModuleDispatchProps<{
......@@ -31,9 +30,7 @@ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.fileUrlList]?: string[];
[NodeInputKeyEnum.forbidStream]?: boolean;
[NodeInputKeyEnum.fileUrlList]?: string[];
}> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
};
}>;
type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.answerText]: string;
[NodeOutputKeyEnum.history]: ChatItemMiniType[];
......
......@@ -20,7 +20,6 @@ import type {
} from '../types/runtime';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import { getErrText, UserError } from '@fastgpt/global/common/error/utils';
import { filterNodeResponseTreeData } from '@fastgpt/global/core/chat/utils';
import { filterWorkflowEdges, valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils';
import type {
InteractiveNodeResponseType,
......@@ -62,10 +61,9 @@ import { delAgentRuntimeStopSign, shouldWorkflowStop } from './workflowStatus';
import { buildQueryUrlFileMap, buildQueryUrlTypeMap, runWithContext } from '../utils/context';
import { createClientAbortTracker } from './utils/clientAbort';
import type { IncomingMessage } from 'node:http';
import type { WorkflowNodeResponseWriter } from '../../chat/nodeResponseStorage';
import { getNodeResponseChildResponseCount } from '../../chat/nodeResponseStorage';
import {
createWorkflowEntryNodeResponseWriter,
createWorkflowEntryNodeResponseSink,
type WorkflowNodeResponseWriteConfig
} from './utils/entry';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
......@@ -247,13 +245,17 @@ export async function dispatchWorkFlow({
}, 100)
: undefined;
const { nodeResponseWriter } = await createWorkflowEntryNodeResponseWriter({
const nodeResponseSink = await createWorkflowEntryNodeResponseSink({
teamId: data.runningAppInfo.teamId,
sourceType: data.runningAppInfo.sourceType,
sourceId: data.runningAppInfo.sourceId,
chatId,
chatItemDataId: responseChatItemId,
nodeResponseWriteConfig: data.nodeResponseWriteConfig
nodeResponseWriteConfig: data.nodeResponseWriteConfig,
apiVersion: data.apiVersion,
responseAllData: data.responseAllData,
responseDetail: data.responseDetail,
workflowStreamResponse: data.workflowStreamResponse
});
// Init some props
......@@ -268,7 +270,7 @@ export async function dispatchWorkFlow({
runWorkflow({
...data,
responseChatItemId,
nodeResponseWriter,
nodeResponseSink,
checkIsStopping,
query,
histories,
......@@ -280,19 +282,19 @@ export async function dispatchWorkFlow({
concatUsage
})
.then(async (result) => {
await nodeResponseWriter.close();
await nodeResponseSink.close();
resolve({
...result,
nodeResponseSummary: nodeResponseWriter.getSummary(),
nodeResponseSummary: nodeResponseSink.getSummary(),
...(data.nodeResponseWriteConfig.retainInMemory
? {
flatNodeResponses: nodeResponseWriter.getFlatNodeResponses()
flatNodeResponses: nodeResponseSink.getFlatNodeResponses()
}
: {})
});
})
.catch(async (error) => {
await nodeResponseWriter.close();
await nodeResponseSink.close();
reject(error);
})
.finally(async () => {
......@@ -322,7 +324,6 @@ export type RunWorkflowProps = ChatDispatchProps & {
runtimeEdges: RuntimeEdgeItemType[];
defaultSkipNodeQueue?: WorkflowDebugResponse['skipNodeQueue'];
concatUsage?: (points: number) => any;
nodeResponseWriter?: WorkflowNodeResponseWriter;
};
/*
工作流队列控制
......@@ -931,7 +932,7 @@ export class WorkflowQueue {
const childResponses = dispatchRes[DispatchNodeResponseKeyEnum.nodeResponses] || [];
const nodeResponse = dispatchRes[DispatchNodeResponseKeyEnum.nodeResponse];
const childResponsesForWrite =
this.data.nodeResponseWriter && !!nodeResponse
this.data.nodeResponseSink && !!nodeResponse
? childResponses.map((response) => ({
...response,
parentId: response.parentId || nodeResponseId
......@@ -968,47 +969,34 @@ export class WorkflowQueue {
childResponsesForWrite.length === 0 || currentNodeError !== undefined
? formatCurrentNodeResponse
: undefined;
const streamResponses = childResponsesForWrite.length
? [...childResponsesForWrite, ...(formatResponseData ? [formatResponseData] : [])]
: formatResponseData
? [formatResponseData]
: [];
// 写库和 SSE 需要完整节点响应;writer 落库后,队列只继续保留摘要信号。
const persistedNodeResponses = this.data.nodeResponseWriter
? await this.data.nodeResponseWriter.record(nodeResponsesForWrite)
// 子节点只产出响应;请求级 sink 统一负责写库、V2 实时发布和 Share 字段裁剪。
const persistedNodeResponses = this.data.nodeResponseSink
? await this.data.nodeResponseSink.publish([
...childResponsesForWrite.map((response) => ({ response })),
...(formatCurrentNodeResponse
? [
{
response: formatCurrentNodeResponse,
// 有内部明细时,父节点只作为树结构和统计信息入库,避免重复展示。
emit: !!formatResponseData
}
]
: [])
])
: nodeResponsesForWrite;
const formatResponseDataForQueue =
formatResponseData && this.data.nodeResponseWriter
formatResponseData && this.data.nodeResponseSink
? persistedNodeResponses.find((item) => item.id === formatResponseData.id) ||
formatResponseData
: formatResponseData;
const childResponsesForQueue = this.data.nodeResponseWriter
const childResponsesForQueue = this.data.nodeResponseSink
? childResponsesForWrite.map(
(item) =>
persistedNodeResponses.find((persistedItem) => persistedItem.id === item.id) || item
)
: childResponses;
const shouldDropPersistedNodeResponses = !!this.data.nodeResponseWriter;
// Response node response
if (
this.data.apiVersion === 'v2' &&
!this.data.isToolCall &&
this.isRootRuntime &&
streamResponses.length > 0
) {
const filteredResponses = this.data.responseAllData
? streamResponses
: filterNodeResponseTreeData({
nodeResponses: streamResponses,
responseDetail: this.data.responseDetail
});
filteredResponses.forEach((item) => {
this.data.workflowStreamResponse?.(workflowSseEvent.flowNodeResponse(item));
});
}
const shouldDropPersistedNodeResponses = !!this.data.nodeResponseSink;
// Add output default value
if (dispatchRes.data) {
......@@ -1550,7 +1538,7 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
},
async (workflowSpan) => {
const startTime = Date.now();
const nodeResponseWriter = data.nodeResponseWriter;
const nodeResponseSink = data.nodeResponseSink;
try {
await rewriteRuntimeWorkFlow({
teamId: data.runningAppInfo.teamId,
......@@ -1649,7 +1637,7 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
workflowQueue.customFeedbackList.length > 0
? workflowQueue.customFeedbackList
: undefined,
nodeResponseSummary: nodeResponseWriter?.getSummary(),
nodeResponseSummary: nodeResponseSink?.getSummary?.(),
runtimeNodeResponseSummary: workflowQueue.runtimeNodeResponseSummary,
durationSeconds
};
......
......@@ -2,7 +2,6 @@ import { cloneDeep } from 'lodash-es';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { DispatchNodeResultType, ModuleDispatchProps } from '../../types/runtime';
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
......@@ -12,8 +11,6 @@ import {
storeEdges2RuntimeEdges
} from '@fastgpt/global/core/workflow/runtime/utils';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import type { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import { serviceEnv } from '../../../../env';
import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { runWorkflow } from '..';
......@@ -37,10 +34,7 @@ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum;
[NodeInputKeyEnum.loopRunInputArray]?: Array<any>;
[NodeInputKeyEnum.childrenNodeIdList]: string[];
}> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
nodeResponseParentId?: string;
};
}>;
type Response = DispatchNodeResultType<Record<string, any>>;
......@@ -230,7 +224,7 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => {
});
// Wrap this iteration as a virtual task node so the whole-response tree
// shows a per-iteration layer through writer/event. Parent loopRun only keeps
// shows a per-iteration layer through sink. Parent loopRun only keeps
// summary stats and business history.
const pushIterationDetail = async (opts: { error?: string }) => {
const wrapper = {
......@@ -247,16 +241,13 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => {
childResponseCount: iterationChildResponseCount
};
childResponseCount += 1 + (wrapper.childResponseCount || 0);
if (props.nodeResponseWriter) {
const recordedWrappers = await props.nodeResponseWriter.recordWithParent(
[wrapper],
props.nodeResponseParentId
);
if (props.apiVersion === 'v2') {
recordedWrappers.forEach((item) => {
props.workflowStreamResponse?.(workflowSseEvent.flowNodeResponse(item));
});
}
if (props.nodeResponseSink) {
await props.nodeResponseSink.publish([
{
response: wrapper,
parentId: props.nodeResponseParentId
}
]);
}
};
......
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { filterNodeResponseTreeData } from '@fastgpt/global/core/chat/utils';
import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse';
import type { WorkflowResponseType } from '@fastgpt/global/core/workflow/runtime/sse';
import type {
NodeResponseWriteSummary,
WorkflowNodeResponseWriter
} from '../../chat/nodeResponseStorage';
export type WorkflowNodeResponseInput = {
response: ChatHistoryItemResType;
/** 只给缺少 parentId 的响应补父级,保留响应内部已有的更细层级。 */
parentId?: string;
/** 父节点只入库不实时展示时设为 false,避免与已展示的内部明细重复。 */
emit?: boolean;
};
export type WorkflowNodeResponseSinkLike = {
publish: (inputs: WorkflowNodeResponseInput[]) => Promise<ChatHistoryItemResType[]>;
getSummary?: () => NodeResponseWriteSummary;
};
/**
* 协调请求内 nodeResponse 的规范化、持久化和 V2 实时发布。
*
* 完整响应始终先交给 writer;对外事件再根据 responseAllData/responseDetail 裁剪。
* Sink 不参与 runtime summary、usage 或计费,调用方继续在自己的运行作用域内汇总。
*/
export class WorkflowNodeResponseSink implements WorkflowNodeResponseSinkLike {
private readonly writer: WorkflowNodeResponseWriter;
private readonly apiVersion?: 'v1' | 'v2';
private readonly responseAllData: boolean;
private readonly responseDetail: boolean;
private readonly workflowStreamResponse?: WorkflowResponseType;
constructor({
writer,
apiVersion,
responseAllData = true,
responseDetail = true,
workflowStreamResponse
}: {
writer: WorkflowNodeResponseWriter;
apiVersion?: 'v1' | 'v2';
responseAllData?: boolean;
responseDetail?: boolean;
workflowStreamResponse?: WorkflowResponseType;
}) {
this.writer = writer;
this.apiVersion = apiVersion;
this.responseAllData = responseAllData;
this.responseDetail = responseDetail;
this.workflowStreamResponse = workflowStreamResponse;
}
/**
* 接收一批同一调度步骤产生的响应,保持 writer 的批量写入顺序,并逐条发布可见响应。
*/
async publish(inputs: WorkflowNodeResponseInput[]): Promise<ChatHistoryItemResType[]> {
if (inputs.length === 0) return [];
const responses = inputs.map(({ response, parentId }) => ({
...response,
...(parentId && !response.parentId ? { parentId } : {})
}));
const recordedResponses = await this.writer.record(responses);
if (this.apiVersion !== 'v2' || !this.workflowStreamResponse) {
return recordedResponses;
}
const responsesToEmit = recordedResponses.filter((_, index) => inputs[index]?.emit !== false);
const visibleResponses = this.responseAllData
? responsesToEmit
: filterNodeResponseTreeData({
nodeResponses: responsesToEmit,
responseDetail: this.responseDetail
});
visibleResponses.forEach((response) => {
this.workflowStreamResponse?.(workflowSseEvent.flowNodeResponse(response));
});
return recordedResponses;
}
async close() {
await this.writer.close();
}
getSummary(): NodeResponseWriteSummary {
return this.writer.getSummary();
}
getFlatNodeResponses(): ChatHistoryItemResType[] {
return this.writer.getFlatNodeResponses();
}
}
......@@ -2,12 +2,10 @@ import { batchRun } from '@fastgpt/global/common/system/utils';
import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse';
import type { DispatchNodeResultType, ModuleDispatchProps } from '../../types/runtime';
import { serviceEnv } from '../../../../env';
import { runWorkflow } from '..';
import type { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import { getNodeResponseChildResponseCount } from '../../../chat/nodeResponseStorage';
import {
clampParallelConcurrency,
......@@ -27,10 +25,7 @@ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.childrenNodeIdList]: string[];
[NodeInputKeyEnum.parallelRunMaxConcurrency]?: number;
[NodeInputKeyEnum.parallelRunMaxRetryTimes]?: number;
}> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
nodeResponseParentId?: string;
};
}>;
type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.parallelSuccessResults]: Array<any>;
......@@ -190,24 +185,19 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
attemptResults
}
);
// 任务包装节点只通过 writer/event 输出;父 parallelRun 只保留轻量统计和业务摘要。
// 任务包装节点只通过 sink 输出;父 parallelRun 只保留轻量统计和业务摘要。
const rootChildResponseCount = getNodeResponseChildResponseCount(attemptResponseDetails);
if (props.nodeResponseWriter) {
if (props.nodeResponseSink) {
for (const detail of attemptResponseDetails) {
const recordedWrappers = await props.nodeResponseWriter.recordWithParent(
[
{
await props.nodeResponseSink.publish([
{
response: {
...detail,
childrenResponses: undefined
}
],
props.nodeResponseParentId
);
if (props.apiVersion === 'v2') {
recordedWrappers.forEach((item) => {
props.workflowStreamResponse?.(workflowSseEvent.flowNodeResponse(item));
});
}
},
parentId: props.nodeResponseParentId
}
]);
}
}
......
......@@ -33,15 +33,12 @@ import { getAppVersionById } from '../../../app/version/controller';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { WorkflowVariableState } from '../utils/variables';
import { SystemToolRepo } from '../../../app/tool/systemTool/systemTool.repo';
import type { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import { getRuntimeNodeResponseSummary } from '../utils';
type RunPluginProps = ModuleDispatchProps<{
[NodeInputKeyEnum.forbidStream]?: boolean;
[key: string]: any;
}> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
};
}>;
type RunPluginResponse = DispatchNodeResultType<
{
[key: string]: any;
......@@ -227,7 +224,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi
} = await runWorkflow({
...props,
// 系统级 workflow tool 只保留工具节点自身的响应,不展开保存其内部 workflow 详情。
...(shouldStoreChildNodeResponses ? {} : { nodeResponseWriter: undefined }),
...(shouldStoreChildNodeResponses ? {} : { nodeResponseSink: undefined }),
// Rewrite stream mode
...(system_forbid_stream
? {
......
......@@ -22,8 +22,8 @@ export type { WorkflowResponseItemType, WorkflowResponseType };
/**
* workflow 内部运行期使用的 nodeResponse 摘要。
*
* 启用 `WorkflowNodeResponseWriter` 后,完整 nodeResponse 会在节点完成时立即写入
* `chat_item_responses`,并且不再通过 `runWorkflow` 返回。父 workflow 仍需要少量
* 启用请求级 `WorkflowNodeResponseSink` 后,完整 nodeResponse 会在节点完成时立即发布并
* 写入 `chat_item_responses`,且不再通过 `runWorkflow` 返回。父 workflow 仍需要少量
* child 节点信号来继续调度、聚合虚拟节点和处理重试,因此这些字段会在每次节点写库后
* 由 `summarizeRuntimeNodeResponses` 从本批 nodeResponse 中提取,并在 `WorkflowQueue`
* 上持续合并。
......
import type { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import { createWorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import type { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import type { WorkflowResponseType } from '@fastgpt/global/core/workflow/runtime/sse';
import { WorkflowNodeResponseSink } from '../nodeResponseSink';
export type WorkflowNodeResponseWriteConfig = {
/** 是否把本轮 nodeResponse rows 持久化到 chat_item_responses。 */
......@@ -10,18 +11,22 @@ export type WorkflowNodeResponseWriteConfig = {
};
/**
* 创建 workflow 入口级 nodeResponse writer
* 创建 workflow 入口级 nodeResponse sink
*
* 写 DB 和保留内存的策略由业务入口显式传入,dispatch 层不再根据 mode/chatId/detail
* 推断。子 workflow 只复用这个 writer,不关心当前请求到底落库还是仅保留请求内 flat 数据。
* 推断。子 workflow 只复用这个 sink,不关心当前请求到底落库还是仅保留请求内 flat 数据。
*/
export const createWorkflowEntryNodeResponseWriter = async ({
export const createWorkflowEntryNodeResponseSink = async ({
teamId,
sourceType,
sourceId,
chatId,
chatItemDataId,
nodeResponseWriteConfig
nodeResponseWriteConfig,
apiVersion,
responseAllData,
responseDetail,
workflowStreamResponse
}: {
teamId: string;
sourceType: ChatSourceTypeEnum;
......@@ -29,18 +34,26 @@ export const createWorkflowEntryNodeResponseWriter = async ({
chatId: string;
chatItemDataId: string;
nodeResponseWriteConfig: WorkflowNodeResponseWriteConfig;
}): Promise<{
nodeResponseWriter: WorkflowNodeResponseWriter;
}> => {
return {
nodeResponseWriter: await createWorkflowNodeResponseWriter({
teamId,
sourceType,
sourceId,
chatId,
chatItemDataId,
persistToDb: nodeResponseWriteConfig.persistToDb,
retainInMemory: nodeResponseWriteConfig.retainInMemory
})
};
apiVersion?: 'v1' | 'v2';
responseAllData?: boolean;
responseDetail?: boolean;
workflowStreamResponse?: WorkflowResponseType;
}): Promise<WorkflowNodeResponseSink> => {
const writer = await createWorkflowNodeResponseWriter({
teamId,
sourceType,
sourceId,
chatId,
chatItemDataId,
persistToDb: nodeResponseWriteConfig.persistToDb,
retainInMemory: nodeResponseWriteConfig.retainInMemory
});
return new WorkflowNodeResponseSink({
writer,
apiVersion,
responseAllData,
responseDetail,
workflowStreamResponse
});
};
......@@ -26,6 +26,7 @@ import type {
RuntimeNodeItemType
} from '@fastgpt/global/core/workflow/runtime/type';
import type { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { WorkflowNodeResponseSinkLike } from '../dispatch/nodeResponseSink';
/*
1. 输入线分类:普通线(实际上就是从 start 直接过来的分支)和递归线(可以追溯到自身的分支)
......@@ -102,6 +103,8 @@ export type ChatDispatchProps = {
responseAllData?: boolean;
responseDetail?: boolean;
nodeResponseParentId?: string; // 传递给 child,用于设置 nodeResponse 的 parentId
/** 请求级 nodeResponse 接收器;child runtime 共享,节点 adapter 不直接操作数据库。 */
nodeResponseSink?: WorkflowNodeResponseSinkLike;
// TODO: 移除
usageId?: string;
......
......@@ -235,6 +235,9 @@ export const serviceEnv = createEnv({
//==================== 安全配置 ====================
USE_IP_LIMIT: BoolSchema.default(false).meta({ description: '是否启用 IP 限流' }),
CHECK_INTERNAL_IP: BoolSchema.default(false).meta({ description: '是否启用内网 IP 检查' }),
AUTH_COOKIE_SECURE: BoolSchema.default(false).meta({
description: '是否强制为登录 Cookie 添加 Secure 属性,仅允许通过 HTTPS 传输'
}),
TRUSTED_PROXY_ENABLE: BoolSchema.default(false).meta({
description:
'是否启用可信反向代理客户端 IP 校验;关闭时兼容旧逻辑,直接信任 X-Forwarded-For/X-Real-IP'
......
......@@ -172,16 +172,36 @@ export async function parseHeaderCert({
};
}
/* set cookie */
export const TokenName = 'fastgpt_token';
/** 统一生成登录 Cookie 属性,确保写入与清理行为一致。 */
const getAuthCookieOptions = () => ({
path: '/',
httpOnly: true,
sameSite: 'strict' as const,
secure: serviceEnv.AUTH_COOKIE_SECURE
});
/**
* 写入登录凭证 Cookie;启用 AUTH_COOKIE_SECURE 后,浏览器只会通过 HTTPS 发送凭证。
*/
export const setCookie = (res: NodeHttpResponse, token: string) => {
res.setHeader(
'Set-Cookie',
`${TokenName}=${token}; Path=/; HttpOnly; Max-Age=604800; Samesite=Strict;`
Cookie.serialize(TokenName, token, {
...getAuthCookieOptions(),
maxAge: 604800
})
);
};
/* clear cookie */
/** 清理登录凭证 Cookie,并复用写入时的路径与安全属性。 */
export const clearCookie = (res: NodeHttpResponse) => {
res.setHeader('Set-Cookie', `${TokenName}=; Path=/; Max-Age=0`);
res.setHeader(
'Set-Cookie',
Cookie.serialize(TokenName, '', {
...getAuthCookieOptions(),
maxAge: 0
})
);
};
import { describe, expect, it, vi } from 'vitest';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { createAgentNodeResponseCollector } from '@fastgpt/service/core/workflow/dispatch/ai/agent/nodeResponseCollector';
const makeResponse = (id: string): ChatHistoryItemResType => ({
id,
nodeId: id,
moduleName: id,
moduleType: FlowNodeTypeEnum.agent
});
describe('createAgentNodeResponseCollector', () => {
it('存在 sink 时逐条串行发布,不在 Agent 内保留完整数组', async () => {
const publishOrder: string[] = [];
const nodeResponseSink = {
publish: vi.fn(async ([{ response }]: Array<{ response: ChatHistoryItemResType }>) => {
publishOrder.push(response.id!);
return [response];
})
};
const nodeResponses: ChatHistoryItemResType[] = [];
const collector = createAgentNodeResponseCollector({ nodeResponseSink, nodeResponses });
collector.appendNodeResponse(makeResponse('first'));
collector.appendNodeResponse(makeResponse('second'));
await collector.flush();
expect(publishOrder).toEqual(['first', 'second']);
expect(nodeResponses).toEqual([]);
expect(collector.getNodeResponses()).toBeUndefined();
expect(collector.getRuntimeNodeResponseSummary()?.responseIds).toEqual(['first', 'second']);
});
it('没有 sink 时兼容调试路径,返回本地数组', () => {
const nodeResponses: ChatHistoryItemResType[] = [];
const collector = createAgentNodeResponseCollector({ nodeResponses });
collector.appendNodeResponse(makeResponse('local'));
expect(collector.getNodeResponses()).toEqual([makeResponse('local')]);
expect(collector.getRuntimeNodeResponseSummary()).toBeUndefined();
});
});
......@@ -27,6 +27,24 @@ import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import type { WorkflowResponseType } from '@fastgpt/global/core/workflow/runtime/sse';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { WorkflowNodeResponseSink } from '@fastgpt/service/core/workflow/dispatch/nodeResponseSink';
const createTestNodeResponseSink = ({
writer,
apiVersion = 'v2',
workflowStreamResponse
}: {
writer: Awaited<ReturnType<typeof createWorkflowNodeResponseWriter>>;
apiVersion?: 'v1' | 'v2';
workflowStreamResponse?: WorkflowResponseType;
}) =>
new WorkflowNodeResponseSink({
writer,
apiVersion,
responseAllData: true,
responseDetail: true,
workflowStreamResponse
});
const makeInput = ({
key,
......@@ -303,7 +321,11 @@ describe('runWorkflow node response persistence', () => {
stream: false,
responseAllData: true,
responseDetail: true,
nodeResponseWriter,
nodeResponseSink: createTestNodeResponseSink({
writer: nodeResponseWriter,
apiVersion,
workflowStreamResponse
}),
workflowStreamResponse,
checkIsStopping: () => false
} as any);
......@@ -525,7 +547,7 @@ describe('runWorkflow node response persistence', () => {
stream: false,
responseAllData: true,
responseDetail: true,
nodeResponseWriter,
nodeResponseSink: createTestNodeResponseSink({ writer: nodeResponseWriter }),
checkIsStopping: () => false
} as any);
await nodeResponseWriter.close();
......@@ -650,7 +672,7 @@ describe('runWorkflow node response persistence', () => {
stream: false,
responseAllData: true,
responseDetail: true,
nodeResponseWriter,
nodeResponseSink: createTestNodeResponseSink({ writer: nodeResponseWriter }),
checkIsStopping: () => false
} as any);
await nodeResponseWriter.close();
......@@ -759,7 +781,7 @@ describe('runWorkflow node response persistence', () => {
stream: false,
responseAllData: true,
responseDetail: true,
nodeResponseWriter,
nodeResponseSink: createTestNodeResponseSink({ writer: nodeResponseWriter }),
checkIsStopping: () => false
} as any);
await nodeResponseWriter.close();
......@@ -871,7 +893,7 @@ describe('runWorkflow node response persistence', () => {
stream: false,
responseAllData: true,
responseDetail: true,
nodeResponseWriter,
nodeResponseSink: createTestNodeResponseSink({ writer: nodeResponseWriter }),
checkIsStopping: () => false
} as any);
await nodeResponseWriter.close();
......@@ -895,6 +917,92 @@ describe('runWorkflow node response persistence', () => {
}
});
it('persists Share citation metadata before filtering it from SSE', async () => {
const appId = '67e0d5535c02d1d5cdede726';
const chatId = 'workflow-share-private-cite-chat';
const responseChatItemId = 'workflow-share-private-cite-ai-item';
const nodeResponseWriter = await createWorkflowNodeResponseWriter({
teamId: '654a4107c32f3bf5f998452f',
sourceType: ChatSourceTypeEnum.app,
sourceId: appId,
chatId,
chatItemDataId: responseChatItemId
});
const responseEvents: ChatHistoryItemResType[] = [];
const nodeResponseSink = new WorkflowNodeResponseSink({
writer: nodeResponseWriter,
apiVersion: 'v2',
responseAllData: false,
responseDetail: false,
workflowStreamResponse: (event) => {
if (
event.event === SseResponseEventEnum.flowNodeResponse &&
typeof event.data !== 'string'
) {
responseEvents.push(event.data);
}
}
});
await nodeResponseSink.publish([
{
response: {
id: 'share-dataset-response',
parentId: 'share-agent-response',
nodeId: 'share-dataset-node',
moduleName: 'Dataset Search',
moduleType: FlowNodeTypeEnum.datasetSearchNode,
runningTime: 1,
quoteList: [
{
id: 'share-quote',
datasetId: 'share-dataset',
collectionId: 'share-collection',
sourceId: 'share-source',
sourceName: 'private-source-name',
q: 'private question',
a: 'private answer'
}
],
toolInput: { secret: true }
} as ChatHistoryItemResType
}
]);
await nodeResponseSink.close();
const row = await MongoChatItemResponse.findOne({
appId,
chatId,
chatItemDataId: responseChatItemId
}).lean();
expect(row?.data).toMatchObject({
id: 'share-dataset-response',
parentId: 'share-agent-response',
quoteList: [
{
id: 'share-quote',
datasetId: 'share-dataset',
collectionId: 'share-collection',
sourceId: 'share-source',
sourceName: 'private-source-name'
}
],
toolInput: { secret: true }
});
expect(row?.data.quoteList?.[0]).not.toHaveProperty('q');
expect(row?.data.quoteList?.[0]).not.toHaveProperty('a');
expect(responseEvents).toEqual([
{
id: 'share-dataset-response',
parentId: 'share-agent-response',
nodeId: 'share-dataset-node',
moduleName: 'Dataset Search',
moduleType: FlowNodeTypeEnum.datasetSearchNode,
runningTime: 1
}
]);
});
it('streams batched node responses into v2 flat rows and composes childrenResponses on detail read', async () => {
const loopItems = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta'];
const { runtimeNodes, runtimeEdges } = createLoopRunWorkflow(loopItems);
......@@ -906,6 +1014,11 @@ describe('runWorkflow node response persistence', () => {
chatId: 'workflow-persistence-chat',
chatItemDataId: 'workflow-persistence-ai-item'
});
const workflowStreamResponse: WorkflowResponseType = (event) => {
if (event.data && event.event) {
responseEvents.push(event.data);
}
};
const result = await runWorkflow({
apiVersion: 'v2',
......@@ -956,13 +1069,12 @@ describe('runWorkflow node response persistence', () => {
stream: true,
responseAllData: true,
responseDetail: true,
nodeResponseWriter,
nodeResponseSink: createTestNodeResponseSink({
writer: nodeResponseWriter,
workflowStreamResponse
}),
checkIsStopping: () => false,
workflowStreamResponse: (event) => {
if (event.data && event.event) {
responseEvents.push(event.data);
}
}
workflowStreamResponse
} as any);
await nodeResponseWriter.close();
......@@ -1050,6 +1162,15 @@ describe('runWorkflow node response persistence', () => {
expect(streamedIterationWrappers.map((item) => item.parentId)).toEqual(
Array(loopItems.length).fill(rootRow.data.id)
);
expect(
streamedNodeResponses.filter((item) => item.moduleType === FlowNodeTypeEnum.loopRunStart)
).toHaveLength(loopItems.length);
expect(
streamedNodeResponses.filter((item) => item.moduleType === FlowNodeTypeEnum.textEditor)
).toHaveLength(loopItems.length);
expect(
streamedNodeResponses.filter((item) => item.moduleType === FlowNodeTypeEnum.nestedEnd)
).toHaveLength(loopItems.length);
expect(streamedNodeResponses.every((item) => item.id)).toBe(true);
});
});
......@@ -29,6 +29,15 @@ import { dispatchLoopRun } from '@fastgpt/service/core/workflow/dispatch/loopRun
// ─── helpers ──────────────────────────────────────────────────────────────────
const createNodeResponseSinkMock = () => ({
publish: vi.fn(async (inputs: Array<{ response: ChatHistoryItemResType; parentId?: string }>) =>
inputs.map(({ response, parentId }) => ({
...response,
...(parentId && !response.parentId ? { parentId } : {})
}))
)
});
const makeInput = (
override: Partial<FlowNodeInputItemType> & { key: string }
): FlowNodeInputItemType =>
......@@ -761,9 +770,7 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
});
it('lastInteractive 恢复 → 完成时只写入恢复后的 wrapper 增量统计', async () => {
const nodeResponseWriter = {
recordWithParent: vi.fn().mockResolvedValue([])
};
const nodeResponseSink = createNodeResponseSinkMock();
runWorkflowMock.mockImplementationOnce(() =>
Promise.resolve(
makeDispatchFlowResponse({
......@@ -807,7 +814,7 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
pendingIterationSummary: preInterruptSummary
}
},
nodeResponseWriter,
nodeResponseSink,
nodeResponseParentId: 'loop-parent-response',
checkIsStopping: () => false
} as any;
......@@ -818,8 +825,8 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
expect(nodeResponse.totalPoints).toBe(5);
expect(nodeResponse.childTotalPoints).toBeUndefined();
expect(nodeResponse.childResponseCount).toBe(3);
expect(nodeResponseWriter.recordWithParent).toHaveBeenCalledTimes(1);
expect(nodeResponseWriter.recordWithParent.mock.calls[0][0][0]).toMatchObject({
expect(nodeResponseSink.publish).toHaveBeenCalledTimes(1);
expect(nodeResponseSink.publish.mock.calls[0][0][0].response).toMatchObject({
id: 'loop-parent-response:iter:1',
totalPoints: 5,
childResponseCount: 2
......@@ -980,10 +987,8 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
expect(nodeResponse.loopRunHistory).toHaveLength(2);
});
it('共用 nodeResponseWriter 时写入每轮包装节点,父响应只保留轻量统计', async () => {
const nodeResponseWriter = {
recordWithParent: vi.fn().mockResolvedValue([])
};
it('共用 nodeResponseSink 时发布每轮包装节点,父响应只保留轻量统计', async () => {
const nodeResponseSink = createNodeResponseSinkMock();
runWorkflowMock.mockImplementation(() =>
Promise.resolve(
makeDispatchFlowResponse({
......@@ -1002,7 +1007,7 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
[NodeInputKeyEnum.loopRunInputArray]: ['a'],
[NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode']
}),
nodeResponseWriter,
nodeResponseSink,
nodeResponseParentId: 'loop-parent-response'
};
......@@ -1012,18 +1017,14 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
expect(runWorkflowMock.mock.calls[0][0].nodeResponseParentId).toBe(
'loop-parent-response:iter:1'
);
expect(nodeResponseWriter.recordWithParent).toHaveBeenCalledTimes(1);
expect(nodeResponseWriter.recordWithParent.mock.calls[0][1]).toBe('loop-parent-response');
expect(nodeResponseWriter.recordWithParent.mock.calls[0][0][0]).toMatchObject({
expect(nodeResponseSink.publish).toHaveBeenCalledTimes(1);
expect(nodeResponseSink.publish.mock.calls[0][0][0].parentId).toBe('loop-parent-response');
expect(nodeResponseSink.publish.mock.calls[0][0][0].response).toMatchObject({
id: 'loop-parent-response:iter:1',
childResponseCount: 2
});
expect(
nodeResponseWriter.recordWithParent.mock.calls[0][0][0].childTotalPoints
).toBeUndefined();
expect(
nodeResponseWriter.recordWithParent.mock.calls[0][0][0].childrenResponses
).toBeUndefined();
expect(nodeResponseSink.publish.mock.calls[0][0][0].response.childTotalPoints).toBeUndefined();
expect(nodeResponseSink.publish.mock.calls[0][0][0].response.childrenResponses).toBeUndefined();
expect(nodeResponse.loopRunDetail).toBeUndefined();
expect(nodeResponse.totalPoints).toBe(3);
expect(nodeResponse.childTotalPoints).toBeUndefined();
......@@ -1031,9 +1032,7 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
it('每轮包装节点独立计时,不累加子节点 runningTime', async () => {
vi.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValue(2400);
const nodeResponseWriter = {
recordWithParent: vi.fn().mockResolvedValue([])
};
const nodeResponseSink = createNodeResponseSinkMock();
runWorkflowMock.mockResolvedValue(
makeDispatchFlowResponse({
nodeResponses: [
......@@ -1049,11 +1048,11 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
[NodeInputKeyEnum.loopRunInputArray]: ['a'],
[NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode']
}),
nodeResponseWriter,
nodeResponseSink,
nodeResponseParentId: 'loop-parent-response'
});
expect(nodeResponseWriter.recordWithParent.mock.calls[0][0][0].runningTime).toBe(1.4);
expect(nodeResponseSink.publish.mock.calls[0][0][0].response.runningTime).toBe(1.4);
});
it('失败轮不内嵌 loopRunDetail,父响应保留错误和 child 统计', async () => {
......@@ -1111,9 +1110,7 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
})
)
);
const nodeResponseWriter = {
recordWithParent: vi.fn().mockResolvedValue([])
};
const nodeResponseSink = createNodeResponseSinkMock();
const props = {
...makeProps({
......@@ -1121,7 +1118,7 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
[NodeInputKeyEnum.loopRunInputArray]: ['a', 'b'],
[NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode']
}),
nodeResponseWriter,
nodeResponseSink,
nodeResponseParentId: 'loop-parent-response'
};
......@@ -1129,9 +1126,9 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse];
expect(nodeResponse.loopRunDetail).toBeUndefined();
expect(nodeResponse.childResponseCount).toBe(2);
expect(nodeResponseWriter.recordWithParent).toHaveBeenCalledTimes(1);
expect(nodeResponseWriter.recordWithParent.mock.calls[0][1]).toBe('loop-parent-response');
expect(nodeResponseWriter.recordWithParent.mock.calls[0][0][0]).toMatchObject({
expect(nodeResponseSink.publish).toHaveBeenCalledTimes(1);
expect(nodeResponseSink.publish.mock.calls[0][0][0].parentId).toBe('loop-parent-response');
expect(nodeResponseSink.publish.mock.calls[0][0][0].response).toMatchObject({
id: 'loop-parent-response:iter:1',
childResponseCount: 1
});
......
import { describe, expect, it, vi } from 'vitest';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { WorkflowNodeResponseSink } from '@fastgpt/service/core/workflow/dispatch/nodeResponseSink';
const createWriter = () => ({
record: vi.fn(async (responses: ChatHistoryItemResType[]) => responses),
close: vi.fn(),
getSummary: vi.fn(() => ({
errorCount: 0,
citeCollectionIds: [],
totalPoints: 0
})),
getFlatNodeResponses: vi.fn(() => [])
});
describe('WorkflowNodeResponseSink', () => {
it('V2 按输入顺序逐条发布,并允许父响应只入库不发布', async () => {
const writer = createWriter();
const workflowStreamResponse = vi.fn();
const sink = new WorkflowNodeResponseSink({
writer: writer as any,
apiVersion: 'v2',
responseAllData: true,
responseDetail: true,
workflowStreamResponse
});
const child = {
id: 'child',
nodeId: 'child-node',
moduleName: 'Child',
moduleType: FlowNodeTypeEnum.agent
};
const parent = {
id: 'parent',
nodeId: 'parent-node',
moduleName: 'Parent',
moduleType: FlowNodeTypeEnum.agent
};
await sink.publish([
{ response: child, parentId: 'parent' },
{ response: parent, emit: false }
]);
expect(writer.record).toHaveBeenCalledWith([{ ...child, parentId: 'parent' }, parent]);
expect(workflowStreamResponse).toHaveBeenCalledTimes(1);
expect(workflowStreamResponse).toHaveBeenCalledWith({
event: SseResponseEventEnum.flowNodeResponse,
data: { ...child, parentId: 'parent' }
});
});
it('V1 只写入,不发布逐条 flowNodeResponse', async () => {
const writer = createWriter();
const workflowStreamResponse = vi.fn();
const sink = new WorkflowNodeResponseSink({
writer: writer as any,
apiVersion: 'v1',
workflowStreamResponse
});
await sink.publish([
{
response: {
id: 'node',
nodeId: 'node',
moduleName: 'Node',
moduleType: FlowNodeTypeEnum.agent
}
}
]);
expect(writer.record).toHaveBeenCalledTimes(1);
expect(workflowStreamResponse).not.toHaveBeenCalled();
});
it('Share 模式保留 id/parentId,但按详情配置过滤引用和私有字段', async () => {
const writer = createWriter();
const workflowStreamResponse = vi.fn();
const sink = new WorkflowNodeResponseSink({
writer: writer as any,
apiVersion: 'v2',
responseAllData: false,
responseDetail: false,
workflowStreamResponse
});
await sink.publish([
{
response: {
id: 'dataset-search',
parentId: 'agent',
nodeId: 'dataset-node',
moduleName: 'Dataset Search',
moduleType: FlowNodeTypeEnum.datasetSearchNode,
runningTime: 1,
quoteList: [{ id: 'quote' }],
toolInput: { secret: true }
} as ChatHistoryItemResType
}
]);
expect(writer.record.mock.calls[0][0][0]).toMatchObject({
quoteList: [{ id: 'quote' }],
toolInput: { secret: true }
});
expect(workflowStreamResponse).toHaveBeenCalledWith({
event: SseResponseEventEnum.flowNodeResponse,
data: {
id: 'dataset-search',
parentId: 'agent',
nodeId: 'dataset-node',
moduleName: 'Dataset Search',
moduleType: FlowNodeTypeEnum.datasetSearchNode,
runningTime: 1
}
});
});
});
......@@ -34,6 +34,15 @@ vi.mock('@fastgpt/service/env', () => ({
import { dispatchParallelRun } from '@fastgpt/service/core/workflow/dispatch/parallelRun/runParallelRun';
const createNodeResponseSinkMock = () => ({
publish: vi.fn(async (inputs: Array<{ response: ChatHistoryItemResType; parentId?: string }>) =>
inputs.map(({ response, parentId }) => ({
...response,
...(parentId && !response.parentId ? { parentId } : {})
}))
)
});
const makeNode = (): RuntimeNodeItemType =>
({
nodeId: 'parallelRun1',
......@@ -122,10 +131,8 @@ describe('dispatchParallelRun', () => {
vi.restoreAllMocks();
});
it('共用 nodeResponseWriter 时写入任务包装节点,父响应只保留轻量统计', async () => {
const nodeResponseWriter = {
recordWithParent: vi.fn().mockResolvedValue([])
};
it('共用 nodeResponseSink 时发布任务包装节点,父响应只保留轻量统计', async () => {
const nodeResponseSink = createNodeResponseSinkMock();
runWorkflowMock.mockResolvedValue(
makeDispatchFlowResponse({
nodeResponses: [
......@@ -142,7 +149,7 @@ describe('dispatchParallelRun', () => {
const result: any = await dispatchParallelRun(
makeProps({
nodeResponseWriter,
nodeResponseSink,
nodeResponseParentId: 'parallel-parent-response'
})
);
......@@ -153,16 +160,14 @@ describe('dispatchParallelRun', () => {
expect(runWorkflowMock.mock.calls[0][0].nodeResponseParentId).toBe(
'parallel-parent-response_task_0'
);
expect(nodeResponseWriter.recordWithParent).toHaveBeenCalledTimes(1);
expect(nodeResponseWriter.recordWithParent.mock.calls[0][1]).toBe('parallel-parent-response');
expect(nodeResponseWriter.recordWithParent.mock.calls[0][0][0]).toMatchObject({
expect(nodeResponseSink.publish).toHaveBeenCalledTimes(1);
expect(nodeResponseSink.publish.mock.calls[0][0][0].parentId).toBe('parallel-parent-response');
expect(nodeResponseSink.publish.mock.calls[0][0][0].response).toMatchObject({
id: 'parallel-parent-response_task_0',
childResponseCount: 2,
childrenResponses: undefined
});
expect(
nodeResponseWriter.recordWithParent.mock.calls[0][0][0].childTotalPoints
).toBeUndefined();
expect(nodeResponseSink.publish.mock.calls[0][0][0].response.childTotalPoints).toBeUndefined();
expect(nodeResponse.totalPoints).toBe(3);
expect(nodeResponse.childTotalPoints).toBeUndefined();
expect(nodeResponse.parallelDetail).toBeUndefined();
......@@ -170,9 +175,7 @@ describe('dispatchParallelRun', () => {
it('任务包装节点独立计时,不累加子节点 runningTime', async () => {
vi.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValue(2250);
const nodeResponseWriter = {
recordWithParent: vi.fn().mockResolvedValue([])
};
const nodeResponseSink = createNodeResponseSinkMock();
runWorkflowMock.mockResolvedValue(
makeDispatchFlowResponse({
nodeResponses: [
......@@ -188,18 +191,16 @@ describe('dispatchParallelRun', () => {
await dispatchParallelRun(
makeProps({
nodeResponseWriter,
nodeResponseSink,
nodeResponseParentId: 'parallel-parent-response'
})
);
expect(nodeResponseWriter.recordWithParent.mock.calls[0][0][0].runningTime).toBe(1.25);
expect(nodeResponseSink.publish.mock.calls[0][0][0].response.runningTime).toBe(1.25);
});
it('重试成功时保留失败 attempt 详情并写入最终成功 attempt', async () => {
const nodeResponseWriter = {
recordWithParent: vi.fn().mockResolvedValue([])
};
const nodeResponseSink = createNodeResponseSinkMock();
runWorkflowMock
.mockResolvedValueOnce(
makeDispatchFlowResponse({
......@@ -233,7 +234,7 @@ describe('dispatchParallelRun', () => {
[NodeInputKeyEnum.parallelRunMaxConcurrency]: 1,
[NodeInputKeyEnum.parallelRunMaxRetryTimes]: 1
},
nodeResponseWriter,
nodeResponseSink,
nodeResponseParentId: 'parallel-parent-response'
})
);
......@@ -242,16 +243,16 @@ describe('dispatchParallelRun', () => {
'parallel-parent-response_task_0_attempt_0',
'parallel-parent-response_task_0_attempt_1'
]);
expect(nodeResponseWriter.recordWithParent).toHaveBeenCalledTimes(2);
expect(nodeResponseWriter.recordWithParent.mock.calls.map((call) => call[0][0].id)).toEqual([
expect(nodeResponseSink.publish).toHaveBeenCalledTimes(2);
expect(nodeResponseSink.publish.mock.calls.map((call) => call[0][0].response.id)).toEqual([
'parallel-parent-response_task_0_attempt_0',
'parallel-parent-response_task_0_attempt_1'
]);
expect(nodeResponseWriter.recordWithParent.mock.calls[0][0][0]).toMatchObject({
expect(nodeResponseSink.publish.mock.calls[0][0][0].response).toMatchObject({
id: 'parallel-parent-response_task_0_attempt_0',
error: expect.any(String)
});
expect(nodeResponseWriter.recordWithParent.mock.calls[1][0][0]).toMatchObject({
expect(nodeResponseSink.publish.mock.calls[1][0][0].response).toMatchObject({
id: 'parallel-parent-response_task_0_attempt_1',
loopOutputValue: 'done'
});
......
......@@ -60,7 +60,7 @@ describe('dispatchRunPlugin', () => {
getSystemToolWorkflowRuntimeMock.mockReset();
});
it('系统级 workflow tool 不把外层 nodeResponseWriter 传给 child workflow', async () => {
it('系统级 workflow tool 不把外层 nodeResponseSink 传给 child workflow', async () => {
getSystemToolWorkflowRuntimeMock.mockResolvedValue({
id: 'commercial-system-workflow',
name: 'System Workflow',
......@@ -121,7 +121,7 @@ describe('dispatchRunPlugin', () => {
}
])
});
const nodeResponseWriter = { record: vi.fn() } as any;
const nodeResponseSink = { publish: vi.fn() } as any;
const result = await dispatchRunPlugin({
node: {
......@@ -147,7 +147,7 @@ describe('dispatchRunPlugin', () => {
chatId: 'chat',
responseChatItemId: 'response',
variableState: await createVariableState(),
nodeResponseWriter,
nodeResponseSink,
usagePush: vi.fn(),
runtimeNodes: [],
runtimeNodesMap: new Map(),
......@@ -156,7 +156,7 @@ describe('dispatchRunPlugin', () => {
expect(runWorkflowMock).toHaveBeenCalledTimes(1);
const childWorkflowProps = runWorkflowMock.mock.calls[0][0];
expect(childWorkflowProps.nodeResponseWriter).toBeUndefined();
expect(childWorkflowProps.nodeResponseSink).toBeUndefined();
expect(childWorkflowProps.chatConfig.variables).toHaveLength(1);
expect(childWorkflowProps.variableState.get('counter')).toBe(0);
expect(result[DispatchNodeResponseKeyEnum.nodeResponse]).toMatchObject({
......
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { NodeHttpResponse } from '@fastgpt/service/types/http';
import { serviceEnv } from '@fastgpt/service/env';
const { clearCookie, setCookie } = await vi.importActual<
typeof import('@fastgpt/service/support/permission/auth/common')
>('@fastgpt/service/support/permission/auth/common');
describe('auth cookie', () => {
const originalAuthCookieSecure = serviceEnv.AUTH_COOKIE_SECURE;
afterEach(() => {
serviceEnv.AUTH_COOKIE_SECURE = originalAuthCookieSecure;
});
const createResponse = () =>
({
setHeader: vi.fn()
}) as unknown as NodeHttpResponse;
it('默认不添加 Secure 属性以兼容 HTTP 自部署环境', () => {
serviceEnv.AUTH_COOKIE_SECURE = false;
const response = createResponse();
setCookie(response, 'test-token');
expect(response.setHeader).toHaveBeenCalledWith(
'Set-Cookie',
'fastgpt_token=test-token; Max-Age=604800; Path=/; HttpOnly; SameSite=Strict'
);
});
it('启用配置后为登录 Cookie 添加 Secure 属性', () => {
serviceEnv.AUTH_COOKIE_SECURE = true;
const response = createResponse();
setCookie(response, 'test-token');
expect(response.setHeader).toHaveBeenCalledWith(
'Set-Cookie',
'fastgpt_token=test-token; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Strict'
);
});
it('清理 Cookie 时复用 Secure 配置', () => {
serviceEnv.AUTH_COOKIE_SECURE = true;
const response = createResponse();
clearCookie(response);
expect(response.setHeader).toHaveBeenCalledWith(
'Set-Cookie',
'fastgpt_token=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Strict'
);
});
});
......@@ -325,6 +325,7 @@ const JSONEditor = ({
fontSize={'xs'}
color={'myGray.500'}
display={placeholderDisplay}
whiteSpace={'pre-wrap'}
pointerEvents={'none'}
userSelect={'none'}
>
......
......@@ -240,10 +240,14 @@
"tool_params.enum_placeholder": "apple \npeach \nwatermelon",
"tool_params.enum_values": "Enum values",
"tool_params.enum_values_tip": "List the possible values for this field, one per line",
"tool_params.custom_schema_placeholder": "{\n \"type\": \"object\",\n \"description\": \"User information\",\n \"properties\": {\n \"name\": { \"type\": \"string\" }\n }\n}",
"tool_params.custom_schema_tip": "Enter the JSON Schema for this parameter; its description is extracted automatically",
"tool_params.custom_type": "Custom",
"tool_params.params_description": "Description",
"tool_params.params_description_placeholder": "Name/Age/SQL statement..",
"tool_params.params_name": "Name",
"tool_params.params_name_placeholder": "name/age/sql",
"tool_params.params_name_tip": "Parameter names may contain only English letters or numbers and cannot start with a number",
"tool_raw_response_description": "The original response of the tool",
"trigger_after_application_completion": "Will be triggered after the application is fully completed",
"unFoldAll": "Expand all",
......
......@@ -238,12 +238,16 @@
"tool_field": "工具参数配置",
"tool_input": "工具参数",
"tool_params.enum_placeholder": "apple \npeach \nwatermelon",
"tool_params.enum_values": "枚举值(可选)",
"tool_params.enum_values": "枚举值",
"tool_params.enum_values_tip": "列举出该字段可能的值,每行一个",
"tool_params.custom_schema_placeholder": "{\n \"type\": \"object\",\n \"description\": \"用户信息\",\n \"properties\": {\n \"name\": { \"type\": \"string\" }\n }\n}",
"tool_params.custom_schema_tip": "输入当前参数的 JSON Schema,参数描述从 description 自动提取",
"tool_params.custom_type": "自定义",
"tool_params.params_description": "参数描述",
"tool_params.params_description_placeholder": "姓名/年龄/SQL 语句...",
"tool_params.params_name": "参数名",
"tool_params.params_name_placeholder": "name/age/sql",
"tool_params.params_name_tip": "参数名只能包含英文字母或数字,且不能以数字开头",
"tool_raw_response_description": "工具的原始响应",
"trigger_after_application_completion": "将在应用完全结束后触发",
"unFoldAll": "全部展开",
......
......@@ -238,12 +238,16 @@
"tool_field": "工具參數設定",
"tool_input": "工具參數",
"tool_params.enum_placeholder": "apple \npeach \nwatermelon",
"tool_params.enum_values": "列舉值(選用)",
"tool_params.enum_values": "列舉值",
"tool_params.enum_values_tip": "列出這個欄位可能的值,每行一個",
"tool_params.custom_schema_placeholder": "{\n \"type\": \"object\",\n \"description\": \"使用者資訊\",\n \"properties\": {\n \"name\": { \"type\": \"string\" }\n }\n}",
"tool_params.custom_schema_tip": "輸入目前參數的 JSON Schema,參數描述會從 description 自動擷取",
"tool_params.custom_type": "自訂",
"tool_params.params_description": "參數描述",
"tool_params.params_description_placeholder": "姓名/年齡/SQL 敘述…",
"tool_params.params_name": "參數名稱",
"tool_params.params_name_placeholder": "name/age/sql",
"tool_params.params_name_tip": "參數名稱只能包含英文字母或數字,且不能以數字開頭",
"tool_raw_response_description": "工具的原始響應",
"trigger_after_application_completion": "將會在應用程式完全結束後觸發",
"unFoldAll": "全部展開",
......
......@@ -921,6 +921,9 @@ importers:
'@larksuiteoapi/node-sdk':
specifier: ^1.59.0
version: 1.67.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)
'@llamaindex/liteparse-wasm':
specifier: 'catalog:'
version: 2.0.8
'@node-rs/jieba':
specifier: 'catalog:'
version: 2.0.1
Subproject commit a21a182ded89dded02e51b56ff45194b38686d15
Subproject commit 34302711ede80fa71b76eb93290babf6e88b7f40
......@@ -160,6 +160,8 @@ FILE_DOMAIN=http://localhost:3000
USE_IP_LIMIT=false
# 启用内网 IP 检查
CHECK_INTERNAL_IP=false
# 是否为登录 Cookie 添加 Secure 属性,仅在全站 HTTPS 时启用
AUTH_COOKIE_SECURE=false
# 是否启用可信反向代理客户端 IP 校验
TRUSTED_PROXY_ENABLE=false
# 可信反向代理 IP/CIDR 列表,逗号或空白分隔。仅 TRUSTED_PROXY_ENABLE=true 时生效;仅显式可信代理传入的 X-Forwarded-For/X-Real-IP 会用于客户端 IP 解析
......@@ -191,8 +193,6 @@ OPENAPI_KEY_MAX_COUNT=100
# Agent 引擎选择:fastAgent(FastGPT agent loop)| piAgent(pi-agent-core 引擎)
AGENT_ENGINE=fastAgent
# 对话标题生成模型(不填则使用默认 LLM 模型)
CHAT_TITLE_MODEL=
SKIP_FILE_TYPE_CHECK=false
# ==================== 对话日志推送(可选) ====================
......
......@@ -30,7 +30,6 @@ const securityHeaders = [
const optimizedPackageImports = [
'@chakra-ui/react',
'@chakra-ui/icons',
'lodash',
'framer-motion',
'@emotion/react',
'@emotion/styled'
......
......@@ -225,7 +225,6 @@ const A = ({
(props.href?.startsWith('CITE') || props.href?.startsWith('QUOTE')) &&
typeof content === 'string'
) {
console.log(allowedCitationIds, allowedCitationIds?.has(content));
if (allowedCitationIds && !allowedCitationIds.has(content)) {
return null;
}
......
.waitingAnimation {
:global(.stream-char) {
:global(.stream-tail) {
display: inline;
opacity: 0;
animation-name: streamCharFadeIn;
animation-duration: 180ms;
animation-timing-function: cubic-bezier(0.33, 0, 0.67, 1);
animation-duration: 500ms;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
:global(.stream-char-revealed) {
opacity: 1;
animation: none;
}
}
@keyframes streamCharFadeIn {
from {
0% {
opacity: 0;
}
to {
35% {
opacity: 0.15;
}
70% {
opacity: 0.65;
}
100% {
opacity: 1;
}
}
......
const STREAM_ANIMATED_BLOCK_TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li']);
const STREAM_ANIMATED_SKIP_TAGS = new Set(['pre', 'code', 'table', 'svg']);
const MAX_RETAINED_SEGMENTS = 14;
const MAX_ANIMATED_SEGMENT_LENGTH = 64;
type HastElement = {
type: 'element';
......@@ -23,20 +25,21 @@ type HastRoot = {
children?: HastNode[];
};
export type StreamAnimationSegment = {
start: number;
end: number;
bornAt: number;
};
export type StreamAnimatedRuntime = {
births: number[];
/**
* 字符首次渲染时冻结的 style。后续 block 重渲染不能改写 animation-delay,
* 否则浏览器会重新启动正在执行的淡入动画。
*/
styles: Array<string | null | undefined>;
segments: StreamAnimationSegment[];
visibleText: string;
};
type RehypeStreamAnimatedOptions = {
fadeDuration: number;
nowMs?: number;
revealed?: boolean;
runtime?: StreamAnimatedRuntime;
runtime: StreamAnimatedRuntime;
};
export const getStreamAnimationNow = () =>
......@@ -45,24 +48,71 @@ export const getStreamAnimationNow = () =>
: performance.now();
/**
* 为流式 Markdown block 建立稳定的字符 DOM 时间线。
* 按可见文本维护固定数量的淡入区间。
*
* AST 因加粗闭合、列表追加等原因重建时,旧区间保留原 bornAt,只以负 delay 继续动画;
* 可见文本发生修正时保留公共前缀,仅把改变后的后缀视为新内容。
*/
const syncVisibleSegments = ({
fadeDuration,
now,
runtime,
visibleText
}: {
fadeDuration: number;
now: number;
runtime: StreamAnimatedRuntime;
visibleText: string;
}) => {
const previousCharacters = [...runtime.visibleText];
const currentCharacters = [...visibleText];
let commonPrefixLength = 0;
while (
commonPrefixLength < previousCharacters.length &&
commonPrefixLength < currentCharacters.length &&
previousCharacters[commonPrefixLength] === currentCharacters[commonPrefixLength]
) {
commonPrefixLength += 1;
}
const retainedSegments = runtime.segments
.filter((segment) => now - segment.bornAt < fadeDuration && segment.start < commonPrefixLength)
.map((segment) => ({
...segment,
end: Math.min(segment.end, commonPrefixLength)
}))
.filter((segment) => segment.end > segment.start);
if (currentCharacters.length > commonPrefixLength) {
retainedSegments.push({
bornAt: now,
end: currentCharacters.length,
start: Math.max(commonPrefixLength, currentCharacters.length - MAX_ANIMATED_SEGMENT_LENGTH)
});
}
runtime.visibleText = visibleText;
runtime.segments = retainedSegments.slice(-MAX_RETAINED_SEGMENTS);
};
/**
* 只包装最近新增的可见文本区间。
*
* 每个可见字符从 block 起点获得固定下标,旧 span 会在 append 更新中保持位置和 style,
* 只有新增字符会追加节点并执行淡入。列表项会作为一个 block 递归处理,避免 `li` 内的
* paragraph 再次包装;代码、表格、SVG 和 KaTeX 保持原始 DOM。
* 一个流式提交最多新增一个 segment,DOM 中最多保留固定数量的动画 span。代码、表格、
* SVG 和 KaTeX 不参与动画;列表项作为动画 block 处理,新增下一项不会重置上一项 bornAt。
*/
export const rehypeStreamAnimated = ({
fadeDuration,
nowMs,
revealed = false,
runtime
}: RehypeStreamAnimatedOptions) => {
return (tree: HastRoot) => {
let globalCharIndex = 0;
const now = nowMs ?? getStreamAnimationNow();
const isHastElement = (node: HastNode): node is HastElement =>
node.type === 'element' && typeof (node as HastElement).tagName === 'string';
const isHastText = (node: HastNode): node is HastText =>
node.type === 'text' && typeof (node as HastText).value === 'string';
const hasClass = (node: HastElement, cls: string) => {
const className = node.properties?.className;
if (Array.isArray(className)) return className.some((item) => String(item).includes(cls));
......@@ -72,77 +122,72 @@ export const rehypeStreamAnimated = ({
const shouldSkip = (node: HastElement) =>
STREAM_ANIMATED_SKIP_TAGS.has(node.tagName) || hasClass(node, 'katex');
const resolveStyle = (index: number): string | null => {
if (!runtime) return null;
const cachedStyle = runtime.styles[index];
const birthTime = runtime.births[index];
if (birthTime !== undefined && now - birthTime >= fadeDuration) {
runtime.styles[index] = null;
return null;
const animatedBlocks: HastElement[] = [];
const collectAnimatedBlocks = (node: HastNode) => {
if (!isHastElement(node) || shouldSkip(node)) return;
if (STREAM_ANIMATED_BLOCK_TAGS.has(node.tagName)) {
animatedBlocks.push(node);
return;
}
if (cachedStyle !== undefined) return cachedStyle;
const style = (() => {
if (birthTime === undefined) return null;
const elapsed = now - birthTime;
if (elapsed >= fadeDuration) return null;
// 负 delay 表示从已流逝的位置继续动画,正 delay 表示同一 commit 内的错峰字符。
return `animation-delay:${-elapsed}ms`;
})();
runtime.styles[index] = style;
return style;
node.children.forEach(collectAnimatedBlocks);
};
tree.children?.forEach(collectAnimatedBlocks);
const buildCharacter = (value: string): HastElement => {
const style = resolveStyle(globalCharIndex);
const className =
revealed || style === null ? 'stream-char stream-char-revealed' : 'stream-char';
const properties: Record<string, any> = { className };
if (style !== null) properties.style = style;
globalCharIndex++;
return {
type: 'element',
tagName: 'span',
properties,
children: [{ type: 'text', value }]
};
const collectText = (node: HastNode): string => {
if (isHastText(node)) return node.value;
if (!isHastElement(node) || shouldSkip(node)) return '';
return node.children.map(collectText).join('');
};
const visibleText = animatedBlocks.map(collectText).join('');
syncVisibleSegments({ fadeDuration, now, runtime, visibleText });
if (runtime.segments.length === 0) return;
let visibleOffset = 0;
const wrapTextNode = (node: HastText): HastNode[] => {
const characters = [...node.value];
const nodeStart = visibleOffset;
const nodeEnd = nodeStart + characters.length;
visibleOffset = nodeEnd;
const boundaries = new Set([nodeStart, nodeEnd]);
runtime.segments.forEach((segment) => {
if (segment.end <= nodeStart || segment.start >= nodeEnd) return;
boundaries.add(Math.max(segment.start, nodeStart));
boundaries.add(Math.min(segment.end, nodeEnd));
});
const sortedBoundaries = [...boundaries].sort((a, b) => a - b);
return sortedBoundaries.slice(0, -1).map((start, index) => {
const end = sortedBoundaries[index + 1];
const value = characters.slice(start - nodeStart, end - nodeStart).join('');
const segment = runtime.segments.find((item) => item.start <= start && item.end >= end);
if (!segment) return { type: 'text', value };
const elapsed = Math.max(now - segment.bornAt, 0);
return {
type: 'element',
tagName: 'span',
properties: {
className: 'stream-tail',
style: `animation-delay:${elapsed === 0 ? 0 : -elapsed}ms`
},
children: [{ type: 'text', value }]
};
});
};
const wrapText = (node: HastElement) => {
const wrapVisibleText = (node: HastElement) => {
const children: HastNode[] = [];
for (const child of node.children) {
if (child.type === 'text' && typeof child.value === 'string') {
for (const character of child.value) {
children.push(buildCharacter(character));
}
continue;
}
if (isHastElement(child) && !shouldSkip(child)) {
wrapText(child);
node.children.forEach((child) => {
if (isHastText(child)) {
children.push(...wrapTextNode(child));
} else {
if (isHastElement(child) && !shouldSkip(child)) wrapVisibleText(child);
children.push(child);
}
children.push(child);
}
});
node.children = children;
};
const visit = (node: HastNode) => {
if (!isHastElement(node) || shouldSkip(node)) return;
if (STREAM_ANIMATED_BLOCK_TAGS.has(node.tagName)) {
wrapText(node);
return;
}
node.children.forEach(visit);
};
tree.children?.forEach(visit);
animatedBlocks.forEach(wrapVisibleText);
};
};
......@@ -4,159 +4,93 @@ import type { StreamAnimatedRuntime } from './rehypeStreamAnimated';
import { getStreamAnimationNow, rehypeStreamAnimated } from './rehypeStreamAnimated';
import type { MarkdownBlock } from './streamMarkdownBlocks';
export const STREAM_FADE_DURATION_MS = 180;
export const STREAM_FADE_DURATION_MS = 500;
export { getStreamAnimationNow };
const STREAM_CHAR_DELAY_MS = 18;
const MIN_STREAM_CHAR_PACE_MS = 2;
const MIN_REVEAL_GAP_MS = 16;
const MAX_REVEAL_GAP_MS = 160;
export type StreamBlockRuntime = StreamAnimatedRuntime & {
charCount: number;
rawSource: string;
settled: boolean;
pluginCache?: {
basePlugins: PluggableList;
value: PluggableList;
};
};
export type StreamBlockAnimationMeta = {
runtime: StreamBlockRuntime;
settled: boolean;
};
export type StreamPluginsCacheEntry = {
basePlugins: PluggableList;
runtime: StreamBlockRuntime;
value: PluggableList;
};
type UpdateStreamBlockAnimationsParams = {
blocks: MarkdownBlock[];
renderNow: number;
revealClock: { lastTime: number };
runtimes: Map<number, StreamBlockRuntime>;
pluginsCache: Map<number, StreamPluginsCacheEntry>;
shouldAnimate: boolean;
};
const countChars = (text: string) => {
let count = 0;
for (const character of text) count += character ? 1 : 0;
return count;
};
/** 流式实例结束后继续使用 block 渲染,避免完成态切换渲染器导致整棵 DOM 重建。 */
export const resolveStreamRenderMode = ({
hasStreamed,
showAnimation
}: {
hasStreamed: boolean;
showAnimation?: boolean;
}) => hasStreamed || !!showAnimation;
/**
* 扩展各 Markdown block 的字符出生时间,并清理已经离开当前文档的 runtime。
* 按 block 顺序维护动画 runtime。
*
* 该函数在 render 阶段写入 ref 中的缓存;同一份 block 内容重复执行不会追加数据,
* 因而兼容 StrictMode 的重复 render。字符出生时间最多领先一个淡入窗口,避免输入速度
* 高于动画速度时积累很长的不可见尾巴。
* 字符 offset 只用于源码切片,不作为生命周期身份;完成态格式化导致 offset 改变时,
* 同一顺序的 block 仍复用原 runtime。过期 segment 和已经离开文档的 runtime 会及时清理。
*/
export const updateStreamBlockAnimations = ({
blocks,
renderNow,
revealClock,
runtimes,
pluginsCache
}: UpdateStreamBlockAnimationsParams) => {
runtimes
}: {
blocks: MarkdownBlock[];
renderNow: number;
runtimes: Map<number, StreamBlockRuntime>;
}) => {
const animationMeta = new Map<number, StreamBlockAnimationMeta>();
const aliveOffsets = new Set<number>();
let revealedNewCharacters = false;
blocks.forEach((block, index) => {
aliveOffsets.add(block.startOffset);
let runtime = runtimes.get(block.startOffset);
blocks.forEach((block, blockIndex) => {
let runtime = runtimes.get(blockIndex);
if (!runtime) {
runtime = {
births: [],
charCount: 0,
rawSource: '',
settled: false,
styles: []
segments: [],
visibleText: ''
};
runtimes.set(block.startOffset, runtime);
}
if (runtime.rawSource !== block.source) {
runtime.rawSource = block.source;
runtime.charCount = countChars(block.source);
runtimes.set(blockIndex, runtime);
}
if (runtime.births.length > runtime.charCount) {
runtime.births.length = runtime.charCount;
runtime.styles.length = runtime.charCount;
runtime.settled = false;
}
if (runtime.births.length < runtime.charCount) {
const newCharacters = runtime.charCount - runtime.births.length;
const revealGap = Math.min(
Math.max(renderNow - revealClock.lastTime, MIN_REVEAL_GAP_MS),
MAX_REVEAL_GAP_MS
);
const pace = Math.min(
STREAM_CHAR_DELAY_MS,
Math.max(revealGap / newCharacters, MIN_STREAM_CHAR_PACE_MS)
);
const latestBirthTime = renderNow + revealGap + STREAM_FADE_DURATION_MS;
runtime.segments = runtime.segments.filter(
(segment) => renderNow - segment.bornAt < STREAM_FADE_DURATION_MS
);
const sourceChanged = runtime.rawSource !== block.source;
runtime.rawSource = block.source;
for (let charIndex = runtime.births.length; charIndex < runtime.charCount; charIndex++) {
const previousBirthTime = charIndex > 0 ? runtime.births[charIndex - 1] : renderNow - pace;
runtime.births.push(
Math.min(latestBirthTime, Math.max(previousBirthTime + pace, renderNow))
);
}
runtime.settled = false;
revealedNewCharacters = true;
}
const lastBirthTime = runtime.births.at(-1) ?? renderNow;
const isStreamingBlock = index === blocks.length - 1;
if (!isStreamingBlock && renderNow - lastBirthTime >= STREAM_FADE_DURATION_MS) {
runtime.settled = true;
}
animationMeta.set(block.startOffset, {
animationMeta.set(blockIndex, {
runtime,
settled: runtime.settled
shouldAnimate:
sourceChanged || runtime.segments.length > 0 || blockIndex === blocks.length - 1
});
});
if (revealedNewCharacters) {
revealClock.lastTime = renderNow;
}
for (const offset of runtimes.keys()) {
if (!aliveOffsets.has(offset)) {
runtimes.delete(offset);
pluginsCache.delete(offset);
}
for (const blockIndex of runtimes.keys()) {
if (blockIndex >= blocks.length) runtimes.delete(blockIndex);
}
return animationMeta;
};
/** 为活动 block 复用同一组 rehype 插件,避免每次流式 commit 重建 unified processor。 */
/** 为同一个 block runtime 复用插件数组,保证已完成 block 可以命中 React.memo。 */
export const resolveStreamBlockPlugins = ({
basePlugins,
pluginsCache,
runtime,
startOffset
runtime
}: {
basePlugins: PluggableList;
pluginsCache: Map<number, StreamPluginsCacheEntry>;
runtime: StreamBlockRuntime;
startOffset: number;
}) => {
const cached = pluginsCache.get(startOffset);
if (cached?.basePlugins === basePlugins && cached.runtime === runtime) {
return cached.value;
}
if (runtime.pluginCache?.basePlugins === basePlugins) return runtime.pluginCache.value;
const value: PluggableList = [
...basePlugins,
[rehypeStreamAnimated, { fadeDuration: STREAM_FADE_DURATION_MS, runtime }]
];
pluginsCache.set(startOffset, { basePlugins, runtime, value });
runtime.pluginCache = { basePlugins, value };
return value;
};
......@@ -5,6 +5,12 @@ export type MarkdownBlock = {
startOffset: number;
};
/** 转换每个 block 的渲染源码,同时保留基于原文计算的稳定 offset。 */
export const mapMarkdownBlockSources = (
blocks: MarkdownBlock[],
transformSource: (source: string) => string
) => blocks.map((block) => ({ ...block, source: transformSource(block.source) }));
const markdownReferenceDefinitionPattern = /^\s{0,3}(?:\[[^\]]+\]:|\[\^[^\]]+\]:)/m;
const hasDocumentWideDefinitions = (source: string, tokens: Token[]) =>
......@@ -50,17 +56,30 @@ export const splitMarkdownBlocks = (source: string): MarkdownBlock[] => {
const tokenSource = token.raw;
if (!tokenSource) continue;
const normalizedStartOffset = normalizedSource.indexOf(tokenSource, searchOffset);
const tokenSourceWithoutTrailingLineBreaks = tokenSource.replace(/\n+$/, '');
const exactStartOffset = normalizedSource.indexOf(tokenSource, searchOffset);
// marked 会把列表尾部的单个空格规范化成换行,此时 raw 无法与原 source 精确匹配。
const normalizedStartOffset =
exactStartOffset >= 0
? exactStartOffset
: normalizedSource.indexOf(tokenSourceWithoutTrailingLineBreaks, searchOffset);
if (normalizedStartOffset < 0) continue;
searchOffset = normalizedStartOffset + tokenSource.length;
searchOffset =
normalizedStartOffset +
(exactStartOffset >= 0 ? tokenSource.length : tokenSourceWithoutTrailingLineBreaks.length);
if (token.type === 'space') continue;
const blockSourceLength = tokenSource.replace(/\n+$/, '').length;
if (!blockSourceLength) continue;
if (!tokenSourceWithoutTrailingLineBreaks) continue;
let normalizedEndOffset = normalizedStartOffset + tokenSourceWithoutTrailingLineBreaks.length;
// token.raw 丢失的同行尾随空格仍属于当前 block,不能让流式帧暂时删除该 block。
while (/^[ \t]$/.test(normalizedSource[normalizedEndOffset] ?? '')) {
normalizedEndOffset += 1;
}
const startOffset = sourceOffsets[normalizedStartOffset];
const endOffset = sourceOffsets[normalizedStartOffset + blockSourceLength];
const endOffset = sourceOffsets[normalizedEndOffset];
blocks.push({ source: source.slice(startOffset, endOffset), startOffset });
}
......
import React, { useMemo } from 'react';
import { Box, useTheme } from '@chakra-ui/react';
import { Box, Skeleton, useTheme } from '@chakra-ui/react';
import type { SearchDataResponseQuoteListItemType } from '@fastgpt/global/core/dataset/type';
import QuoteItem, { formatScore } from '@/components/core/dataset/QuoteItem';
......@@ -40,7 +40,7 @@ const QuoteList = React.memo(function QuoteList({
(v) => v.showRouteToDatasetDetail
);
const { data: quoteList } = useRequest(
const { data: quoteList, loading } = useRequest(
async () =>
!!chatItemDataId
? await getQuoteDataList({
......@@ -56,8 +56,11 @@ const QuoteList = React.memo(function QuoteList({
manual: false
}
);
const isLoadingQuoteList = !!chatItemDataId && loading;
const formatedDataList = useMemo(() => {
if (isLoadingQuoteList) return [];
const processedData = rawSearch.map((item) => {
if (chatItemDataId && quoteList) {
const currentFilterItem = quoteList.find((res) => res._id === item.id);
......@@ -77,7 +80,24 @@ const QuoteList = React.memo(function QuoteList({
const bScore = formatScore(b.score);
return (bScore.primaryScore?.value || 0) - (aScore.primaryScore?.value || 0);
});
}, [rawSearch, quoteList, chatItemDataId]);
}, [rawSearch, quoteList, chatItemDataId, isLoadingQuoteList]);
if (isLoadingQuoteList) {
return (
<Box aria-busy={'true'}>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton
key={index}
h={'72px'}
borderRadius={'sm'}
_notLast={{ mb: 2 }}
startColor={'myGray.100'}
endColor={'myGray.200'}
/>
))}
</Box>
);
}
return (
<>
......
import React from 'react';
import { Box, Flex, HStack } from '@chakra-ui/react';
import Markdown from '@/components/Markdown';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { getFileIcon } from '@fastgpt/global/common/file/icon';
/**
* 表单输入结果中的单个文件项。
* 工作流 `formInputResult` 里 fileSelect 字段可能存 URL 字符串或 `{ name, url }` 对象,
* 归一化后统一为该结构,便于 UI 展示与跨模块复用(流恢复回填、响应详情等)
* 归一化后统一为该结构,供流恢复和表单交互回填复用
*/
export type FormInputResultFileItem = {
name: string;
......@@ -70,64 +67,13 @@ export const normalizeFormInputResultFile = (
};
};
/**
* 只读展示用户提交的表单输入结果(`formInputResult`)。
*
* `value` 为字段 key -> 字段值的映射。每个字段按值类型分支渲染:
* - 值为文件 URL 数组:渲染可点击的文件 chip(新窗口打开下载链接);
* - 其他类型:以 JSON 代码块展示,便于查看文本、数字、嵌套结构等非文件字段。
*
* 文件数组元素经 {@link normalizeFormInputResultFile} 归一化,跳过无法识别的项。
*/
/** 将用户提交的完整表单结果统一展示为格式化 JSON。 */
const FormInputResult = React.memo(function FormInputResult({
value
}: {
value: Record<string, unknown>;
}) {
return (
<Flex flexDirection={'column'} gap={3}>
{Object.entries(value).map(([key, inputValue]) => {
// 仅当字段值为数组时尝试按文件列表解析;非数组走 JSON 展示分支
const files = Array.isArray(inputValue)
? inputValue
.map(normalizeFormInputResultFile)
.filter((file): file is FormInputResultFileItem => Boolean(file))
: [];
return (
<Box key={key}>
<Box fontSize={'12px'} color={'myGray.900'} fontWeight={500} mb={1}>
{key}
</Box>
{files.length > 0 ? (
<Flex flexWrap={'wrap'} gap={2}>
{files.map((file, index) => (
<HStack
key={`${file.url}-${index}`}
bg={'myGray.50'}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'sm'}
py={1}
px={2}
maxW={'100%'}
cursor={'pointer'}
onClick={() => window.open(file.url, '_blank')}
>
<MyIcon name={getFileIcon(file.name) as any} w={'1rem'} flexShrink={0} />
<Box className={'textEllipsis'}>{file.name}</Box>
</HStack>
))}
</Flex>
) : (
// 非文件字段或空数组:Markdown JSON 块,保持与聊天消息区一致的代码高亮样式
<Markdown source={`~~~json\n${JSON.stringify(inputValue, null, 2)}`} />
)}
</Box>
);
})}
</Flex>
);
return <Markdown source={`~~~json\n${JSON.stringify(value, null, 2)}`} />;
});
export default FormInputResult;
import { type THelperLine } from '@/web/core/workflow/type';
import { type CSSProperties, useEffect, useRef } from 'react';
import { type ReactFlowState, useStore, useViewport } from 'reactflow';
import {
type CSSProperties,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react';
import { useStore, useStoreApi } from 'reactflow';
const canvasStyle: CSSProperties = {
width: '100%',
......@@ -10,88 +18,119 @@ const canvasStyle: CSSProperties = {
pointerEvents: 'none'
};
const storeSelector = (state: ReactFlowState) => ({
width: state.width,
height: state.height,
transform: state.transform
});
export type HelperLinesProps = {
horizontal?: THelperLine;
vertical?: THelperLine;
};
function HelperLinesRenderer({ horizontal, vertical }: HelperLinesProps) {
const { width, height, transform } = useStore(storeSelector);
const { zoom } = useViewport();
export type HelperLinesController = {
draw: (lines: HelperLinesProps) => void;
clear: () => void;
};
const HelperLinesRenderer = forwardRef<HelperLinesController>(function HelperLinesRenderer(_, ref) {
const width = useStore((state) => state.width);
const height = useStore((state) => state.height);
const storeApi = useStoreApi();
const canvasRef = useRef<HTMLCanvasElement>(null);
const latestLinesRef = useRef<HelperLinesProps>({});
const [devicePixelRatio, setDevicePixelRatio] = useState(() =>
typeof window === 'undefined' ? 1 : window.devicePixelRatio
);
/** 使用 React Flow 最新视口直接绘制辅助线,不经过 React render。 */
const renderLines = useCallback(
({ horizontal, vertical }: HelperLinesProps) => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
const transform = storeApi.getState().transform;
const zoom = transform[2];
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = '#D92D20';
const drawCross = (x: number, y: number, size: number) => {
ctx.beginPath();
ctx.moveTo(x - size, y - size);
ctx.lineTo(x + size, y + size);
ctx.moveTo(x + size, y - size);
ctx.lineTo(x - size, y + size);
ctx.stroke();
};
if (vertical?.nodes.length) {
const x = vertical.position * zoom + transform[0];
ctx.beginPath();
ctx.moveTo(x, Math.min(...vertical.nodes.map((node) => node.top)) * zoom + transform[1]);
ctx.lineTo(x, Math.max(...vertical.nodes.map((node) => node.bottom)) * zoom + transform[1]);
ctx.stroke();
vertical.nodes.forEach((node) => {
drawCross(x, node.top * zoom + transform[1], 5 * zoom);
drawCross(x, node.bottom * zoom + transform[1], 5 * zoom);
});
}
if (horizontal?.nodes.length) {
const y = horizontal.position * zoom + transform[1];
ctx.beginPath();
ctx.moveTo(Math.min(...horizontal.nodes.map((node) => node.left)) * zoom + transform[0], y);
ctx.lineTo(
Math.max(...horizontal.nodes.map((node) => node.right)) * zoom + transform[0],
y
);
ctx.stroke();
horizontal.nodes.forEach((node) => {
drawCross(node.left * zoom + transform[0], y, 5 * zoom);
drawCross(node.right * zoom + transform[0], y, 5 * zoom);
});
}
},
[height, storeApi, width]
);
const clear = useCallback(() => {
latestLinesRef.current = {};
canvasRef.current?.getContext('2d')?.clearRect(0, 0, width, height);
}, [height, width]);
useImperativeHandle(
ref,
() => ({
draw: (lines) => {
latestLinesRef.current = lines;
renderLines(lines);
},
clear
}),
[clear, renderLines]
);
// 浏览器缩放或跨屏幕移动时同步 DPR,保证 Canvas 清晰度和尺寸正确。
useEffect(() => {
const updateDevicePixelRatio = () => setDevicePixelRatio(window.devicePixelRatio);
window.addEventListener('resize', updateDevicePixelRatio);
return () => window.removeEventListener('resize', updateDevicePixelRatio);
}, []);
// 仅在容器尺寸或 DPR 变化时重建 Canvas 像素缓冲区。
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!ctx || !canvas) {
return;
}
const dpi = window.devicePixelRatio;
canvas.width = width * dpi;
canvas.height = height * dpi;
ctx.scale(dpi, dpi);
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = '#D92D20';
const drawCross = (x: number, y: number, size: number) => {
ctx.beginPath();
ctx.moveTo(x - size, y - size);
ctx.lineTo(x + size, y + size);
ctx.moveTo(x + size, y - size);
ctx.lineTo(x - size, y + size);
ctx.stroke();
};
if (vertical) {
const x = vertical.position * transform[2] + transform[0];
ctx.beginPath();
ctx.moveTo(
x,
Math.min(...vertical.nodes.map((node) => node.top)) * transform[2] + transform[1]
);
ctx.lineTo(
x,
Math.max(...vertical.nodes.map((node) => node.bottom)) * transform[2] + transform[1]
);
ctx.stroke();
vertical.nodes.forEach((node) => {
drawCross(x, node.top * transform[2] + transform[1], 5 * zoom);
drawCross(x, node.bottom * transform[2] + transform[1], 5 * zoom);
});
}
if (horizontal) {
const y = horizontal.position * transform[2] + transform[1];
ctx.beginPath();
ctx.moveTo(
Math.min(...horizontal.nodes.map((node) => node.left)) * transform[2] + transform[0],
y
);
ctx.lineTo(
Math.max(...horizontal.nodes.map((node) => node.right)) * transform[2] + transform[0],
y
);
ctx.stroke();
horizontal.nodes.forEach((node) => {
drawCross(node.left * transform[2] + transform[0], y, 5 * zoom);
drawCross(node.right * transform[2] + transform[0], y, 5 * zoom);
});
}
}, [width, height, transform, horizontal, vertical, zoom]);
if (!canvas) return;
const canvasWidth = Math.round(width * devicePixelRatio);
const canvasHeight = Math.round(height * devicePixelRatio);
if (canvas.width !== canvasWidth) canvas.width = canvasWidth;
if (canvas.height !== canvasHeight) canvas.height = canvasHeight;
canvas.getContext('2d')?.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
renderLines(latestLinesRef.current);
}, [width, height, devicePixelRatio, renderLines]);
return <canvas ref={canvasRef} style={canvasStyle} />;
}
});
export default HelperLinesRenderer;
......@@ -136,6 +136,7 @@ export const useDebug = () => {
});
return Promise.reject();
}, [
appDetail.chatConfig,
edges,
fitView,
getNodes,
......
......@@ -19,7 +19,7 @@ export const useKeyboard = () => {
const getNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodes);
const setNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.setNodes);
const mouseInCanvas = useContextSelector(WorkflowUIContext, (v) => v.mouseInCanvas);
const mousePosition = useContextSelector(WorkflowUIContext, (v) => v.mousePosition);
const getMousePosition = useContextSelector(WorkflowUIContext, (v) => v.getMousePosition);
const { getMyModelList } = useSystemStore();
const { data: myModels } = useRequest(getMyModelList, {
......@@ -51,7 +51,9 @@ export const useKeyboard = () => {
if (hasInputtingElement()) return;
// Only paste if mouse is in canvas and we have mouse position
if (!mouseInCanvas || !mousePosition) return;
if (!mouseInCanvas) return;
const mousePosition = getMousePosition();
if (!mousePosition) return;
const copyResult = await navigator.clipboard.readText();
try {
......@@ -118,9 +120,9 @@ export const useKeyboard = () => {
} catch {}
}, [
computedNewNodeName,
getMousePosition,
hasInputtingElement,
mouseInCanvas,
mousePosition,
myModels,
screenToFlowPosition,
setNodes
......
......@@ -12,7 +12,7 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { WorkflowInitContext, WorkflowBufferDataContext } from '../context/workflowInitContext';
import ContextMenu from './components/ContextMenu';
import FlowController from './components/FlowController';
import HelperLines from './components/HelperLines';
import HelperLines, { type HelperLinesController } from './components/HelperLines';
import { useWorkflow } from './hooks/useWorkflow';
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { NodeProps } from 'reactflow';
......@@ -76,6 +76,7 @@ const edgeTypes = {
const Workflow = () => {
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
const edges = useContextSelector(WorkflowBufferDataContext, (v) => v.edges);
const helperLinesRef = useRef<HelperLinesController>(null);
const { reactFlowWrapperCallback, workflowControlMode, menu } = useContextSelector(
WorkflowUIContext,
(v) => v
......@@ -89,12 +90,10 @@ const Workflow = () => {
customOnConnect,
onEdgeMouseEnter,
onEdgeMouseLeave,
helperLineHorizontal,
helperLineVertical,
onNodeDragStop,
onPaneContextMenu,
onPaneClick
} = useWorkflow();
} = useWorkflow({ helperLinesRef });
const {
isOpen: isOpenTemplate,
......@@ -212,7 +211,7 @@ const Workflow = () => {
>
{!!menu && <ContextMenu />}
<FlowController />
<HelperLines horizontal={helperLineHorizontal} vertical={helperLineVertical} />
<HelperLines ref={helperLinesRef} />
</ReactFlow>
</Box>
</>
......
......@@ -4,7 +4,6 @@ import NodeCard from '../render/NodeCard';
import React, { useMemo, useState } from 'react';
import Container from '../../components/Container';
import {
Box,
Button,
Flex,
FormLabel,
......@@ -69,6 +68,9 @@ const NodeToolParams = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
<Th p={0} px={4} bg={'myGray.50'}>
{t('workflow:tool_params.params_description')}
</Th>
<Th p={0} px={4} bg={'myGray.50'}>
{t('workflow:field_required')}
</Th>
<Th p={0} px={4} bg={'myGray.50'} borderBottomRightRadius={'none !important'}>
{t('common:Operation')}
</Th>
......@@ -105,6 +107,14 @@ const NodeToolParams = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
p={0}
px={4}
borderBottom={index === inputs.length - 1 ? 'none' : undefined}
fontSize={'xs'}
>
{item.required ? '✔' : ''}
</Td>
<Td
p={0}
px={4}
borderBottom={index === inputs.length - 1 ? 'none' : undefined}
whiteSpace={'nowrap'}
>
<Flex alignItems={'center'}>
......
......@@ -7,6 +7,8 @@ import { AppContext } from '@/pageComponents/app/detail/context';
import { WorkflowBufferDataContext } from './workflowInitContext';
import { useWorkflowDemoTrack } from '@/web/common/middle/tracks/workflowDemoTrack';
type MousePosition = { x: number; y: number };
// 创建 Context
type WorkflowUIContextValue = {
/** 悬停的节点 ID */
......@@ -24,8 +26,8 @@ type WorkflowUIContextValue = {
/** 鼠标是否在 Canvas 中 */
mouseInCanvas: boolean;
/** 鼠标在 Canvas 中的屏幕位置 */
mousePosition: { x: number; y: number } | null;
/** 获取鼠标在 Canvas 中的最新屏幕位置 */
getMousePosition: () => MousePosition | null;
/** ReactFlow 包装器 callback ref */
reactFlowWrapperCallback: (node: HTMLDivElement | null) => void;
......@@ -56,7 +58,7 @@ export const WorkflowUIContext = createContext<WorkflowUIContextValue>({
throw new Error('Function not implemented.');
},
mouseInCanvas: false,
mousePosition: null,
getMousePosition: () => null,
reactFlowWrapperCallback: function (_node: HTMLDivElement | null): void {
throw new Error('Function not implemented.');
},
......@@ -81,11 +83,14 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
// Canvas 交互
const [mouseInCanvas, setMouseInCanvas] = useState(false);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
const mousePositionRef = useRef<MousePosition | null>(null);
// 使用 ref 来存储 wrapper 引用和 cleanup 函数
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const cleanupRef = useRef<(() => void) | null>(null);
/** 读取最新鼠标坐标,避免 mousemove 触发 Context 更新。 */
const getMousePosition = useCallback(() => mousePositionRef.current, []);
const reactFlowWrapperCallback = useCallback((node: HTMLDivElement | null) => {
// 先清理旧的事件监听器
if (cleanupRef.current) {
......@@ -101,10 +106,10 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
};
const handleMouseOutCanvas = () => {
setMouseInCanvas(false);
setMousePosition(null);
mousePositionRef.current = null;
};
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
mousePositionRef.current = { x: e.clientX, y: e.clientY };
};
node.addEventListener('mouseenter', handleMouseInCanvas);
......@@ -117,7 +122,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
node.removeEventListener('mouseleave', handleMouseOutCanvas);
node.removeEventListener('mousemove', handleMouseMove);
setMouseInCanvas(false);
setMousePosition(null);
mousePositionRef.current = null;
};
} else {
(reactFlowWrapper as any).current = null;
......@@ -160,7 +165,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
hoverEdgeId,
setHoverEdgeId,
mouseInCanvas,
mousePosition,
getMousePosition,
reactFlowWrapperCallback,
workflowControlMode,
setWorkflowControlMode,
......@@ -173,7 +178,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
hoverNodeId,
hoverEdgeId,
mouseInCanvas,
mousePosition,
getMousePosition,
reactFlowWrapperCallback,
workflowControlMode,
setWorkflowControlMode,
......
......@@ -33,6 +33,13 @@ export const uiWorkflow2StoreWorkflow = ({
const systemConfigNode = nodes.find(
(node) => node.data.flowNodeType === FlowNodeTypeEnum.systemConfig
)?.data;
const childrenNodeIdListMap = nodes.reduce<Record<string, string[]>>((map, node) => {
const parentNodeId = node.data.parentNodeId;
if (!parentNodeId) return map;
map[parentNodeId] = [...(map[parentNodeId] ?? []), node.data.nodeId];
return map;
}, {});
const formatNodes: StoreNodeItemType[] = nodes.map((item) => ({
nodeId: item.data.nodeId,
......@@ -51,7 +58,8 @@ export const uiWorkflow2StoreWorkflow = ({
edges,
chatConfig,
systemConfigNode,
getNodeById
getNodeById,
childrenNodeIdListMap
}),
outputs: item.data.outputs,
isFolded: item.data.isFolded,
......@@ -95,7 +103,8 @@ const filterUnselectableReferenceInputs = ({
edges,
chatConfig,
systemConfigNode,
getNodeById
getNodeById,
childrenNodeIdListMap
}: {
node: FlowNodeItemType;
inputs: FlowNodeInputItemType[];
......@@ -103,6 +112,7 @@ const filterUnselectableReferenceInputs = ({
chatConfig?: AppChatConfigType;
systemConfigNode?: FlowNodeItemType;
getNodeById: (nodeId: string | null | undefined) => FlowNodeItemType | undefined;
childrenNodeIdListMap: Record<string, string[]>;
}) => {
return inputs.map((input) => {
if (!nodeInputIsReference(input)) return input;
......@@ -113,7 +123,9 @@ const filterUnselectableReferenceInputs = ({
getNodeById,
edges,
chatConfig: chatConfig ?? ({} as AppChatConfigType),
t: emptyT
t: emptyT,
includeChildren: input.canEdit === true,
childrenNodeIdListMap
});
const value = input.value as ReferenceValueType | undefined;
......
......@@ -128,6 +128,13 @@ async function handler(req: ApiRequestProps<CreateAppBodyType>) {
}
export default NextAPI(handler);
export const config = {
api: {
bodyParser: {
sizeLimit: '5mb'
}
}
};
export const onCreateApp = async ({
parentId,
......
......@@ -37,6 +37,7 @@ async function handler(req: ApiRequestProps): Promise<GetLogUsersResponse> {
const { teamId } = await authApp({
req,
authToken: true,
authApiKey: true,
appId,
per: AppReadChatLogPerVal
});
......
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