Compare commits

...
130 Commits
Author SHA1 Message Date
吉鑫升 ca2696b23f Merge branch 'master' into master-zl 2026-08-31 18:06:54 +08:00
吉鑫升 90e94994f5 Merge remote-tracking branch 'origin/master' into master 2026-08-31 18:06:10 +08:00
吉鑫升 2718915d34 Merge branch 'master' into master-zl 2026-08-31 18:02:26 +08:00
吉鑫升 8e0d755b57 fix: preserve knowledge base references 2026-08-31 18:02:05 +08:00
吉鑫升 ee83258b70 Merge branch 'master' into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/input-box.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
#	offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
2026-08-31 17:55:11 +08:00
吉鑫升 40542db400 feat: add configurable chat display modes 2026-08-31 17:49:48 +08:00
董宏凯andCursor f18994fd38 fix: show generated HTML charts on Win7 Chrome
Rewrite oklch/color-mix and space-separated rgb before iframe preview so pie and bar fills are not dropped on Chrome 109.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:25:09 +08:00
董宏凯andCursor 7c15ca6710 fix: show sandbox flowcharts on Win7 Chrome and repair common Mermaid syntax
Disable HTML labels so nodes use native SVG text, and rewrite bare edge labels plus invalid class statements before render.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:05:29 +08:00
董宏凯 31cfa93408 feat: 智能体列表页引导改为手动触发,完成/跳过不再跳转创建页
- AgentGuide 新增 autoStart 属性(默认 true),列表页挂载传 false:进智能体列表页不再自动弹引导,仅由头部「使用引导」按钮手动触发
- 去掉 list 段与创建页的联动:finish 不再 router.push,最后一步按钮恢复默认「完成」,跳过/走完都就地结束并写记忆
- AGENTS.md 同步更新智能体引导条目
2026-08-30 08:53:34 +08:00
董宏凯andCursor b164e70541 fix: unlock send immediately after SSE close
Normal Q&A no longer waits on GET /runs before the next message. Probe run status only on 409 or dropped streams.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 08:48:53 +08:00
吉鑫升 3715af3138 chore: move offline release assets to standalone repository 2026-08-27 23:35:57 +08:00
吉鑫升 973500a0f0 fix: preserve LF in offline deployment assets 2026-08-27 22:35:57 +08:00
吉鑫升 d9a94245fc feat: standardize DeerFlow offline deployment 2026-08-27 22:06:43 +08:00
董宏凯 1764d0d5f2 Merge branch 'master-nj' into 'master'
fix: 8BF 无 taskId 开全新会话,公共链条发布联动席位智能体

See merge request knowledge-base-bj/deerflow-code!35
2026-08-27 13:48:17 +00:00
董宏凯andCursor 84370e2072 fix: 8BF 无 taskId 开全新会话,公共链条发布联动席位智能体
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 21:47:31 +08:00
董宏凯 0289a76fce Merge branch 'master-nj' into 'master'
feat: 新增 agentfx 任务深链与研判报告入库流程

See merge request knowledge-base-bj/deerflow-code!34
2026-08-27 10:39:04 +00:00
董宏凯andCursor 32e509b2b3 fix: 岗位会商 handoff 将上游产物镜像到当前席位沙箱
席位线程隔离导致下游 read_file 找不到上游路径;镜像到 workspace/upstream 并改写 handoff 引用,附带安全路径校验与 SQL 回退测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 18:35:17 +08:00
董宏凯 440b490b0d Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj 2026-08-27 16:33:33 +08:00
董宏凯andCursor 52b9cbf707 feat: 新增 agentfx 任务深链与研判报告入库流程
支持 goPath=agentfx 的 iframe 嵌入问答、内置研判助手种子与报告导入协助卡,并完善深度研究流式报告能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 16:32:22 +08:00
吉鑫升 87505feac4 feat: improve skill uploads and agent runtime stability 2026-08-27 11:15:25 +08:00
吉鑫升 11326eeb1c feat: improve skill uploads and agent runtime stability 2026-08-27 11:14:23 +08:00
吉鑫升 485a8fa9d9 feat: standardize Word export and Office conversion 2026-08-27 00:09:53 +08:00
吉鑫升 c5eefe60f0 feat: standardize Word export and Office conversion 2026-08-27 00:09:08 +08:00
董宏凯 4ce2f3a502 Merge branch 'master-nj' into 'master'
feat: 智能体问答页右侧面板受控折叠,收起整列归零并在 header 提供展开入口

See merge request knowledge-base-bj/deerflow-code!33
2026-08-26 10:25:11 +00:00
董宏凯andCursor 6e9e89a6cb fix: 流式自动开沙箱分阶段收起侧栏,避免同帧布局卡顿
新增 preOpen 两阶段标记,先错开收起左右侧栏再展开沙箱;智能体详情面板据此提前收起,减少与沙箱展开同帧叠加造成的卡顿。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 18:24:06 +08:00
董宏凯 5be21f2c3d Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/agents/agent-card.tsx
#	frontend-web/src/components/workspace/custom-prompt-menu.tsx
2026-08-26 17:45:39 +08:00
董宏凯andCursor 90d29ed232 feat: 智能体问答页右侧面板受控折叠,收起整列归零并在 header 提供展开入口
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:37:23 +08:00
董宏凯 d9de41dd67 代码修改 2026-08-26 17:15:04 +08:00
董宏凯 f1245da3e4 Merge branch 'master-nj' into 'master'
feat: 舆情分析智能体改为经 Gateway 转发外部 AG-UI 网关

See merge request knowledge-base-bj/deerflow-code!32
2026-08-26 09:00:55 +00:00
董宏凯 38c3de39f0 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
# Conflicts:
#	AGENTS.md
#	frontend-web/src/pages/AgentChatPage.tsx
2026-08-26 16:37:41 +08:00
董宏凯andCursor 33639e5b89 fix: 修复嵌套 iframe 污染 embed 会话与协助卡后流式显示异常
顶层窗口通过 topOwned 标记自愈老版嵌套页写脏的 embed.session;协助卡提交后避免冻结 checkpoint 覆盖实时 SSE,并仅在服务端回显新 human 消息时清除乐观气泡。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 16:28:10 +08:00
吉鑫升 d488e65e49 Merge branch 'master' into master-zl 2026-08-26 16:09:06 +08:00
吉鑫升 676b00afb6 Merge branch 'ji' into master 2026-08-26 16:07:37 +08:00
吉鑫升 0980eea975 feat: unify formal Word export 2026-08-26 16:02:23 +08:00
董宏凯 eba3008482 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj 2026-08-26 15:48:23 +08:00
吉鑫升 9b904ac3dd Merge branch 'ji' into master
# Conflicts:
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/README.md
#	offline-backend-20260512/backend/app/gateway/routers/llmwiki.py
#	offline-backend-20260512/backend/config.yaml
#	offline-backend-20260512/backend/packages/harness/deerflow/config/llmwiki_config.py
#	offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
2026-08-26 15:46:58 +08:00
董宏凯 704438b3d2 bug修改 2026-08-26 15:46:49 +08:00
吉鑫升 d35d075669 feat: add compact toolbox and public wiki vector search 2026-08-26 14:40:57 +08:00
董宏凯 910cee9e67 用户引导沙箱优化 2026-08-26 14:14:26 +08:00
fengxiangdebaozi dcc3e0799d Merge branch 'master-zl' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-26 12:31:48 +08:00
董宏凯 21f3787b8b 代码修改 2026-08-26 11:30:39 +08:00
董宏凯 33b719d460 用户引导 2026-08-26 11:09:43 +08:00
董宏凯 c6fcf51e0c 用户引导 2026-08-26 09:41:53 +08:00
董宏凯andCursor 62a3faa7c8 feat: 舆情分析智能体改为经 Gateway 转发外部 AG-UI 网关
前端不再直连上游 SSE,改为 POST /api/sentiment-agent/stream 由后台代理转发,支持关闭 TLS 校验并回传完整错误信息。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 18:26:48 +08:00
fengxiangdebaozi 7cc6aca398 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 17:36:14 +08:00
董宏凯 d87dfec2de Merge branch 'master-nj' into 'master'
feat: 业务链条发布审核流程 + 编辑器去除岗位职责输入框

See merge request knowledge-base-bj/deerflow-code!31
2026-08-25 09:35:16 +00:00
董宏凯 5c0864bf0a Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj 2026-08-25 17:33:40 +08:00
董宏凯andCursor 8ce3a62826 fix: 岗位会商历史按用户隔离,并为 write-file 预览启用 AI 写作视图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:32:56 +08:00
董宏凯 8c3d6a02fc feat: 业务链条发布审核流程 + 编辑器去除岗位职责输入框
- 发布改为可选「确认审核 / 直接发布」:链条编辑页与圆桌「业务链条」弹窗行为一致;审核中可撤回
- 管理员在业务链条列表新增「待审核」筛选,审核发布后进入公共库
- 后端 roundtable_chains 新增 pending_review 字段(启动期自动补列)与 scope=pending 筛选;新增审核流程测试
- 编辑器席位去掉「岗位职责」输入框(保存时存量值原样保留)
2026-08-25 17:30:29 +08:00
fengxiangdebaozi dd1064b3cb Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 17:07:09 +08:00
吉鑫升 be9a490923 fix: harden wiki delivery and leaderboard performance 2026-08-25 16:58:58 +08:00
吉鑫升 e053b36b6e fix: harden wiki delivery and leaderboard performance 2026-08-25 16:58:13 +08:00
吉鑫升 9f29ecae9e fix: harden wiki delivery and leaderboard performance 2026-08-25 16:57:42 +08:00
fengxiangdebaozi 2ea331f9ec Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 14:53:57 +08:00
fengxiangdebaozi 296974e9e5 Merge branch 'master-zl' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 14:28:45 +08:00
吉鑫升 ed5b550ead fix: restore embedded wiki images and optimize vector sync 2026-08-25 11:27:02 +08:00
吉鑫升 4b86106a24 fix: restore embedded wiki images and optimize vector sync 2026-08-25 11:26:27 +08:00
fengxiangdebaozi 7f2c51957b Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-25 11:26:24 +08:00
董宏凯 043b0900d5 Merge branch 'master-nj' into 'master'
Master nj

See merge request knowledge-base-bj/deerflow-code!30
2026-08-25 03:26:03 +00:00
吉鑫升 0c5e2e8cb0 fix: restore embedded wiki images and optimize vector sync 2026-08-25 11:24:01 +08:00
董宏凯 40d06b781a 接口调整 2026-08-25 11:23:53 +08:00
董宏凯 c5328e6a35 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj 2026-08-24 20:47:55 +08:00
董宏凯 babc5e95c6 代码提交 2026-08-24 20:47:31 +08:00
吉鑫升 644cabd23d feat: complete public wiki and embedded previews 2026-08-24 20:42:39 +08:00
吉鑫升 1736227723 feat: complete public wiki and embedded previews 2026-08-24 20:41:24 +08:00
吉鑫升 30943159bf feat: complete public wiki and embedded previews 2026-08-24 20:40:44 +08:00
fengxiangdebaozi c4d61288b4 Merge branch 'master-zl' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/input-box.tsx
2026-08-24 17:18:39 +08:00
fengxiangdebaozi 389df2ace5 Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/components/workspace/skill-display-sources.tsx
2026-08-24 17:14:46 +08:00
董宏凯 007ed5a7de Merge branch 'master-nj' into 'master'
Master nj

See merge request knowledge-base-bj/deerflow-code!29
2026-08-24 09:08:51 +00:00
董宏凯 ebc727e4fd 代码提交 2026-08-24 17:08:22 +08:00
吉鑫升 32dbdca480 Merge branch 'ji' into master-zl 2026-08-24 16:27:53 +08:00
吉鑫升 bc49df4561 feat: add public wiki embeds and ES query routing 2026-08-24 15:21:28 +08:00
fengxiangdebaozi 28eb8f86a7 Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-24 14:45:45 +08:00
董宏凯 cdb8aa5f81 弹窗样式修改 2026-08-24 14:45:25 +08:00
吉鑫升 e818c24769 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	.gitignore
#	frontend-web/src/components/workspace/artifacts/context.tsx
#	frontend-web/src/components/workspace/messages/message-group.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/README.md
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/app/gateway/deps.py
#	offline-backend-20260512/backend/app/gateway/routers/llmwiki.py
2026-08-24 14:21:19 +08:00
吉鑫升 49babb6d25 feat: improve wiki document and artifact views 2026-08-24 14:01:10 +08:00
fengxiangdebaozi b3aa9f4bac Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-24 09:10:20 +08:00
Administrator 3785345353 feat: add local LLMWiki wiki vector index 2026-08-24 00:49:31 +08:00
董宏凯 daaa0eab26 Merge branch 'master-nj' into 'master'
feat: 深度研究支持独立新报告与原文改写,并完善沙箱、模型管理与全文改写

See merge request knowledge-base-bj/deerflow-code!28
2026-08-23 14:30:21 +00:00
董宏凯andCursor 541bc3280f fix: 岗位会商本轮只允许 present_files 一次,避免重复产物卡
弱模型写完报告后仍会再调用一次 present_files,前端会画出两张相同卡片。上限同时覆盖呈现,已呈现则只催收尾。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 21:26:57 +08:00
董宏凯andCursor b81d200eca fix: 协助卡停轮与提交防 409,隐藏配置向导会话,并限制岗位会商产物重复写入
澄清中间件改为 ToolMessage + after_model 停轮,前端提交前取消残留 run;系统/向导 thread_type 从对话列表排除;岗位总结席位加 write_file 上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 20:29:59 +08:00
董宏凯andCursor 51afebb394 feat: 报告结构支持检索方向/来源与智能新增,并修复协助卡停轮与宿主跳转
为深度研究报告结构增加检索方向与技能来源、对话式智能新增页与详情排版;澄清中间件在 after_model 可靠结束本轮,避免弱模型出卡后继续生成;任务按钮补充 host 跳转与相关深链能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 17:46:06 +08:00
fengxiangdebaozi 1c0e4e9723 Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-21 21:48:29 +08:00
董宏凯andCursor a5a9c952e8 feat: 深度研究支持独立新报告与原文改写,并完善沙箱、模型管理与全文改写
新报告不再覆盖当前文件卡片,写入时打开沙箱;步骤条仅在改写原文时位于卡片上方。同时补齐改写本地回退、模型管理与会商第三步相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 21:46:04 +08:00
吉鑫升 4c9f75f31e feat: add fixed questions and improve authentication 2026-08-21 17:58:39 +08:00
fengxiangdebaozi d19d6ced66 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-20 17:05:41 +08:00
董宏凯 7c34e18ad6 Merge branch 'master-nj' into 'master'
fix: 单轮 RAG 步骤条思考内容支持可点击参考序号

See merge request knowledge-base-bj/deerflow-code!27
2026-08-20 09:04:49 +00:00
董宏凯andCursor 11f928bc95 feat: 新增独立企业深度研究工作台,并接入工作流报告结构与 8BF 深色锁定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:43:03 +08:00
董宏凯andCursor e881c242fe feat: 岗位职责迁至业务链席位,并支持 WeKnora 嵌入页知识库切换
将岗位职责从智能体配置迁移到业务链席位 JSON,会商面板只读展示;WeKnora 嵌入 iframe 支持在授权可见范围内切换知识库并重新签发会话。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 22:26:40 +08:00
fengxiangdebaozi 8e77e2da1e Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl 2026-08-19 21:24:58 +08:00
董宏凯andCursor e860357563 fix: 单轮 RAG 步骤条思考内容支持可点击参考序号
把思考区的 `[n]` 按正文同一套引用规则渲染,并去掉回答气泡里重复的 Thought for a few seconds。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 21:21:22 +08:00
fengxiangdebaozi bc49ef6027 merge: integrate master-nj RAG updates 2026-08-19 20:46:37 +08:00
fengxiangdebaozi 288ba64923 代码提交 2026-08-19 20:39:06 +08:00
董宏凯 c69fde22bd Merge branch 'master-nj' into 'master'
feat: 支持会商 Markdown 产物提交到任务过程,并补充 Angular 对接文档

See merge request knowledge-base-bj/deerflow-code!26
2026-08-19 12:38:26 +00:00
董宏凯andCursor 0ce060693b feat: 会商链条启停与深度研究报告结构,并完善问答展示与 8BF 返回。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:37:34 +08:00
fengxiangdebaozi db5b6ba574 fix(rag): restore single-round source streaming 2026-08-19 20:29:00 +08:00
董宏凯 be4851555d 代码提交 2026-08-19 17:11:36 +08:00
董宏凯andCursor 3afa76894f feat: 完善会商产物提交匹配与流式写文件预览
席位流程图按已提交 Markdown 对齐;流式未命名产物不再误请求 artifacts API,并兼容临时文件名路径。同时补强会话流中断识别与 8BF/岗位会商展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:08:52 +08:00
董宏凯andCursor acf538ac01 feat: 简介模式菜单按类型 A/B 分套,兼容未配置的旧部署
两套网页可通过 runtime-config.js 的 VITE_COMPACT_MENU_TYPE 加载各自左侧菜单;缺省或旧页面不传参数时仍读取原来的类型 A 数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 16:59:34 +08:00
董宏凯andCursor 5e1765d889 feat: 完善 8BF 会商第一步产物与智能体列表,第二步对话区对齐第一步样式
抽出共用分析成果卡片,第一步展示第二、三步 Markdown 产物;智能体列表对齐岗位会商选择样式。第二步对话面板改为深蓝顶栏,第一步保留可配置返回按钮。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:12:35 +08:00
董宏凯andCursor 30799f17be fix: 节流会商流式写文件预览,避免 Maximum update depth
write_file 的 content 在每个 SSE chunk 中持续增长,原先会反复触发 React 状态更新并打断整页;现改为按身份入参去重、节流发布 stub,并收口第 3 步为 Markdown 报告工作台。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:23:46 +08:00
董宏凯andCursor dcfaa199bf feat: 支持会商 Markdown 产物提交到任务过程,并补充 Angular 对接文档
8BF/会商工作区可将 Markdown 产物一键提交到任务过程报告,后端记录提交状态以便跨用户与刷新后保持已提交;同时为 Angular 智能体对话对接文档补上前后端参考代码索引。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 20:08:51 +08:00
fengxiangdebaozi 1f961ee1df fix: bundle workspace branding assets 2026-08-18 19:43:35 +08:00
董宏凯 33b0af333c Merge branch 'master-nj' into 'master'
增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。

See merge request knowledge-base-bj/deerflow-code!25
2026-08-18 09:55:17 +00:00
董宏凯 172250e6b5 代码提交 2026-08-18 17:54:07 +08:00
董宏凯andCursor 15a46b903a 增强文档改写配图与深度研究流式稳定性,并完善 8BF/会商工作区。
全文改写支持可选配图与思考过程展示,深度研究接入 DashScope 原生出图并剥离内联 think 标签;8BF 会商补齐第一步工作区与任务卡回退,同时限制热重载扫描范围。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 17:46:11 +08:00
fengxiangdebaozi 6ff04ea4e3 fix: restore skill list styling 2026-08-18 16:50:27 +08:00
fengxiangdebaozi 0f667261be Merge branch 'master-zl' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/skill-display-sources.tsx
2026-08-18 16:43:48 +08:00
fengxiangdebaozi e412d7db3f 代码合并 2026-08-18 16:39:56 +08:00
吉鑫升 6de3026f8f fix: adapt WeKnora source preview on master-zl 2026-08-18 15:48:58 +08:00
吉鑫升 64067ea478 Merge branch 'ji' into master-zl 2026-08-18 15:38:17 +08:00
吉鑫升 8af4fed89d feat: update knowledge workspace and menu configuration 2026-08-18 15:23:14 +08:00
fengxiangdebaozi e0b6e1cd19 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/App.tsx
#	frontend-web/src/components/workspace/agents/agent-detail-panel.tsx
#	frontend-web/src/components/workspace/agents/agent-gallery.tsx
#	frontend-web/src/components/workspace/appearance-controls.tsx
#	frontend-web/src/components/workspace/compact-welcome.tsx
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/components/workspace/settings/appearance-settings-page.tsx
#	frontend-web/src/components/workspace/settings/skill-settings-page.tsx
#	frontend-web/src/components/workspace/skill-display-sources.tsx
#	frontend-web/src/components/workspace/workspace-sidebar.tsx
#	frontend-web/src/contexts/ThemeContext.tsx
#	frontend-web/src/core/embed/embed-session.ts
#	frontend-web/src/core/i18n/locales/en-US.ts
#	frontend-web/src/core/i18n/locales/types.ts
#	frontend-web/src/core/i18n/locales/zh-CN.ts
#	frontend-web/src/core/system-settings/api.ts
#	frontend-web/src/env.ts
#	frontend-web/src/pages/ChatPage.tsx
#	frontend-web/src/pages/LightAppIframeView.tsx
#	frontend-web/src/pages/LightAppListPage.tsx
#	frontend-web/src/pages/LightAppNoteQAPage.tsx
#	frontend-web/src/pages/NotebookAIWritingPage.tsx
#	frontend-web/src/pages/PageRoutes.tsx
#	frontend-web/src/pages/WorkspaceRoutes.tsx
#	frontend-web/src/pages/light-app-management.css
#	frontend-web/src/pages/light-app-pages.css
#	frontend-web/src/strategy-components/lib/light-app.ts
#	frontend-web/src/styles/globals.css
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/README.md
#	offline-backend-20260512/backend/app/gateway/routers/system_settings.py
#	offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py
2026-08-18 10:06:34 +08:00
Administrator 64a5305bbf feat: add markdown word editor for knowledge workspace 2026-08-18 00:44:29 +08:00
董宏凯 819e537aa3 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj 2026-08-17 19:30:37 +08:00
吉鑫升 68c4896088 fix: stabilize auth sync and stream recovery for ji 2026-08-17 19:27:39 +08:00
董宏凯andCursor 771957429a 增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。
修复 MySQL 读写分离下的持久化与 SSE 重连问题,报告完成前先落库;8BF 嵌入页支持 taskId 任务卡与独立意图 metadata;前端统一 createClientId 并更新 runtime 配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 18:16:05 +08:00
吉鑫升 5bf08c83a6 Merge remote-tracking branch 'origin/master' into ji 2026-08-17 15:53:36 +08:00
吉鑫升 3ab2c9b282 feat: improve WeKnora source preview and wiki management 2026-08-17 15:53:19 +08:00
吉鑫升 94cf37a2ae fix: normalize system messages before model calls 2026-08-17 10:12:43 +08:00
董宏凯 47faa9bcdf 代码提交 2026-08-16 21:48:38 +08:00
董宏凯 5901246030 Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj 2026-08-16 20:43:43 +08:00
董宏凯 44e0b7596b Merge branch 'master-baogao' into 'master'
Master baogao

See merge request knowledge-base-bj/deerflow-code!24
2026-08-16 11:21:33 +00:00
fengxiangdebaozi 5c586652af daimhebing1 2026-08-14 21:25:29 +08:00
fengxiangdebaozi 067f602732 代码提交 2026-08-14 19:31:15 +08:00
董宏凯 96bfdacee5 Merge branch 'master-nj' into 'master'
Master nj

See merge request knowledge-base-bj/deerflow-code!23
2026-08-14 05:01:17 +00:00
Administrator a28ad2140e fix: stabilize embedded WeKnora detail page 2026-08-14 11:29:58 +08:00
董宏凯 e2041c0b07 feat(frontend): add theme variants and compact navigation config 2026-07-22 14:55:42 +08:00
董宏凯 2fa7000ff7 feat(workspace): add red theme assets and adapt workspace styles 2026-07-19 17:30:38 +08:00
董宏凯 760b88d6f1 feat: refresh workspace UI and agent visuals 2026-07-16 00:06:40 +08:00
504 changed files with 59843 additions and 8058 deletions

No files matched your search

+62 -31
View File
@@ -1,10 +1,10 @@
# Workspace/editor
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
@@ -56,9 +56,9 @@ offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/stop_backend.sh
# offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260512/.env.example
offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/.env.example
offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
@@ -66,14 +66,14 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
@@ -84,15 +84,41 @@ offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned.
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
deploy
.history
@@ -102,17 +128,22 @@ git-clone/
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
# Local dependency caches and backend scratch archives
/.pnpm-store/
/offline-backend-20260512/backend/backend*.zip
.codegraph
codegraph.mdc
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
# Local dependency caches and backend scratch archives
/.pnpm-store/
/offline-backend-20260512/backend/backend*.zip
# Local offline bundles and scratch tooling
/*.tar.gz
/offline-backend-20260512/*.zip
/tmp/
.codegraph
codegraph.mdc
docs/aidev-docs
docs/backups
@@ -129,4 +160,4 @@ offline-backend-20260512/backend/uv.lock
__pycache__/
# misc
.DS_Store
.DS_Store
+1 -1
View File
@@ -47,7 +47,7 @@
"name": "DeerFlow: Open Frontend in Chrome",
"type": "node-terminal",
"request": "launch",
"preLaunchTask": "DeerFlow: Start Full Stack and Wait",
"preLaunchTask": "DeerFlow: VS Code Start",
"command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"",
"cwd": "${workspaceFolder}"
}
+23
View File
@@ -1,6 +1,29 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "DeerFlow: VS Code Start",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}/scripts/vscode-start.ps1",
"-OpenBrowser"
],
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
},
"problemMatcher": []
},
{
"label": "DeerFlow: Start Full Stack and Wait",
"type": "process",
+13 -3
View File
@@ -11,6 +11,13 @@ This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI
Managed together via shell scripts in `scripts/`.
## Offline Release Repository
Linux AMD64 离线发版脚本、wheelhouse、`deploy.env` 模板和部署文档统一维护在
`knowledge-base-bj/deerflow-offline-deployment`,不要把离线镜像或发版资产提交到
本业务源码仓库。正式发版从该仓库执行 `scripts/build-latest-master.sh`,脚本会读取
本仓库最新 `master` 的干净源码快照。
## Commands
### Full Application (from repo root)
@@ -81,16 +88,19 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `src/core/sentiment-agent/`。
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** uses a separate overlay: every compact workspace menu item can be placed in `sidebar`(左侧菜单)、`settings`(设置和更多)or `toolbox`(右侧工具箱); `CompactToolbox` (`components/workspace/compact-toolbox.tsx`, mounted by `PageRoutes.tsx`) opens only when the square right-edge trigger is clicked, then slides a compact, content-height right-side drawer over a backdrop above global dropdowns; configured items render top-to-bottom as centered square cards with a large icon above the label while the effective layout is compact. `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` + `task-reports.json`(14 个 categoryType 的 `{contentJson, categoryType}` 数组,contentJson 为 JSON 字符串)→ 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "task-report:saved", taskId: String(taskId)}, "*")`(`notifyTaskReportSaved`,`agentfx-report-schema.ts`)——卡片路径在 `TaskReportImportCard` 导入成功回调里直发;技能路径由 `AgentChatPage` 从消息流检测 `task_report_import.py` 的成功 stdout(`"success": true` + `task_id`)代发——**仅在回合结束后检测**(`!thread.isLoading` 才扫描;流式过程中 messages Line truncated
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
### Configuration
+3 -3
View File
@@ -82,13 +82,13 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三Line truncated
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
- **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」.
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error.
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text). A premature SSE close (SDK treats it as success and restores the send button while the backend run is still occupying the thread) is recovered by `confirmOrRejoin`: hold the stop button, `joinStream` the live run, and on 409 auto-reconnect instead of a fake AI bubble. `sse_consumer` no longer ends a resumable (`on_disconnect=continue`) stream on a false-positive `is_disconnected()`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
+1 -1
View File
@@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true
VITE_MOCK_COP_TASK_DETAIL=true
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_SQ_REPORT=true
VITE_MOCK_SQ_REPORT=false
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_COP_TASK_DETAIL=true
+3
View File
@@ -56,4 +56,7 @@
# VITE_STATIC_WEBSITE_ONLY=
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
# VITE_FORCE_COLLAPSED_SIDEBAR=false
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A
# VITE_GITHUB_OAUTH_TOKEN=
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
<title id="title">CM 助手</title>
<defs>
<linearGradient id="logoTopLeft" x1="7" y1="7" x2="25" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#5DB5FF"/>
<stop offset="1" stop-color="#328AF2"/>
</linearGradient>
<linearGradient id="logoTopRight" x1="24" y1="3" x2="43" y2="19" gradientUnits="userSpaceOnUse">
<stop stop-color="#62B2FF"/>
<stop offset="1" stop-color="#4B98F4"/>
</linearGradient>
<linearGradient id="logoLeft" x1="5" y1="14" x2="24" y2="45" gradientUnits="userSpaceOnUse">
<stop stop-color="#2C7CF0"/>
<stop offset="1" stop-color="#1F5EDA"/>
</linearGradient>
<linearGradient id="logoBottom" x1="9" y1="31" x2="43" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#276DE3"/>
<stop offset="1" stop-color="#4E9DF5"/>
</linearGradient>
</defs>
<!-- The mark is drawn as four independent folded faces. Together they form
the open C-shaped cube used in the product reference. -->
<path d="M5 13.5 24 3v10L14 18.5 5 13.5Z" fill="url(#logoTopLeft)"/>
<path d="M24 3 43 13.5 34 18.5 24 13V3Z" fill="url(#logoTopRight)"/>
<path d="M5 13.5 14 18.5v11L24 35v10L5 34.5v-21Z" fill="url(#logoLeft)"/>
<path d="M14 29.5 24 35l10-5.5 9 5L24 45 5 34.5l9-5Z" fill="url(#logoBottom)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+23 -5
View File
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_BRAND_WELCOME_DARK_URL: "",
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
@@ -50,6 +50,19 @@ window.__CM_RUNTIME_CONFIG__ = {
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
// 不影响原多智能体会商使用的任务详情接口。
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zl_consumer",
// 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。
// 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。
VITE_8BF_ROUNDTABLE_BACK_URL: "",
// 任务深链「宿主路由」按钮的 postMessage targetOrigin(HOST_NAVIGATE)。
// 填宿主系统 origin,例如 https://localhost:4912;生产改成他们线上地址。
// 留空时尝试用 iframe 的 ancestorOrigins / referrer 自动探测,探测失败则不发送。
VITE_HOST_NAVIGATE_ORIGIN: "",
// 佳来模型对话接口。
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
@@ -90,7 +103,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer",
VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer",
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
@@ -130,6 +143,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
// 简介模式左侧菜单类型:与菜单管理页「简介模式」里的类型 A / B 对应。
// 两套网页部署时分别填 "A" 或 "B"。不填、留空、或旧配置没有这一项,都按类型 A,
// 继续读取原来的简介菜单数据,无需改旧页面。
VITE_COMPACT_MENU_TYPE: "A",
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
@@ -138,8 +156,8 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_SENTIMENT_AGENT_VISIBLE: "true",
// 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。
// 留空时该智能体对话页会提示未配置。
VITE_SENTIMENT_AGENT_API_URL: "https://gw.y.sit.4.cn/alg/stream/report-agent",
// 前端会把该地址随请求传给后台转发。留空时该智能体对话页会提示未配置。
VITE_SENTIMENT_AGENT_API_URL: "http://alg.gw.y.uat.cn/a6_report_agent/alg/stream/report-agent",
// 舆情分析智能体:请求头 Authorization Basic Auth 用户名。
VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296",
@@ -31,6 +31,16 @@ const requiredLegacyFallbacks = [
pattern: /<InputGroup className="h-auto min-h-16">/,
description: "Prompt textarea must not rely on :has() for its parent height",
},
{
file: "src/core/utils/mermaid.ts",
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
},
{
file: "src/core/utils/legacy-html-preview.ts",
pattern: /export function downlevelModernColors[\s\S]*function injectLegacyChartShim/,
description: "Generated HTML charts must downlevel modern CSS colors for Chrome 109",
},
];
for (const file of cssFiles) {
+22 -11
View File
@@ -38,6 +38,7 @@ import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
import {
isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_ROUTE,
@@ -45,7 +46,12 @@ import {
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
const PUBLIC_EMBED_PREFIXES = [
"/share/",
"/public/",
"/roundtable-share/",
"/embed/knowledge/",
];
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
@@ -102,16 +108,17 @@ function AppProviders({ children }: { children: React.ReactNode }) {
forcedTheme={forcedTheme}
>
<QueryClientProvider>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
<ChatProvider>
{!positionStandaloneMode && <AuthSyncWatcher />}
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
</I18nProvider>
<AuthSyncWatcher disabled={positionStandaloneMode}>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
<ChatProvider>
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
</I18nProvider>
</AuthSyncWatcher>
</QueryClientProvider>
</NextThemesProvider>
);
@@ -188,6 +195,10 @@ export function App() {
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/embed/knowledge/:noteId"
element={<WeKnoraWikiPageView publicEmbed />}
/>
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden="true">
<path d="M13 6H1.5M1.5 6L6.2 1.4M1.5 6L6.2 10.6" stroke="#A2E1F9" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -165,7 +165,7 @@ export const ReasoningTrigger = memo(
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
children: ReactNode;
};
export const ReasoningContent = memo(
@@ -178,7 +178,11 @@ export const ReasoningContent = memo(
)}
{...props}
>
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
{typeof children === "string" ? (
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
) : (
children
)}
</CollapsibleContent>
),
);
@@ -0,0 +1,104 @@
import { FileOutput } from "lucide-react";
import { toast } from "sonner";
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import "./analysis-results.css";
import type { AnalysisResultCardItem } from "./types";
function formatResultTime(value: string | undefined, fallback: string): string {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
export function AnalysisResultCardList({
items,
emptyTitle = "暂无交付产物",
emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。",
padded = true,
onOpen,
}: {
items: AnalysisResultCardItem[];
emptyTitle?: string;
emptyDescription?: string;
padded?: boolean;
onOpen: (item: AnalysisResultCardItem) => void;
}) {
if (items.length === 0) {
return (
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
<p className="text-sm font-semibold">{emptyTitle}</p>
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
{emptyDescription}
</p>
</div>
);
}
return (
<div className={cn("analysis-result-card-list", padded && "p-[15px]")}>
{items.map((item, index) => {
const displayedTime = formatResultTime(
item.updatedAt,
item.fallbackTime || "刚刚",
);
const displayedProductName = item.delivered ? item.productName : "未交付";
return (
<button
key={item.id}
type="button"
className="position-roundtable-analysis-result-card"
onClick={() => {
if (!item.delivered) {
toast.info(
item.unavailableMessage ||
`「${desensitize(item.agentName)}」尚未交付可预览的 Markdown 文件。`,
);
return;
}
onOpen(item);
}}
>
<div className="position-roundtable-analysis-result-card-body">
<div
className={cn(
"position-roundtable-analysis-result-tag",
`position-roundtable-analysis-result-tag--${(index % 6) + 1}`,
)}
aria-label={desensitize(item.agentName)}
>
<span>{desensitize(item.agentName)}</span>
</div>
<p className="position-roundtable-analysis-result-product">
{desensitize(displayedProductName)}
</p>
<footer className="position-roundtable-analysis-result-meta">
<span>
<img src={responsibilityIcon} alt="" aria-hidden="true" />
{desensitize(item.positionName)}
</span>
<i aria-hidden="true" />
<span>
<img src={resultTimeIcon} alt="" aria-hidden="true" />
{displayedTime}
</span>
</footer>
</div>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,141 @@
import { useQuery } from "@tanstack/react-query";
import { FileOutput, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { ArtifactFilePreview } from "@/components/workspace/artifacts";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { loadArtifactContent } from "@/core/artifacts/loader";
import { desensitize } from "@/lib/desensitize";
import { Button as TButton } from "tdesign-react";
import type { AnalysisResultCardItem } from "./types";
function isArtifactErrorContent(content: string | null | undefined): boolean {
const trimmed = content?.trim();
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return false;
}
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const detail = parsed.detail ?? parsed.message ?? parsed.error;
return typeof detail === "string" && detail.trim().length > 0;
} catch {
return false;
}
}
function fallbackContent(item: AnalysisResultCardItem | null): string {
return (
item?.artifactContent?.trim() ||
item?.summary?.trim() ||
"暂无产物正文"
);
}
export function AnalysisResultDetailDialog({
item,
onOpenChange,
extraBody,
footerStart,
}: {
item: AnalysisResultCardItem | null;
onOpenChange: (open: boolean) => void;
extraBody?: ReactNode;
footerStart?: ReactNode;
}) {
const artifactThreadId = item?.artifactThreadId || null;
const artifactPathValue = item?.artifactPath || "";
const artifactContentQuery = useQuery({
queryKey: [
"analysis-results",
"deliverable-artifact-content",
artifactThreadId,
artifactPathValue,
],
queryFn: async () => {
if (!artifactThreadId || !artifactPathValue) return "";
const result = await loadArtifactContent({
filepath: artifactPathValue,
threadId: artifactThreadId,
isMock: false,
});
return result.content;
},
enabled: Boolean(item && artifactThreadId && artifactPathValue),
staleTime: 30_000,
});
const loadedArtifactContent =
typeof artifactContentQuery.data === "string" ? artifactContentQuery.data : "";
const loadedArtifactIsError = isArtifactErrorContent(loadedArtifactContent);
const artifactContent =
loadedArtifactContent.trim() && !loadedArtifactIsError ? loadedArtifactContent : null;
const displayedContent = artifactContent || fallbackContent(item);
const artifactLoadFailed =
(artifactContentQuery.isError || loadedArtifactIsError) &&
!item?.artifactContent?.trim();
return (
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName="!z-[6000]"
className="!z-[6001] flex max-h-[86vh] w-[900px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="flex items-center gap-2 text-base">
<FileOutput className="size-4 text-primary" />
交付产物详情
</DialogTitle>
</DialogHeader>
{!item ? null : (
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{desensitize(item.productName)}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{desensitize(item.agentName)} · {desensitize(item.positionName)}
</p>
</div>
{extraBody}
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">产物内容</p>
</div>
<div className="h-[52vh] min-h-[320px] overflow-hidden rounded-lg border border-slate-100 bg-slate-50/60 dark:border-[#2b8ffe]/20 dark:bg-[#021d3d]/70">
{artifactContentQuery.isLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground dark:text-[#8aa0c4]">
<Loader2 className="size-4 animate-spin" />
正在加载 Markdown 文件内容…
</div>
) : (
<ArtifactFilePreview
content={displayedContent}
language="markdown"
threadId={artifactThreadId ?? ""}
/>
)}
</div>
{artifactLoadFailed && (
<p className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
Markdown 文件内容加载失败,当前展示的是已保存正文。
</p>
)}
</div>
</div>
)}
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
{footerStart}
<TButton variant="outline" onClick={() => onOpenChange(false)}>
关闭
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,130 @@
.analysis-result-card-list {
display: grid;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-card {
position: relative;
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 102px;
box-sizing: border-box;
padding: 40px 12px 12px;
border: 1px solid rgba(85, 166, 254, 0.1);
border-radius: 4px;
background: rgba(0, 98, 217, 0.1);
color: #ffffff;
text-align: left;
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.position-roundtable-analysis-result-card:hover,
.position-roundtable-analysis-result-card:focus-visible {
border-color: #0062d9;
background: rgba(0, 98, 217, 0.3);
box-shadow: 0 4px 14px rgba(0, 44, 110, 0.18);
outline: none;
}
.position-roundtable-analysis-result-card-body {
position: relative;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-tag {
position: absolute;
top: -40px;
left: -12px;
display: inline-flex;
width: fit-content;
max-width: calc(100% + 12px);
height: 26px;
align-items: center;
box-sizing: border-box;
padding: 0 22px 0 12px;
overflow: hidden;
border-radius: 4px 0 0 0;
background: rgba(212, 120, 0, 0.5);
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
line-height: normal;
white-space: nowrap;
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
}
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
.position-roundtable-analysis-result-tag span {
position: relative;
z-index: 1;
box-sizing: border-box;
min-width: 0;
max-width: 100%;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
}
.position-roundtable-analysis-result-product {
margin: 0;
overflow: hidden;
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 18px;
font-weight: 500;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
margin-top: 6px;
color: #80b7ff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
font-weight: 300;
line-height: 24px;
}
.position-roundtable-analysis-result-meta span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta img {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.position-roundtable-analysis-result-meta i {
width: 1px;
height: 14px;
flex: 0 0 auto;
background: rgba(128, 183, 255, 0.45);
}
.position-roundtable-analysis-results-empty {
color: #80b7ff;
}
@@ -0,0 +1,3 @@
export { AnalysisResultCardList } from "./AnalysisResultCardList";
export { AnalysisResultDetailDialog } from "./AnalysisResultDetailDialog";
export type { AnalysisResultCardItem } from "./types";
@@ -0,0 +1,17 @@
export interface AnalysisResultCardItem {
id: string;
agentName: string;
productName: string;
positionName: string;
updatedAt?: string;
/** Shown when `updatedAt` is missing. */
fallbackTime?: string;
/** Clickable preview is available. */
delivered: boolean;
artifactPath?: string;
artifactMimeType?: string | null;
artifactThreadId?: string | null;
artifactContent?: string | null;
summary?: string;
unavailableMessage?: string;
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
/**
* Sandboxed preview of a generated HTML document.
@@ -26,7 +26,7 @@ export function HtmlPreviewFrame({
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
@@ -10,6 +10,7 @@ import {
FolderOpen,
Gauge,
ListTree,
MessageSquareText,
MousePointerClick,
BriefcaseBusiness,
Settings,
@@ -237,6 +238,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/fixed-questions")}
>
<MessageSquareText className="size-4" />
问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/position-roles")}
@@ -558,6 +567,12 @@ function LegacyAccordionSidebar({
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/fixed-questions")}>
<MessageSquareText className="size-4" />
问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
<BriefcaseBusiness className="size-4" />
+3 -1
View File
@@ -27,7 +27,9 @@ import {
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
// Keep the workbench navigation compact across every theme. The visual theme
// only changes tokens; the information density should remain consistent.
const SIDEBAR_WIDTH = "14rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@@ -0,0 +1,71 @@
"use client";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { reasoningPlugins } from "@/core/streamdown";
const lightTheme = basicLightInit({ settings: { background: "transparent", fontSize: "14px" } });
const darkTheme = monokaiInit({
settings: { background: "transparent", gutterBackground: "transparent", fontSize: "14px" },
});
/** Markdown-aware source editor shared by Wiki create/edit dialogs. */
export function MarkdownCodeEditor({
value,
onChange,
className,
}: {
value: string;
onChange: (value: string) => void;
className?: string;
}) {
const { resolvedTheme } = useTheme();
const extensions = useMemo(
() => [markdown({ base: markdownLanguage, codeLanguages: languages })],
[],
);
return (
<div className={cn("grid min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
<section className="flex min-h-0 flex-col border-b lg:border-r lg:border-b-0">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
Markdown 编辑
</div>
<CodeMirror
value={value}
onChange={onChange}
theme={resolvedTheme === "dark" ? darkTheme : lightTheme}
extensions={extensions}
basicSetup={{
foldGutter: true,
highlightActiveLine: true,
highlightActiveLineGutter: false,
lineNumbers: true,
}}
className="min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
/>
</section>
<section className="flex min-h-0 flex-col bg-muted/10">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
实时渲染
</div>
<div className="min-h-[42vh] flex-1 overflow-auto p-4">
<MarkdownContent
content={value || "*暂无正文*"}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</div>
</section>
</div>
);
}
@@ -77,7 +77,7 @@ export function WikiHeader({
<Menu className="size-5" />
</button>
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
问答梳理
知识库管理
</div>
</div>
@@ -204,7 +204,7 @@ export function noteFolderSegments(note: KnowledgeNote): string[] {
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
return ["知识库管理", ...noteFolderSegments(note).map(localizeFolderName)];
}
function sortTree(nodes: TreeNode[]): void {
@@ -0,0 +1,55 @@
"use client";
import agentAvatarDark from "@/assets/img/agent-avatar.png";
import agentAvatarLight from "@/assets/img/agent-avatar-red.png";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
type AgentBrandIconProps = {
className?: string;
size?: "nav" | "sm" | "md" | "lg";
};
const SIZE_STYLES = {
nav: {
frame: "size-[22px] rounded-md",
},
sm: {
frame: "size-10 rounded-full",
},
md: {
frame: "size-12 rounded-full",
},
lg: {
frame: "size-16 rounded-full",
},
} as const;
export function AgentBrandIcon({
className,
size = "md",
}: AgentBrandIconProps) {
const styles = SIZE_STYLES[size];
const { theme } = useTheme();
const avatarSrc =
theme === "light" ? agentAvatarLight : agentAvatarDark;
return (
<span
aria-hidden="true"
data-agent-brand-icon={size}
className={cn(
"relative inline-flex shrink-0 overflow-hidden rounded-full",
styles.frame,
className,
)}
>
<img
src={avatarSrc}
alt=""
className="size-full object-contain"
draggable={false}
/>
</span>
);
}
@@ -1,13 +1,13 @@
"use client";
import {
BotIcon,
import {
ClockIcon,
CopyIcon,
EyeOffIcon,
GlobeIcon,
LockIcon,
MessageSquareIcon,
MessageSquareIcon,
MoreHorizontalIcon,
PinIcon,
ShieldIcon,
SparklesIcon,
@@ -28,7 +28,7 @@ import {
} from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -38,14 +38,21 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -59,7 +66,8 @@ import { Textarea } from "@/components/ui/textarea";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -548,10 +556,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
{isOwned && (
<Badge
variant="default"
className="border border-emerald-200 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300"
>
我的
</Badge>
@@ -559,7 +567,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
>
来自他人
@@ -568,7 +576,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
title="该智能体已对所有用户可见"
>
<GlobeIcon className="size-3" />
@@ -663,8 +671,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
node: (
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
node: (
<Badge
key={`tg:${group}`}
variant="outline"
className="border-0 bg-primary/5 text-primary hover:bg-primary/10 px-2.5 py-1 text-xs font-normal"
>
{desensitize(group)}
</Badge>
),
@@ -674,8 +686,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
node: (
<Badge
key={`sk:${skill}`}
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
variant="secondary"
className="bg-primary/5 text-primary hover:bg-primary/10 cursor-pointer border-0 px-2.5 py-1 text-xs font-normal"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
@@ -823,80 +835,185 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</>
);
const chatButton = (
<Button
size="sm"
className={cn(isList ? "shrink-0" : "w-full")}
onClick={handleChat}
const chatButton = (
<Button
variant={isList ? "default" : "outline"}
size="sm"
className={cn(
isList
? "bg-primary hover:bg-primary/90 shrink-0 rounded-xl px-4 font-medium text-primary-foreground"
: "border-primary/35 text-primary hover:border-primary/55 hover:bg-primary/5 h-11 w-full rounded-lg bg-white font-medium shadow-none dark:bg-slate-900 dark:text-primary",
)}
onClick={handleChat}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
开始对话
</Button>
);
);
const cardStatusBadge = agent.is_private ? (
<Badge className="border-0 bg-purple-50 px-2.5 py-1 text-xs font-normal text-purple-600 hover:bg-purple-50 dark:bg-purple-500/15 dark:text-purple-300">
<span className="mr-1.5 size-1.5 rounded-full bg-purple-500" />
私密
</Badge>
) : agent.published || isBuiltin ? (
<Badge className="border-0 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-normal dark:bg-primary/15 dark:text-primary">
<span className="bg-primary mr-1.5 size-1.5 rounded-full" />
已发布
</Badge>
) : (
<Badge className="border-0 bg-emerald-50 px-2.5 py-1 text-xs font-normal text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300">
<span className="mr-1.5 size-1.5 rounded-full bg-emerald-500" />
运行中
</Badge>
);
const updatedAtText = formatDateTime(agent.updated_at ?? agent.created_at);
const cardActionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-8 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-white"
title="更多操作"
>
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{canFavorite && (
<DropdownMenuItem onSelect={() => void handleToggleFavorite()}>
<StarIcon className={cn("size-4", isFavorite && "fill-current text-amber-500")} />
{isFavorite ? "取消收藏" : "收藏到我的"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void handleTogglePin()}>
<PinIcon className={cn("size-4", isPinned && "fill-current text-emerald-600")} />
{isPinned ? "取消置顶" : "置顶"}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onSelect={() => void handleToggleFeatured()}>
<SparklesIcon className="size-4" />
{agent.featured_order != null ? "取消推荐" : "设为推荐"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setTagPickerOpen(true)}>
<TagsIcon className="size-4" />
{t.adminTags.manageTags}
</DropdownMenuItem>
{isOwned && (
<DropdownMenuItem onSelect={() => void handleTogglePublished()}>
{agent.published ? <LockIcon className="size-4" /> : <GlobeIcon className="size-4" />}
{agent.published ? "取消发布" : "发布"}
</DropdownMenuItem>
)}
{(isOwned || isAdmin) && (
<DropdownMenuItem onSelect={() => void handleTogglePrivate()}>
<ShieldIcon className="size-4" />
{agent.is_private ? "移出私密广场" : "放入私密广场"}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDuplicate()}>
<CopyIcon className="size-4" />
复制智能体
</DropdownMenuItem>
{canEdit && (
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
<SquarePenIcon className="size-4" />
编辑智能体
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" />
{t.agents.delete}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
const cardLayout = (
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
<CardHeader className="min-w-0 pb-2">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{desensitize(agent.model)}
</Badge>
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{statusBadges}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
)}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
const cardLayout = (
<Card
className={cn(
"group flex min-h-[334px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-primary/30 hover:!bg-primary/5 hover:!ring-1 hover:!ring-primary/10 hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:border-slate-800 dark:bg-slate-900",
agent.featured_order != null && "border-primary/40 ring-1 ring-primary/15 dark:border-primary/45 dark:ring-primary/20",
)}
data-agent-guide="agent-card"
>
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
{agent.featured_order != null ? (
<>
<div className="mb-1 flex h-7 items-center justify-between">
<Badge className="border border-primary/20 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-medium dark:border-primary/40 dark:bg-primary/15 dark:text-primary">
<SparklesIcon className="mr-1 size-3.5 fill-current" />
推荐
</Badge>
{cardActionsMenu}
</div>
<AgentBrandIcon size="lg" />
</>
) : (
<div className="flex items-start justify-between">
<AgentBrandIcon size="lg" />
{cardActionsMenu}
</div>
)}
<CardTitle
className="mt-3 block w-full min-w-0 overflow-hidden text-[18px] leading-6 font-semibold tracking-[-0.015em] text-ellipsis whitespace-nowrap text-slate-950 dark:text-white"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.description && (
<CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="px-5 pt-0 pb-1.5">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{hasSkillsOrTools && (
<CardContent className="pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} />
</CardContent>
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
{showActionRow && (
<div className="flex flex-wrap items-center justify-end gap-1">
{actionButtons}
</div>
)}
{chatButton}
</CardFooter>
<CardContent className="px-5 pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} />
</CardContent>
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-4 px-5 pt-2 pb-4">
<div className="flex min-w-0 items-center gap-3">
{cardStatusBadge}
<span
className="min-w-0 truncate text-xs text-slate-400"
title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
>
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
</span>
</div>
{chatButton}
</CardFooter>
</Card>
);
const listLayout = (
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div
className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-primary/25 hover:shadow-[0_12px_32px_rgba(15,23,42,0.09)] dark:border-slate-800 dark:bg-slate-900"
data-agent-guide="agent-card"
>
<AgentBrandIcon className="agent-avatar" size="md" />
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2">
<span
@@ -980,7 +1097,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="max-h-[45vh] min-h-48 resize-y text-xs"
className="max-h-[45vh] min-h-48 resize-y text-xs"
/>
</div>
<div className="space-y-2">
@@ -72,6 +72,7 @@ import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
bootstrapResumePath,
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
sortThreadsPinnedFirst,
@@ -372,17 +373,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
// (带机器人图标、点击回创建页);置顶优先。
// 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致;
// 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。
const visibleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
@@ -2,6 +2,7 @@
import {
BotIcon,
BriefcaseBusinessIcon,
CheckIcon,
DatabaseIcon,
GlobeIcon,
@@ -143,6 +144,8 @@ export function AgentDetailPanel({
onAskQuestion,
disabled,
defaultCollapsed,
collapsed: collapsedProp,
onCollapsedChange,
showRecentChats,
compactAppearanceActions,
avatarSrc,
@@ -150,6 +153,7 @@ export function AgentDetailPanel({
skillsIcon,
skillsTitle,
showCommonQuestions = false,
jobResponsibilities,
panelTitle,
embedded,
hideStatusBadges = false,
@@ -161,6 +165,14 @@ export function AgentDetailPanel({
disabled?: boolean;
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
defaultCollapsed?: boolean;
/**
* 受控折叠态:传入时由父级持有状态(智能体问答页把展开/收起按钮放在顶部
* 「新对话」右侧)。此时收起 = 整列宽度动画归零(不保留细条),面板内置的
* 收起按钮不再渲染。不传则沿用面板内置状态 + 细条收起交互(会商等工作区)。
*/
collapsed?: boolean;
/** 受控折叠态变化回调(配合 `collapsed` 使用)。 */
onCollapsedChange?: (collapsed: boolean) => void;
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
showRecentChats?: boolean;
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
@@ -170,6 +182,11 @@ export function AgentDetailPanel({
skillsIcon?: ReactNode;
skillsTitle?: string;
showCommonQuestions?: boolean;
/**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
* 不传则隐藏该区块。此字段来自业务链,不随智能体本身编辑。
*/
jobResponsibilities?: string | null;
/** Overrides the standard panel heading for an embedded consumer. */
panelTitle?: string;
/** Renders one fixed panel instead of the responsive collapsible rail. */
@@ -180,7 +197,15 @@ export function AgentDetailPanel({
editIcon?: ReactNode;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
// 折叠态:受控(collapsedProp 传入,智能体问答页)优先;否则面板内置(会商等工作区)。
const [internalCollapsed, setInternalCollapsed] = useState(
defaultCollapsed ?? false,
);
const collapsed = collapsedProp ?? internalCollapsed;
const setCollapsed = (next: boolean) => {
if (collapsedProp === undefined) setInternalCollapsed(next);
onCollapsedChange?.(next);
};
const [mobileOpen, setMobileOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<AgentDraft | null>(null);
@@ -230,9 +255,10 @@ export function AgentDetailPanel({
const isSaving = updateAgent.isPending || setFeatured.isPending;
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
// 信息面板给预览让位;切换到新文件时同样收起。编辑中不打断用户,用户随时
// 可手动重新展开。
// 对话生成文件(md/txt 等 artifact)并首次展开预览时,自动收起智能体
// 信息面板给预览让位。目录内切换文件不能再次改写用户的手动选择:否则用户
// 刚展开智能体信息,点另一个文件时面板又会突然收起,看起来就像收起按钮被
// 移除了。编辑中也不打断用户。
//
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
@@ -241,20 +267,19 @@ export function AgentDetailPanel({
const artifactsCtx = useArtifactsOptional();
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
// preOpen:流式自动开沙箱的两阶段标记 —— 沙箱真正展开(open 翻转)前就
// 先收起本面板,让收起布局先落地,避免与沙箱展开同帧叠加造成卡顿。
const artifactPanelVisible =
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0);
(!!artifactsCtx?.open || !!artifactsCtx?.preOpen) &&
(!!selectedArtifact || artifactCount > 0);
const prevArtifactPanelVisible = useRef(false);
const prevSelectedArtifact = useRef<string | null>(null);
useEffect(() => {
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
const switched =
artifactPanelVisible && selectedArtifact !== prevSelectedArtifact.current;
prevArtifactPanelVisible.current = artifactPanelVisible;
prevSelectedArtifact.current = selectedArtifact;
if ((opened || switched) && !editing) {
if (opened && !editing) {
setCollapsed(true);
}
}, [artifactPanelVisible, selectedArtifact, editing]);
}, [artifactPanelVisible, editing]);
const displayName = desensitize(agent?.name ?? agentName);
const questions = useMemo(
@@ -720,6 +745,23 @@ export function AgentDetailPanel({
</section>
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
{jobResponsibilities !== undefined && (
<Section
icon={<BriefcaseBusinessIcon className="size-3.5" />}
title="岗位职责"
>
{jobResponsibilities?.trim() ? (
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
{desensitize(jobResponsibilities)}
</p>
) : (
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
)}
</Section>
)}
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
<Section
@@ -905,14 +947,18 @@ export function AgentDetailPanel({
const panelChrome = (collapseButton: ReactNode) => (
<>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
{/* 标题不参与压缩(此前被简洁模式外观控件挤成单字);空间不足时只
裁剪 compactAppearanceActions,编辑等主操作与标题始终完整。 */}
<span className="shrink-0 text-sm font-semibold whitespace-nowrap">
{panelTitle ?? "智能体信息"}
</span>
<div className="flex min-w-0 items-center gap-1">
{headerActions}
{compactAppearanceActions}
{collapseButton}
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
<div className="flex shrink-0 items-center gap-1">{headerActions}</div>
<div className="flex min-w-0 items-center justify-end gap-1 overflow-hidden">
{compactAppearanceActions}
</div>
{collapseButton && <div className="shrink-0">{collapseButton}</div>}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
@@ -921,21 +967,48 @@ export function AgentDetailPanel({
return (
<>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{/* ── Desktop (lg+) ────────────────────────────────────────────────── */}
{embedded ? (
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
{panelChrome(null)}
</aside>
) : collapsedProp !== undefined ? (
/* 受控模式(智能体问答页):收起时整列宽度动画归零(含左边框),不再保留细条;
展开态的「收起」按钮在面板右上角,收起态的「展开」按钮在页面
header「新对话」按钮右侧。 */
<div
className={cn(
"hidden h-full shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out lg:block",
collapsed ? "w-0" : "w-[360px]",
)}
>
<aside className="bg-card/40 flex h-full w-[360px] flex-col border-l">
{panelChrome(
<Tooltip content="收起">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(true)}
aria-label="收起智能体信息"
aria-expanded={true}
>
<PanelRightCloseIcon className="size-4" />
</Button>
</Tooltip>,
)}
</aside>
</div>
) : collapsed ? (
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
<div className="flex items-center gap-1">
{compactAppearanceActions}
<Tooltip content="智能体信息">
<Tooltip content="展开智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(false)}
aria-label="展开智能体信息"
aria-expanded={false}
>
<PanelRightOpenIcon className="size-4" />
</Button>
@@ -951,6 +1024,7 @@ export function AgentDetailPanel({
size="icon-sm"
onClick={() => setCollapsed(true)}
aria-label="收起智能体信息"
aria-expanded={true}
>
<PanelRightCloseIcon className="size-4" />
</Button>
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -21,6 +21,11 @@ import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/s
import { AgentCard } from "./agent-card";
import { SentimentAgentCard } from "./sentiment-agent-card";
import {
AgentGuide,
isAgentGuideEnvironment,
startAgentGuide,
} from "./agent-guide";
const PAGE_SIZE = 12;
@@ -346,15 +351,22 @@ export function AgentGallery() {
请输入访问密码,验证通过后才能查看其中的智能体。
</p>
</div>
<div className="flex flex-col gap-2">
<form
className="flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSubmitPassword();
}}
>
<label className="sr-only" htmlFor="private-square-access-code">
访问密码
</label>
<Input
id="private-square-access-code"
type="password"
placeholder="访问密码"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitPassword();
}}
// This is a one-off access code, not a login credential. Tell
// browsers / password managers not to save or autofill it (which
// would also stop them from spilling a saved account into the
@@ -365,8 +377,8 @@ export function AgentGallery() {
data-lpignore="true"
data-form-type="other"
/>
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
</div>
<Button type="submit">进入私密广场</Button>
</form>
</div>
);
}
@@ -391,10 +403,22 @@ export function AgentGallery() {
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title="重新播放智能体使用引导"
onClick={() => startAgentGuide("list")}
>
<Compass className="mr-1.5 h-4 w-4" />
使用引导
</Button>
<Button onClick={handleNewAgent} data-agent-guide="new-agent-btn">
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
</div>
{/* Tabs */}
@@ -414,7 +438,10 @@ export function AgentGallery() {
>
<div className="mx-6 mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
<TabsList
className="w-auto max-w-full flex-wrap justify-start self-start"
data-agent-guide="tabs"
>
<TabsTrigger value="mine">我的</TabsTrigger>
{defaultSquare && (
<TabsTrigger
@@ -468,7 +495,8 @@ export function AgentGallery() {
</div>
</div>
{showSearchBar && (
<TagSearchBar
<div data-agent-guide="search">
<TagSearchBar
// Remount per tab so the internal keyword input / 展开 state also
// resets when the parent-owned filters are cleared on tab switch.
key={activeTab}
@@ -499,6 +527,7 @@ export function AgentGallery() {
</Select>
}
/>
</div>
)}
{publisher && (
<div className="flex items-center gap-2">
@@ -586,6 +615,11 @@ export function AgentGallery() {
</TabsContent>
)}
</Tabs>
<AgentGuide
stage="list"
eligible={isAgentGuideEnvironment()}
autoStart={false}
/>
</div>
);
}
@@ -0,0 +1,331 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Guide, type GuideStep } from "tdesign-react";
import { env } from "@/env";
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import { isWujieEnv } from "@/core/embed/wujie";
/**
* 智能体使用引导(三段式,基于 TDesign React 的 Guide 组件),完整覆盖
* 「浏览管理 → 创建与配置 → 发起问答」流程:
*
* - list :智能体列表页(/page/workspace/agents)——分区、搜索排序、卡片操作、
* 创建入口;完成 / 跳过就地结束,不跳转创建页(创建页有自己的一段
* 引导,点「创建智能体」进入时自动演示)。
* - create :创建页(/page/workspace/agents/new)——对话式创建、基本信息、模型、
* 知识空间、SOUL.md、技能、保存。
* - chat :智能体对话页(/page/workspace/agents/:id/chats/...)——侧栏切换与
* 历史、常用问题、输入框、右侧详情面板。
*
* 记忆语义与简洁模式引导(compact-mode-guide.tsx)一致:每个阶段各自记一个
* localStorage 标记,**只有用户显式点「完成」或「跳过」才写入**;中途刷新/离开
* 不记忆,下次进入该页面还会自动演示。各页头部的「使用引导」按钮可随时强制重放。
*
* 自动演示范围:只有 create 段(进入新建页)与 chat 段自动弹;list 段挂在
* 智能体列表页但不自动演示(`autoStart={false}`,避免一进列表页就弹引导),
* 仅由头部「使用引导」按钮手动触发。
*
* 高亮目标通过 `data-agent-guide` 标记在各组件上;启动时按目标是否存在过滤步骤
* (如新用户无智能体卡片、无预设常用问题时自动剔除该步)——Guide 对找不到的
* 目标元素会抛错,不能盲传。
*/
export type AgentGuideStage = "list" | "create" | "chat";
const finishedKey = (stage: AgentGuideStage) =>
`deerflow.agent-guide.${stage}.finished`;
const startEventName = (stage: AgentGuideStage) =>
`agent-guide:start:${stage}`;
/** 手动触发某阶段的引导(各页面头部「使用引导」按钮调用,忽略已看过记忆)。 */
export function startAgentGuide(stage: AgentGuideStage) {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(startEventName(stage)));
}
}
/** 环境闸门:iframe 嵌入 / 去 chrome / 强制收起侧栏(无界)等环境不弹引导。 */
export function isAgentGuideEnvironment(): boolean {
return (
!env.FORCE_COLLAPSED_SIDEBAR &&
!isEmbedActive() &&
!isHideChromeActive() &&
!isWujieEnv()
);
}
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
const TARGET = {
// list 阶段(智能体列表页)
tabs: '[data-agent-guide="tabs"]',
search: '[data-agent-guide="search"]',
agentCard: '[data-agent-guide="agent-card"]',
newAgentBtn: '[data-agent-guide="new-agent-btn"]',
// create 阶段(创建页)
createChatInput: '[data-agent-guide="create-chat-input"]',
createBasic: '[data-agent-guide="create-basic"]',
createModel: '[data-agent-guide="create-model"]',
createKnowledge: '[data-agent-guide="create-knowledge"]',
createSoul: '[data-agent-guide="create-soul"]',
createSkills: '[data-agent-guide="create-skills"]',
createSaveBtn: '[data-agent-guide="create-save-btn"]',
// chat 阶段(智能体对话页)
chatSidebar: '[data-agent-guide="chat-sidebar"]',
chatQuestions: '[data-agent-guide="chat-questions"]',
chatInput: '[data-agent-guide="chat-input"]',
chatDetail: '[data-agent-guide="chat-detail"]',
} as const;
// 等布局稳定后再启动:Guide 只在步骤切换时测量目标位置,起步太早会把高亮框
// 定位到过渡中的坐标且不会自行纠正。
const START_DELAY_MS = 800;
interface StepSpec {
/** 高亮目标选择器;缺省(dialog 步骤)以 body 兜底。 */
selector?: string;
mode: "popup" | "dialog";
placement: NonNullable<GuideStep["placement"]>;
title: string;
body: string;
}
const LIST_STEPS: StepSpec[] = [
{
mode: "dialog",
placement: "center",
title: "欢迎使用智能体",
body: "这里是你的智能体中心。接下来花几分钟走完整个流程:浏览与管理智能体 → 创建与配置 → 发起问答。",
},
{
selector: TARGET.tabs,
mode: "popup",
placement: "bottom",
title: "分区浏览",
body: "「我的」放着你创建和收藏的智能体;各「广场」是他人发布共享的,可直接使用或收藏;「私密广场」需要密码访问(管理员还可以查看「内置智能体」)。",
},
{
selector: TARGET.search,
mode: "popup",
placement: "bottom",
title: "搜索与排序",
body: "支持关键词、标签搜索,可按最新创建、最近更新、名称排序;右侧按钮可在卡片 / 列表两种视图间切换。",
},
{
selector: TARGET.agentCard,
mode: "popup",
placement: "top",
title: "智能体卡片",
body: "卡片展示名称、简介、模型与技能徽章;底部操作栏支持收藏、置顶、打标签、发布到广场、复制、编辑、删除;点「对话」直接进入该智能体的问答。",
},
{
selector: TARGET.newAgentBtn,
mode: "popup",
placement: "bottom-left",
title: "创建智能体",
body: "准备好了就点这里,开始创建你的专属智能体;进入创建页后还会有一段配置引导。",
},
];
const CREATE_STEPS: StepSpec[] = [
{
mode: "dialog",
placement: "center",
title: "创建智能体",
body: "页面分两栏:左侧直接与模型对话,让它帮你配置;右侧表单手动填写。两边内容实时联动,选顺手的方式即可。",
},
{
selector: TARGET.createChatInput,
mode: "popup",
placement: "top",
title: "对话式创建",
body: "在这里描述你想要的智能体,例如「帮我做一个周报助手,语气正式、要点条目化」。模型会追问细节并给出配置方案,你确认后方案自动填入右侧表单,可继续微调。",
},
{
selector: TARGET.createBasic,
mode: "popup",
placement: "left",
title: "基本信息",
body: "「名称」显示在卡片和对话页;「描述」用一句话说明它擅长什么,方便日后挑选。",
},
{
selector: TARGET.createModel,
mode: "popup",
placement: "left",
title: "运行模型与入库",
body: "「模型」选择该智能体被使用时调用的模型;「入库模板名」配置后,对话生成的文档可一键发布入库(不需要可留空)。",
},
{
selector: TARGET.createKnowledge,
mode: "popup",
placement: "left",
title: "知识空间",
body: "为智能体挂载知识库,回答时会优先检索其中的资料,让结果更贴合你的业务数据。",
},
{
selector: TARGET.createSoul,
mode: "popup",
placement: "left",
title: "SOUL.md · 人设",
body: "智能体的「灵魂文件」——写清角色定位、能力边界、工作步骤和输出格式。这份提示词直接决定回答质量,是最值得花时间打磨的配置。",
},
{
selector: TARGET.createSkills,
mode: "popup",
placement: "left",
title: "技能挂载",
body: "从可用技能中勾选要赋予的能力,已选技能会作为工具在对话中被调用;全部不选则是纯对话型智能体。",
},
{
selector: TARGET.createSaveBtn,
mode: "popup",
placement: "top",
title: "创建并保存",
body: "配置完成后点这里保存(左侧对话只是试用场,内容不会自动保存)。保存成功会回到智能体列表。",
},
{
mode: "dialog",
placement: "center",
title: "开始问答",
body: "回到列表页后,点智能体卡片上的「对话」按钮即可开始问答;进入对话页还会有最后一段引导。",
},
];
const CHAT_STEPS: StepSpec[] = [
{
mode: "dialog",
placement: "center",
title: "智能体问答",
body: "一切就绪!这里就是与智能体对话的工作区,快速认识一下。",
},
{
selector: TARGET.chatSidebar,
mode: "popup",
placement: "right",
title: "切换与历史",
body: "左栏顶部可在「我的 / 广场」智能体之间切换;下方是当前智能体的历史会话,点击任意一条继续上次的对话。",
},
{
selector: TARGET.chatQuestions,
mode: "popup",
placement: "bottom",
title: "常用问题",
body: "智能体预设的开场问题,点击即可直接提问,快速体验它的能力。",
},
{
selector: TARGET.chatInput,
mode: "popup",
placement: "top",
title: "输入框",
body: "输入问题后发送即可开始问答;新对话里还可以挂载知识空间,让回答优先检索知识库。",
},
{
selector: TARGET.chatDetail,
mode: "popup",
placement: "left",
title: "智能体详情",
body: "右侧面板展示当前智能体的配置(人设、技能、常用问题),可以直接修改并即时生效;里面的快捷提问也能一键发送。",
},
];
const STAGE_STEPS: Record<AgentGuideStage, StepSpec[]> = {
list: LIST_STEPS,
create: CREATE_STEPS,
chat: CHAT_STEPS,
};
function isBrowser(): boolean {
return typeof window !== "undefined";
}
function isStageFinished(stage: AgentGuideStage): boolean {
return (
isBrowser() && localStorage.getItem(finishedKey(stage)) === "1"
);
}
function markStageFinished(stage: AgentGuideStage) {
if (isBrowser()) localStorage.setItem(finishedKey(stage), "1");
}
function buildSteps(stage: AgentGuideStage): GuideStep[] {
if (!isBrowser()) return [];
return STAGE_STEPS[stage]
.filter((spec) => !spec.selector || document.querySelector(spec.selector))
.map((spec) => ({
element: spec.selector ?? (() => document.body),
mode: spec.mode,
placement: spec.placement,
title: spec.title,
body: spec.body,
}));
}
/**
* 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为 false 时完全
* 静默(不监听、不自动弹)。挂在三个页面各自的作用域内;`autoStart` 为 false
* 时仅响应手动触发(列表页不自动弹,进新建页才自动演示)。
*/
export function AgentGuide({
stage,
eligible,
autoStart = true,
}: {
stage: AgentGuideStage;
eligible: boolean;
autoStart?: boolean;
}) {
const [current, setCurrent] = useState(-1);
const stepsRef = useRef<GuideStep[]>([]);
const start = useCallback(() => {
const steps = buildSteps(stage);
if (steps.length === 0) return;
stepsRef.current = steps;
setCurrent(0);
}, [stage]);
// 自动演示:没显式结束过(没点过完成/跳过)时,每次进入该页都演示;是否
// 「已看过」只由 finish 回调(完成/跳过)写入。
const autoTriedRef = useRef(false);
useEffect(() => {
if (!eligible || !autoStart || autoTriedRef.current) return;
autoTriedRef.current = true;
if (isStageFinished(stage)) return;
const timer = setTimeout(start, START_DELAY_MS);
return () => {
clearTimeout(timer);
// 组件在计时窗口内卸载(用户迅速离开页面)则下次进入再演示。
autoTriedRef.current = false;
};
}, [eligible, autoStart, stage, start]);
// 手动触发:页面头部「使用引导」按钮广播事件,忽略已看过记忆强制重放。
useEffect(() => {
if (!eligible) return;
const handler = () => start();
window.addEventListener(startEventName(stage), handler);
return () =>
window.removeEventListener(startEventName(stage), handler);
}, [eligible, stage, start]);
// 完成 / 跳过都是用户显式结束引导,就地关闭并记忆「已看过」,不跳转任何页面。
const finish = useCallback(() => {
markStageFinished(stage);
setCurrent(-1);
}, [stage]);
if (!eligible) return null;
return (
<Guide
current={current}
steps={stepsRef.current}
onChange={setCurrent}
onFinish={finish}
onSkip={finish}
/>
);
}
@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useThreadsPage } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -22,12 +22,9 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
pageSize: RECENT_LIMIT,
metadata: { agent_id: agentId },
});
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
// 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。
const threads = (data?.threads ?? []).filter(
(th) =>
th.metadata?.thread_type !== "bootstrap" &&
th.metadata?.thread_type !== "scheduler" &&
th.metadata?.thread_type !== "roundtable",
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
);
return (
@@ -22,7 +22,7 @@ import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
/**
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
* 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
* 对话经 Gateway 转发外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
*/
export function SentimentAgentCard({
@@ -165,10 +165,26 @@ export function SkillPickerSheet({
return;
}
try {
const validation = await validateUpload.mutateAsync(file);
const validation = await validateUpload.mutateAsync({
file,
targetCategory: "custom",
});
if (validation.conflict) {
const owner = validation.owner_username ?? validation.owner_user_id;
if (validation.owned_by_other && owner) {
toast.error(
`技能「${validation.skill_name}」已由用户「${owner}」上传,不能覆盖。`,
);
return;
}
if (validation.owned_by_me && validation.can_overwrite) {
toast.warning(
`您已上传过同名技能「${validation.skill_name}」,可到「技能」设置页重复上传并覆盖原版本。`,
);
return;
}
toast.warning(
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
`已存在同名技能「${validation.skill_name}」,当前不可覆盖。`,
);
return;
}
@@ -14,11 +14,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import type { CompactShortcutRouteParam } from "@/core/system-settings/api";
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { isHideChromeActive } from "@/core/embed/embed-session";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
/**
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
@@ -47,6 +48,34 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground";
function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) {
const pathSegments = params
.filter((param) => param.paramType === "path")
.map((param) =>
resolveRouteParamValue({ ...param, paramType: "login" }).trim(),
)
.filter(Boolean)
.map((value) => encodeURIComponent(value));
if (pathSegments.length === 0) return url;
const hashIndex = url.indexOf("#");
if (hashIndex < 0) {
const [path, query = ""] = url.split("?", 2);
return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
const beforeHash = url.slice(0, hashIndex);
const fragment = url.slice(hashIndex + 1);
const [routePath, query = ""] = fragment.split("?", 2);
return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
function isQueryShortcutParam(
param: CompactShortcutRouteParam,
): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } {
return param.paramType === "login" || param.paramType === "theme";
}
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
const { data: appearance } = useAppearanceSettings();
const buttons = (appearance?.compact_shortcut_buttons ?? [])
@@ -69,9 +98,10 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
)}
title={button.label}
onClick={() => {
const routeParams = (button.route_params ?? []).map((param) => ({ ...param }));
const url = buildLightAppUrl({
url: button.url,
routeParams: button.route_params.map((param) => ({ ...param })),
url: appendShortcutPathParams(button.url, routeParams),
routeParams: routeParams.filter(isQueryShortcutParam),
});
window.open(url, "_blank", "noopener,noreferrer");
}}
@@ -122,6 +152,7 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
return (
<div
className="relative"
data-compact-guide="theme-switcher"
onMouseEnter={() => {
cancelClose();
setOpen(true);
@@ -221,6 +252,7 @@ export function LayoutModeSwitcher({
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
title="界面模式"
aria-label="界面模式"
data-compact-guide="layout-mode"
>
<TriggerIcon className="size-4" />
</Button>
@@ -0,0 +1,226 @@
import {
ChevronDownIcon,
ChevronRightIcon,
FolderIcon,
FolderOpenIcon,
RefreshCwIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { artifactPathname, artifactRelativePath } from "@/core/artifacts/listing";
import { getFileIcon, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
type DirectoryNode = {
name: string;
path: string;
directories: DirectoryNode[];
files: Array<{ artifact: string; name: string; relativePath: string }>;
};
function buildDirectoryTree(artifacts: string[]): DirectoryNode {
const root: DirectoryNode = {
name: "生成文件",
path: "",
directories: [],
files: [],
};
for (const artifact of artifacts) {
const relativePath = artifactRelativePath(artifact);
const parts = relativePath.split("/").filter(Boolean);
if (parts.length === 0) continue;
let current = root;
for (const directoryName of parts.slice(0, -1)) {
let child = current.directories.find((node) => node.name === directoryName);
if (!child) {
child = {
name: directoryName,
path: current.path ? `${current.path}/${directoryName}` : directoryName,
directories: [],
files: [],
};
current.directories.push(child);
}
current = child;
}
current.files.push({
artifact,
name: parts.at(-1) ?? getFileName(artifact),
relativePath,
});
}
const sort = (node: DirectoryNode) => {
node.directories.sort((left, right) =>
left.name.localeCompare(right.name, "zh-CN"),
);
node.files.sort((left, right) =>
left.name.localeCompare(right.name, "zh-CN"),
);
node.directories.forEach(sort);
};
sort(root);
return root;
}
function DirectoryBranch({
node,
depth,
collapsedDirectories,
onToggleDirectory,
selectedArtifact,
onSelect,
}: {
node: DirectoryNode;
depth: number;
collapsedDirectories: Set<string>;
onToggleDirectory: (path: string) => void;
selectedArtifact: string | null;
onSelect: (artifact: string) => void;
}) {
return (
<>
{node.directories.map((directory) => {
const collapsed = collapsedDirectories.has(directory.path);
return (
<div key={directory.path}>
<button
type="button"
className="text-muted-foreground hover:bg-muted/70 hover:text-foreground flex h-8 w-full items-center gap-1.5 rounded-md pr-2 text-left text-xs"
style={{ paddingLeft: `${8 + depth * 14}px` }}
onClick={() => onToggleDirectory(directory.path)}
title={directory.path}
>
{collapsed ? (
<ChevronRightIcon className="size-3.5 shrink-0" />
) : (
<ChevronDownIcon className="size-3.5 shrink-0" />
)}
{collapsed ? (
<FolderIcon className="size-4 shrink-0 text-amber-500" />
) : (
<FolderOpenIcon className="size-4 shrink-0 text-amber-500" />
)}
<span className="truncate">{directory.name}</span>
</button>
{!collapsed && (
<DirectoryBranch
node={directory}
depth={depth + 1}
collapsedDirectories={collapsedDirectories}
onToggleDirectory={onToggleDirectory}
selectedArtifact={selectedArtifact}
onSelect={onSelect}
/>
)}
</div>
);
})}
{node.files.map((file) => {
const selected =
!!selectedArtifact &&
artifactPathname(selectedArtifact) === artifactPathname(file.artifact);
return (
<button
key={file.relativePath}
type="button"
className={cn(
"hover:bg-muted/70 flex min-h-8 w-full items-center gap-2 rounded-md py-1 pr-2 text-left text-xs",
selected
? "bg-primary/10 text-primary font-medium"
: "text-foreground/85",
)}
style={{ paddingLeft: `${27 + depth * 14}px` }}
onClick={() => onSelect(file.artifact)}
title={file.relativePath}
aria-current={selected ? "page" : undefined}
>
<span className="shrink-0">{getFileIcon(file.artifact, "size-4")}</span>
<span className="truncate">{file.name}</span>
</button>
);
})}
</>
);
}
export function ArtifactDirectory({
className,
artifacts,
selectedArtifact,
onSelect,
onRefresh,
refreshing = false,
}: {
className?: string;
artifacts: string[];
selectedArtifact: string | null;
onSelect: (artifact: string) => void;
onRefresh?: () => void;
refreshing?: boolean;
}) {
const tree = useMemo(() => buildDirectoryTree(artifacts), [artifacts]);
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(
() => new Set(),
);
const toggleDirectory = (path: string) => {
setCollapsedDirectories((current) => {
const next = new Set(current);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
};
return (
<aside
className={cn(
"bg-muted/20 flex min-h-0 w-48 shrink-0 flex-col border-r xl:w-56",
className,
)}
aria-label="生成文件目录"
>
<div className="flex h-12 shrink-0 items-center justify-between gap-2 border-b px-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">生成文件</div>
<div className="text-muted-foreground text-[11px]">{artifacts.length} 个文件</div>
</div>
{onRefresh && (
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-7 shrink-0"
onClick={onRefresh}
disabled={refreshing}
title="刷新文件目录"
aria-label="刷新文件目录"
>
<RefreshCwIcon className={cn("size-3.5", refreshing && "animate-spin")} />
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{artifacts.length > 0 ? (
<DirectoryBranch
node={tree}
depth={0}
collapsedDirectories={collapsedDirectories}
onToggleDirectory={toggleDirectory}
selectedArtifact={selectedArtifact}
onSelect={onSelect}
/>
) : (
<div className="text-muted-foreground px-2 py-8 text-center text-xs leading-5">
生成的文件会显示在这里
</div>
)}
</div>
</aside>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -29,6 +29,7 @@ export function ArtifactFileList({
threadId,
onOpenArtifact,
onDownloadArtifact,
fileDescriptions,
}: {
className?: string;
files: string[];
@@ -36,6 +37,8 @@ export function ArtifactFileList({
onOpenArtifact?: (filepath: string) => void;
/** Optional host download action for virtual/session-owned artifacts. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional per-file secondary text, such as the report structure used. */
fileDescriptions?: Record<string, ReactNode>;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
@@ -102,10 +105,10 @@ export function ArtifactFileList({
<div className="absolute top-2 -left-0.5">
{getFileIcon(file, "size-6")}
</div>
</CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs">
{getFileExtensionDisplayName(file)} file
</CardDescription>
</CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs">
{fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`}
</CardDescription>
<CardAction className="row-span-1 self-center">
{file.endsWith(".skill") && (
<Button
@@ -1,13 +1,14 @@
import {
createContext,
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { useSidebarSafe } from "@/components/ui/sidebar";
type ReactNode,
} from "react";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env";
export function artifactPreviewKey(artifact: string): string {
@@ -26,11 +27,11 @@ export function artifactPreviewKey(artifact: string): string {
}
return artifact;
}
export interface ArtifactsContextType {
artifacts: string[];
setArtifacts: (artifacts: string[]) => void;
export interface ArtifactsContextType {
artifacts: string[];
setArtifacts: (artifacts: string[]) => void;
selectedArtifact: string | null;
selectedArtifactKey: string | null;
autoSelect: boolean;
@@ -38,46 +39,107 @@ export interface ArtifactsContextType {
/** Open/update a streamed write without overriding a manual close of that call. */
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
deselect: () => void;
open: boolean;
autoOpen: boolean;
setOpen: (open: boolean) => void;
}
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
undefined,
);
interface ArtifactsProviderProps {
children: ReactNode;
}
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
open: boolean;
autoOpen: boolean;
setOpen: (open: boolean) => void;
/**
* 流式写文件自动开沙箱的两阶段标记:openStreamedArtifact 先收起两侧侧栏
* (阶段一),等收起布局落定后再真正翻转 open(阶段二)。两阶段之间为
* true —— 右侧智能体信息面板据此提前收起,避免与沙箱展开同帧叠加造成卡顿。
*/
preOpen: boolean;
}
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
undefined,
);
interface ArtifactsProviderProps {
children: ReactNode;
}
// 流式写文件自动开沙箱的分步延迟:t=0 收起左侧聊天记录侧栏,延迟
// STREAMED_PREOPEN_DELAY_MS 后再收右侧智能体信息面板(两次收起错开、
// 不做「左边收完再收右边」的判断,只给固定延迟,避免收起瞬间同帧叠加
// 卡顿),到 STREAMED_OPEN_DELAY_MS(右侧 300ms 收起动画结束)才展开沙箱。
const STREAMED_PREOPEN_DELAY_MS = 200;
const STREAMED_OPEN_DELAY_MS = 500;
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
const normalized = Array.isArray(next) ? next : [];
setArtifactsState((previous) =>
previous.length === normalized.length &&
previous.every((artifact, index) => artifact === normalized[index])
? previous
: normalized,
);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpenState] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [preOpen, setPreOpen] = useState(false);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
const autoOpenRef = useRef(autoOpen);
const openStateRef = useRef(open);
const preOpenTimerRef = useRef<number | null>(null);
const openTimerRef = useRef<number | null>(null);
const sidebar = useSidebarSafe();
const select = useCallback(
const applyOpen = useCallback((next: boolean) => {
openStateRef.current = next;
setOpenState((previous) => (previous === next ? previous : next));
}, []);
// 取消尚未触发的分步自动开沙箱(用户手动关闭 / 切换会话时)。
const cancelPendingStreamedOpen = useCallback(() => {
if (preOpenTimerRef.current !== null) {
window.clearTimeout(preOpenTimerRef.current);
preOpenTimerRef.current = null;
}
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
setPreOpen(false);
}, []);
useEffect(
() => () => {
if (preOpenTimerRef.current !== null) {
window.clearTimeout(preOpenTimerRef.current);
preOpenTimerRef.current = null;
}
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
},
[],
);
const select = useCallback(
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
setSelectedArtifactKey(artifactPreviewKey(artifact));
const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
if (!autoSelect) {
setAutoSelect(false);
}
},
sidebar?.setOpen(false);
}
if (!autoSelect) {
setAutoSelect((previous) => (previous ? false : previous));
}
},
[sidebar],
);
@@ -86,67 +148,100 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
setSelectedArtifact(artifact);
setSelectedArtifactKey(previewKey);
setAutoSelect(true);
setAutoOpen(true);
setOpenState(true);
selectedArtifactKeyRef.current = previewKey;
autoOpenRef.current = true;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
setAutoSelect((previous) => (previous ? previous : true));
setAutoOpen((previous) => (previous ? previous : true));
// 阶段一(t=0):立即收起左侧聊天记录侧栏。
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
if (openStateRef.current) {
// 沙箱已展开(如连续写多个文件):只需切换选中文件,无需分步。
cancelPendingStreamedOpen();
return true;
}
if (openTimerRef.current === null) {
// 阶段二(t=+200ms):延迟固定时长再置 preOpen,右侧智能体信息
// 面板此时才开始收起 —— 与左侧错开,避免两次收起同帧叠加卡顿。
preOpenTimerRef.current = window.setTimeout(() => {
preOpenTimerRef.current = null;
setPreOpen(true);
}, STREAMED_PREOPEN_DELAY_MS);
// 阶段三(t=+500ms,右侧 300ms 收起动画结束):翻转 open 展开
// 沙箱,把「聊天列变窄 + 沙箱内容挂载」与侧栏收起彻底分帧。
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
applyOpen(true);
setPreOpen(false);
}, STREAMED_OPEN_DELAY_MS);
}
return true;
},
[sidebar],
[applyOpen, cancelPendingStreamedOpen, sidebar],
);
const deselect = useCallback(() => {
setSelectedArtifact(null);
setSelectedArtifactKey(null);
setAutoSelect(true);
setOpenState(false);
const deselect = useCallback(() => {
selectedArtifactKeyRef.current = null;
setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
setAutoSelect((previous) => (previous ? previous : true));
cancelPendingStreamedOpen();
applyOpen(false);
suppressedAutoOpenKeyRef.current = null;
}, []);
const value: ArtifactsContextType = {
artifacts,
setArtifacts,
open,
autoOpen,
autoSelect,
setOpen: (isOpen: boolean) => {
if (!isOpen && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
}
}, [applyOpen, cancelPendingStreamedOpen]);
const setOpen = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
// 两阶段等待期间用户手动关闭:取消尚未触发的自动展开。
cancelPendingStreamedOpen();
if (autoOpenRef.current) {
autoOpenRef.current = false;
setAutoOpen((previous) => (previous ? false : previous));
setAutoSelect((previous) => (previous ? false : previous));
}
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
}
setOpenState(isOpen);
applyOpen(isOpen);
},
[applyOpen, cancelPendingStreamedOpen],
);
const value: ArtifactsContextType = {
artifacts,
setArtifacts,
open,
preOpen,
autoOpen,
autoSelect,
setOpen,
selectedArtifact,
selectedArtifactKey,
select,
openStreamedArtifact,
deselect,
};
return (
<ArtifactsContext.Provider value={value}>
{children}
</ArtifactsContext.Provider>
);
}
export function useArtifacts() {
const context = useContext(ArtifactsContext);
if (context === undefined) {
throw new Error("useArtifacts must be used within an ArtifactsProvider");
}
return context;
}
export function useArtifactsOptional(): ArtifactsContextType | null {
return useContext(ArtifactsContext) ?? null;
}
};
return (
<ArtifactsContext.Provider value={value}>
{children}
</ArtifactsContext.Provider>
);
}
export function useArtifacts() {
const context = useContext(ArtifactsContext);
if (context === undefined) {
throw new Error("useArtifacts must be used within an ArtifactsProvider");
}
return context;
}
export function useArtifactsOptional(): ArtifactsContextType | null {
return useContext(ArtifactsContext) ?? null;
}
@@ -1,7 +1,7 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { FilePenLineIcon, ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -20,8 +20,24 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures";
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
const DEFAULT_GENERATE_INSTRUCTION = "基于已选资料按所选报告结构生成一篇新的完整报告;保持事实可核验,不补充没有依据的信息。";
export interface DocumentRewriteDialogInput {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
structureMode: "fixed" | "adaptive";
generateImages: boolean;
maxGeneratedImages: number;
}
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
{
@@ -51,64 +67,130 @@ export function DocumentRewriteDialog({
onOpenChange,
fileName,
charCount,
imageGenerationAvailable = false,
maxGeneratedImages = 4,
initialReportOutline,
operationLabel,
operationDescription,
submitLabel,
operation,
onStart,
onRewriteOriginal,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
charCount: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
}) => void;
imageGenerationAvailable?: boolean;
maxGeneratedImages?: number;
/** A saved report structure selected before the dialog was opened. */
initialReportOutline?: string;
/** Optional copy for flows that create a new document instead of replacing one. */
operationLabel?: string;
operationDescription?: string;
submitLabel?: string;
/** ``generate`` creates an independent report; ``rewrite`` edits this report in place. */
operation?: "rewrite" | "generate";
onStart: (input: DocumentRewriteDialogInput) => void;
/** Optional secondary action for generation flows that rewrites the source report in place. */
onRewriteOriginal?: (input: DocumentRewriteDialogInput) => void;
}) {
const [instruction, setInstruction] = useState(
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。",
);
const isNewReportGeneration = operation === "generate";
const [instruction, setInstruction] = useState(DEFAULT_REWRITE_INSTRUCTION);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
const reportStructuresQuery = useReportStructures({ status: "enabled" });
const reportStructures = reportStructuresQuery.data ?? [];
const selectedReportStructure = reportStructures.find((item) => item.id === reportStructureId);
const initialReportStructureId = useMemo(() => {
const outline = initialReportOutline?.trim();
if (!outline) return null;
return reportStructures.find((item) => item.content.trim() === outline)?.id ?? null;
}, [initialReportOutline, reportStructures]);
useEffect(() => {
if (!open) return;
setInstruction(isNewReportGeneration ? DEFAULT_GENERATE_INSTRUCTION : DEFAULT_REWRITE_INSTRUCTION);
setStyle("formal_analysis");
setModelName(null);
}, [open]);
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
setStructureMode("adaptive");
setGenerateImages(false);
setImageCount("2");
}, [isNewReportGeneration, open]);
// The structures query may resolve after the dialog opens. Only seed the
// value while the user still has the default selection, so a manual choice
// is never overwritten by a query refresh.
useEffect(() => {
if (!open || !initialReportStructureId) return;
setReportStructureId((current) => (
current === KEEP_CURRENT_REPORT_STRUCTURE ? initialReportStructureId : current
));
}, [initialReportStructureId, open]);
useEffect(() => {
if (!selectedReportStructure) return;
setStructureMode(selectedReportStructure.structureMode);
}, [selectedReportStructure?.id, selectedReportStructure?.structureMode]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
const canStart = instruction.trim().length > 0;
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
if (!canStart) return;
handler({
instruction: instruction.trim(),
style,
modelName,
reportOutline: selectedReportStructure?.content ?? "",
structureMode,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-primary" />
AI 全文改写
{operationLabel ?? "AI 全文改写"}
</DialogTitle>
<DialogDescription>
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。
{operationDescription ?? "将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。"}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
<div className="font-medium">{fileName}</div>
<div className="text-muted-foreground mt-1">原文约 {charCount.toLocaleString()} 字</div>
<div className="text-muted-foreground mt-1">
{isNewReportGeneration ? "来源报告" : "原文"}约 {charCount.toLocaleString()} 字
</div>
</div>
<label className="grid gap-2 text-sm font-medium">
改写要求
{isNewReportGeneration ? "生成要求" : "改写要求"}
<Textarea
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。"
placeholder={isNewReportGeneration
? "例如:突出政策影响,并按所选结构组织章节;仅使用已选资料。"
: "例如:统一为正式行业分析语气,减少重复,不新增外部事实。"}
rows={4}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
改写风格
{isNewReportGeneration ? "报告风格" : "改写风格"}
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
<SelectTrigger>
<SelectValue />
@@ -124,6 +206,58 @@ export function DocumentRewriteDialog({
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
</label>
<label className="grid gap-2 text-sm font-medium">
报告结构
<Select value={reportStructureId} onValueChange={setReportStructureId}>
<SelectTrigger aria-label="报告结构">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={KEEP_CURRENT_REPORT_STRUCTURE}>沿用当前结构</SelectItem>
{reportStructuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>
正在加载结构…
</SelectItem>
)}
{reportStructuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>
报告结构库加载失败
</SelectItem>
)}
{reportStructures.map((structure) => (
<SelectItem key={structure.id} value={structure.id}>
{structure.title}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{selectedReportStructure?.name || "默认保留原文的章节安排。"}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
结构约束
<Select
value={structureMode}
onValueChange={(value) => setStructureMode(normalizeReportStructureMode(value))}
>
<SelectTrigger aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
模型
<ModelNameSelect
@@ -132,23 +266,76 @@ export function DocumentRewriteDialog({
triggerClassName="h-10 text-sm"
defaultLabel="系统默认模型"
/>
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span>
<span className="text-muted-foreground text-xs">
仅影响本次{isNewReportGeneration ? "新报告生成" : "全文改写"}。
</span>
</label>
</div>
{imageGenerationAvailable && (
<div className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-4">
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<ImagePlusIcon className="size-4" />
</span>
<span className="grid gap-0.5">
<span className="text-sm font-medium">
{isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
</span>
<span className="text-muted-foreground text-xs leading-5">
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
</span>
</span>
</label>
<Switch
aria-label={isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
checked={generateImages}
id="document-rewrite-images"
onCheckedChange={setGenerateImages}
/>
</div>
{generateImages && (
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
<label className="flex items-center gap-2 text-sm">
配图数量
<Select value={imageCount} onValueChange={setImageCount}>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
{isNewReportGeneration && onRewriteOriginal && (
<Button
variant="outline"
disabled={!canStart}
onClick={() => startWith(onRewriteOriginal)}
>
<FilePenLineIcon />
改写原文
</Button>
)}
<Button
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
}}
onClick={() => startWith(onStart)}
>
<SparklesIcon />
开始改写
{submitLabel ?? "开始改写"}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,4 +1,5 @@
export * from "./artifact-file-detail";
export * from "./artifact-file-list";
export * from "./artifact-file-detail";
export * from "./artifact-directory";
export * from "./artifact-file-list";
export * from "./artifact-trigger";
export * from "./context";
@@ -117,7 +117,7 @@ export function SpreadsheetPreview({
{Array.from({ length: columnCount }, (_, columnIndex) => (
<th
key={columnIndex}
className="border-border bg-muted/60 text-foreground sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-pre-wrap"
className="border-border bg-muted/60 text-foreground border px-3 py-1.5 text-left font-semibold whitespace-pre-wrap"
>
{desensitize(headerRow?.[columnIndex] ?? "")}
</th>
@@ -1,4 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useQuery } from "@tanstack/react-query";
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -13,8 +14,13 @@ import {
} from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiFetch } from "@/core/api";
import {
listThreadArtifacts,
mergeArtifactPaths,
} from "@/core/artifacts/listing";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks";
@@ -32,6 +38,7 @@ import { cn } from "@/lib/utils";
import {
ArtifactFileDetail,
ArtifactDirectory,
ArtifactFileList,
useArtifacts,
} from "../artifacts";
@@ -40,6 +47,7 @@ import { SkillDisplaySources } from "../skill-display-sources";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 };
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
@@ -78,7 +86,11 @@ const ChatBox: React.FC<{
threadId,
disableArtifacts = false,
}) => {
const { thread } = useThread();
const {
isMock,
thread,
knowledgeReferencesEnabled = false,
} = useThread();
const pathname = usePathname();
const threadIdRef = useRef(threadId);
const artifactsCountRef = useRef(0);
@@ -92,8 +104,15 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings();
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
// Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs.
@@ -107,9 +126,55 @@ const ChatBox: React.FC<{
);
const referencesEnabled = ragModeEnabled || hasFrontendRag;
const shouldFetchBackendReferenceBatches =
referencesEnabled && !hasFrontendRag;
referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
const { skills } = useSkills();
const canScanArtifactDirectory =
artifactDirectoryModeEnabled &&
!disableArtifacts &&
!isMock &&
!!threadId &&
threadId !== "new";
const artifactListingQuery = useQuery({
queryKey: ["artifact-directory", threadId],
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
enabled: canScanArtifactDirectory,
refetchInterval: thread.isLoading ? 1_500 : false,
retry: false,
});
const checkpointArtifacts = useMemo(
() => (disableArtifacts ? [] : (thread.values.artifacts ?? [])),
[disableArtifacts, thread.values.artifacts],
);
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
const availableArtifacts = useMemo(
() =>
artifactDirectoryModeEnabled
? mergeArtifactPaths(
checkpointArtifacts,
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
)
: checkpointArtifacts,
[
artifactDirectoryModeEnabled,
artifactListingQuery.data?.files,
checkpointArtifacts,
],
);
useEffect(() => {
if (!canScanArtifactDirectory || thread.isLoading) return;
// Run once after streaming settles as well as when checkpoint artifacts
// change, so files written without present_files are not missed by the
// last polling tick.
void artifactListingQuery.refetch();
}, [
artifactListingQuery.refetch,
canScanArtifactDirectory,
checkpointArtifactSignature,
thread.isLoading,
]);
const {
artifacts,
open: artifactsOpen,
@@ -302,7 +367,8 @@ const ChatBox: React.FC<{
};
}, []);
useEffect(() => {
if (threadIdRef.current !== threadId) {
const threadChanged = threadIdRef.current !== threadId;
if (threadChanged) {
threadIdRef.current = threadId;
artifactsCountRef.current = 0;
deselect();
@@ -316,15 +382,21 @@ const ChatBox: React.FC<{
}
// Update artifacts from the current thread
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
const nextArtifacts = availableArtifacts;
const hadArtifacts = artifactsCountRef.current > 0;
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
artifactsCountRef.current = nextArtifacts.length;
setArtifacts(nextArtifacts);
const artifactsChanged =
artifacts.length !== nextArtifacts.length ||
artifacts.some((artifact, index) => artifact !== nextArtifacts[index]);
if (artifactsChanged) {
setArtifacts(nextArtifacts);
}
if (
!disableArtifacts &&
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
!selectedArtifact
nextArtifacts.length > 0 &&
((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
@@ -351,15 +423,41 @@ const ChatBox: React.FC<{
}
}, [
threadId,
artifactDirectoryModeEnabled,
autoSelectFirstArtifact,
deselect,
selectArtifact,
selectedArtifact,
artifacts,
setArtifacts,
thread.values.artifacts,
availableArtifacts,
disableArtifacts,
]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current;
directoryModeWasEnabledRef.current = artifactDirectoryModeEnabled;
if (
wasEnabled ||
!artifactDirectoryModeEnabled ||
availableArtifacts.length === 0
) {
return;
}
if (!selectedArtifact) {
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
}
setArtifactsOpen(true);
setSideTab("document");
}, [
artifactDirectoryModeEnabled,
availableArtifacts,
selectArtifact,
selectedArtifact,
setArtifactsOpen,
]);
const artifactPanelOpen = useMemo(() => {
if (disableArtifacts) {
return false;
@@ -370,7 +468,10 @@ const ChatBox: React.FC<{
return artifactsOpen;
}, [artifactsOpen, artifacts, disableArtifacts]);
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
const showTabs = !!selectedArtifact && hasCitationReferences;
const showTabs =
hasCitationReferences &&
(!!selectedArtifact ||
(artifactDirectoryModeEnabled && artifacts.length > 0));
useEffect(() => {
if (selectedArtifact) {
@@ -390,12 +491,103 @@ const ChatBox: React.FC<{
useEffect(() => {
if (layoutRef.current) {
if (sidePanelOpen) {
layoutRef.current.setLayout(OPEN_MODE);
layoutRef.current.setLayout(
artifactDirectoryModeEnabled && artifactPanelOpen
? DIRECTORY_OPEN_MODE
: OPEN_MODE,
);
} else {
layoutRef.current.setLayout(CLOSE_MODE);
}
}
}, [sidePanelOpen]);
}, [artifactDirectoryModeEnabled, artifactPanelOpen, sidePanelOpen]);
const artifactDocumentPanel = artifactDirectoryModeEnabled ? (
<div className="flex size-full min-w-0 overflow-hidden rounded-lg border">
<ArtifactDirectory
artifacts={artifacts}
selectedArtifact={selectedArtifact}
onSelect={(artifact) => {
selectArtifact(artifact);
setArtifactsOpen(true);
}}
onRefresh={
canScanArtifactDirectory
? () => void artifactListingQuery.refetch()
: undefined
}
refreshing={artifactListingQuery.isFetching}
/>
<div className="relative min-w-0 flex-1 overflow-hidden">
{selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : (
<>
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
<ConversationEmptyState
icon={<FilesIcon />}
title="请选择文件"
description="从左侧目录中选择一个文件进行查看"
/>
</>
)}
</div>
</div>
) : selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={threadId}
/>
</main>
</div>
)}
</div>
);
if (disableArtifacts) {
return (
@@ -455,13 +647,7 @@ const ChatBox: React.FC<{
</Button>
</div>
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
{artifactDocumentPanel}
</TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
<ReferencePanel
@@ -470,14 +656,8 @@ const ChatBox: React.FC<{
/>
</TabsContent>
</Tabs>
) : selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : artifactPanelOpen ? (
artifactDocumentPanel
) : hasCitationReferences ? (
<div className="relative size-full overflow-hidden">
<div className="absolute top-1 right-1 z-30">
@@ -497,41 +677,7 @@ const ChatBox: React.FC<{
sources={referenceSources}
/>
</div>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
) : null}
</div>
</ResizablePanel>
</ResizablePanelGroup>
@@ -780,11 +926,10 @@ function buildTurnReferenceBatches(
continue;
}
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
if (nextHumanIndex === -1 && isLoading) {
continue;
}
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
const assistant =
findLastAssistantAnswer(messages, index + 1, endIndex) ??
findFrontendRagAssistant(messages, index + 1, endIndex);
if (!assistant?.id) {
continue;
}
@@ -794,6 +939,14 @@ function buildTurnReferenceBatches(
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
}).filter((source) => source.mode !== "none");
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
// those cards available immediately, while keeping normal streaming turns
// on their existing "wait for an answer" behavior.
const isFrontendRagTurn =
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
continue;
}
if (sources.length > 0) {
batches.push({
id: assistant.id,
@@ -832,6 +985,23 @@ function findLastAssistantAnswer(
return null;
}
function findFrontendRagAssistant(
messages: Message[],
start: number,
end: number,
): Message | null {
for (let index = end - 1; index >= start; index -= 1) {
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
) {
return message;
}
}
return null;
}
function hasAssistantAnswerContent(message: Message): boolean {
const content = message.content;
if (typeof content === "string") {
@@ -0,0 +1,222 @@
"use client";
import {
BookOpenCheck,
Brain,
CalendarClock,
MessageSquare,
MessageSquarePlus,
PanelLeftClose,
PenLine,
Settings,
Sparkles,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { MouseEvent } from "react";
import { useNavigate } from "react-router-dom";
import cmAssistantLogoDark from "@/assets/img/cm-assistant-logo.png";
import cmAssistantLogoLight from "@/assets/img/cm-assistant-logo-red.png";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
import { AgentBrandIcon } from "./agents/agent-brand-icon";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
const NEW_CHAT_PATH = "/page/workspace/chats/new";
type NavItem = {
label: string;
href: string;
icon: typeof MessageSquare;
match: string;
};
const NAV_ITEMS: NavItem[] = [
{ label: "新建对话", href: NEW_CHAT_PATH, icon: MessageSquarePlus, match: NEW_CHAT_PATH },
{ label: "对话", href: "/page/workspace/chats", icon: MessageSquare, match: "/page/workspace/chats" },
{ label: "智能体", href: "/page/workspace/agents", icon: Sparkles, match: "/page/workspace/agents" },
{ label: "技能", href: "/page/workspace/skills", icon: Sparkles, match: "/page/workspace/skills" },
{ label: "记忆", href: "/page/workspace/memory", icon: Brain, match: "/page/workspace/memory" },
{ label: "AI 写作", href: "/page/workspace/ai-writing", icon: PenLine, match: "/page/workspace/ai-writing" },
{
label: "多智能体规划",
href: "/page/workspace/roundtable/planning",
icon: UsersRound,
match: "/page/workspace/roundtable",
},
{
label: "定时任务",
href: "/page/workspace/scheduled/tasks",
icon: CalendarClock,
match: "/page/workspace/scheduled",
},
{ label: "问答梳理", href: "/page/workspace/knowledge", icon: BookOpenCheck, match: "/page/workspace/knowledge" },
] as const;
export function CmChatSidebar({
onCollapse,
collapsed = false,
forceCollapsed = false,
}: {
onCollapse?: () => void;
collapsed?: boolean;
/** Deployment-level compact navigation: retain only accessible menu icons. */
forceCollapsed?: boolean;
}) {
const pathname = usePathname();
const navigate = useNavigate();
const { theme } = useTheme();
const brandLogoSrc = theme === "light" ? cmAssistantLogoLight : cmAssistantLogoDark;
const handleNewChat = (event: MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
event.preventDefault();
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
};
if (collapsed) {
return (
<aside className="flex h-full min-h-0 w-14 flex-col border-r border-slate-200/80 bg-white text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-200">
{!forceCollapsed && (
<>
<button
type="button"
onClick={onCollapse}
aria-label="展开侧边栏"
title="展开侧边栏"
className="mx-auto mt-2 flex size-11 shrink-0 items-center justify-center rounded-xl transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900"
>
<img
src={brandLogoSrc}
alt="CM 助手"
className="size-10 object-contain"
/>
</button>
<div className="mx-2 mt-2 h-px shrink-0 bg-slate-200/80 dark:bg-slate-800" />
</>
)}
<nav
className={cn(
"flex shrink-0 flex-col items-center gap-1 px-2 py-3",
forceCollapsed && "pt-4",
)}
aria-label="工作台导航"
>
{NAV_ITEMS.map((item) => {
const active = item.href === NEW_CHAT_PATH
? pathname === NEW_CHAT_PATH
: item.href === "/page/workspace/chats"
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
: pathname.startsWith(item.match);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
aria-current={active ? "page" : undefined}
aria-label={item.label}
title={item.label}
className={cn(
"flex size-10 items-center justify-center rounded-xl transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
active
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{item.href === "/page/workspace/agents" ? (
<AgentBrandIcon size="nav" />
) : (
<Icon className="size-[19px]" strokeWidth={1.8} />
)}
<span className="sr-only">{item.label}</span>
</Link>
);
})}
</nav>
<div className="mt-auto flex shrink-0 justify-center border-t border-slate-200/80 py-3 dark:border-slate-800">
<Link
href="/page/workspace/settings"
aria-label="设置和更多"
title="设置和更多"
className="flex size-10 items-center justify-center rounded-xl text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"
>
<Settings className="size-[19px]" strokeWidth={1.8} />
</Link>
</div>
</aside>
);
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden border-r border-slate-200/80 bg-white text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100">
<div className="flex shrink-0 items-center gap-1.5 px-4 pt-3.5 pb-2.5">
<Link href={NEW_CHAT_PATH} className="flex min-w-0 flex-1 items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-primary/40">
<img
src={brandLogoSrc}
alt="CM 助手"
className="size-9 shrink-0 object-contain"
/>
<span className="truncate text-[19px] font-semibold tracking-[-0.025em]">CM 助手</span>
</Link>
<button
type="button"
onClick={onCollapse}
aria-label="收起侧边栏"
title="收起侧边栏"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900 dark:hover:text-white"
>
<PanelLeftClose className="size-[18px]" strokeWidth={1.8} />
</button>
</div>
<nav className="shrink-0 px-3 pt-1" aria-label="工作台导航">
<div className="space-y-1">
{NAV_ITEMS.map((item) => {
const active = item.href === NEW_CHAT_PATH
? pathname === NEW_CHAT_PATH
: item.href === "/page/workspace/chats"
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
: pathname.startsWith(item.match);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
aria-current={active ? "page" : undefined}
className={cn(
"flex h-10 items-center gap-3 rounded-xl px-3 text-[14px] font-medium transition-colors",
active
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{item.href === "/page/workspace/agents" ? (
<AgentBrandIcon size="nav" />
) : (
<Icon className="size-[18px] shrink-0" strokeWidth={1.8} />
)}
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-1">
<RecentChatList compact limit={5} moreHref="/page/workspace/chats" />
</div>
<div className="shrink-0 border-t border-slate-200/80 px-3 py-3 dark:border-slate-800">
<WorkspaceNavMenu />
</div>
</div>
);
}
@@ -11,12 +11,11 @@ import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import { memo, useMemo } from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useThread } from "./messages/context";
const customDarkTheme = monokaiInit({
settings: {
background: "transparent",
@@ -34,7 +33,7 @@ const customLightTheme = basicLightInit({
},
});
export function CodeEditor({
export const CodeEditor = memo(function CodeEditor({
className,
placeholder,
value,
@@ -43,6 +42,7 @@ export function CodeEditor({
disabled,
autoFocus,
settings,
isStreaming = false,
streamingReadonlyFallback = true,
}: {
className?: string;
@@ -53,11 +53,10 @@ export function CodeEditor({
disabled?: boolean;
autoFocus?: boolean;
settings?: unknown;
/** Parent-owned streaming flag so this editor does not subscribe to `thread`. */
isStreaming?: boolean;
streamingReadonlyFallback?: boolean;
}) {
const {
thread: { isLoading },
} = useThread();
const { resolvedTheme } = useTheme();
const extensions = useMemo(() => {
@@ -81,7 +80,7 @@ export function CodeEditor({
className,
)}
>
{isLoading && streamingReadonlyFallback ? (
{isStreaming && streamingReadonlyFallback ? (
<Textarea
className={cn(
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
@@ -116,4 +115,4 @@ export function CodeEditor({
)}
</div>
);
}
});
@@ -0,0 +1,318 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Guide, type GuideStep } from "tdesign-react";
import { PROMPT_DIALOG_GUIDE_EVENT } from "@/components/workspace/custom-prompt-menu";
/**
* 简洁模式(隐藏全局导航栏 + 页面侧边栏的布局)新手引导,基于 TDesign React 的
* Guide 组件(https://tdesign.tencent.com/react/components/guide)。
*
* 触发方式:
* - 自动:简洁模式问答落地页自动演示,直到用户显式点「完成」或「跳过」才写
* localStorage 记忆;中途刷新/离开不记忆,下次进入还会再演示。
* - 手动:左侧「设置和更多」下拉的「界面使用引导」。问答页内通过
* COMPACT_GUIDE_START_EVENT 直接启动;其它页面先落 sessionStorage 再导航到
* /chats/new,由本组件在挂载后消费。
*
* 高亮目标通过 `data-compact-guide` 标记在各组件上(见 TARGET 选择器)。启动时逐个
* 检查目标是否存在(新用户无聊天记录、侧栏被收起等场景对应区块不渲染),缺失的
* 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。唯一例外是提示词
* 管理弹窗步骤:弹窗由引导在切到该步时才打开(PROMPT_DIALOG_GUIDE_EVENT 事件),
* 存在性检查落在它的入口(提示词栏)上,且必须等弹窗锚点真正挂载后才切步。
*/
const COMPACT_GUIDE_DONE_KEY = "deerflow.compact-guide-finished";
const COMPACT_GUIDE_PENDING_KEY = "deerflow.compact-guide-pending";
/** 同页手动触发引导的窗口事件名(「设置和更多」→「界面使用引导」)。 */
export const COMPACT_GUIDE_START_EVENT = "compact-guide:start";
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
const TARGET = {
nav: '[data-compact-guide="sidebar-nav"]',
recentChats: '[data-compact-guide="recent-chats"]',
settingsMenu: '[data-compact-guide="settings-menu"]',
themeSwitcher: '[data-compact-guide="theme-switcher"]',
layoutMode: '[data-compact-guide="layout-mode"]',
promptBar: '[data-compact-guide="prompt-bar"]',
promptDialog: '[data-compact-guide="prompt-dialog"]',
promptInput: '[data-compact-guide="prompt-input"]',
} as const;
// 等侧栏宽度过渡 / 落地态布局稳定后再启动:Guide 只在步骤切换时测量目标位置,
// 起步太早会把高亮框定位到过渡中的坐标且不会自行纠正。
const START_DELAY_MS = 800;
// 引导打开提示词管理弹窗后等待其锚点挂载的最长时间,超时视为弹窗打不开,
// 跳过该步避免引导卡死。
const PROMPT_DIALOG_TIMEOUT_MS = 4000;
function isBrowser(): boolean {
return typeof window !== "undefined";
}
export function isCompactGuideDone(): boolean {
return isBrowser() && localStorage.getItem(COMPACT_GUIDE_DONE_KEY) === "1";
}
function markCompactGuideDone() {
if (isBrowser()) localStorage.setItem(COMPACT_GUIDE_DONE_KEY, "1");
}
/** 跨页触发:非问答页点击「界面使用引导」时先记录请求,导航到 /chats/new 后消费。 */
export function requestCompactGuideStart() {
if (isBrowser()) sessionStorage.setItem(COMPACT_GUIDE_PENDING_KEY, "1");
}
function hasCompactGuideRequest(): boolean {
return (
isBrowser() &&
sessionStorage.getItem(COMPACT_GUIDE_PENDING_KEY) === "1"
);
}
function consumeCompactGuideRequest() {
if (isBrowser()) sessionStorage.removeItem(COMPACT_GUIDE_PENDING_KEY);
}
interface StepSpec {
/** 高亮目标选择器;缺省(欢迎步骤 dialog 模式)以 body 兜底。 */
selector?: string;
/**
* 目标元素在引导过程中才渲染时(提示词管理弹窗由引导动态打开),存在性检查
* 改查这个已存在的「入口」元素 —— Guide 只在切到某步时才解析其元素,所以
* 该步可以留在步骤列表里,等锚点真正挂载后再切过去。
*/
dependsOn?: string;
mode: "popup" | "dialog";
placement: NonNullable<GuideStep["placement"]>;
title: string;
body: string;
}
const STEP_SPECS: StepSpec[] = [
{
mode: "dialog",
placement: "center",
title: "欢迎使用简洁模式",
body: "当前已隐藏顶部导航栏与全局侧边栏,为您留出更大的工作区。接下来花一分钟认识一下界面布局。",
},
{
selector: TARGET.nav,
mode: "popup",
placement: "right",
title: "功能导航",
body: "新对话、智能体、技能、AI 写作、深度研究等主要入口都在左侧这里,点击即可切换(菜单项由管理员统一配置)。",
},
{
selector: TARGET.recentChats,
mode: "popup",
placement: "right",
title: "聊天记录",
body: "最近的对话会保留在这里,点击任意一条即可继续上次的问答。",
},
{
selector: TARGET.settingsMenu,
mode: "popup",
placement: "right-top",
title: "设置和更多",
body: "通知、设置、用户排行榜,以及菜单管理、技能管理等管理入口都收纳在这个菜单里。",
},
{
selector: TARGET.themeSwitcher,
mode: "popup",
placement: "bottom-left",
title: "主题换肤",
body: "在这里一键切换红色、蓝色、深蓝等多套界面主题。",
},
{
selector: TARGET.layoutMode,
mode: "popup",
placement: "bottom-left",
title: "切回全量模式",
body: "需要顶部导航栏和全局侧边栏时,从这里随时切换回「全量模式」。",
},
{
selector: TARGET.promptBar,
mode: "popup",
placement: "top",
title: "常用提示词",
body: "输入框上方是常用提示词栏:点击任意提示词即可一键填入输入框,省去重复输入。最右侧的齿轮按钮用来管理这些提示词,点击「下一步」带你看一看。",
},
{
selector: TARGET.promptDialog,
dependsOn: TARGET.promptBar,
mode: "popup",
placement: "top",
title: "管理常用提示词",
body: "这就是齿轮按钮打开的管理弹窗:填写名称与提示词内容、选择关联的智能体后保存,即可添加自己的常用提示词(与账号绑定、跨设备可用);下方列表中已保存的提示词可随时编辑、删除;顶部开关可让本账号只用自己保存的提示词。",
},
{
selector: TARGET.promptInput,
mode: "popup",
placement: "top",
title: "开始提问",
body: "在输入框中输入问题,可先选择智能体与知识空间,发送后即可开始问答。",
},
];
/**
* 简洁模式引导。挂在 ChatPage:`eligible` 表示当前处于简洁模式(无全局
* chrome)且非嵌入/强制收起等特殊环境;`autoStart` 表示处于新对话落地态,
* 满足「首次进入」条件时自动演示一遍。
*/
export function CompactModeGuide({
eligible,
autoStart = false,
}: {
eligible: boolean;
autoStart?: boolean;
}) {
const [current, setCurrent] = useState(-1);
const specsRef = useRef<StepSpec[]>([]);
const stepsRef = useRef<GuideStep[]>([]);
const pollTimerRef = useRef<number | null>(null);
// 引导是否处于「已要求打开并接管提示词管理弹窗」状态(事件已发出 owner:true)。
const promptDialogTakenRef = useRef(false);
const clearPollTimer = useCallback(() => {
if (pollTimerRef.current !== null) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
const setPromptDialog = useCallback(
(open: boolean, takeOver: boolean) => {
promptDialogTakenRef.current = takeOver;
window.dispatchEvent(
new CustomEvent(PROMPT_DIALOG_GUIDE_EVENT, {
detail: { open, owner: takeOver },
}),
);
},
[],
);
const releasePromptDialog = useCallback(() => {
if (!promptDialogTakenRef.current) return;
setPromptDialog(false, false);
}, [setPromptDialog]);
const start = useCallback(() => {
clearPollTimer();
releasePromptDialog();
const specs = STEP_SPECS.filter(
(spec) =>
!spec.selector || document.querySelector(spec.dependsOn ?? spec.selector),
);
if (specs.length === 0) return;
specsRef.current = specs;
stepsRef.current = specs.map((spec) => ({
element: spec.selector ?? (() => document.body),
mode: spec.mode,
placement: spec.placement,
title: spec.title,
body: spec.body,
}));
setCurrent(0);
}, [clearPollTimer, releasePromptDialog]);
// 自动引导:没显式结束过(没点过完成/跳过)时,每次落地页都演示。是否「已看过」
// 只由下方 finish 回调(完成/跳过)写入 —— 这里启动时绝不能提前写,否则中途刷新
// 又会被当成已看过。
const autoTriedRef = useRef(false);
useEffect(() => {
if (!eligible || !autoStart || autoTriedRef.current) return;
autoTriedRef.current = true;
if (isCompactGuideDone()) return;
const timer = setTimeout(start, START_DELAY_MS);
return () => {
clearTimeout(timer);
// 组件在计时窗口内卸载(用户迅速离开页面)则下次落地再演示。
autoTriedRef.current = false;
};
}, [eligible, autoStart, start]);
// 跨页手动触发:非问答页点「界面使用引导」→ 记录请求 → 导航到 /chats/new → 此处启动。
// 请求只在定时器真正启动引导时才消费 —— StrictMode 下 effect 会 mount→cleanup→mount
// 双跑,若在检查时就删除,第二次跑就拿不到请求了。
useEffect(() => {
if (!eligible) return;
if (!hasCompactGuideRequest()) return;
const timer = setTimeout(() => {
consumeCompactGuideRequest();
start();
}, START_DELAY_MS);
return () => clearTimeout(timer);
}, [eligible, start]);
// 同页手动触发:已在问答页时「界面使用引导」直接广播事件启动。
useEffect(() => {
if (!eligible) return;
const handler = () => start();
window.addEventListener(COMPACT_GUIDE_START_EVENT, handler);
return () => window.removeEventListener(COMPACT_GUIDE_START_EVENT, handler);
}, [eligible, start]);
// 中途卸载(用户离开页面)也要归还弹窗控制权并停止等待。
useEffect(() => {
return () => {
clearPollTimer();
releasePromptDialog();
};
}, [clearPollTimer, releasePromptDialog]);
// 受控切步:进入提示词弹窗步骤前先打开弹窗并等锚点挂载(Guide 切步时才解析
// 元素,元素必须已存在);离开该步 / 跳过 / 完成时收回弹窗。跳过和完成时
// onChange 也会收到 -1,与 finish 回调各自幂等。
const goTo = (next: number) => {
const specs = specsRef.current;
clearPollTimer();
if (next < 0 || next >= specs.length) {
releasePromptDialog();
setCurrent(-1);
return;
}
if (specs[next].selector === TARGET.promptDialog) {
setPromptDialog(true, true);
const startedAt = Date.now();
pollTimerRef.current = window.setInterval(() => {
if (document.querySelector(TARGET.promptDialog)) {
clearPollTimer();
setCurrent(next);
} else if (Date.now() - startedAt > PROMPT_DIALOG_TIMEOUT_MS) {
clearPollTimer();
releasePromptDialog();
goTo(next + 1 >= specs.length ? -1 : next + 1);
}
}, 50);
return;
}
releasePromptDialog();
setCurrent(next);
};
// 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」;中途刷新或
// 离开不记忆,下次进入简洁模式问答页还会再演示。
const finish = useCallback(() => {
clearPollTimer();
releasePromptDialog();
markCompactGuideDone();
setCurrent(-1);
}, [clearPollTimer, releasePromptDialog]);
if (!eligible) return null;
return (
<Guide
current={current}
steps={stepsRef.current}
onChange={goTo}
onFinish={finish}
onSkip={finish}
/>
);
}
@@ -0,0 +1,162 @@
"use client";
import { Wrench, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { cn } from "@/lib/utils";
import { useWorkspaceCompactNavItems } from "./workspace-nav-chat-list";
const TOOLBOX_ICON_TONES = [
"bg-blue-500/12 text-blue-600 dark:text-blue-400",
"bg-violet-500/12 text-violet-600 dark:text-violet-400",
"bg-emerald-500/12 text-emerald-600 dark:text-emerald-400",
"bg-amber-500/14 text-amber-600 dark:text-amber-400",
] as const;
/**
* 简洁模式右侧工具箱。
*
* 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目
* 放到 ``toolbox`` 泳道。点击右侧竖排按钮后展开单列竖向抽屉。
*/
export function CompactToolbox() {
const { toolboxItems } = useWorkspaceCompactNavItems();
const { pathname } = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => setOpen(false), [pathname]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
closeRef.current?.focus();
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
if (toolboxItems.length === 0) return null;
const closeDrawer = () => {
setOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-label="打开工具箱"
aria-controls="compact-toolbox-panel"
aria-expanded={open}
title="打开工具箱"
onClick={() => setOpen(true)}
className={cn(
"fixed right-0 top-1/2 z-[80] flex size-16 -translate-y-1/2 flex-col items-center justify-center gap-1 rounded-l-2xl border border-r-0 border-primary/25 bg-primary text-primary-foreground shadow-xl transition-[transform,opacity,background-color] duration-200 hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
open && "pointer-events-none translate-x-full opacity-0",
)}
>
<Wrench className="size-6 shrink-0" />
<span className="text-xs font-semibold leading-4">工具箱</span>
</button>
<button
type="button"
tabIndex={-1}
aria-label="关闭工具箱抽屉"
aria-hidden={!open}
onClick={closeDrawer}
className={cn(
"fixed inset-0 z-[90] bg-black/20 transition-opacity duration-200",
open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
)}
/>
<aside
id="compact-toolbox-panel"
role="dialog"
aria-modal={open}
aria-label="工具箱"
aria-hidden={!open}
inert={!open}
className={cn(
"fixed right-0 top-1/2 z-[100] flex max-h-[calc(100vh-32px)] w-32 max-w-[calc(100vw-48px)] -translate-y-1/2 flex-col overflow-hidden rounded-l-2xl bg-background text-foreground shadow-2xl transition-transform duration-300 ease-out",
open
? "pointer-events-auto translate-x-0"
: "pointer-events-none translate-x-full",
)}
>
<header className="relative flex h-12 shrink-0 items-center justify-center px-2">
<h2 className="text-sm font-semibold">工具箱</h2>
<button
ref={closeRef}
type="button"
aria-label="关闭工具箱"
title="关闭"
onClick={closeDrawer}
className="absolute right-2 flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
</header>
<nav
aria-label="工具箱菜单"
className="flex min-h-0 flex-col items-center gap-2.5 overflow-y-auto bg-muted/20 p-2"
>
{toolboxItems.map((item, index) => {
const Icon = item.icon;
const active = item.match(pathname);
return (
<button
key={item.id}
type="button"
aria-current={active ? "page" : undefined}
className={cn(
"group flex h-24 w-24 aspect-square shrink-0 flex-col items-center justify-center gap-1.5 rounded-xl p-2 text-center transition-[color,background-color,transform] hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-primary/12 text-primary ring-1 ring-inset ring-primary/25"
: "bg-muted/55 text-card-foreground hover:bg-primary/8 hover:text-primary",
)}
onClick={() => {
setOpen(false);
navigate(item.path ?? "/page/workspace/chats/new");
}}
>
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform group-hover:scale-105",
active
? "bg-primary text-primary-foreground"
: TOOLBOX_ICON_TONES[index % TOOLBOX_ICON_TONES.length],
)}
>
<Icon className="size-6" strokeWidth={1.8} />
</span>
<span
className="line-clamp-2 w-full text-center text-xs font-medium leading-4"
title={item.label}
>
{item.label}
</span>
</button>
);
})}
</nav>
</aside>
</>
);
}
@@ -42,24 +42,46 @@ import {
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* 简洁模式引导控制提示词管理弹窗的窗口事件。detail 为
* `{ open: boolean; owner?: boolean }`:`owner` 表示弹窗由引导接管,期间 ESC /
* 点击遮罩等关闭请求一律忽略,直到引导以 `owner: false` 收回控制权。
*/
export const PROMPT_DIALOG_GUIDE_EVENT = "compact-guide:prompt-dialog";
export function CustomPromptBar({
onInsert,
className,
currentAgentId,
compact = false,
}: {
onInsert: (content: string) => void;
className?: string;
currentAgentId?: string | null;
compact?: boolean;
}) {
const allPrompts = useCustomPrompts();
const prompts = allPrompts.filter(
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
);
const [manageOpen, setManageOpen] = useState(false);
const guideOwnsDialogRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false);
useEffect(() => {
const handler = (event: Event) => {
const detail = (
event as CustomEvent<{ open?: boolean; owner?: boolean }>
).detail;
guideOwnsDialogRef.current = Boolean(detail?.owner);
setManageOpen(Boolean(detail?.open));
};
window.addEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
return () => window.removeEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
}, []);
const updateArrows = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
@@ -81,7 +103,14 @@ export function CustomPromptBar({
return (
<>
<div className={cn("flex min-w-0 items-center gap-1", className)}>
<div
className={cn(
"flex min-w-0 items-center gap-1",
compact && "min-h-9 justify-end",
className,
)}
data-compact-guide="prompt-bar"
>
{prompts.length > 0 && (
<>
{overflowing && (
@@ -111,6 +140,7 @@ export function CustomPromptBar({
title={prompt.content}
className={cn(
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
compact && "border-slate-200 bg-white px-3.5 py-1.5 text-[12px] text-slate-600 shadow-none hover:border-primary/25 hover:bg-primary/5 hover:text-primary",
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
)}
>
@@ -139,7 +169,11 @@ export function CustomPromptBar({
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full",
compact &&
"h-9 w-9 rounded-xl border border-slate-200 bg-white text-slate-600 shadow-[0_6px_18px_-14px_rgba(15,23,42,0.4)] hover:border-primary/25 hover:bg-primary/5 hover:text-primary dark:border-slate-700 dark:bg-slate-900",
)}
title="管理常用提示词"
aria-label="管理常用提示词"
onClick={() => setManageOpen(true)}
@@ -148,7 +182,14 @@ export function CustomPromptBar({
</Button>
</div>
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
<ManagePromptsDialog
open={manageOpen}
onOpenChange={(next) => {
// 引导演示期间接管弹窗:ESC / 点击遮罩不关闭,避免高亮目标消失导致引导报错。
if (!next && guideOwnsDialogRef.current) return;
setManageOpen(next);
}}
/>
</>
);
}
@@ -257,7 +298,10 @@ function ManagePromptsDialog({
onOpenChange(next);
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-xl"
data-compact-guide="prompt-dialog"
>
<DialogHeader>
<DialogTitle>管理常用提示词</DialogTitle>
<DialogDescription>
@@ -12,10 +12,11 @@ export function FlipDisplay({
className?: string;
}) {
return (
<div className={cn("relative overflow-hidden", className)}>
<div className={cn("relative min-w-0 overflow-hidden", className)}>
<AnimatePresence mode="wait">
<motion.div
key={uniqueKey}
className="min-w-0 max-w-full"
initial={{ y: 8, opacity: 0 }}
animate={{ y: 2, opacity: 1 }}
exit={{ y: -8, opacity: 0 }}
@@ -76,6 +76,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
@@ -226,14 +227,8 @@ export function GeneralQaSidebar() {
const visibleThreads = useMemo(
() =>
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
@@ -1,15 +1,20 @@
"use client";
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
LightbulbIcon,
import type { ChatStatus } from "ai";
import {
BarChart3Icon,
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
ClipboardCheckIcon,
FileTextIcon,
FolderTreeIcon,
GraduationCapIcon,
LightbulbIcon,
MessageCircleQuestionIcon,
PaperclipIcon,
PlusIcon,
RefreshCwIcon,
ShrinkIcon,
SparklesIcon,
RocketIcon,
@@ -92,19 +97,69 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { CustomPromptBar } from "./custom-prompt-menu";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
const COMPACT_QUICK_STARTS = [
{
id: "report",
title: "撰写报告",
content: "请帮我撰写一份结构清晰的专业报告,先向我确认主题、受众和篇幅要求。",
icon: FileTextIcon,
},
{
id: "knowledge",
title: "知识问答",
content: "请基于已有资料回答我的问题,并明确区分已知事实、推断和需要补充的信息。",
icon: MessageCircleQuestionIcon,
},
{
id: "planning",
title: "任务规划",
content: "请把我的目标拆解为可执行的任务计划,列出优先级、里程碑和风险。",
icon: ClipboardCheckIcon,
},
{
id: "analysis",
title: "数据分析",
content: "请帮我分析数据中的关键趋势、异常和可能原因,并给出下一步验证建议。",
icon: BarChart3Icon,
},
{
id: "solution",
title: "方案策划",
content: "请围绕我的目标设计一套完整方案,包含背景、目标、实施步骤、资源需求和风险预案。",
icon: LightbulbIcon,
},
{
id: "summary",
title: "内容总结",
content: "请提炼我提供内容的核心观点、关键结论和待办事项,并按重要程度整理。",
icon: BookMarkedIcon,
},
{
id: "research",
title: "调研提纲",
content: "请根据我的调研主题生成一份结构完整的调研提纲,覆盖对象、问题、方法和输出形式。",
icon: GraduationCapIcon,
},
{
id: "polish",
title: "文案润色",
content: "请在不改变原意的前提下润色我提供的内容,使表达更清晰、专业、简洁。",
icon: SparklesIcon,
},
] as const;
function getResolvedMode(
@@ -141,6 +196,7 @@ export function InputBox({
assistantId,
hideAdminRecommendedQuestions,
compact,
hideContextTools,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -148,6 +204,9 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -216,9 +275,12 @@ export function InputBox({
const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -227,6 +289,7 @@ export function InputBox({
const wasStreamingRef = useRef(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [compactPromptOrder, setCompactPromptOrder] = useState(0);
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null,
);
@@ -237,6 +300,29 @@ export function InputBox({
!hideAdminRecommendedQuestions &&
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
const compactQuickStarts = useMemo(() => {
if (showAdminRecommendedQuestions) {
return recommendedQuestions.map((configured, index) => {
const fallback =
COMPACT_QUICK_STARTS[index % COMPACT_QUICK_STARTS.length]!;
return {
...fallback,
id: configured.id,
title: desensitize(configured.title) || `提示词 ${index + 1}`,
content: configured.content || fallback.content,
};
});
}
return [...COMPACT_QUICK_STARTS];
}, [recommendedQuestions, showAdminRecommendedQuestions]);
const orderedCompactQuickStarts = useMemo(() => {
if (compactQuickStarts.length < 2) return compactQuickStarts;
const offset = compactPromptOrder % compactQuickStarts.length;
return [
...compactQuickStarts.slice(offset),
...compactQuickStarts.slice(0, offset),
];
}, [compactPromptOrder, compactQuickStarts]);
useEffect(() => {
// Multi-round RAG is a frontend execution profile, not a model option.
@@ -375,6 +461,14 @@ export function InputBox({
});
}, [context, onContextChange]);
const artifactDirectoryModeEnabled =
localSettings.display.artifactDirectoryModeEnabled ?? false;
const handleArtifactDirectoryModeToggle = useCallback(() => {
setLocalSettings("display", {
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
@@ -593,14 +687,53 @@ export function InputBox({
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
<div ref={promptRootRef} className={cn("relative flex flex-col", compact ? "gap-[clamp(32px,6vh,58px)]" : "gap-6")}>
{extraHeader && (
<div className="flex items-center justify-center">
{extraHeader}
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
{isNewThread && searchParams.get("mode") !== "skill" && compact && (
<section className="px-2" aria-labelledby="recommended-prompts-title">
<div className="mb-3 flex items-center justify-center gap-1.5">
<h2 id="recommended-prompts-title" className="text-[14px] font-normal text-slate-400 dark:text-slate-500">
你可以这样问我
</h2>
<button
type="button"
data-prompt-order-refresh="true"
title="调整提示词顺序"
aria-label="调整提示词顺序"
onClick={() => setCompactPromptOrder((current) => current + 1)}
className="flex size-7 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:hover:bg-slate-800 dark:hover:text-primary"
>
<RefreshCwIcon className="size-3.5" strokeWidth={1.8} />
</button>
</div>
<div className="mx-auto flex max-w-[900px] flex-wrap justify-center gap-x-3 gap-y-3">
{orderedCompactQuickStarts.map((item) => (
<button
key={item.id}
type="button"
data-quick-prompt={item.id}
title={item.content}
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-xl border border-[#e5e7eb] bg-white px-4 py-2 text-center text-[14px] font-normal leading-5 text-slate-700 shadow-[0_2px_6px_rgba(15,23,42,0.025)] transition-[background-color,border-color,box-shadow] hover:border-primary/25 hover:bg-primary/5 hover:text-slate-900 hover:shadow-[0_4px_10px_rgba(15,23,42,0.04)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-primary/35 dark:hover:bg-primary/10"
onClick={() => {
textInput.setInput(item.content);
requestAnimationFrame(() =>
document.querySelector<HTMLTextAreaElement>("textarea[name='message']")?.focus(),
);
}}
>
<span className="max-w-full whitespace-normal">{item.title}</span>
</button>
))}
</div>
</section>
)}
{isNewThread && searchParams.get("mode") !== "skill" && !compact && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
@@ -653,7 +786,8 @@ export function InputBox({
<div className="flex flex-col gap-1.5">
{isNewThread && (
<CustomPromptBar
className="px-1"
compact={compact}
className={cn(compact ? "px-2" : "px-1")}
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
onInsert={(content) => {
const current = textInput.value?.trim() ?? "";
@@ -677,7 +811,7 @@ export function InputBox({
: "*:data-[slot='input-group']:rounded-3xl",
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
compact && isNewThread
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
? "overflow-hidden rounded-[24px] border border-slate-200/90 bg-white shadow-[0_18px_50px_-32px_rgba(15,23,42,0.22),0_8px_24px_-20px_rgba(15,23,42,0.35)] transition-all duration-300 focus-within:border-primary/35 focus-within:ring-4 focus-within:ring-primary/10 dark:border-slate-700 dark:bg-slate-800"
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
@@ -701,7 +835,7 @@ export function InputBox({
className={cn(
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
isNewThread && "min-h-[110px]!",
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
compact && "min-h-[clamp(150px,18vh,176px)]! px-7 pt-7 pb-2 text-[15px]",
)}
disabled={disabled}
placeholder={t.inputBox.placeholder}
@@ -712,7 +846,7 @@ export function InputBox({
<PromptInputFooter
className={cn(
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3",
compact && "gap-2 px-6 pt-2 pb-5",
)}
>
<PromptInputTools
@@ -736,11 +870,11 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
: "开启写作模式:对话照常进行,交付时由深度研究管线撰写报告并保存为 Markdown 进入画布"
}
>
<PromptInputButton
@@ -763,6 +897,35 @@ export function InputBox({
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
artifactDirectoryModeEnabled
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
: "开启文件模式:在右侧目录中浏览当前对话生成的全部文件"
}
>
<PromptInputButton
type="button"
aria-pressed={artifactDirectoryModeEnabled}
disabled={disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
artifactDirectoryModeEnabled
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleArtifactDirectoryModeToggle}
>
{artifactDirectoryModeEnabled && (
<CheckIcon className="size-3 text-sky-700 dark:text-sky-200" />
)}
<FolderTreeIcon className="size-3" />
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && !hideContextTools && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -788,9 +951,9 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
ragModeEnabled
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
@@ -76,6 +76,7 @@ function SelectionContent({
error,
source,
variant,
readOnly,
}: {
rows: LlmWikiKnowledgeBase[];
value: string[];
@@ -86,6 +87,7 @@ function SelectionContent({
error: unknown;
source: SelectorSource;
variant: SelectorVariant;
readOnly: boolean;
}) {
const selected = useMemo(() => new Set(value), [value]);
const filtered = useMemo(() => {
@@ -97,6 +99,7 @@ function SelectionContent({
}, [rows, search]);
const groups = useMemo(() => groupRows(filtered), [filtered]);
const toggle = (id: string) => {
if (readOnly) return;
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
};
@@ -168,6 +171,7 @@ function SelectionContent({
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
@@ -183,6 +187,7 @@ function SelectionContent({
type="button"
role="checkbox"
aria-checked={selected.has(item.id)}
disabled={readOnly}
onClick={() => toggle(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
@@ -205,15 +210,21 @@ function SelectionContent({
</div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<span className="text-[11px] text-muted-foreground">已选择 {value.length} 个</span>
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
全选
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
清空
</Button>
</div>
<span className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
</span>
{readOnly ? (
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
) : (
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
全选
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
清空
</Button>
</div>
)}
</div>
</>
);
@@ -226,6 +237,7 @@ export function LlmWikiKnowledgeSelector({
agentId,
autoSelectAll = false,
disabled = false,
readOnly = false,
className,
label = "知识空间",
variant = "popover",
@@ -236,6 +248,8 @@ export function LlmWikiKnowledgeSelector({
agentId?: string | null;
autoSelectAll?: boolean;
disabled?: boolean;
/** Keep the selected knowledge spaces visible without allowing the thread scope to change. */
readOnly?: boolean;
className?: string;
label?: string;
variant?: SelectorVariant;
@@ -293,6 +307,7 @@ export function LlmWikiKnowledgeSelector({
error={error}
source={source}
variant={variant}
readOnly={readOnly}
/>
);
@@ -315,9 +330,14 @@ export function LlmWikiKnowledgeSelector({
);
}
const selectedName = value.length === 1
? rows.find((item) => item.id === value[0])?.name
: null;
const selectedNames = value
.map((id) => rows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const selectedLabel = selectedNames.length > 0
? selectedNames.join("、")
: value.length > 0
? `已选 ${value.length} 个知识空间`
: label;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -332,9 +352,10 @@ export function LlmWikiKnowledgeSelector({
className,
)}
disabled={disabled || loading}
title={value.length > 0 ? selectedLabel : undefined}
>
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
<span className="truncate text-xs font-normal">{selectedName || label}</span>
<span className="truncate text-xs font-normal">{selectedLabel}</span>
{value.length > 0 && (
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
{value.length}
@@ -4,11 +4,17 @@ import { createContext, useContext } from "react";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType {
thread: BaseStream<AgentThreadState>;
isMock?: boolean;
/**
* When set, the conversation is rendered from a public read-only share.
export interface ThreadContextType {
thread: BaseStream<AgentThreadState>;
isMock?: boolean;
/**
* True when the active conversation has selected LLMWiki knowledge bases.
* Knowledge-base Q&A should always expose its source documents, independent
* from the admin-controlled manual reference-mode button.
*/
knowledgeReferencesEnabled?: boolean;
/**
* When set, the conversation is rendered from a public read-only share.
* Artifact/image URLs then resolve to the unauthenticated
* `/api/public/shares/{code}/files/...` endpoint instead of the
* auth-gated thread artifact route.
@@ -2,34 +2,61 @@
import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT,
type DocumentRewritePresentation,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
loadArtifactRewriteFallback,
sha256DocumentContent,
} from "@/core/document-rewrite/local-fallback";
import { getBackendBaseURL } from "@/core/config";
import { cn } from "@/lib/utils";
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const STEPS = [
const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
const GENERATE_STEPS = [
{ key: "snapshot", label: "固定来源报告快照" },
{ key: "requirements", label: "确认用户生成需求" },
{ key: "writing", label: "流式生成 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验新报告" },
{ key: "comparison", label: "生成报告说明" },
] as const;
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
switch (stage) {
case "snapshot":
@@ -38,15 +65,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
return 1;
case "writing":
return 2;
case "validation":
case "illustrating":
return 3;
case "validation":
return 4;
case "committing":
case "completed":
return 5;
return 6;
case "conflict":
case "error":
case "cancelled":
return 4;
return 5;
default:
return -1;
}
@@ -56,23 +85,218 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
return ["completed", "conflict", "error", "cancelled"].includes(stage);
}
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
const [open, setOpen] = useState(active);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const contentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const previousActiveRef = useRef(active);
const lastAutoScrollTopRef = useRef<number | null>(null);
useEffect(() => {
if (active && text) setOpen(true);
}, [active, text]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
lastAutoScrollTopRef.current = null;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = contentRef.current;
if (!content) return;
content.scrollTop = content.scrollHeight;
lastAutoScrollTopRef.current = content.scrollTop;
}, []);
useLayoutEffect(() => {
if (!active || !open || !text) return;
scrollToLatestThinking();
}, [active, open, scrollToLatestThinking, text]);
useEffect(() => {
if (!active || !open || !text) return;
const content = contentRef.current;
if (!content) return;
let frame: number | null = null;
const observer = new MutationObserver(() => {
if (frame !== null) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
frame = null;
scrollToLatestThinking();
});
});
observer.observe(content, { childList: true, characterData: true, subtree: true });
return () => {
observer.disconnect();
if (frame !== null) cancelAnimationFrame(frame);
};
}, [active, open, scrollToLatestThinking, text]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current) return;
const lastAutoScrollTop = lastAutoScrollTopRef.current;
if (
lastAutoScrollTop === null ||
Math.abs(event.currentTarget.scrollTop - lastAutoScrollTop) > 1
) {
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
}
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
if (!text.trim()) return null;
return (
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
<CollapsibleTrigger asChild>
<Button
aria-label={open ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={contentRef}
>
{text}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
export function DocumentRewriteTimeline({
item,
}: {
item: DocumentRewritePresentation;
}) {
const isNewReportGeneration = item.operation === "generate";
const steps = isNewReportGeneration ? GENERATE_STEPS : REWRITE_STEPS;
const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed";
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
? "requirements"
: "writing";
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
const canUseLocalFallback =
!reapplying &&
!activeVersionId &&
item.source.kind === "artifact" &&
typeof item.source.path === "string" &&
item.localFallbackAvailable &&
typeof item.committedHash === "string";
const handleUndo = async () => {
if (!activeVersionId || undoing) return;
if ((!activeVersionId && !canUseLocalFallback) || undoing) return;
if (canUseLocalFallback && item.source.kind === "artifact" && item.source.path && item.committedHash) {
setUndoing(true);
try {
const fallback = loadArtifactRewriteFallback({
threadId: item.source.id,
path: item.source.path,
committedHash: item.committedHash,
});
if (!fallback) {
console.error("本地 AI 全文改写撤销备份不可用,未修改当前文件。");
return;
}
const currentResponse = await apiFetch(
urlOfArtifact({ filepath: item.source.path, threadId: item.source.id }),
);
if (!currentResponse.ok) {
throw new Error(`读取当前文件失败(${currentResponse.status})。`);
}
const currentHash = await sha256DocumentContent(await currentResponse.text());
if (!currentHash || currentHash !== fallback.committedHash) {
console.error("当前文件已在改写后发生变化,已跳过本地 AI 全文改写撤销。");
return;
}
const guard = new CustomEvent<{
source: DocumentRewritePresentation["source"];
rewrittenContent: string | null;
unchanged: boolean;
}>(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, {
detail: {
source: item.source,
rewrittenContent: item.rewrittenContent,
unchanged: false,
},
});
window.dispatchEvent(guard);
if (!guard.detail.unchanged) {
console.error("沙箱中存在改写后的未保存编辑,已跳过本地 AI 全文改写撤销。");
return;
}
await updateArtifactContent({
filepath: item.source.path,
threadId: item.source.id,
content: fallback.originalContent,
});
clearArtifactRewriteFallback(item.source.id, item.source.path);
presentation?.update(item.id, {
localFallbackAvailable: false,
revertedAt: new Date().toISOString(),
});
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
detail: { source: item.source, content: fallback.originalContent },
}),
);
} catch (error) {
console.error("本地 AI 全文改写撤销失败:", error);
} finally {
setUndoing(false);
}
return;
}
if (!activeVersionId) return;
const baseUrl = getBackendBaseURL();
const endpoint =
item.source.kind === "artifact"
@@ -109,7 +333,15 @@ export function DocumentRewriteTimeline({
detail: { source: item.source, content },
}),
);
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。");
toast.success(
isNewReportGeneration
? reapplying
? "已重新应用本次生成的新报告版本。"
: "已恢复本次生成前的新报告快照。"
: reapplying
? "已重新应用本次 AI 全文改写版本。"
: "已恢复到本次 AI 全文改写前的版本。",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
} finally {
@@ -125,7 +357,9 @@ export function DocumentRewriteTimeline({
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">AI 全文改写 · {item.fileName}</h3>
<h3 className="font-semibold">
{isNewReportGeneration ? "生成新报告" : "AI 全文改写"} · {item.fileName}
</h3>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
@@ -136,7 +370,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"}
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
@@ -146,9 +380,15 @@ export function DocumentRewriteTimeline({
</header>
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
{steps.map((step, index) => {
const active = !isTerminal(item.stage) && index === current;
const done = completed || index < current;
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
const illustrationFailed =
step.key === "illustrations" &&
item.generateImages &&
completed &&
item.generatedImageCount === 0;
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
return (
<div className="flex items-start gap-3" key={step.key}>
<div
@@ -173,7 +413,7 @@ export function DocumentRewriteTimeline({
<div className="text-sm font-medium">{step.label}</div>
{step.key === "snapshot" && item.originalCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
已冻结 {item.originalCharCount.toLocaleString()} 字原文快照。
已冻结 {item.originalCharCount.toLocaleString()} 字{isNewReportGeneration ? "来源报告" : "原文"}快照。
</p>
)}
{step.key === "requirements" && (
@@ -187,18 +427,52 @@ export function DocumentRewriteTimeline({
</div>
) : active ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在识别改写目标、保留约束与执行方式…
{isNewReportGeneration
? "正在确认新报告的结构、资料范围与生成要求…"
: "正在识别改写目标、保留约束与执行方式…"}
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
已根据改写要求确定执行计划。
{isNewReportGeneration ? "已根据所选结构确定生成计划。" : "已根据改写要求确定执行计划。"}
</p>
)
)}
{step.key === "writing" && item.writtenCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
{step.key === "writing" && (
item.writtenCharCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
</p>
) : null
)}
{step.key === "illustrations" && (
illustrationSkipped ? (
<p className="text-muted-foreground mt-0.5 text-xs">
本次未启用配图,保留原文已有图片与链接。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{item.illustrationTarget
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
</p>
) : item.generatedImageCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
已生成并保存 {item.generatedImageCount} 张本地配图。
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
未成功生成配图,文字改写结果仍已保留。
</p>
)
)}
{step.key === thinkingStep && (
<RewriteThinkingTrace active={active} text={item.thinkingText} />
)}
{step.key === "validation" && item.warnings.length > 0 && (
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
@@ -213,9 +487,10 @@ export function DocumentRewriteTimeline({
{item.summary && (
<div className="border-t px-5 py-4 text-sm leading-6">
<div className="mb-2 font-semibold">改写说明</div>
<div className="mb-2 font-semibold">{isNewReportGeneration ? "生成说明" : "改写说明"}</div>
<p>
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "}
{isNewReportGeneration ? "来源报告" : "原文"} {item.summary.originalCharCount.toLocaleString()} 字,
{isNewReportGeneration ? "新报告" : "改写后"}{" "}
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
{item.summary.changedBlockCount} 个内容块。
</p>
@@ -228,6 +503,11 @@ export function DocumentRewriteTimeline({
本次优化:{item.summary.requestedOptimizations.join("、")}。
</p>
) : null}
{item.summary.requestedImageCount ? (
<p className="text-muted-foreground">
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
</p>
) : null}
{item.summary.headingChanges && (
item.summary.headingChanges.reordered ||
item.summary.headingChanges.added.length > 0 ||
@@ -256,7 +536,25 @@ export function DocumentRewriteTimeline({
查看前后对比
</Button>
)}
{completed && activeVersionId && (
{isNewReportGeneration && completed && item.source.kind === "deep_research_report" && (
<Button
className="mt-3 mr-2"
onClick={() => {
window.dispatchEvent(
new CustomEvent(DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, {
detail: { sessionId: item.source.id },
}),
);
}}
size="sm"
type="button"
variant="outline"
>
<FilePenLineIcon className="mr-1.5 size-3.5" />
改写新报告
</Button>
)}
{completed && (activeVersionId || canUseLocalFallback) && (
<Button
className="mt-3"
disabled={undoing}
@@ -266,12 +564,19 @@ export function DocumentRewriteTimeline({
variant="outline"
>
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
{reapplying ? "重新应用此次改写" : "撤销此次改写"}
{isNewReportGeneration
? reapplying
? "重新应用本次生成"
: "撤销本次生成"
: reapplying
? "重新应用此次改写"
: "撤销此次改写"}
</Button>
)}
{item.revertedAt && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""}
已恢复为{isNewReportGeneration ? "生成前" : "改写前"}版本。
{item.redoVersionId ? `可重新应用本次${isNewReportGeneration ? "生成" : "改写"}。` : ""}
</p>
)}
</div>
@@ -1,8 +1,14 @@
import { FileText, Loader2 } from "lucide-react";
import { FileText, Loader2, MessagesSquare } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { Tooltip } from "../tooltip";
@@ -11,29 +17,84 @@ export function DownloadWordButton({
markdown,
fileName,
title,
questionMarkdown,
...props
}: ComponentProps<typeof Button> & {
markdown: string;
fileName?: string;
title?: string;
/** When present, the button offers both answer-only and current Q&A export. */
questionMarkdown?: string;
}) {
const [busy, setBusy] = useState(false);
const handleDownload = async () => {
const [busyScope, setBusyScope] = useState<"answer" | "qa" | null>(null);
const handleDownload = async (scope: "answer" | "qa" = "answer") => {
const source = markdown?.trim();
if (!source) {
toast.error("无可下载的 Markdown 内容");
return;
}
setBusy(true);
const question = questionMarkdown?.trim();
setBusyScope(scope);
try {
await downloadMarkdownAsDocx(source, fileName || "message", title);
if (scope === "qa" && question) {
const qaMarkdown = `## 问题\n\n${question}\n\n## 回答\n\n${source}`;
await downloadMarkdownAsDocx(
qaMarkdown,
`${fileName || "message"}-问答`,
"问答记录",
);
} else {
await downloadMarkdownAsDocx(
source,
fileName || "message",
title || (question ? "问答回答" : undefined),
);
}
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
} finally {
setBusy(false);
setBusyScope(null);
}
};
const busy = busyScope !== null;
const icon = busy ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FileText size={12} />
);
if (questionMarkdown?.trim()) {
return (
<DropdownMenu>
<Tooltip content="下载为 Word">
<DropdownMenuTrigger asChild>
<Button
size="icon-sm"
type="button"
variant="ghost"
disabled={busy}
{...props}
>
{icon}
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => void handleDownload("answer")}>
<FileText className="text-muted-foreground" />
仅下载当前回答
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleDownload("qa")}>
<MessagesSquare className="text-muted-foreground" />
下载当前问题和回答
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<Tooltip content="下载为 Word">
<Button
@@ -41,14 +102,10 @@ export function DownloadWordButton({
type="button"
variant="ghost"
disabled={busy}
onClick={handleDownload}
onClick={() => void handleDownload("answer")}
{...props}
>
{busy ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FileText size={12} />
)}
{icon}
</Button>
</Tooltip>
);
@@ -1,7 +1,12 @@
"use client";
import { useMemo } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import type {
AnchorHTMLAttributes,
HTMLAttributes,
MouseEvent as ReactMouseEvent,
ReactNode,
} from "react";
import {
MessageResponse,
@@ -34,6 +39,12 @@ export type MarkdownContentProps = {
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"];
linkClassName?: (href: string, label: string) => string | undefined;
onLinkClick?: (
event: ReactMouseEvent<HTMLAnchorElement>,
href: string,
label: string,
) => void;
};
/** Renders markdown content. */
@@ -43,6 +54,8 @@ export function MarkdownContent({
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
linkClassName,
onLinkClick,
}: MarkdownContentProps) {
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const components = useMemo(() => {
@@ -93,6 +106,7 @@ export function MarkdownContent({
);
}
const finalHref = resolvedHref ?? props.href;
const label = extractTextFromChildren(props.children);
return (
<a
{...rest}
@@ -100,6 +114,7 @@ export function MarkdownContent({
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
linkClassName?.(finalHref || "", label),
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
@@ -110,6 +125,7 @@ export function MarkdownContent({
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
onLinkClick?.(event, finalHref || "", label);
rest.onClick?.(event);
}}
/>
@@ -117,7 +133,7 @@ export function MarkdownContent({
},
...componentsFromProps,
};
}, [anchorResolver, componentsFromProps]);
}, [anchorResolver, componentsFromProps, linkClassName, onLinkClick]);
if (!content) return null;
@@ -127,6 +143,7 @@ export function MarkdownContent({
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{content}
@@ -1,21 +1,22 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUp,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
MessageCircleQuestionMarkIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
import {
ChainOfThought,
@@ -31,61 +32,70 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
resolveSearchQuery,
type SearchResultItem,
} from "@/core/messages/search-results";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useSkills } from "@/core/skills/hooks";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
applyInlineCitations,
buildSkillDisplayMap,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts";
import { CitationLink } from "../citations/citation-link";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import {
isWeKnoraSourcePreviewUrl,
SourceCard,
} from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
export function MessageGroup({
className,
messages,
isLoading = false,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
}) {
export function MessageGroup({
className,
messages,
isLoading = false,
displaySources = [],
ragModeEnabled = false,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
@@ -93,13 +103,12 @@ export function MessageGroup({
const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
const [settings] = useLocalSettings();
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills],
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
return filteredSteps[filteredSteps.length - 1];
@@ -121,11 +130,29 @@ export function MessageGroup({
}
}, [lastToolCallStep, steps]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(null);
const handleCitationClick = useCallback(
(referenceId: string) => {
const referenceIndex = Number(referenceId);
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
const source =
displaySources.find((item) => item.index === referenceIndex) ??
displaySources[referenceIndex - 1];
if (source) setSelectedCitation(source);
},
[displaySources],
);
const reasoningId =
lastReasoningStep?.id ??
messages.find((message) => message.type === "ai")?.id;
return (
<ChainOfThought
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
open={true}
>
<>
<ChainOfThought
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
data-reference-batch-id={reasoningId}
open={true}
>
{aboveLastToolCallSteps.length > 0 && (
<Button
key="above"
@@ -160,10 +187,12 @@ export function MessageGroup({
<ChainOfThoughtStep
key={step.id}
label={
<MarkdownContent
<StepReasoningMarkdown
content={step.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/>
}
></ChainOfThoughtStep>
@@ -171,11 +200,11 @@ export function MessageGroup({
<ToolCall
key={step.id}
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/>
),
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled}
/>
),
)}
{lastToolCallStep && (
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
@@ -183,10 +212,10 @@ export function MessageGroup({
key={lastToolCallStep.id}
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/>
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled}
/>
</FlipDisplay>
)}
</ChainOfThoughtContent>
@@ -220,10 +249,12 @@ export function MessageGroup({
<ChainOfThoughtStep
key={lastReasoningStep.id}
label={
<MarkdownContent
<StepReasoningMarkdown
content={lastReasoningStep.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/>
}
></ChainOfThoughtStep>
@@ -231,11 +262,64 @@ export function MessageGroup({
)}
</>
)}
</ChainOfThought>
</ChainOfThought>
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
</>
);
}
function ToolCall({
function StepReasoningMarkdown({
content,
displaySources,
isLoading,
rehypePlugins,
onCitationClick,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onCitationClick: (referenceId: string) => void;
}) {
const markdown = useMemo(
() =>
applyInlineCitations(content, displaySources, { appendSummary: false }),
[content, displaySources],
);
const components = useMemo(() => {
if (displaySources.length === 0) return undefined;
return {
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return (
<CitationLink
href={href}
{...props}
onReferenceClick={onCitationClick}
/>
);
}
return <a href={href} {...props} />;
},
};
}, [displaySources.length, onCitationClick]);
return (
<MarkdownContent
content={markdown}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
);
}
function ToolCall({
id,
messageId,
name,
@@ -257,45 +341,51 @@ function ToolCall({
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
label={
displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
}
label={
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`)
}
icon={SearchIcon}
>
<DisplayResultsList results={displayItems} />
</ChainOfThoughtStep>
);
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
@@ -303,24 +393,24 @@ function ToolCall({
mode: "citation" as const,
}));
return (
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
</ChainOfThoughtStep>
);
}
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? `知识库检索 "${query}"`
: t.toolCalls.searchFor(query);
} else if (name === "web_search") {
label = "知识库检索";
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? `知识库检索 "${query}"`
: t.toolCalls.searchFor(query);
} else if (name === "web_search") {
label = "知识库检索";
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{searchItems.length > 0 && (
<WebSearchResultsList results={searchItems} />
)}
@@ -437,18 +527,18 @@ function ToolCall({
if (!description) {
description = t.toolCalls.writeFile;
}
return (
<ArtifactWriteToolCall
id={id}
messageId={messageId}
name={name}
args={args}
result={result}
description={description}
isLast={isLast}
isLoading={isLoading}
/>
);
return (
<ArtifactWriteToolCall
id={id}
messageId={messageId}
name={name}
args={args}
result={result}
description={description}
isLast={isLast}
isLoading={isLoading}
/>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
@@ -509,129 +599,242 @@ function ToolCall({
icon={WrenchIcon}
></ChainOfThoughtStep>
);
}
}
function DeepResearchProgressStep({
id,
args,
}: {
id?: string;
args: Record<string, unknown>;
}) {
const label = typeof args.label === "string" && args.label.trim()
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const active = args.status !== "complete";
return (
<ChainOfThoughtStep
key={id}
status={active ? "active" : "complete"}
icon={active
? <Loader2Icon className="text-primary size-4 animate-spin" />
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
label={
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>{label}</span>
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
{active ? "进行中" : "已完成"}
</span>
</span>
}
>
{detail && (
<div className="text-muted-foreground text-xs leading-5">
{detail}
</div>
)}
</ChainOfThoughtStep>
);
}
function ArtifactWriteToolCall({
id,
messageId,
name,
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
messageId?: string;
name: "write_file" | "str_replace";
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
description: string;
isLast: boolean;
isLoading: boolean;
}) {
const artifacts = useArtifactsOptional();
const openStreamedArtifact = artifacts?.openStreamedArtifact;
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: sourcePath;
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
return (
<ChainOfThoughtStep
key={id}
className={cn(artifactPath && "cursor-pointer")}
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) return;
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{normalizedPath || sourcePath}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
}
}
function DeepResearchProgressStep({
id,
args,
}: {
id?: string;
args: Record<string, unknown>;
}) {
const label = typeof args.label === "string" && args.label.trim()
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const thinkingContentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const programmaticScrollRef = useRef(false);
const previousActiveRef = useRef(active);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = thinkingContentRef.current;
if (!content) return;
// Scroll events do not identify their initiator reliably across browsers.
// Mark this assignment so it never disables following as if it were a
// user drag; a user-originated scroll away from the bottom still pauses it.
programmaticScrollRef.current = true;
content.scrollTop = content.scrollHeight;
requestAnimationFrame(() => {
programmaticScrollRef.current = false;
});
}, []);
useLayoutEffect(() => {
if (!active || !thinkingOpen || !thinking) return;
scrollToLatestThinking();
// A second frame covers the browser's delayed reflow of a growing <pre>.
const frame = requestAnimationFrame(scrollToLatestThinking);
return () => cancelAnimationFrame(frame);
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
// Being at the bottom is still the follow state. Only an actual move away
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
if (scrollTop + clientHeight >= scrollHeight - 4) return;
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
return (
<ChainOfThoughtStep
key={id}
status={active ? "active" : "complete"}
icon={active
? <Loader2Icon className="text-primary size-4 animate-spin" />
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
label={
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>{label}</span>
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
{active ? "进行中" : "已完成"}
</span>
</span>
}
>
{detail && (
<div className="text-muted-foreground text-xs leading-5">
{detail}
</div>
)}
{thinking && (
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
<CollapsibleTrigger asChild>
<Button
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={thinkingContentRef}
>
{thinking}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</ChainOfThoughtStep>
);
}
/** 文件卡片只显示文件名:宿主泄漏路径(F:\...\outputs\report.md)与虚拟路径
* (/mnt/user-data/outputs/report.md)统一收敛到最后一段,避免把服务端磁盘结构暴露给用户。 */
function artifactDisplayName(path: string): string {
const filename = path.replace(/\\/g, "/").split("/").filter(Boolean).at(-1);
return filename || path;
}
function ArtifactWriteToolCall({
id,
messageId,
name,
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
messageId?: string;
name: "write_file" | "str_replace";
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
description: string;
isLast: boolean;
isLoading: boolean;
}) {
const artifacts = useArtifactsOptional();
const openStreamedArtifact = artifacts?.openStreamedArtifact;
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: null;
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
return (
<ChainOfThoughtStep
key={id}
className={cn(artifactPath && "cursor-pointer")}
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) return;
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
}
@@ -734,7 +937,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
open && "rotate-180",
)}
/>
<span>{results.length} 条真实返回结果</span>
<span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => (
@@ -745,19 +948,43 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
);
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
const date = item.published_date ?? item.publishedDate ?? item.date;
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
const date = item.published_date ?? item.publishedDate ?? item.date;
let sourceLabel: string | undefined = item.source;
if (item.url) {
try {
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
} catch {
sourceLabel = sourceLabel ?? item.url;
}
}
return (
} catch {
sourceLabel = sourceLabel ?? item.url;
}
}
// MCP retrieval tools without a configured display adapter fall back to this
// generic result card. Preserve the normal web-search UI, but route our
// credential-free WeKnora pointer through SourceCard so its dialog exposes a
// deliberate “查看原文” action rather than rendering the internal route text.
if (isWeKnoraSourcePreviewUrl(item.url)) {
const weknoraSource: RuntimeDisplaySource = {
id: item.id ?? item.uuid ?? item.docId,
title: item.title ?? "检索结果",
type: item.source ?? "document",
snippet: item.snippet ?? item.content ?? "",
content,
source: item.source,
author: item.author,
time: date,
url: item.url,
index: 0,
skillName: "weknora-knowledge-search",
mode: "list",
raw: { ...item },
};
return <SourceCard source={weknoraSource} />;
}
return (
<>
<button
type="button"
@@ -8,6 +8,7 @@ import {
Loader2Icon,
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
@@ -18,6 +19,7 @@ import {
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import rehypeKatex from 'rehype-katex';
@@ -157,8 +159,9 @@ export function MessageListItem({
className,
fitRowToContent = false,
anchorId,
message,
isLoading,
message,
questionMarkdown,
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
@@ -175,7 +178,9 @@ export function MessageListItem({
* message. `scroll-mt` keeps the target clear of the overlay header.
*/
anchorId?: string;
message: Message;
message: Message;
/** Nearest preceding user question, used by the current-Q&A Word export. */
questionMarkdown?: string;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
@@ -325,10 +330,12 @@ export function MessageListItem({
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
/>
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
title="问答回答"
questionMarkdown={questionMarkdown}
/>
)}
{canSaveAssistantEdit && (
<Tooltip content="编辑消息">
@@ -693,6 +700,10 @@ function MessageContent_({
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const parsedHumanContent = useMemo(
() =>
isHuman
@@ -780,7 +791,7 @@ function MessageContent_({
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
) : (
@@ -789,7 +800,7 @@ function MessageContent_({
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
)}
@@ -838,7 +849,7 @@ function FrontendRagSourcesPanel({
const [open, setOpen] = useState(false);
if (sources.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
@@ -891,7 +902,7 @@ function FrontendRagSourcesPanel({
);
}
function SingleRoundRagCitationDialog({
export function SingleRoundRagCitationDialog({
source,
onOpenChange,
}: {
@@ -947,11 +958,14 @@ function MultiRoundRagContent({
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
if (!trace.isComplete) setOpen(true);
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
return (
<div className="space-y-3">
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
@@ -965,10 +979,10 @@ function MultiRoundRagContent({
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索过程</div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `已完成 ${trace.rounds.length} 轮检索`
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
@@ -980,14 +994,26 @@ function MultiRoundRagContent({
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<MultiRoundRagRoundCard
<div
key={round.id}
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
className={cn(
'relative pl-5',
index < trace.rounds.length - 1 && 'pb-1',
)}
>
{index < trace.rounds.length - 1 && (
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
)}
@@ -1017,85 +1043,244 @@ function MultiRoundRagContent({
function MultiRoundRagRoundCard({
index,
round,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
index: number;
round: MultiRoundRagRound;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!round.query || round.isSearching);
useEffect(() => {
// A newly created round stays expanded to expose its thought and loading
// query. Once that query has returned, retain only the round header until
// the user asks to inspect the completed detail again.
setOpen(!round.query || round.isSearching);
}, [round.id, round.isSearching, round.query]);
const thoughtContent = useMemo(
() =>
applyInlineCitations(round.thought, displaySources, {
appendSummary: false,
}),
[displaySources, round.thought],
);
return (
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
{index}
</span>
<span>{round.title}</span>
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.thought && (
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</section>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{round.query && (
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
<SearchIcon className="size-3 shrink-0" />
<span className="truncate">{round.query}</span>
</div>
)}
{round.retrievals.length > 0 && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">
检索内容 · {round.retrievals.length} 条
</div>
<div className="space-y-2">
{round.retrievals.map((item, resultIndex) => (
<details
key={`${item.id ?? item.title}-${resultIndex}`}
className="border-border/60 rounded-md border px-3 py-2"
open={resultIndex === 0}
<Collapsible open={open} onOpenChange={setOpen}>
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate">{round.title}</span>
{round.isSearching ? (
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
检索中
</span>
) : (
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
<div className="pt-3">
{round.thought && (
<MultiRoundRagDetailSection
title="思考内容"
icon={<SparklesIcon className="text-primary size-4" />}
>
<summary className="cursor-pointer list-none text-sm font-medium">
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
{item.title}
</summary>
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</div>
{(item.source || item.time) && (
<div className="text-muted-foreground mt-2 text-xs">
{[item.source, item.time].filter(Boolean).join(' · ')}
<MarkdownContent
content={thoughtContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</MultiRoundRagDetailSection>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-lg border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{(round.query || round.isSearching) && (
<div
className={cn(
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
round.isSearching && 'animate-pulse motion-reduce:animate-none',
)}
aria-live={round.isSearching ? 'polite' : undefined}
>
{round.isSearching ? (
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">检索词</span>
<span className="min-w-0 truncate font-medium">
{round.query || '正在生成检索词…'}
</span>
</div>
)}
{(round.query || round.isSearching || round.retrievals.length > 0) && (
<MultiRoundRagDetailSection
defaultOpen={!round.isSearching}
title="检索内容"
icon={<FileIcon className="text-primary size-3.5" />}
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
>
{round.isSearching ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
正在召回相关内容…
</div>
) : (
<div className="space-y-2">
{round.retrievals.length > 0 ? (
round.retrievals.map((item, resultIndex) => (
<MultiRoundRagResultCard
key={`${item.id ?? item.title}-${resultIndex}`}
item={item}
index={resultIndex + 1}
/>
))
) : (
<p className="text-muted-foreground py-1 text-sm">
本轮未召回相关资料。
</p>
)}
</div>
)}
</details>
))}
</MultiRoundRagDetailSection>
)}
</div>
</section>
)}
</div>
</CollapsibleContent>
</article>
</Collapsible>
);
}
function MultiRoundRagDetailSection({
title,
icon,
suffix,
defaultOpen = true,
children,
}: {
title: string;
icon: ReactNode;
suffix?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
{icon}
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
{suffix}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
{children}
</CollapsibleContent>
</section>
</Collapsible>
);
}
function MultiRoundRagResultCard({
item,
index,
}: {
item: MultiRoundRagRound['retrievals'][number];
index: number;
}) {
const [open, setOpen] = useState(index === 1);
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
{item.title}
</span>
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</p>
{(sourceMeta || item.url) && (
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{sourceMeta && <span>{sourceMeta}</span>}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
查看原文
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
@@ -11,8 +11,11 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import {
DOCUMENT_REWRITE_STARTED_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -25,11 +28,12 @@ import {
hasToolCalls,
isHiddenFromUIMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
@@ -46,8 +50,8 @@ import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import {
@@ -89,6 +93,22 @@ function ScrollToBottomOnUserMessage({
}
}, [messages, scrollToBottom]);
useEffect(() => {
const handleDocumentRewriteStarted = (event: Event) => {
const detail = (event as CustomEvent<{ threadId?: string }>).detail;
if (detail?.threadId !== threadId) return;
// The presentation card is inserted by a context update in the same
// interaction. Two frames let React commit it before the smooth scroll.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
void scrollToBottom("smooth");
});
});
};
window.addEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]);
return null;
}
@@ -112,10 +132,14 @@ export function MessageList({
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
}: {
onSetupReportStructureApprove,
onSetupReportStructureReject,
researchApprovalSlot,
taskReportImportSlot,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -137,24 +161,34 @@ export function MessageList({
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupReportStructureApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onSetupReportStructureReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
/**
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
*/
researchApprovalSlot?: (message: Message) => React.ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
researchApprovalSlot?: (message: Message) => React.ReactNode;
/**
* agentfx「入库」协助卡插槽:任务工作区宿主(AgentChatPage taskBind)向 displayThread
* 注入带 `additional_kwargs.task_report_import_approval` 的虚拟 ai 消息,由本插槽渲染
* 「是否入库」卡片。不传该 slot 时(普通对话等)该分组不渲染,零行为变化。
*/
taskReportImportSlot?: (message: Message) => React.ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
const documentRewritePresentation = useDocumentRewritePresentation();
const updateSubtask = useUpdateSubtask();
const documentRewritePresentation = useDocumentRewritePresentation();
const { t } = useI18n();
const threadContext = useContext(ThreadContext);
// Read-only contexts (public share, mock previews) must not offer deletion.
@@ -194,7 +228,7 @@ export function MessageList({
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
@@ -205,9 +239,9 @@ export function MessageList({
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
@@ -252,9 +286,31 @@ export function MessageList({
return map;
}, [messages, askTimes]);
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
// Associate each assistant answer with the nearest preceding user question.
// This powers the per-answer Word menu without changing message persistence.
const questionMarkdownByAssistantId = useMemo(() => {
const map = new Map<string, string>();
let currentQuestion = "";
for (const message of messages) {
if (message.type === "human") {
currentQuestion = extractContentFromMessage(message)?.trim() ?? "";
} else if (message.type === "ai" && message.id && currentQuestion) {
map.set(message.id, currentQuestion);
}
}
return map;
}, [messages]);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
const hasSingleRoundRag = useMemo(
() =>
messages.some(
@@ -322,6 +378,32 @@ export function MessageList({
return map;
}, [thread.messages]);
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
const frontendRagSources = msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
});
},
[
frontendRagSourcesByAiId,
messages,
referenceBatches,
referencesEnabled,
skillDisplayMap,
],
);
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet).
@@ -438,44 +520,35 @@ export function MessageList({
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
{(() => {
const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
const messageIsLoading =
thread.isLoading ||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
const isAssistantMessage =
return group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
const messageIsLoading =
thread.isLoading ||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
const isAssistantMessage =
referencesEnabled &&
group.type === "assistant" &&
msg.type === "ai";
const batchSources = isAssistantMessage
? referenceBatches.find(
(batch) => batch.assistant_message_id === msg.id,
)?.sources ?? []
: [];
// 单轮/多轮 RAG 的参考资料来自隐藏的 tool 消息,优先于通用扫描。
const frontendRagSources =
isAssistantMessage && msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
const displaySources = isAssistantMessage
? batchSources.length > 0
? batchSources
: frontendRagSources.length > 0
? frontendRagSources
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
? resolveDisplaySources(msg)
: [];
return (
<MessageListItem
@@ -485,9 +558,12 @@ export function MessageList({
msg.type === "human"
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
isLoading={messageIsLoading}
}
message={msg}
questionMarkdown={
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
}
isLoading={messageIsLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
@@ -582,15 +658,40 @@ export function MessageList({
/>
</div>
) : null;
} else if (group.type === "assistant:setup-report-structure-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupReportStructureApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupReportStructureApprove}
onReject={onSetupReportStructureReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:research-approval") {
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
const message = group.messages[0];
return researchApprovalSlot && message ? (
<div key={group.id} className="w-full pb-2">
{researchApprovalSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:task-report-import") {
// agentfx「入库」协助卡:宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return taskReportImportSlot && message ? (
<div key={group.id} className="w-full pb-2">
{taskReportImportSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -599,8 +700,15 @@ export function MessageList({
files.push(...presentFiles);
}
}
const rewriteAbove = !didRenderRewriteAboveFile
? rewriteItemsAboveFile
: [];
didRenderRewriteAboveFile = true;
return (
<div className="w-full" key={group.id}>
{rewriteAbove.map((item) => (
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
))}
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
@@ -610,11 +718,11 @@ export function MessageList({
/>
)}
<ArtifactFileList
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
onDownloadArtifact={onDownloadArtifact}
/>
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
onDownloadArtifact={onDownloadArtifact}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -678,11 +786,13 @@ export function MessageList({
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={thread.isLoading}
/>,
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
ragModeEnabled={ragModeEnabled}
/>,
);
}
results.push(
@@ -725,12 +835,17 @@ export function MessageList({
message.type === "ai" &&
(hasToolCalls(message) ? true : !hasContent(message)),
);
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
return (
<div key={"group-" + group.id} className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
/>
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
ragModeEnabled={ragModeEnabled}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -738,11 +853,21 @@ export function MessageList({
/>
</div>
);
})}
{documentRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{thread.isLoading && <StreamingIndicator className="my-4" />}
});
const leftoverRewriteItems = didRenderRewriteAboveFile
? rewriteItemsBelowFile
: documentRewriteItems;
return (
<>
{grouped}
{leftoverRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{afterDocumentRewriteItems}
</>
);
})()}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
@@ -799,6 +924,15 @@ export type SetupWritingApprovalPayload = {
outline?: string;
};
export type SetupReportStructureApprovalPayload = {
title?: string;
name?: string;
content?: string;
structure_mode?: string;
retrieval_directions?: string[];
retrieval_skills?: string[];
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -1169,6 +1303,147 @@ function SetupWritingApprovalCard({
);
}
function getSetupReportStructureApprovalPayload(
message: unknown,
): SetupReportStructureApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_report_structure_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupReportStructureApprovalPayload;
}
const STRUCTURE_MODE_LABELS: Record<string, string> = {
fixed: "固定大纲",
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupReportStructureApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
</div>
{approvalPayload.title ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">标题</div>
<p className="text-sm font-medium">{approvalPayload.title}</p>
</div>
) : null}
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.name}</p>
</div>
) : null}
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{approvalPayload.structure_mode ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">结构约束</div>
<p className="text-sm font-medium">
{STRUCTURE_MODE_LABELS[approvalPayload.structure_mode] ?? approvalPayload.structure_mode}
</p>
</div>
) : null}
{directions.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索方向</div>
<p className="text-sm font-medium">{directions.join("、")}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索来源</div>
<p className="text-sm font-medium">{skills.join("、")}</p>
</div>
) : null}
</div>
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">报告大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
@@ -66,6 +66,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
@@ -193,13 +194,9 @@ export function RecentChatList({
);
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
const visibleThreads = sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
if (visibleThreads.length === 0) {
@@ -209,7 +206,10 @@ export function RecentChatList({
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroup
className="px-2 pt-3 mt-2 border-t border-sidebar-border/60"
data-compact-guide="recent-chats"
>
<SidebarGroupLabel
className={cn(
"text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none flex items-center justify-between transition-colors",
@@ -54,6 +54,7 @@ import type {
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return createClientId("shortcut");
}
function newShortcutParam(): CompactShortcutRouteParam {
@@ -119,7 +117,7 @@ function normalizeShortcutDraft(
? button.route_params
: [newShortcutParam()]
).map((param) => ({
key: param.key || "",
key: param.paramType === "path" ? "" : param.key || "",
paramType: param.paramType || "login",
source: param.source || "access_token",
customValue: param.customValue ?? "",
@@ -134,8 +132,18 @@ function cleanShortcutParams(
params: CompactShortcutRouteParam[],
): CompactShortcutRouteParam[] {
return params
.filter((param) => param.key.trim())
.filter((param) => param.paramType === "path" || param.key.trim())
.map((param) => {
if (param.paramType === "path") {
const source = param.source || "access_token";
return {
key: "",
paramType: "path" as const,
source,
customValue:
source === "other" ? (param.customValue ?? "").trim() : undefined,
};
}
const base: CompactShortcutRouteParam = {
key: param.key.trim(),
paramType: param.paramType,
@@ -197,6 +205,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
useEffect(() => {
setShortcutDraft(
@@ -356,6 +366,13 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
return;
}
for (const param of item.route_params) {
if (param.paramType === "path") {
if (param.source === "other" && !param.customValue?.trim()) {
toast.error("路径参数自定义值不可为空");
return;
}
continue;
}
if (!param.key.trim()) {
toast.error("路由参数键名不可为空");
return;
@@ -550,7 +567,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<Separator />
<SettingsSection
title="顶部快捷按钮(管理员)"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停,以及登录、主题和路径参数。"
>
<CompactShortcutSettings
buttons={shortcutDraft}
@@ -830,23 +847,45 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
aria-label="回答速度指示器"
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">RAG 引用模式</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</p>
</div>
<Switch
checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked })
}
aria-label="RAG 引用模式"
/>
</div>
{isAdmin && (
<div className="rounded-lg border p-4">
{isAdmin && (
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">参考文献模式入口</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
管理员配置。开启后,普通问答和 iframe 问答输入区会显示「参考文献」按钮;默认关闭。
</p>
</div>
<Switch
checked={referenceModeEntryEnabled}
disabled={updateCitationDisplay.isPending}
onCheckedChange={(checked) =>
updateCitationDisplay.mutate({
reference_mode_enabled: checked,
})
}
aria-label="参考文献模式入口"
/>
</div>
)}
{referenceModeEntryEnabled && (
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">RAG 引用模式</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</p>
</div>
<Switch
checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked })
}
aria-label="RAG 引用模式"
/>
</div>
)}
{isAdmin && (
<div className="rounded-lg border p-4">
<div className="mb-3">
<div className="text-sm font-medium">参考文献去除词</div>
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
@@ -917,7 +956,7 @@ function CompactShortcutSettings({
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
已配置 {buttons.length} 个按钮;支持追加登录、主题及路径参数。
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
@@ -1014,7 +1053,7 @@ function CompactShortcutSettings({
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm font-medium">
<ExternalLink className="size-4" />
路由参数
@@ -1030,13 +1069,13 @@ function CompactShortcutSettings({
</Button>
</div>
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
const themeMap =
param.paramType === "theme"
? parseThemeMap(param.themeValue)
@@ -1046,26 +1085,36 @@ function CompactShortcutSettings({
key={`${button.id}-${paramIndex}`}
className="space-y-2 rounded-md border bg-background p-2"
>
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
<div
className={cn(
"grid gap-2",
param.paramType === "path"
? "md:grid-cols-[130px_minmax(180px,1fr)_32px]"
: "md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]",
)}
>
{param.paramType !== "path" && (
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
)}
<Select
value={param.paramType}
onValueChange={(value) =>
onPatchParam(button.id, paramIndex, {
paramType: value as "login" | "theme",
paramType: value as "login" | "theme" | "path",
key: value === "path" ? "" : param.key,
source:
value === "login"
? param.source || "access_token"
: undefined,
value === "theme"
? undefined
: param.source || "access_token",
})
}
>
@@ -1075,9 +1124,10 @@ function CompactShortcutSettings({
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
<SelectItem value="path">路径参数</SelectItem>
</SelectContent>
</Select>
{param.paramType === "login" ? (
{(param.paramType === "login" || param.paramType === "path") ? (
<div className="flex min-w-0 gap-2">
<Select
value={param.source || "access_token"}
@@ -1103,6 +1153,11 @@ function CompactShortcutSettings({
</Select>
{param.source === "other" && (
<Input
// This config value becomes a query parameter;
// it is not a login form field. A password input
// makes Chrome invoke password-manager heuristics
// whenever the settings dialog opens.
type="text"
value={param.customValue ?? ""}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
@@ -1111,6 +1166,8 @@ function CompactShortcutSettings({
}
placeholder="自定义值"
className="w-32 text-xs"
autoComplete="off"
aria-label="自定义参数值"
/>
)}
</div>
@@ -1133,6 +1190,12 @@ function CompactShortcutSettings({
</Button>
</div>
{param.paramType === "path" && (
<p className="text-muted-foreground text-xs">
不填写参数名,按添加顺序拼到链接或 Hash 路由的路径末尾。
</p>
)}
{param.paramType === "theme" && (
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
{APP_THEME_COLORS.map((color) => (
@@ -1166,9 +1229,9 @@ function CompactShortcutSettings({
)}
</div>
);
})}
</div>
)}
})}
</div>
)}
</div>
</div>
))}
@@ -43,6 +43,7 @@ import {
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { createClientId } from "@/lib/client-id";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
return `custom-theme-${suffix}`;
}
@@ -25,7 +25,8 @@ import {
Trash2Icon,
TrendingUpIcon,
Upload,
UserIcon,
UserIcon,
WorkflowIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -50,13 +51,6 @@ import {
EmptyTitle,
} from "@/components/ui/empty";
import {
Item,
ItemActions,
ItemTitle,
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -756,7 +750,7 @@ export function SkillRow({
const badgeGroup = (
<div className="flex flex-wrap items-center gap-2">
<span className={cn(isCard && "truncate font-medium")}>
<span className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-100">
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
</span>
{skill.name_zh?.trim() && (
@@ -861,65 +855,12 @@ export function SkillRow({
</div>
);
const metaNodes = (
<>
{!isBuiltin && skill.owner_username && (
<button
type="button"
disabled={!skill.owner_user_id || !onPublisherClick}
onClick={(e) => {
if (!skill.owner_user_id || !onPublisherClick) return;
e.stopPropagation();
onPublisherClick({
id: skill.owner_user_id,
label: desensitize(skill.owner_username!),
});
}}
className={cn(
"text-muted-foreground mt-1 flex max-w-full items-center gap-1 text-sm font-medium",
skill.owner_user_id && onPublisherClick &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
skill.owner_user_id && onPublisherClick
? `点击筛选发布者:${desensitize(skill.owner_username)}`
: `发布者:${desensitize(skill.owner_username)}`
}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
</button>
)}
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
)}
{formatDateTime(skill.created_at) && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<ClockIcon className="size-3.5 shrink-0" />
<span className="truncate">
创建时间:{formatDateTime(skill.created_at)}
</span>
</div>
)}
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" />
)}
</>
);
const actionButtons = (
<>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
@@ -930,7 +871,7 @@ export function SkillRow({
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.featured && "text-amber-600 hover:text-amber-700",
)}
disabled={setFeatured.isPending}
@@ -951,7 +892,7 @@ export function SkillRow({
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
@@ -972,7 +913,7 @@ export function SkillRow({
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
skill.always_on && "text-amber-600 hover:text-amber-700",
)}
disabled={setAlwaysOn.isPending}
@@ -992,7 +933,7 @@ export function SkillRow({
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
@@ -1011,7 +952,7 @@ export function SkillRow({
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
@@ -1022,7 +963,7 @@ export function SkillRow({
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
@@ -1031,39 +972,125 @@ export function SkillRow({
)}
</>
);
const iconStyle = skill.name.includes("web") || skill.name.includes("search")
? {
Icon: GlobeIcon,
className: "bg-emerald-600 text-white shadow-[0_6px_16px_rgba(22,163,74,0.22)]",
}
: skill.name.includes("doc") || skill.name.includes("file")
? {
Icon: FileArchive,
className: "bg-violet-600 text-white shadow-[0_6px_16px_rgba(124,58,237,0.22)]",
}
: skill.source === "agent"
? {
Icon: WorkflowIcon,
className: "bg-[#2166e8] text-white shadow-[0_6px_16px_rgba(37,99,235,0.22)]",
}
: {
Icon: Upload,
className: "bg-amber-500 text-white shadow-[0_6px_16px_rgba(245,158,11,0.22)]",
};
const SkillIcon = iconStyle.Icon;
const activityAt = formatDateTime(skill.last_activity_at ?? skill.created_at);
const actionArea = (
<div
className="flex flex-wrap items-center justify-end gap-1"
onClick={(event) => event.stopPropagation()}
>
{actionButtons}
</div>
);
return (
<>
{isCard ? (
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex h-full min-w-0 flex-col rounded-xl border border-slate-200 bg-white p-5 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)}
>
{badgeGroup}
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
{desensitize(skill.description ?? "")}
</p>
{metaNodes}
<div className="flex items-start gap-3.5">
<div className={cn("flex size-12 shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
<SkillIcon className="size-6" />
</div>
<div className="min-w-0 flex-1">
{badgeGroup}
<p className="mt-1.5 line-clamp-3 text-sm leading-6 text-slate-600 dark:text-slate-400">
{desensitize(skill.description ?? "")}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
{skill.enabled ? "已启用" : "未启用"}
</Badge>
<span className="text-xs text-slate-400">{skill.use_count ? `调用 ${skill.use_count} 次` : "暂无调用"}</span>
{activityAt && <span className="text-xs text-slate-400">更新于 {activityAt}</span>}
</div>
{(skill.tags?.length ?? 0) > 0 && <TagChipList tags={skill.tags!} className="mt-3" />}
</div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
{actionButtons}
<div className="mt-4 border-t border-slate-100 pt-3 dark:border-slate-800">
{actionArea}
</div>
</div>
) : (
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
>
<ItemTitle>{badgeGroup}</ItemTitle>
<ItemDescription className="line-clamp-4">
{desensitize(skill.description ?? "")}
</ItemDescription>
{metaNodes}
</ItemContent>
<ItemActions>{actionButtons}</ItemActions>
</Item>
<div className="group/skill-card w-full rounded-xl border border-slate-200 bg-white px-5 py-4 shadow-[0_5px_18px_rgba(15,23,42,0.03)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.65fr)_minmax(150px,0.72fr)_minmax(100px,0.42fr)_minmax(132px,0.58fr)_auto] xl:items-center xl:gap-x-6">
<div className="flex min-w-0 cursor-pointer items-start gap-4" onClick={() => setDetailOpen(true)}>
<div className={cn("flex size-[60px] shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
<SkillIcon className="size-7" />
</div>
<div className="min-w-0 flex-1 pt-0.5">
{badgeGroup}
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-slate-600 dark:text-slate-400">
{desensitize(skill.description ?? "")}
</p>
{!isBuiltin && skill.owner_username && (
<button
type="button"
disabled={!skill.owner_user_id || !onPublisherClick}
onClick={(event) => {
event.stopPropagation();
if (!skill.owner_user_id || !onPublisherClick) return;
onPublisherClick({ id: skill.owner_user_id, label: desensitize(skill.owner_username!) });
}}
className={cn(
"mt-2 flex max-w-full items-center gap-1 text-xs text-slate-400",
skill.owner_user_id && onPublisherClick && "cursor-pointer hover:text-blue-600 hover:underline",
)}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
</button>
)}
</div>
</div>
<div className="min-w-0 xl:border-l xl:border-slate-100 xl:pl-5 xl:dark:border-slate-800">
{(skill.tags?.length ?? 0) > 0 ? (
<TagChipList tags={skill.tags!} />
) : (
<span className="text-xs text-slate-400">未设置标签</span>
)}
</div>
<div className="min-w-0 text-sm text-slate-600 dark:text-slate-300">
<span className="block text-xs text-slate-400 xl:hidden">使用次数</span>
{skill.use_count ? `${skill.use_count} 次` : "暂无调用"}
</div>
<div className="min-w-0">
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
{skill.enabled ? "已启用" : "未启用"}
</Badge>
{activityAt && <p className="mt-2 truncate text-xs text-slate-400" title={activityAt}>更新于 {activityAt}</p>}
</div>
<div className="min-w-0 border-t border-slate-100 pt-3 xl:border-t-0 xl:pt-0 dark:border-slate-800">
{actionArea}
</div>
</div>
</div>
)}
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
@@ -1201,7 +1228,24 @@ function InstallSkillDialog({
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
const validateFile = useCallback(
async (selected: File, category: "custom" | "public") => {
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync({
file: selected,
targetCategory: category,
});
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[validateMutation],
);
const reset = useCallback(() => {
setFile(null);
@@ -1240,19 +1284,12 @@ function InstallSkillDialog({
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error(dialog.rejectInvalidExt);
return;
}
setFile(selected);
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync(selected);
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[dialog.rejectInvalidExt, validateMutation],
);
}
setFile(selected);
await validateFile(selected, targetCategory);
},
[dialog.rejectInvalidExt, targetCategory, validateFile],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
@@ -1329,12 +1366,29 @@ function InstallSkillDialog({
}
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && validation.can_install_as_is;
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
const customConflictMessage = !validation
? ""
: otherCustomConflict
? dialog.conflictOwnedByOther
.replace("{name}", validation.skill_name)
.replace(
"{owner}",
validation.owner_username ?? validation.owner_user_id ?? "—",
)
: validation.owned_by_me
? (ownCustomCanOverwrite
? dialog.conflictOwnedByMe
: dialog.conflictOwnedByMeWrongTarget
).replace("{name}", validation.skill_name)
: dialog.conflictCustom.replace("{name}", validation.skill_name);
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
@@ -1453,9 +1507,12 @@ function InstallSkillDialog({
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => setTargetCategory(cat)}
value={cat}
checked={targetCategory === cat}
onChange={() => {
setTargetCategory(cat);
if (file) void validateFile(file, cat);
}}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
@@ -1513,15 +1570,18 @@ function InstallSkillDialog({
</ul>
</Field>
)}
{isCustomConflict && (
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{desensitize(
dialog.conflictCustom.replace("{name}", validation.skill_name),
)}
</span>
</div>
{isCustomConflict && (
<div
className={cn(
"mt-1 flex items-start gap-2 text-xs",
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
)}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{desensitize(customConflictMessage)}
</span>
</div>
)}
{isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
@@ -1,4 +1,4 @@
import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react";
import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -9,12 +9,22 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import {
parseWeKnoraPreviewUrl,
resolveWeKnoraSourceTarget,
} from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import { useState } from "react";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
/** Whether a source URL is an internal WeKnora preview pointer. */
export function isWeKnoraSourcePreviewUrl(url?: string | null): boolean {
return parseWeKnoraPreviewUrl(url) !== null;
}
export function SkillDisplaySources({
highlightedReferenceId,
@@ -52,17 +62,31 @@ export function SourceCard({
source: RuntimeDisplaySource;
referenceTarget?: boolean;
}) {
const [open, setOpen] = useState(false);
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
const [contextLoading, setContextLoading] = useState(false);
const [contextError, setContextError] = useState<string | null>(null);
const [relatedOpen, setRelatedOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const previewTarget = resolveWeKnoraSourceTarget({
...(source.raw || {}),
url: source.url,
});
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const snippet = compactReferenceText(content).slice(0, 280);
const paragraphs = segmentReferenceText(content);
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
const matchedHeading = isWikiSource
? String(
(source.raw?.matched_heading as string | undefined) ||
(source.raw?.matched_sections as Array<{ heading?: string }> | undefined)?.find(
(section) => section.heading,
)?.heading ||
"",
).trim()
: "";
const summary = isWikiSource && typeof source.raw?.summary === "string"
? source.raw.summary
: "";
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
const score =
typeof source.score === "number"
? source.score <= 1
@@ -71,64 +95,43 @@ export function SourceCard({
: null;
const meta = [
source.source,
isWikiSource ? "检索对象:Wiki 文档" : null,
matchedHeading ? `命中章节:${matchedHeading}` : null,
source.author ? `作者 ${source.author}` : null,
source.type && source.type !== "result" ? source.type : null,
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
source.time,
score ? `相关度 ${score}` : null,
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
const previewTarget = parseWeKnoraPreviewUrl(source.url);
useEffect(() => {
if (!open || !previewTarget) {
return;
const openSource = () => {
if (previewTarget) {
setDetailOpen(true);
} else {
setRelatedOpen(true);
}
let cancelled = false;
setContext(null);
setContextError(null);
setContextLoading(true);
const params = new URLSearchParams({
knowledge_base_id: previewTarget.knowledgeBaseId,
chunk_id: previewTarget.chunkId,
});
void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`)
.then(async (response) => {
const payload = (await response.json().catch(() => null)) as unknown;
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
throw new Error(readPreviewError(payload, response.status));
}
if (!cancelled) {
setContext(payload.data);
}
})
.catch((error: unknown) => {
if (!cancelled) {
setContextError(error instanceof Error ? error.message : "无法加载原文上下文");
}
})
.finally(() => {
if (!cancelled) {
setContextLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
};
return (
<>
<button
<div
id={referenceTarget ? `reference-${source.index}` : undefined}
type="button"
role="button"
tabIndex={0}
className={cn(
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
"target:border-amber-300 target:bg-amber-50/80",
highlighted &&
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
"cursor-pointer",
)}
onClick={() => setOpen(true)}
onClick={openSource}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openSource();
}
}}
>
<div className="flex items-start gap-3">
{showReferenceNumber && (
@@ -156,20 +159,40 @@ export function SourceCard({
</p>
)}
</div>
{source.url && (
{(source.url || isWikiSource) && (
previewTarget ? (
<span className="text-primary bg-primary/10 mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
<FileTextIcon className="size-3.5" />
查看原文
<span className={cn(
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
isWikiSource
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-primary/10 text-primary",
)}>
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
{isWikiSource ? "Wiki 文档" : "查看原文"}
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
)
)}
</div>
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
{previewTarget && (
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
)}
</div>
{previewTarget && (
<WeKnoraSourceDrawer
open={detailOpen}
target={previewTarget}
title={source.title}
quote={content}
onOpenChange={setDetailOpen}
/>
)}
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
<DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1">
@@ -205,19 +228,23 @@ export function SourceCard({
)}
</div>
</DialogHeader>
{previewTarget ? (
<WeKnoraSourceContextPanel
context={context}
loading={contextLoading}
error={contextError}
/>
) : paragraphs.length > 0 ? (
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
{paragraphs.map((paragraph, index) => (
<p key={index} className="whitespace-pre-wrap">
{paragraph}
</p>
))}
{content ? (
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{isWikiSource && (
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<BookOpenTextIcon className="size-4" />
本次检索命中的 Wiki 文档内容
</div>
)}
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</article>
</div>
) : null}
{source.url && !previewTarget && (
@@ -226,161 +253,11 @@ export function SourceCard({
</div>
)}
</DialogContent>
</Dialog>
</Dialog>}
</>
);
}
type WeKnoraPreviewTarget = {
knowledgeBaseId: string;
chunkId: string;
};
type WeKnoraSourceContext = {
knowledge_base_name?: string;
document_title?: string;
document_file_name?: string;
display_mode?: "wiki" | "chunks";
focus_chunk_id: string;
wiki_page?: {
id?: string;
slug?: string;
title?: string;
page_type?: string;
status?: string;
summary?: string;
content?: string;
source_refs?: string[];
} | null;
chunks: Array<{
id: string;
chunk_index?: number;
content: string;
is_focus: boolean;
}>;
};
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) {
return null;
}
const queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim();
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
}
function isWeKnoraSourceContextResponse(
value: unknown,
): value is { success: true; data: WeKnoraSourceContext } {
if (!value || typeof value !== "object") {
return false;
}
const data = (value as { data?: unknown }).data;
return (
(value as { success?: unknown }).success === true &&
!!data &&
typeof data === "object" &&
Array.isArray((data as WeKnoraSourceContext).chunks)
);
}
function readPreviewError(payload: unknown, status: number): string {
if (payload && typeof payload === "object") {
const detail = (payload as { detail?: unknown }).detail;
if (typeof detail === "string" && detail) {
return detail;
}
}
return status === 404 ? "原文已不可用" : "无法加载原文上下文";
}
function WeKnoraSourceContextPanel({
context,
loading,
error,
}: {
context: WeKnoraSourceContext | null;
loading: boolean;
error: string | null;
}) {
if (loading) {
return (
<div className="text-muted-foreground flex min-h-36 items-center justify-center gap-2 text-sm">
<LoaderCircle className="size-4 animate-spin" />
正在加载原文上下文…
</div>
);
}
if (error) {
return (
<div className="border-destructive/30 bg-destructive/5 text-destructive rounded-lg border p-3 text-sm">
{error}
</div>
);
}
if (!context) {
return null;
}
const wikiPage = context.wiki_page;
const wikiContent = cleanReferenceText(
[wikiPage?.summary, wikiPage?.content].filter(Boolean).join("\n\n"),
[],
);
if (wikiPage && wikiContent) {
return (
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
<span> · LLMWiki</span>
{wikiPage.page_type && <span> · {wikiPage.page_type}</span>}
</div>
<section className="border-primary/40 bg-primary/5 rounded-lg border p-3 text-sm leading-relaxed shadow-sm">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{wikiPage.title || wikiPage.slug || "LLMWiki 页面"}
</span>
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 text-xs font-medium">
清洗页
</span>
</div>
<div className="space-y-3 whitespace-pre-wrap">{wikiContent}</div>
</section>
</div>
);
}
return (
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
{context.document_title && <span> · {context.document_title}</span>}
</div>
{context.chunks.map((chunk) => (
<section
key={chunk.id}
className={cn(
"rounded-lg border p-3 text-sm leading-relaxed",
chunk.is_focus
? "border-primary/50 bg-primary/5 shadow-sm"
: "bg-muted/20",
)}
>
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs">
<span>分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"}</span>
{chunk.is_focus && (
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium">
检索命中
</span>
)}
</div>
<p className="whitespace-pre-wrap">{chunk.content}</p>
</section>
))}
</div>
);
}
function cleanReferenceText(
raw: string | undefined,
cleanupWords: string[],
@@ -405,44 +282,18 @@ function compactReferenceText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function segmentReferenceText(text: string): string[] {
const explicit = text
.split(/\n{2,}/)
.map((item) => item.trim())
.filter(Boolean);
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
return base.slice(0, 80);
}
function splitLongParagraph(text: string): string[] {
const normalized = text
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.join("\n");
if (normalized.length <= 360) {
return normalized ? [normalized] : [];
}
const sentences = normalized
.split(/(?<=[。!?!?;;])\s*/)
.map((item) => item.trim())
.filter(Boolean);
if (sentences.length <= 1) {
return normalized.match(/[\s\S]{1,360}/g) ?? [];
}
const paragraphs: string[] = [];
let current = "";
for (const sentence of sentences) {
if ((current + sentence).length > 360 && current) {
paragraphs.push(current.trim());
current = "";
}
current += sentence;
}
if (current.trim()) {
paragraphs.push(current.trim());
}
return paragraphs;
function plainTextReferencePreview(text: string): string {
return compactReferenceText(
text
.replace(/^---[\s\S]*?---\s*/m, "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+[.)]\s+/gm, "")
.replace(/[>*_`~|]/g, " "),
);
}
@@ -1,10 +1,10 @@
import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
@@ -17,18 +17,18 @@ export function ThreadTitle({
threadId: string;
thread: BaseStream<AgentThreadState>;
}) {
const { t } = useI18n();
const { isNewThread } = useThreadChat();
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = displayTitle;
} else if (isNewThread) {
_title = t.pages.newChat;
}
const { t } = useI18n();
const { isNewThread } = useThreadChat();
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = displayTitle;
} else if (isNewThread) {
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`;
} else {
@@ -38,18 +38,21 @@ export function ThreadTitle({
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
displayTitle,
]);
t.pages.appName,
thread.isThreadLoading,
thread.values,
displayTitle,
]);
if (!thread.values?.title) {
return null;
}
return (
<FlipDisplay uniqueKey={threadId}>
{desensitize(displayTitle)}
</FlipDisplay>
);
}
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className="min-w-0 w-full">
<span className="block truncate" title={visibleTitle}>
{visibleTitle}
</span>
</FlipDisplay>
);
}
@@ -1,6 +1,6 @@
"use client";
import { User } from "lucide-react";
import { UserRound } from "lucide-react";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useMyPosition } from "@/core/positions";
@@ -15,7 +15,13 @@ import { cn } from "@/lib/utils";
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
* 而非 Header 的白字贴 banner 方案。
*/
export function UserIdentityBadge({ className }: { className?: string }) {
export function UserIdentityBadge({
className,
variant = "plain",
}: {
className?: string;
variant?: "plain" | "topbar";
}) {
// userInfo belongs to external-app route parameters and can legitimately
// outlive a login switch. The visible account name must instead match the
// settings page's current auth identity.
@@ -26,13 +32,33 @@ export function UserIdentityBadge({ className }: { className?: string }) {
return (
<div
className={cn("flex shrink-0 items-center gap-2", className)}
data-workspace-user={variant}
className={cn(
"flex shrink-0 items-center gap-2",
variant === "topbar" &&
"h-10 max-w-[240px] rounded-lg px-1.5 py-1 text-slate-700 dark:text-slate-200",
className,
)}
title={positionName ? `${displayName}·${positionName}` : displayName}
>
<div className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full">
<User className="size-3.5" />
<div
className={cn(
"bg-muted text-muted-foreground relative flex size-7 shrink-0 items-center justify-center rounded-full",
variant === "topbar" &&
"bg-primary text-primary-foreground size-8 shadow-[0_4px_12px_-6px_rgba(15,23,42,0.26)] ring-1 ring-primary/15",
)}
>
<UserRound className={cn("size-3.5", variant === "topbar" && "size-4")} />
{variant === "topbar" && (
<span className="absolute right-0 bottom-0 size-2.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-950" />
)}
</div>
<div className="hidden min-w-0 text-left sm:block">
<div
className={cn(
"hidden min-w-0 text-left sm:block",
variant === "topbar" && "max-w-[165px]",
)}
>
<p className="text-foreground truncate text-sm font-medium leading-tight">
{displayName}
</p>
@@ -12,8 +12,11 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { ThemeSwitcher } from "@/components/workspace/appearance-controls";
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
export function WorkspaceContainer({
className,
@@ -93,9 +96,19 @@ export function WorkspaceHeader({
</BreadcrumbList>
</Breadcrumb>
</div>
{rightActions ? (
<div className="flex items-center gap-2 px-4">{rightActions}</div>
) : null}
<div className="flex items-center gap-2 px-4">
{rightActions}
{!env.FORCE_COLLAPSED_SIDEBAR && (
<>
<ThemeSwitcher tone="content" />
<div
aria-hidden="true"
className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"
/>
</>
)}
<UserIdentityBadge variant="topbar" />
</div>
</header>
);
}
@@ -123,7 +136,8 @@ function nameOfSegment(
t: ReturnType<typeof useI18n>["t"],
) {
if (!segment) return t.common.home;
if (segment === "workspace") return t.breadcrumb.workspace;
if (segment === "chats") return t.breadcrumb.chats;
return segment[0]?.toUpperCase() + segment.slice(1);
}
if (segment === "workspace") return t.breadcrumb.workspace;
if (segment === "chats") return t.breadcrumb.chats;
if (segment === "agents") return t.agents.title;
return segment[0]?.toUpperCase() + segment.slice(1);
}
@@ -5,21 +5,26 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import cmAssistantLogoDark from "@/assets/img/cm-assistant-logo.png";
import cmAssistantLogoLight from "@/assets/img/cm-assistant-logo-red.png";
import { useTheme } from "@/contexts/ThemeContext";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) {
const { theme } = useTheme();
const brandLogoSrc =
theme === "dark"
? cmAssistantLogoDark
: cmAssistantLogoLight;
return (
<div
className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
className,
)}
aria-hidden
>
C
</div>
<img
src={brandLogoSrc}
alt="CM 助手"
className={cn("size-9 shrink-0 object-contain", className)}
/>
);
}
@@ -5,6 +5,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useNavigate } from "react-router-dom";
import { useMemo } from "react";
import {
SidebarGroup,
@@ -13,13 +14,18 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
import {
applyCompactMenuOverrides,
type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides";
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS =
@@ -40,6 +46,108 @@ const NEW_CHAT_COMPACT_CLASS =
"hover:bg-[rgb(var(--legacy-primary-rgb)/4%)] hover:text-[var(--primary)] hover:border-[rgb(var(--legacy-primary-rgb)/32%)] " +
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
export type WorkspaceCompactNavItem = MenuItem & {
match: (pathname: string) => boolean;
};
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
{
id: "workspace-new-chat",
label: "新对话",
icon: MessageSquarePlus,
path: "/page/workspace/chats/new",
match: (pathname) => pathname === "/page/workspace/chats/new",
},
{
id: "workspace-chats",
label: "对话",
icon: MessagesSquare,
path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats",
},
{
id: "workspace-agents",
label: "智能体",
icon: BotIcon,
path: "/page/workspace/agents",
match: (pathname) => pathname.startsWith("/page/workspace/agents"),
},
{
id: "workspace-skills",
label: "技能",
icon: SparklesIcon,
path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
},
{
id: "workspace-memory",
label: "记忆",
icon: BrainIcon,
path: "/page/workspace/memory",
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
},
{
id: "workspace-ai-writing",
label: "AI写作",
icon: PenLine,
path: "/page/workspace/ai-writing",
match: (pathname) => pathname.startsWith("/page/workspace/ai-writing"),
},
{
id: "workspace-deep-research",
label: "深度研究",
icon: Telescope,
path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
// {
// id: "workspace-enterprise-research",
// label: "企业深度研究",
// icon: FileSearch,
// path: "/page/workspace/enterprise-research",
// match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
// },
{
id: "workspace-roundtable-planning",
label: "多智能体规划",
icon: Users,
path: "/page/workspace/roundtable/planning",
match: (pathname) => pathname.startsWith("/page/workspace/roundtable/planning"),
},
{
id: "workspace-scheduled-tasks",
label: "定时任务",
icon: ListChecks,
path: "/page/workspace/scheduled/tasks",
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
},
{
id: "workspace-knowledge",
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
},
];
export type EffectiveWorkspaceCompactNavItem = CompactMenuItem & WorkspaceCompactNavItem;
export function useWorkspaceCompactNavItems(): {
sidebarItems: EffectiveWorkspaceCompactNavItem[];
settingsItems: EffectiveWorkspaceCompactNavItem[];
toolboxItems: EffectiveWorkspaceCompactNavItem[];
} {
const { data: overrides = [] } = useCompactMenuOverrides();
return useMemo(() => {
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
return {
sidebarItems: applied.sidebarItems as EffectiveWorkspaceCompactNavItem[],
settingsItems: applied.settingsItems as EffectiveWorkspaceCompactNavItem[],
toolboxItems: applied.toolboxItems as EffectiveWorkspaceCompactNavItem[],
};
}, [overrides]);
}
export function WorkspaceNavChatList({
showCollapseTrigger = false,
compact = false,
@@ -50,6 +158,8 @@ export function WorkspaceNavChatList({
const { t } = useI18n();
const pathname = usePathname();
const navigate = useNavigate();
const { sidebarItems } = useWorkspaceCompactNavItems();
const newChatItem = sidebarItems.find((item) => item.id === "workspace-new-chat");
// 「新对话」点击时,如果已经在 /chats/new 路径,直接 navigate 会被 react-router
// 当成同路径 no-op,不更新 location.key,挂在 location.key 上的下游 hook
// (useThreadChat)就感知不到「用户想重开一个对话」,UI 不刷新。
@@ -66,173 +176,77 @@ export function WorkspaceNavChatList({
};
return (
<>
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarGroup
className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}
data-compact-guide="sidebar-nav"
>
<SidebarMenu className="gap-1">
<SidebarMenuItem>
{compact ? (
{(showCollapseTrigger || newChatItem) && (
<SidebarMenuItem>
{compact ? (
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
) : (
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
)}
</SidebarMenuItem>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
) : (
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
>
<MessageSquarePlus />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
)}
</SidebarMenuItem>
)}
{!showCollapseTrigger && (
<>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats"}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/agents")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/skills")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/skills">
<SparklesIcon />
<span>技能</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/memory")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/memory">
<BrainIcon />
<span>记忆</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/ai-writing")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/ai-writing">
<PenLine />
<span>AI写作</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/deep-research")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/deep-research">
<Telescope />
<span>深度研究</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/roundtable/planning">
<Users />
<span>多智能体规划</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* <SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/roundtable/position">
<BriefcaseBusiness />
<span>岗位会商</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem> */}
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/scheduled/tasks">
<ListChecks />
<span>定时任务</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{sidebarItems
.filter((item) => item.id !== "workspace-new-chat")
.map((item) => {
const Icon = item.icon;
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={item.match(pathname)}
asChild
className={NAV_ITEM_CLASS}
>
<Link href={item.path ?? "/page/workspace/chats/new"}>
<Icon />
<span>{item.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
<KnowledgeRagToggle className={NAV_ITEM_CLASS} />
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/knowledge")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/knowledge">
<BookOpen />
<span>问答梳理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</>
)}
</SidebarMenu>
@@ -7,10 +7,14 @@ import {
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
Compass,
CpuIcon,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
ListTreeIcon,
MessageCircleQuestionIcon,
MessageSquareTextIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
@@ -19,6 +23,7 @@ import {
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import {
DropdownMenu,
@@ -43,9 +48,14 @@ import {
} from "@/core/system-settings/api";
import { NotificationDialog } from "./notification-dialog";
import {
COMPACT_GUIDE_START_EVENT,
requestCompactGuideStart,
} from "./compact-mode-guide";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
import { useWorkspaceCompactNavItems } from "./workspace-nav-chat-list";
function NavMenuButtonContent({
isSidebarOpen,
@@ -69,6 +79,7 @@ function NavMenuButtonContent({
export function WorkspaceNavMenu() {
const navigate = useNavigate();
const pathname = usePathname();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
@@ -80,11 +91,26 @@ export function WorkspaceNavMenu() {
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const { settingsItems } = useWorkspaceCompactNavItems();
useEffect(() => {
setMounted(true);
}, []);
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
const startCompactGuide = () => {
if (/^\/page\/workspace\/chats\/.+/.test(pathname)) {
window.dispatchEvent(new CustomEvent(COMPACT_GUIDE_START_EVENT));
return;
}
requestCompactGuideStart();
navigate("/page/workspace/chats/new", {
replace: true,
state: { resetAt: Date.now() },
});
};
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
@@ -111,7 +137,7 @@ export function WorkspaceNavMenu() {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
@@ -129,6 +155,18 @@ export function WorkspaceNavMenu() {
sideOffset={4}
>
<DropdownMenuGroup>
{settingsItems.map((item) => {
const Icon = item.icon;
return (
<DropdownMenuItem
key={item.id}
onClick={() => item.path && navigate(item.path)}
>
<Icon />
{item.label}
</DropdownMenuItem>
);
})}
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
@@ -138,6 +176,10 @@ export function WorkspaceNavMenu() {
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={startCompactGuide}>
<Compass />
界面使用引导
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
@@ -212,6 +254,24 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
>
<CpuIcon />
模型配置
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTreeIcon />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
@@ -254,6 +314,16 @@ export function WorkspaceNavMenu() {
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/fixed-questions")
}
>
<MessageSquareTextIcon />
问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
@@ -1,25 +1,25 @@
"use client";
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
"use client";
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
@@ -29,13 +29,13 @@ export function WorkspaceSidebar({
compactChrome?: boolean;
forceCollapsed?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
if (forceCollapsed) {
@@ -56,85 +56,85 @@ export function WorkspaceSidebar({
);
}
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
if (compactChrome) {
return (
<div
aria-hidden={!isSidebarOpen}
className={cn(
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
)}
>
<Sidebar
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-(--sidebar-width) border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
<SidebarContent>
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent>
<SidebarRail />
</Sidebar>
</div>
);
}
return (
<>
<Sidebar
variant="sidebar"
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
// 非紧凑沿用 icon。
collapsible={compactChrome ? "none" : "icon"}
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
{!compactChrome && (
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
)}
<SidebarContent>
<WorkspaceNavChatList
showCollapseTrigger={compactChrome}
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
{isSidebarOpen && !compactChrome && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
</>
);
}
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
if (compactChrome) {
return (
<div
aria-hidden={!isSidebarOpen}
className={cn(
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
)}
>
<Sidebar
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-(--sidebar-width) border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
<SidebarContent>
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent>
<SidebarRail />
</Sidebar>
</div>
);
}
return (
<>
<Sidebar
variant="sidebar"
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
// 非紧凑沿用 icon。
collapsible={compactChrome ? "none" : "icon"}
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
{!compactChrome && (
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
)}
<SidebarContent>
<WorkspaceNavChatList
showCollapseTrigger={compactChrome}
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
{isSidebarOpen && !compactChrome && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
</>
);
}
+8 -8
View File
@@ -61,9 +61,9 @@ export interface Agent {
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
seat_skill_directive?: boolean | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
seat_skill_directive?: boolean | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -98,8 +98,8 @@ export interface CreateAgentRequest {
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
export interface UpdateAgentRequest {
@@ -116,6 +116,6 @@ export interface UpdateAgentRequest {
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
+10 -100
View File
@@ -1,112 +1,22 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star) {
const raw = star[1].replace(/["']/g, "").trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
const plain = /filename="?([^";\n]+)"?/i.exec(header);
return plain ? plain[1].trim() : null;
}
function sanitizeFallbackFilename(title: string | undefined): string {
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
}
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "导出 Word 失败" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
throw new Error("导出 Word 失败");
}
const blob = await response.blob();
const filename =
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
fallbackFilename;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
/**
* 通用 Markdown → Word 导出(Gateway `writing` 路由,与 `/api/writing/rewrite` 同级)。
* Unified Markdown → Word export.
*
* POST /api/writing/export/docx
* Body: { title?: string, markdown: string }
*
* 返回 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
* 文件名在 `Content-Disposition` 中。
* All download surfaces use the same required Gateway endpoint. The Gateway
* applies the formal OOXML profile and embeds its bundled title font; there is
* deliberately no client-font fallback.
*/
export async function downloadMarkdownDocxFromBackend(options: {
title?: string;
markdown: string;
}): Promise<void> {
const markdown = options.markdown.trim();
if (!markdown) {
throw new Error("文档内容为空");
}
if (!markdown) throw new Error("文档内容为空");
const fallbackFilename = sanitizeFallbackFilename(options.title);
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/writing/export/docx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: options.title?.trim() || undefined,
markdown,
}),
});
if (response.status === 404) {
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-导出-Word",
url: `${getBackendBaseURL()}/api/writing/export/docx`,
reqParam: { title: options.title?.trim() || undefined },
});
return;
}
await downloadBlobResponse(response, fallbackFilename);
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-导出-Word",
url: `${getBackendBaseURL()}/api/writing/export/docx`,
reqParam: { title: options.title?.trim() || undefined },
});
} catch (err) {
if (err instanceof TypeError) {
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-导出-Word",
url: `${getBackendBaseURL()}/api/writing/export/docx`,
reqParam: { title: options.title?.trim() || undefined },
});
return;
}
throw err;
}
await downloadMarkdownAsDocx(
markdown,
options.title?.trim() || "document",
options.title,
);
}
+80 -26
View File
@@ -1,27 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import {
loadArtifactContent,
loadArtifactContentFromLatestWriteFile,
loadArtifactContentFromToolCall,
} from "./loader";
import {
SANDBOX_STREAM_FLUSH_MS,
isSandboxStreamPerfEnabled,
} from "./sandbox-stream-perf";
import { isArtifactApiPath } from "./streaming-write-preview";
import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight";
import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
function hasToolResult(
thread: ReturnType<typeof useThread>["thread"],
toolCallId?: string,
) {
if (!toolCallId) {
return false;
}
@@ -76,6 +74,48 @@ function applyPendingTextEdit({
return null;
}
/**
* Keep the latest write_file text in a ref (always current for later readers)
* but only publish a display snapshot on a timer while the run is in flight.
* Display therefore does not follow `thread` object identity / every SSE chunk.
*/
function useThrottledWriteFileDisplay({
filepath,
liveContent,
enabled,
streaming,
}: {
filepath: string;
liveContent: string | undefined;
enabled: boolean;
streaming: boolean;
}) {
const latestRef = useRef(liveContent);
latestRef.current = liveContent;
const [displayed, setDisplayed] = useState(liveContent);
useEffect(() => {
if (!enabled) {
setDisplayed(latestRef.current);
return;
}
const flush = () => {
setDisplayed((previous) =>
Object.is(previous, latestRef.current) ? previous : latestRef.current,
);
};
if (!streaming) {
flush();
return;
}
flush();
const intervalId = window.setInterval(flush, SANDBOX_STREAM_FLUSH_MS);
return () => window.clearInterval(intervalId);
}, [enabled, filepath, streaming]);
return enabled && streaming ? displayed : liveContent;
}
export function useArtifactContent({
filepath,
threadId,
@@ -89,19 +129,32 @@ export function useArtifactContent({
return isWriteFileStreamingUrl(filepath);
}, [filepath]);
const { thread, isMock, publicShareCode } = useThread();
const content = useMemo(() => {
if (useWriteFileStreaming) {
return loadArtifactContentFromToolCall({ url: filepath, thread });
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const liveWriteFileContent = useWriteFileStreaming
? loadArtifactContentFromToolCall({ url: filepath, thread })
: undefined;
const writeFileInFlight =
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
const displayedWriteFileContent = useThrottledWriteFileDisplay({
filepath,
liveContent: liveWriteFileContent,
enabled: streamPerfEnabled && useWriteFileStreaming,
streaming: writeFileInFlight,
});
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
const toolCallFallbackContent = useMemo(() => {
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
return undefined;
}
return null;
}, [filepath, useWriteFileStreaming, thread]);
return loadArtifactContentFromLatestWriteFile({ filepath, thread });
}, [filepath, thread, useWriteFileStreaming]);
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
},
enabled,
enabled: Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath),
refetchInterval: thread.isLoading ? 1000 : false,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
@@ -120,11 +173,12 @@ export function useArtifactContent({
return {
content: useWriteFileStreaming
? content
: (pendingEdit?.content ?? data?.content),
: (pendingEdit?.content ?? data?.content ?? toolCallFallbackContent),
url: useWriteFileStreaming ? undefined : data?.url,
isLoading,
isApplyingEdit: pendingEdit?.kind === "editing",
error,
refetch,
};
}
+113
View File
@@ -0,0 +1,113 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface ArtifactListingFile {
path: string;
name: string;
sizeBytes: number;
mimeType: string | null;
modifiedAt: string;
}
export interface ArtifactListing {
threadId: string;
files: ArtifactListingFile[];
}
/**
* Scan the thread's outputs directory instead of relying only on artifacts
* explicitly surfaced by `present_files`. This is used by file mode so files
* written by any tool are still discoverable from the right-hand directory.
*/
export async function listThreadArtifacts(
threadId: string,
signal?: AbortSignal,
): Promise<ArtifactListing> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
{ method: "GET", signal },
);
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Failed to list artifacts (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
const data = (await response.json()) as {
thread_id?: string;
files?: Array<{
path: string;
name: string;
size_bytes: number;
mime_type: string | null;
modified_at: string;
}>;
};
return {
threadId: data.thread_id ?? threadId,
files: (data.files ?? []).map((file) => ({
path: file.path,
name: file.name,
sizeBytes: file.size_bytes,
mimeType: file.mime_type,
modifiedAt: file.modified_at,
})),
};
}
/** Return the generated file path represented by a real or streaming artifact. */
export function artifactPathname(artifact: string): string {
const trimmed = artifact.trim();
if (!trimmed.startsWith("write-file:")) {
return trimmed.replace(/^\/+/, "");
}
try {
return decodeURIComponent(new URL(trimmed).pathname).replace(/^\/+/, "");
} catch {
return trimmed;
}
}
export function artifactRelativePath(artifact: string): string {
const pathname = artifactPathname(artifact);
return pathname.replace(/^mnt\/user-data\/outputs\/?/i, "") || pathname;
}
/**
* Keep the checkpoint order, append files found only on disk from old to new,
* and replace a completed streaming URL with its durable path.
*/
export function mergeArtifactPaths(
checkpointArtifacts: string[],
discoveredArtifacts: string[],
): string[] {
const merged: string[] = [];
const indexByPath = new Map<string, number>();
for (const artifact of checkpointArtifacts) {
const key = artifactPathname(artifact);
if (!key || indexByPath.has(key)) continue;
indexByPath.set(key, merged.length);
merged.push(artifact);
}
// The listing endpoint returns newest first. Append in chronological order
// so the last item remains the newest artifact for the existing auto-open.
for (const artifact of [...discoveredArtifacts].reverse()) {
const key = artifactPathname(artifact);
if (!key) continue;
const existingIndex = indexByPath.get(key);
if (existingIndex !== undefined) {
if (merged[existingIndex]?.startsWith("write-file:")) {
merged[existingIndex] = artifact;
}
continue;
}
indexByPath.set(key, merged.length);
merged.push(artifact);
}
return merged;
}
+56 -3
View File
@@ -46,7 +46,7 @@ export async function loadArtifactContent({
return { content: text, url };
}
export function loadArtifactContentFromToolCall({
export function loadArtifactContentFromToolCall({
url: urlString,
thread,
}: {
@@ -66,5 +66,58 @@ export function loadArtifactContentFromToolCall({
return toolCall.args.content;
}
}
}
}
}
}
function normalizeArtifactPath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized.replace(/\\/g, "/").replace(/^\/+/, "");
}
/**
* Last-resort preview for an output whose artifact HTTP request is unavailable.
*
* A completed `write_file` can be selected before its backing sandbox file is
* visible through the artifacts endpoint (or when a deployment temporarily
* serves a stale mount). The original tool arguments still contain the
* Markdown, so use the newest matching write as a read-only display fallback.
*/
export function loadArtifactContentFromLatestWriteFile({
filepath,
thread,
}: {
filepath: string;
thread: BaseStream<AgentThreadState>;
}): string | undefined {
const normalizedFilepath = normalizeArtifactPath(filepath);
if (!normalizedFilepath) return undefined;
for (const message of [...thread.messages].reverse()) {
if (message.type !== "ai" || !message.tool_calls) continue;
for (const toolCall of [...message.tool_calls].reverse()) {
if (toolCall.name !== "write_file") continue;
if (!toolCall.args || typeof toolCall.args !== "object" || Array.isArray(toolCall.args)) {
continue;
}
const args = toolCall.args as { path?: unknown; content?: unknown };
if (
typeof args.path === "string" &&
typeof args.content === "string" &&
normalizeArtifactPath(args.path) === normalizedFilepath
) {
return args.content;
}
}
}
return undefined;
}
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SANDBOX_STREAM_PERF_QUERY,
resolveSandboxStreamPerfEnabled,
} from "./sandbox-stream-perf";
test("sandbox stream perf defaults to on", () => {
assert.equal(resolveSandboxStreamPerfEnabled({}), true);
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: null }), true);
});
test("sandbox stream perf can be rolled back from hash or search query", () => {
assert.equal(
resolveSandboxStreamPerfEnabled({
hash: "#/page/workspace/chats/new?sandboxStreamPerf=0",
}),
false,
);
assert.equal(
resolveSandboxStreamPerfEnabled({
search: `?${SANDBOX_STREAM_PERF_QUERY}=off`,
}),
false,
);
assert.equal(
resolveSandboxStreamPerfEnabled({
hash: "#/page/workspace/agents/abc/chats/def?foo=1&sandboxStreamPerf=false",
}),
false,
);
});
test("sandbox stream perf query wins over localStorage", () => {
assert.equal(
resolveSandboxStreamPerfEnabled({
hash: "#/page/workspace/chats/new?sandboxStreamPerf=1",
storageValue: "0",
}),
true,
);
});
test("sandbox stream perf can be rolled back from localStorage", () => {
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "0" }), false);
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "off" }), false);
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "1" }), true);
});
@@ -0,0 +1,115 @@
import { useEffect } from "react";
/**
* Sandbox streaming performance guard (write_file live preview).
*
* Default ON. Rollback without a rebuild:
* 1. Append `?sandboxStreamPerf=0` to the page URL and refresh
* e.g. `/#/page/workspace/chats/new?sandboxStreamPerf=0`
* 2. Or in the console:
* localStorage.setItem("deerflow.sandbox-stream-perf", "0"); location.reload();
*
* Restore the optimized path:
* localStorage.removeItem("deerflow.sandbox-stream-perf") and drop the query, then refresh.
*/
export const SANDBOX_STREAM_PERF_STORAGE_KEY = "deerflow.sandbox-stream-perf";
export const SANDBOX_STREAM_PERF_QUERY = "sandboxStreamPerf";
/** Aligns with roundtable write_file preview (~12fps). */
export const SANDBOX_STREAM_FLUSH_MS = 80;
export const SANDBOX_LEGACY_SCROLL_INTERVAL_MS = 180;
function parseFlag(raw: string | null | undefined): boolean | null {
if (raw == null) return null;
const value = raw.trim().toLowerCase();
if (value === "0" || value === "off" || value === "false" || value === "no") {
return false;
}
if (value === "1" || value === "on" || value === "true" || value === "yes") {
return true;
}
return null;
}
function queryValue(search: string, key: string): string | null {
if (!search) return null;
const normalized = search.startsWith("?") ? search.slice(1) : search;
return new URLSearchParams(normalized).get(key);
}
export function resolveSandboxStreamPerfEnabled(input: {
search?: string;
hash?: string;
storageValue?: string | null;
}): boolean {
const searchFlag = parseFlag(queryValue(input.search ?? "", SANDBOX_STREAM_PERF_QUERY));
if (searchFlag != null) return searchFlag;
const hash = input.hash ?? "";
const hashQueryIndex = hash.indexOf("?");
if (hashQueryIndex >= 0) {
const hashFlag = parseFlag(
queryValue(hash.slice(hashQueryIndex), SANDBOX_STREAM_PERF_QUERY),
);
if (hashFlag != null) return hashFlag;
}
const storedFlag = parseFlag(input.storageValue);
if (storedFlag != null) return storedFlag;
return true;
}
export function isSandboxStreamPerfEnabled(): boolean {
if (typeof window === "undefined") return true;
let storageValue: string | null = null;
try {
storageValue = window.localStorage.getItem(SANDBOX_STREAM_PERF_STORAGE_KEY);
} catch {
storageValue = null;
}
return resolveSandboxStreamPerfEnabled({
search: window.location.search,
hash: window.location.hash,
storageValue,
});
}
export function useSandboxFollowScroll({
follow,
scrollToBottom,
contentKey,
viewMode,
}: {
follow: boolean;
scrollToBottom: (behavior?: ScrollBehavior) => boolean;
contentKey: string;
viewMode: string;
}) {
const perfEnabled = isSandboxStreamPerfEnabled();
useEffect(() => {
if (!perfEnabled || !follow) return;
let raf = 0;
raf = requestAnimationFrame(() => {
raf = 0;
scrollToBottom("auto");
});
return () => {
if (raf) cancelAnimationFrame(raf);
};
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
useEffect(() => {
if (perfEnabled || !follow) return;
const tick = () => {
scrollToBottom("auto");
};
tick();
const intervalId = window.setInterval(tick, SANDBOX_LEGACY_SCROLL_INTERVAL_MS);
return () => window.clearInterval(intervalId);
}, [perfEnabled, follow, scrollToBottom]);
useEffect(() => {
if (perfEnabled || !follow) return;
scrollToBottom("auto");
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
}
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isArtifactApiPath,
isProvisionalStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
test("provisionalWriteFilePath uses html only for html documents", () => {
assert.equal(provisionalWriteFilePath("# title"), "未命名文件.md");
assert.equal(provisionalWriteFilePath("<!DOCTYPE html><html>"), "未命名文件.html");
assert.equal(provisionalWriteFilePath("<html lang='zh'>"), "未命名文件.html");
});
test("isProvisionalStreamingArtifactPath matches the streaming placeholder names", () => {
assert.equal(isProvisionalStreamingArtifactPath("未命名文件.md"), true);
assert.equal(isProvisionalStreamingArtifactPath("/未命名文件.html"), true);
assert.equal(isProvisionalStreamingArtifactPath("mnt/user-data/outputs/report.md"), false);
});
test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
assert.equal(isArtifactApiPath("未命名文件.md"), false);
assert.equal(
isArtifactApiPath(
"write-file:未命名文件.md?message_id=m1&tool_call_id=t1&language=markdown",
),
false,
);
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
});
@@ -23,6 +23,11 @@ export function hasCompleteArtifactExtension(path: string): boolean {
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
export const PROVISIONAL_WRITE_FILE_NAMES = new Set([
"未命名文件.md",
"未命名文件.html",
]);
export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
@@ -31,6 +36,29 @@ export function provisionalWriteFilePath(content: string): string {
return "未命名文件.md";
}
export function isProvisionalStreamingArtifactPath(path: string): boolean {
const pathname =
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
}
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
export function isArtifactApiPath(path: string): boolean {
if (!path || path.startsWith("write-file:")) {
return false;
}
if (isProvisionalStreamingArtifactPath(path)) {
return false;
}
const normalized = path.replace(/^\/+/, "").replace(/\\/g, "/");
return (
normalized === "mnt/user-data" ||
normalized.startsWith("mnt/user-data/") ||
normalized.startsWith("mnt/skills/")
);
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
test("resolveWriteFilePath reads the virtual URL pathname", () => {
assert.equal(
resolveWriteFilePath("write-file:report.md?message_id=m1&tool_call_id=t1"),
"report.md",
);
assert.equal(resolveWriteFilePath("mnt/report.md"), "mnt/report.md");
});
test("isWriteFileInFlight is true only until that file's tool result arrives", () => {
const inFlight = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
],
};
assert.equal(isWriteFileInFlight(inFlight, "write-file:report.md?tool_call_id=w1"), true);
const completed = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
{ type: "tool", tool_call_id: "w1", name: "write_file" },
],
};
assert.equal(
isWriteFileInFlight(completed, "write-file:report.md?tool_call_id=w1"),
false,
);
});
test("a later thread turn does not treat a finished write as in-flight", () => {
const laterTurn = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
{ type: "tool", tool_call_id: "w1", name: "write_file" },
{ type: "human", content: "按方案 A" },
{
type: "ai",
tool_calls: [{ id: "c2", name: "web_search", args: { query: "x" } }],
},
],
};
assert.equal(
isWriteFileInFlight(laterTurn, "write-file:report.md?tool_call_id=w1"),
false,
);
});
@@ -0,0 +1,77 @@
import { isImageFile } from "../utils/files";
export function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
export function resolveWriteFilePath(filepath: string): string {
if (filepath.startsWith("write-file:")) {
try {
return decodeURIComponent(new URL(filepath).pathname).trim();
} catch {
const rest = filepath.slice("write-file:".length);
return rest.split(/[?#]/, 1)[0]?.trim() ?? filepath;
}
}
return filepath.trim();
}
type WriteFileThreadLike = {
messages: Array<{
type?: string;
tool_call_id?: string;
tool_calls?: Array<{
id?: string;
name?: string;
args?: unknown;
}>;
}>;
};
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
if (!toolCallId) {
return false;
}
return thread.messages.some(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
}
/** True only while THIS file's write_file call has not yet returned a tool result. */
export function isWriteFileInFlight(
thread: WriteFileThreadLike,
filepath: string,
): boolean {
const path = resolveWriteFilePath(filepath);
if (!path) {
return false;
}
for (const message of thread.messages) {
if (message.type !== "ai" || !message.tool_calls) {
continue;
}
for (const toolCall of message.tool_calls) {
if (toolCall.name !== "write_file") {
continue;
}
const args = toolCall.args as { path?: unknown } | undefined;
const callPath = typeof args?.path === "string" ? args.path.trim() : "";
if (callPath !== path) {
continue;
}
if (!hasToolResult(thread, toolCall.id)) {
return true;
}
}
}
return false;
}
@@ -0,0 +1,116 @@
/**
* 「agentfx」任务深链:复制自 [[task-deeplink]] 的 3qfx 单智能体模式,供新系统 iframe 嵌入
* (``${loginBase}?password=xxx&taskId=xxx&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue``)。
*
* 与 3qfx 的差异(也是单独复制一份的原因,后续逻辑改动互不影响):
* 1. 任务详情不走外部 consumer(``VITE_AIPI_CONSUMER_URL``),改调本系统 Gateway 的
* ``GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=``(带 DeerFlow 登录态,任务数据
* 来自 TaskCOP 任务表,即任务列表 / 报告导入用的同一份)。
* 2. **固定使用内置智能体** ``agentfx-analyst``(后端 seeder ``_agentfx_seed.py`` 启动时补建:
* 搜集信息 → 生成 report.md + task-reports.json(14 类)→ 总结 → 前端「入库」协助卡 /
* task-report-import 技能入库),不走「配置管理」业务映射。
* 3. 路由挂在 ``plan-task-agentfx`` 前缀下,页面代码见 [[AgentFxWorkspace]]。
*
* 任务详情整包仍暂存到与 3qfx 相同的 sessionStorage 键(``login.copTaskDetail``,见
* [[persistCopTaskDetail]]):该键按标签页隔离(嵌入态无跨标签污染),工作区「当前任务」
* 卡片与 AgentChatPage 的 taskBind 自恢复逻辑都读它,保持行为一致。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import {
type CopTaskDetail,
type TaskDeeplinkTarget,
} from "@/core/auth/task-deeplink";
/** 深链 goPath 基础码(大小写不敏感匹配;不支持 ``-A`` 会商后缀)。 */
export const AGENTFX_GOPATH = "agentfx";
/**
* 固定使用的内置智能体 id(后端 ``_agentfx_seed.py`` 启动时补建并 upsert 进 agents 表,
* ``user_id IS NULL`` 对所有用户可见)。前端深链不查业务映射,直接进该智能体的问答。
*/
export const AGENTFX_BUILTIN_AGENT_ID = "agentfx-analyst";
/** 工作区路由前缀(与 StrategyRoutes 的 plan-task-agentfx 子树一致)。 */
export const AGENTFX_TASK_BASE = "/page/strategy/task-management/plan-task-agentfx";
/** 判断 goPath 是否为 agentfx(大小写不敏感)。 */
export function isAgentfxGoPath(goPath: string): boolean {
return goPath.trim().toLowerCase() === AGENTFX_GOPATH;
}
/**
* 拉取任务详情:本系统 Gateway 的 cop-task-detail(返回与旧 consumer 同构的
* ``{state, msg, data}`` 信封)。任务不存在 / 网络失败时返回 null,调用方(LoginPage)
* 用仅含任务 id 的 stub 兜底继续跳转。
*/
export async function fetchAgentfxTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
if (!taskId) return null;
try {
// apiFetch 已统一注入 DeerFlow 登录态(Authorization / cookie),登录刚完成即可用。
const query = new URLSearchParams({ taskId });
const res = await apiFetch(
`${getBackendBaseURL()}/taskAnalyseSearch/cop-task-detail?${query.toString()}`,
{ method: "GET" },
);
if (!res.ok) {
console.warn("[agentfx-deeplink] cop-task-detail failed", { taskId, status: res.status });
return null;
}
const body = (await res.json().catch(() => null)) as { data?: unknown } | null;
const data = body && typeof body === "object" ? body.data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
} catch (error) {
console.warn("[agentfx-deeplink] cop-task-detail failed", { taskId, error });
return null;
}
}
/**
* 用 taskName / taskContent 拼开场信息文本;末尾附任务 id(task_id)供后端识别任务。
* 复制自 task-deeplink 的 buildOpeningText 基础三行(无 xdfx 行动追加),便于后续按新系统
* 要求单独调整格式。
*/
export function buildAgentfxOpeningText(detail: CopTaskDetail, taskId: string): string {
return [
detail.taskName ? `任务名称:${detail.taskName}` : "",
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
taskId ? `任务id(task_id)为${taskId}` : "",
]
.filter(Boolean)
.join("\n");
}
/**
* 解析 agentfx 跳转目标:内置「任务研判报告智能体」的问答工作区(任务↔对话 1:N,按
* taskId + agentId 隔离)。清残留任务上下文 → 暂存任务上下文(taskId + agentId + 开场
* 信息)→ 返回 chats 索引路径,由工作区决定开最新历史对话 / 新建(首次无历史自动发
* 开场信息,见 AgentChatPage 的 taskBind)。
*/
export async function resolveAgentfxDeeplinkTarget(
detail: CopTaskDetail,
taskId: string,
): Promise<TaskDeeplinkTarget | null> {
// 清掉上一次深链可能残留的任务上下文(下面用本次任务重新写入)。
clearTaskContext();
const agentId = AGENTFX_BUILTIN_AGENT_ID;
persistTaskContext({
taskId,
agentId,
openingText: buildAgentfxOpeningText(detail, taskId),
actionId: taskId,
partitionByAction: false,
});
return { path: `${AGENTFX_TASK_BASE}/agent/${agentId}/chats` };
}
+16 -9
View File
@@ -1,14 +1,14 @@
/**
* 大屏绘制智能体的任务输入适配层。
*
* 报告详情由 Gateway 的 `/api/task-reports/by-task/{taskId}` 提供,返回格式
* 与原 `selectSqReport` 一致:`{state, msg, data}`。每条 `contentJson` 是
* 报告详情由 Consumer 的 `/cop/selectSqReport?taskId=` 提供,返回格式为
* `{state, msg, data}`。每条 `contentJson` 是
* JSON 字符串,本模块将其按 `categoryType` 解析为智能体和大屏页面可直接使用的
* 结构化输入。
*/
import { getTaskReport } from "@/strategy-components/api/task-reports";
import { readRuntimeFlag } from "@/env";
import { readRuntimeEnv, readRuntimeFlag } from "@/env";
import { requestCrudService } from "@/service/request-axios-service";
import { SQ_REPORT_MOCK_RESPONSE } from "@/strategy-components/components/task-components/sq-report-mock-data";
/** 保留给既有调用方的 sessionStorage key;报告本身不再使用 mock 缓存。 */
@@ -32,9 +32,15 @@ function categoryKey(categoryType: unknown, index: number): string {
return cleaned || `分类${index + 1}`;
}
/** Consumer 研判情报报告接口完整地址。 */
function sqReportUrl(): string {
const base = readRuntimeEnv("VITE_AIPI_CONSUMER_URL")?.replace(/\/+$/, "") ?? "";
return base ? `${base}/cop/selectSqReport` : "";
}
/**
* 将接口 envelope 提取为 `{ taskId, source, categories }`。
* 返回 null 表示该任务尚未保存可用的报告内容。
* 返回 null 表示接口没有返回可用的报告内容。
*/
export function extractSqReport(body: unknown, taskId: string): unknown | null {
const data = (body && typeof body === "object" ? (body as { data?: unknown }).data : null) as
@@ -51,7 +57,7 @@ export function extractSqReport(body: unknown, taskId: string): unknown | null {
categories[key] = content;
});
if (Object.keys(categories).length === 0) return null;
return { taskId, source: "api/task-reports", categories };
return { taskId, source: "cop/selectSqReport", categories };
}
/** 兼容旧调用方:报告由接口实时读取,不再在浏览器中缓存。 */
@@ -70,8 +76,7 @@ export async function fetchDashboardInputJson(
_opts?: { forceRefresh?: boolean },
): Promise<unknown | null> {
if (!taskId) return null;
// 开发演示模式使用与 Gateway 接口同形的本地样例;这样不依赖报告库是否已为
// 当前深链 taskId 写入数据,同时真实/生产环境仍始终从 Gateway 读取。
// 开发演示模式不发外部请求,直接返回与 Consumer 接口同形的本地样例。
if (readRuntimeFlag("VITE_MOCK_SQ_REPORT")) {
return extractSqReport(
{
@@ -81,7 +86,9 @@ export async function fetchDashboardInputJson(
taskId,
);
}
const body = await getTaskReport(taskId);
const url = sqReportUrl();
if (!url) return null;
const body = await requestCrudService.get(url, { taskId });
return extractSqReport(body, taskId);
}
+18 -2
View File
@@ -10,6 +10,7 @@
*/
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
import type { ThemeType } from "@/contexts/ThemeContext";
export const EXTERNAL_LOGIN_STORAGE_KEYS = {
ticket: "login.ticket",
@@ -58,6 +59,21 @@ export function readExternalLoginParams(): Required<ExternalLoginParams> {
* - ``blue`` → 暂无该主题,先用浅色代替(命中 else 分支)
* 其余/缺省同样回退浅色。将来若新增 blue 主题,在此扩展映射即可。
*/
export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" {
return themeParam === "dark-blue" ? "dark" : "light";
export function resolveThemeFromQuery(themeParam: string | null | undefined): ThemeType {
switch (themeParam?.trim().toLowerCase()) {
case "dark":
case "dark-blue":
case "darkblue":
return "dark";
case "blue":
case "lightblue":
case "light-blue":
return "lightblue";
case "green":
case "lightgreen":
case "light-green":
return "lightgreen";
default:
return "light";
}
}
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
HOST_NAVIGATE_TYPE,
buildHostNavigateMessage,
isHostNavigatePath,
parseHostTaskId,
resolveHostNavigateOrigin,
} from "./host-navigate";
test("isHostNavigatePath accepts a host route", () => {
assert.equal(isHostNavigatePath("/task/item-aiceh/step-analysis-audience"), true);
assert.equal(isHostNavigatePath(" /task/foo "), true);
});
test("isHostNavigatePath rejects urls and protocol-relative paths", () => {
assert.equal(isHostNavigatePath(""), false);
assert.equal(isHostNavigatePath("https://localhost:4912/task"), false);
assert.equal(isHostNavigatePath("//evil.example/task"), false);
assert.equal(isHostNavigatePath("task/foo"), false);
assert.equal(isHostNavigatePath("/task\\foo"), false);
});
test("parseHostTaskId returns a number", () => {
assert.equal(parseHostTaskId("108"), 108);
assert.equal(parseHostTaskId(" 108 "), 108);
assert.equal(parseHostTaskId("0108"), 108);
});
test("parseHostTaskId rejects non-integers", () => {
assert.equal(parseHostTaskId(""), null);
assert.equal(parseHostTaskId("abc"), null);
assert.equal(parseHostTaskId("108.5"), null);
assert.equal(parseHostTaskId("12e3"), null);
assert.equal(parseHostTaskId(null), null);
});
test("resolveHostNavigateOrigin prefers configured origin", () => {
assert.equal(
resolveHostNavigateOrigin({
configured: "https://localhost:4912",
ancestorOrigin: "https://other.example",
referrer: "https://referrer.example/page",
selfOrigin: "http://localhost:5174",
}),
"https://localhost:4912",
);
});
test("resolveHostNavigateOrigin strips a path from configured origin", () => {
assert.equal(
resolveHostNavigateOrigin({ configured: "https://localhost:4912/app" }),
"https://localhost:4912",
);
});
test("resolveHostNavigateOrigin falls back to ancestorOrigins then referrer", () => {
assert.equal(
resolveHostNavigateOrigin({
ancestorOrigin: "https://host.example",
referrer: "https://referrer.example/",
selfOrigin: "http://localhost:5174",
}),
"https://host.example",
);
assert.equal(
resolveHostNavigateOrigin({
referrer: "https://referrer.example/page",
selfOrigin: "http://localhost:5174",
}),
"https://referrer.example",
);
});
test("resolveHostNavigateOrigin never uses * or same-origin referrer", () => {
assert.equal(resolveHostNavigateOrigin({ configured: "*" }), null);
assert.equal(
resolveHostNavigateOrigin({
referrer: "http://localhost:5174/page/foo",
selfOrigin: "http://localhost:5174",
}),
null,
);
assert.equal(resolveHostNavigateOrigin({}), null);
});
test("buildHostNavigateMessage matches the host contract", () => {
assert.deepEqual(
buildHostNavigateMessage("/task/item-aiceh/step-analysis-audience", { id: 108 }),
{
type: HOST_NAVIGATE_TYPE,
path: "/task/item-aiceh/step-analysis-audience",
queryParams: { id: 108 },
},
);
});
@@ -0,0 +1,95 @@
/**
* iframe 嵌入宿主系统时,任务跳转按钮通过 ``window.parent.postMessage`` 让
* **父页面**改路由(宿主协议 ``HOST_NAVIGATE``)。不改 iframe 自己的地址。
*
* 消息形状对齐宿主约定:
* ``{ type: 'HOST_NAVIGATE', path, queryParams: { id: number } }``
* 第二参必须是宿主 origin,禁止 ``'*'``。
*/
export const HOST_NAVIGATE_TYPE = "HOST_NAVIGATE";
export interface HostNavigateMessage {
type: typeof HOST_NAVIGATE_TYPE;
path: string;
queryParams: Record<string, number>;
}
/** 宿主路由:以单个 ``/`` 开头的路径,不含 ``//`` / 反斜杠。 */
export function isHostNavigatePath(path: string): boolean {
const value = path.trim();
if (!value || value.length > 2048) return false;
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
return true;
}
/** 任务 id 转数字。只接受整数字符串(可带正负号),否则返回 null。 */
export function parseHostTaskId(raw: string | null | undefined): number | null {
const value = (raw ?? "").trim();
if (!value || !/^-?\d+$/.test(value)) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function normalizeOrigin(value: string | null | undefined): string | null {
const raw = (value ?? "").trim();
if (!raw || raw === "*") return null;
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.origin;
} catch {
return null;
}
}
/**
* 解析 postMessage 的 targetOrigin。
* 优先级:显式配置 → ``ancestorOrigins[0]`` → 跨源 ``document.referrer``。
* 解析失败返回 null(调用方应中止,不得退回 ``'*'``)。
*/
export function resolveHostNavigateOrigin(input: {
configured?: string | null;
ancestorOrigin?: string | null;
referrer?: string | null;
selfOrigin?: string | null;
}): string | null {
const configured = normalizeOrigin(input.configured);
if (configured) return configured;
const ancestor = normalizeOrigin(input.ancestorOrigin);
if (ancestor) return ancestor;
const referrer = normalizeOrigin(input.referrer);
if (!referrer) return null;
const selfOrigin = normalizeOrigin(input.selfOrigin);
if (selfOrigin && referrer === selfOrigin) return null;
return referrer;
}
export function readAncestorOrigin(): string | null {
if (typeof window === "undefined") return null;
try {
return window.location.ancestorOrigins?.[0] ?? null;
} catch {
return null;
}
}
export function isEmbeddedInParent(): boolean {
if (typeof window === "undefined") return false;
try {
return window.parent !== window;
} catch {
return true;
}
}
export function buildHostNavigateMessage(
path: string,
queryParams: Record<string, number> = {},
): HostNavigateMessage {
return {
type: HOST_NAVIGATE_TYPE,
path,
queryParams,
};
}
+73
View File
@@ -45,6 +45,79 @@ export function getStoredAuth(): LoginByUsernameResponse | null {
}
}
function normalizeIdentityPart(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function identityFromLoginName(value: unknown): string | null {
const normalized = normalizeIdentityPart(value);
if (!normalized) return null;
// Explicit email login is domain-specific. Bare username login is the
// stable business principal even if the backend's synthetic email domain or
// internal UUID changes.
return normalized.includes("@")
? `email:${normalized}`
: `account:${normalized}`;
}
function readUserInfoIdentity(): string | null {
let info: Record<string, unknown> | null = null;
try {
const raw = authStorageGet("userInfo");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
info = parsed as Record<string, unknown>;
}
}
} catch {
info = null;
}
if (!info) return null;
const candidates = [
["yUserId", info.yUserId],
["userName", info.userName],
["userId", info.userId],
] as const;
for (const [prefix, value] of candidates) {
const normalized = normalizeIdentityPart(value);
if (normalized) return `${prefix}:${normalized}`;
}
return null;
}
/**
* A stable cross-tab identity key for "did the account really change?" checks.
*
* Some external login paths can re-issue a backend ``user_id`` or use a
* different synthetic email domain while resolving to the same business
* account. Prefer the raw login username returned by the backend, then the
* email local-part, and keep ``user_id`` only as the final fallback.
*/
export function getAccountIdentityKey(
auth: LoginByUsernameResponse | null = getStoredAuth(),
): string | null {
if (!auth) return null;
const loginIdentity = identityFromLoginName(auth.username);
if (loginIdentity) return loginIdentity;
const email = normalizeIdentityPart(auth.email);
if (email) {
const localPart = email.split("@", 1)[0];
return localPart ? `account:${localPart}` : `email:${email}`;
}
const userInfoIdentity = readUserInfoIdentity();
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
const userId = normalizeIdentityPart(auth.user_id);
return userId ? `user_id:${userId}` : null;
}
export function setStoredAuth(auth: LoginByUsernameResponse) {
if (!isBrowser()) {
return;
+69 -21
View File
@@ -1,27 +1,56 @@
"use client";
import { useEffect, useRef } from "react";
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import {
AUTH_STATE_CHANGED_EVENT,
getAccountDisplayName,
getAccountIdentityKey,
} from "@/core/auth";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth";
const SETTLE_DELAY_MS = 1200;
const IDENTITY_TOAST_ID = "auth-identity-changed";
function readUserId(): string | null {
return getStoredAuth()?.user_id ?? null;
function readIdentityKey(): string | null {
return getAccountIdentityKey();
}
function freshRouteForAccount(pathname: string): string {
// A thread URL belongs to the old account. Keep its workspace/agent context
// but move to a valid new conversation before the user can submit into the
// stale thread. Other pages can stay in place and are remounted below.
const chatMatch = pathname.match(/^(.*\/chats)\/[^/]+\/?$/);
return chatMatch ? `${chatMatch[1]}/new` : pathname;
}
/**
* Mount once at the app root. Watches localStorage auth changes from other
* tabs and lets the user decide when to refresh on account switches.
* Cross-tab auth boundary for normal pages.
*
* Same-account token re-issuance is ignored. A genuine account change clears
* account-scoped query state, remounts the page, and moves an open chat to its
* fresh-chat route without a full browser reload. Embedded/isolated tabs keep
* their sessionStorage identity and never participate in this synchronization.
*/
export function AuthSyncWatcher() {
const lastUserIdRef = useRef<string | null>(readUserId());
export function AuthSyncWatcher({
children,
disabled = false,
}: {
children?: ReactNode;
disabled?: boolean;
}) {
const lastIdentityRef = useRef<string | null>(readIdentityKey());
const [revision, setRevision] = useState(0);
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (disabled) return;
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
if (isEmbedActive() || isIsolatedSession()) return;
@@ -29,25 +58,34 @@ export function AuthSyncWatcher() {
let settleTimer: ReturnType<typeof setTimeout> | null = null;
function evaluateSettledIdentity() {
const nextUserId = readUserId();
if (nextUserId === lastUserIdRef.current) {
const nextIdentity = readIdentityKey();
if (nextIdentity === lastIdentityRef.current) {
return;
}
lastUserIdRef.current = nextUserId;
lastIdentityRef.current = nextIdentity;
if (nextUserId === null) {
void queryClient.cancelQueries();
queryClient.clear();
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
setRevision((value) => value + 1);
if (nextIdentity === null) {
toast.info("账号已在其它页面退出", {
id: IDENTITY_TOAST_ID,
description: "当前页面已同步退出状态。",
});
navigate("/login", { replace: true });
return;
}
toast.warning("检测到账号在其它标签页发生变化", {
const freshRoute = freshRouteForAccount(location.pathname);
if (freshRoute !== location.pathname) {
navigate(freshRoute, { replace: true });
}
toast.info("账号已在页面间自动同步", {
id: IDENTITY_TOAST_ID,
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
duration: Infinity,
action: {
label: "刷新同步",
onClick: () => window.location.reload(),
},
description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`,
});
}
@@ -68,14 +106,24 @@ export function AuthSyncWatcher() {
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
}
function handleSameTabAuthChange() {
// `storage` never fires in the tab that called setStoredAuth/clearStoredAuth.
// Keep the comparison baseline current so a later same-account token
// renewal from another normal page is not compared with the pre-login
// `null` identity and falsely classified as an account switch.
lastIdentityRef.current = readIdentityKey();
}
window.addEventListener("storage", handleStorage);
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
if (settleTimer) {
clearTimeout(settleTimer);
}
};
}, []);
}, [disabled, location.pathname, navigate, queryClient]);
return null;
return children ? <Fragment key={revision}>{children}</Fragment> : null;
}
+51 -5
View File
@@ -7,9 +7,11 @@
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
*
* 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 按钮类型(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)/
* ``purpose`` 弹出任务目的树 / ``host`` iframe 内向父页面 ``postMessage({ type:'HOST_NAVIGATE' })``
* 让宿主系统跳转(见 [[host-navigate]])。
*/
import {
@@ -25,6 +27,15 @@ import {
readLoginToken,
readLoginUsername,
} from "@/core/auth/login-credentials";
import {
buildHostNavigateMessage,
isEmbeddedInParent,
isHostNavigatePath,
parseHostTaskId,
readAncestorOrigin,
resolveHostNavigateOrigin,
} from "@/core/auth/host-navigate";
import { readRuntimeEnv } from "@/env";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
@@ -62,8 +73,8 @@ export const TASK_BUSINESSES: {
},
];
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose";
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格 / ``host``=宿主路由(postMessage)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose" | "host";
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
export type TaskButtonIdKind = "task" | "action";
@@ -91,7 +102,7 @@ export interface TaskButton {
business: TaskBusiness;
label: string;
linkType: TaskButtonLinkType;
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``);``host``=宿主系统路由(如 ``/task/...``)。 */
target: string;
/** ``url`` 类型是否追加 id 查询参数。 */
appendTaskId: boolean;
@@ -316,11 +327,46 @@ export interface RunTaskButtonCtx {
* 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
*/
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
const fail = (m: string) => ctx.onError?.(m);
// purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。
if (btn.linkType === "purpose") return;
if (btn.linkType === "host") {
const path = btn.target.trim();
if (!isHostNavigatePath(path)) {
fail(`未配置「${btn.label}」的宿主路由`);
return;
}
if (!isEmbeddedInParent()) {
fail("当前不在嵌入页,无法跳转宿主系统");
return;
}
const origin = resolveHostNavigateOrigin({
configured: readRuntimeEnv("VITE_HOST_NAVIGATE_ORIGIN"),
ancestorOrigin: readAncestorOrigin(),
referrer: typeof document !== "undefined" ? document.referrer : null,
selfOrigin: typeof window !== "undefined" ? window.location.origin : null,
});
if (!origin) {
fail("未配置宿主地址(VITE_HOST_NAVIGATE_ORIGIN),无法安全跳转");
return;
}
const queryParams: Record<string, number> = {};
if (btn.appendTaskId) {
const id = parseHostTaskId(ctx.taskId);
if (id === null) {
fail("任务id不是数字,无法跳转宿主系统");
return;
}
queryParams[(btn.idParam || "id").trim() || "id"] = id;
}
const message = buildHostNavigateMessage(path, queryParams);
console.info(`[task-button] 「${btn.label}」宿主路由 →`, message, origin);
window.parent.postMessage(message, origin);
return;
}
if (btn.linkType === "url") {
const base = btn.target.trim();
if (!base) {
+41 -16
View File
@@ -95,25 +95,41 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
};
}
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法时返回 null。 */
/** 详情接口不可用时仍能继续深链跳转:至少带上任务 id,标题/简介可空。 */
export function stubCopTaskDetail(taskId: string): CopTaskDetail {
return {
id: taskId,
overview: "",
taskDirection: "",
taskName: "",
taskContent: "",
};
}
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法 / 网络失败时返回 null。 */
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
if (copTaskDetailMockEnabled()) {
return mockCopTaskDetail(taskId);
}
const base = consumerBaseUrl();
if (!base || !taskId) return null;
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
try {
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
} catch (error) {
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
return null;
}
}
/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */
@@ -223,7 +239,12 @@ export async function fetchTaskIdFromAction(actionId: string): Promise<string> {
export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> {
const parsed = parseGoPath(goPath);
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
return fetchTaskIdFromAction(rawId);
try {
return (await fetchTaskIdFromAction(rawId)) || "";
} catch (error) {
console.warn("[task-deeplink] action→taskId resolve failed", { rawId, error });
return "";
}
}
return rawId;
}
@@ -332,10 +353,14 @@ export async function resolveTaskDeeplinkTarget(
try {
const mappings = await listBusinessMappings();
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
} catch {
} catch (error) {
console.warn("[task-deeplink] business mapping failed", error);
agentId = null;
}
if (!agentId) return null;
if (!agentId) {
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
return null;
}
// 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表):
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
+13 -5
View File
@@ -5,9 +5,10 @@ import { useEffect, useRef, useState } from "react";
import {
AUTH_STATE_CHANGED_EVENT,
getAccountDisplayName,
getAccountIdentityKey,
getStoredAuth,
} from "@/core/auth";
import { isEmbedActive } from "@/core/embed/embed-session";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth";
@@ -36,11 +37,12 @@ export interface LiveAccount {
* 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。
*/
export function useLiveAccount(): LiveAccount {
const mountRef = useRef<{ id: string | null; name: string } | null>(null);
const mountRef = useRef<{ id: string | null; key: string | null; name: string } | null>(null);
if (mountRef.current === null) {
const auth = getStoredAuth();
mountRef.current = {
id: auth?.user_id ?? null,
key: getAccountIdentityKey(auth),
name: getAccountDisplayName(auth),
};
}
@@ -50,9 +52,10 @@ export function useLiveAccount(): LiveAccount {
function refresh() {
const auth = getStoredAuth();
const id = auth?.user_id ?? null;
const key = getAccountIdentityKey(auth);
const name = getAccountDisplayName(auth);
setLive((prev) =>
prev.id === id && prev.name === name ? prev : { id, name },
prev.id === id && prev.key === key && prev.name === name ? prev : { id, key, name },
);
}
// 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件
@@ -62,7 +65,9 @@ export function useLiveAccount(): LiveAccount {
refresh();
}
}
if (!isEmbedActive()) {
// Normal pages share localStorage identity across tabs. Embedded and
// isolated pages deliberately keep their per-tab sessionStorage identity.
if (!isEmbedActive() && !isIsolatedSession()) {
window.addEventListener("storage", onStorage);
}
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
@@ -83,6 +88,9 @@ export function useLiveAccount(): LiveAccount {
displayName: live.name,
mountUserId: mount.id,
mountDisplayName: mount.name,
switched: live.id !== null && mount.id !== null && live.id !== mount.id,
switched:
live.key !== null && mount.key !== null
? live.key !== mount.key
: live.id !== null && mount.id !== null && live.id !== mount.id,
};
}
Loaded 100 of 504 files, more files were not shown because too many files have changed in this diff. Show more