Compare commits

...
46 Commits
Author SHA1 Message Date
Rocky Kwok a723a9ca02 fix 2026-09-04 11:16:38 +08:00
吉鑫升 49d9ced04f Merge branch 'ji' into master 2026-09-03 10:25:57 +08:00
董宏凯 c2153ec238 Merge branch 'master-nj' into 'master'
feat(workflow): 增加会话级多智能体任务规划运行能力

See merge request knowledge-base-bj/deerflow-code!36
2026-09-02 09:55:59 +00:00
吉鑫升 6b724bd00d feat: improve assistant knowledge import and database compatibility 2026-09-02 17:55:17 +08:00
董宏凯 65d6b61d5d 开发计划 2026-09-02 14:57:34 +08:00
董宏凯 1c891df8fb feat(backend): 新增页面报告设计器 Seed 与模板工具
功能说明:\n- 新增页面报告设计器的路由级 seed 接口与初始化模板资源加载能力\n- 新增内置页面报告模板工具,实现报告生成流程中的模板路径与参数接入\n- 补充页面报告模板与相关资产目录,供运行时/离线环境直接读取\n- 将页面报告配置与工具链纳入后端启动路径,支持离线部署下可复用的默认模板
2026-09-02 12:52:40 +08:00
董宏凯 6d326a21cd feat(backend): 完善 Workflow 与 DeepResearch 的运行链路与持久化能力
功能说明:\n- 重构并增强工作流网关执行链路,支持更完整的 proposal 与 run 调度参数透传\n- 增加 DeepResearch/workflow 运行事件持久化能力并补齐建表 SQL 与查询/保存接口适配\n- 优化 lead agent 与 deep research runner 的中间态处理,增强线程收集与执行链并发控制\n- 调整工作流执行器与任务路由,提升兼容性并完善错误与回放行为\n- 补充回归测试:deep_research 持久化、workflow 并发、定义加载、引擎执行、run 流程等覆盖
2026-09-02 12:52:37 +08:00
董宏凯 f5bf7897b9 feat(frontend): 支持 Roundtable 写文件流式预览与消息流聚合展示
功能说明:\n- 为写文件工具链增加流式预览状态模型(in-flight、chunk 归并、已完成)\n- 在消息面板中支持 write_file 流式 artifact 的动态插入/更新与完成态呈现\n- 优化 artifact 详情页的文件名与来源显示,兼容流式生成中文件路径\n- 在 Roundtable 工作流 step2 流程中注入与监听 write_file 事件,支持多阶段消息统一展示\n- 补充单测覆盖:streaming-write-preview、write-file-in-flight、streaming-artifacts 等关键路径
2026-09-02 12:52:33 +08:00
Administrator fbd8dec48f feat: add skill and assistant knowledge bases 2026-09-02 00:49:13 +08:00
吉鑫升 dd0ee282df feat: add artifact library and improve public wiki 2026-09-01 18:18:28 +08:00
董宏凯 553f72604a Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj 2026-09-01 15:36:47 +08:00
董宏凯 9455160101 fix(prompt-input): 提供可选上下文入口并避免历史路由切换期异常 2026-09-01 14:51:21 +08:00
董宏凯 519e4c0c01 feat(chat): 修复 Strategy/Workspace 统一聊天布局与历史路由归属 2026-09-01 14:51:16 +08:00
董宏凯 dbf62e5889 feat(deep-research): 优化深度研究侧边栏与结构配置弹窗的暗色主题适配
为深度研究沙箱预览区和工作流配置弹窗补充暗色下背景与边框配色,避免在深色模式出现对比度不足和样式不一致。
2026-09-01 13:47:39 +08:00
董宏凯 aeb9a55497 feat(agent-chat): 修复智能体会话归属与历史路由恢复:侧边栏按当前agent过滤会话并携带agent上下文跳转,补充旧线程路径回溯到智能体工作区,防止误入通用问答,并完善输入框记忆按钮显隐与侧边栏展开入口,提升体验一致性。 2026-09-01 13:47:31 +08:00
董宏凯 051e66f7d8 feat(chat): 优化聊天页布局与 iframe 工具栏行为 2026-09-01 12:49:03 +08:00
董宏凯 1706a24182 feat(workflow): 增加会话级多智能体任务规划运行能力
提交范围聚焦于工作流编排新增:新增 workflow_planning 路由与前后端运行入口(workflow_planning.py、workflow_proposal_planner.py、workflow_deep_research_adapter.py),支持基于会话的提案-规划-执行流程,满足任务驱动式多智能体协作。

完成网关层运行时适配:扩展 workflow_resource_checks、workflow_executor、workflow_runs、workflow_agent_runner 与依赖注入,新增/更新配置(config.yaml、workflow_config),并补充 lead_agent 路径与节点实现,确保 Planner 与普通工作流运行态衔接一致。

补齐持久化与建模:新增 workflow_planning 持久化模块与 MySQL migration(20260831_01_workflow_planning_sessions),注册 SQL 与模型、内存 store,并扩展错误/表达式/validator/节点语义以承载新会话态输入输出。

增强文档与测试:新增会话式多智能体编排中文说明、更新开发文档与 AGENTS 指南、补充 workflow_engine/workflow_runs 回归测试,提高新路径的行为可验证性;同时留存 seed 资产(workflow-planner)便于一体化部署。
2026-09-01 11:47:30 +08:00
董宏凯 38857c425b 代码提交 2026-09-01 11:09:23 +08:00
董宏凯 99f1ca6029 Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code 2026-08-31 23:21:32 +08:00
董宏凯 a020458eeb Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code 2026-08-31 23:21:00 +08:00
董宏凯 8e49fd2e95 代码分析 2026-08-31 23:20:48 +08:00
fengxiangdebaozi 57b6a89f20 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
# Conflicts:
#	frontend-web/src/components/workspace/messages/message-group.tsx
#	offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
2026-08-31 20:12:05 +08:00
董宏凯 ac92f29db1 chore(persistence): 增加 Workflow Studio 表重建开关与迁移兼容修正
提交内容:为数据库生命周期增加按白名单清单重建 Workflow Studio 表的受限流程,限定仅 MySQL 且检查当前连接数据库名称一致,避免误删非目标 schema。

启动流程中新增 DEER_FLOW_REBUILD_WORKFLOW_SCHEMA 环境变量入口与持久层重建 API,并在 lifespan 里按需执行;导出到 persistence 包,便于后续调用与测试复用。

迁移与模型修复:将 workflow 相关大文本字段切到 MySQL LONGTEXT,不再给 TEXT/LONGTEXT 设置 server_default;新增 draft_canvas_schema_json 的迁移回填与非空约束变更,规避 MySQL 5.7 对默认值限制。

测试覆盖:补充 workflow 定义 ddl 长文本断言与 schema 重建保护性测试(仅允许清单表、数据库名不匹配直接拒绝),并把新测试文件加入版本追踪。
2026-08-31 16:45:06 +08:00
董宏凯 f0ddade8a7 feat(roundtable): 增强多智能体 Step2 流式推理与产物展示
提交内容:将 roundtable 的流式消息拆分为文本与 think 推理,支持思考内容以 <think> 形式内联渲染,解决仅返回 reasoning token 时的内容空洞问题。新增 stream-reasoning 工具与单元测试,保证 reasoning 首包到正文的闭合行为稳定。

交互与预览修正:write_file/str_replace 工具的沙箱打开策略改为更接近实时席位行为,不再因为文件后缀限制阻塞临时脚本写入可视化;通过新增 streaming-artifacts 工具统一处理可预览路径、最终交付产物过滤与目录快照比对,支持 bash/脚本直接落盘场景在流式阶段自动发现新增文档。

体验细节:步骤面板文件卡片从工具路径与最终 artifact 统一收敛,避免 read_file 点击误触发;自动滚动逻辑新增布局高度抖动识别,防止模型推理渲染导致的误判向上滚动。
2026-08-31 16:45:01 +08:00
董宏凯 fbf9031e25 feat(会商第三步): 恢复普通会商完整成果并隔离8BF报告模式
- 为普通多智能体会商恢复报告完成后的结构化流程图、最终方案展示、经典结果绘制和大屏多页,并保持三个生成流独立并行。

 - 8BF 入口显式采用 report-only 模式:只生成和编辑 Markdown 总结报告,不再启动或露出流程图、结果绘制及大屏功能。

 - 结构化流程图增加抽取、校验、暂停、失败重试和歧义选择交互;报告完成后状态文案准确切换,自动滚动定位到抽取与校验进度。

 - 为总结报告、经典 HTML 绘制和大屏虚拟 write_file 补齐完成结果及历史恢复处理,避免并行分支或旧草稿让完成产物误显示为生成中。

 - 最终方案展示中的已落盘文件直接按完成态预览,并恢复 MD、HTML 等文件类型徽标,不再被提交按钮覆盖。
2026-08-30 21:24:47 +08:00
董宏凯 2254841822 fix(会商第二步): 恢复总控派活实时展示并修正沙箱预览状态
- 流式解析改为跟踪当前 tool call id,连续多次 agent_orchestration 派活时前端即时展示最新席位和任务,不再等模型完整回复后一次性出现。

 - 总控派活步骤按 tool call 独立归档并合并增量参数,避免同名工具调用互相覆盖或只显示半截任务。

 - 沙箱自动打开改为按单个产物预览键管理:用户关闭当前文件后不反复弹出,但后续新文件仍可自动预览。

 - 为圆桌虚拟 write_file 补齐工具完成结果,并在席位收尾和历史草稿恢复时兜底收口,防止完成文件长期显示“正在生成”。
2026-08-30 21:24:30 +08:00
董宏凯 74fc849241 fix(会商滚动): 让第二、三步新消息与结构化进度即时定位到底部
- 为圆桌滚动钩子增加强制落底能力,阶段切换和布局变化不再被平滑滚动的旧高度截断。\n- 第二步用户发送消息后立即下滑到底部,不再等待模型首个输出。\n- 经典结果绘制与大屏对话面板接入内容高度观察,新增气泡、Markdown 和工具卡继续增高时保持跟随。\n- 保留用户手动上翻时的正常阅读行为,仅在新一轮发起或关键阶段切换时强制定位。
2026-08-30 21:24:08 +08:00
董宏凯 dd560ceb46 fix: 会商沙箱预览头仅在可提交的 Markdown 产物显示提交按钮 2026-08-30 20:02:21 +08:00
董宏凯 a4f95a4fa9 fix: 会商第一步隐藏继续追问与跳过此项按钮 2026-08-30 20:02:21 +08:00
董宏凯 b92a26cdd4 fix: 会商第二步只对可预览成品出文件卡/开沙箱,去掉干预并发送按钮 2026-08-30 20:02:21 +08:00
董宏凯 7066798ab0 chore: 配置新增 qwen3.8-flash 模型 2026-08-30 20:02:15 +08:00
董宏凯 222595d6c4 feat: 工作流画布支持草稿试运行(target=draft 执行当前草稿图) 2026-08-30 20:02:15 +08:00
董宏凯 3384f2e51c fix: 会商席位与总控角色隔离,编排能力只归总控 2026-08-30 20:02:09 +08:00
董宏凯 ff80cc110d fix: 写作管线步骤条改用不涉及检索的文案
- 「拆解检索问题」→「梳理写作思路」(已梳理出 N 个论述角度)
- 「检索参考资料」→「整理对话资料」(已整理 1/2 个论述角度的
  资料 · 累计 N 条)
- 管线实际只汇总对话已收集的资料再成稿,原文案会让用户误以为
  管线在重新检索;测试补标签断言防止回退
2026-08-30 16:21:02 +08:00
董宏凯 2ab3ab8444 fix: 写作管线整批接收对话检索结果,不再自己检索
- 相关性过滤新增位置通道:收割器给每条材料标记来源消息位置,课题
  请求之后的检索结果整批入池(与语言/词面无关)——线上中文课题 +
  英文检索结果被纯词面匹配误杀 31/35 条,报告只带着 4 条残料在写
- 课题切换保护保留:新请求之前的旧课题检索仍按词面强过滤,不凑
  门槛、不入报告;对话里找不到课题消息时退化为原词面过滤
- 删除死代码 HarvestPlusSearchMaterialProvider(retrieve=True 的
  自检索补充分支,早已被显式忽略):写作管线只负责写文件,检索
  全部由对话完成成为结构保证
- 测试:锚点规则(澄清回答不充当新课题)、中文课题+英文资料整批
  保留、课题切换旧检索过滤等场景,25 个用例入库(此前该测试文件
  一直被 tests/* 忽略规则挡在版本库外)
2026-08-30 16:20:55 +08:00
董宏凯 f7e6ed65bd fix: retry httpx timeout family as transient LLM errors
Live incident: the compatible-mode provider stalled mid-stream after
200 OK (~17s without data) and raised httpx.ReadTimeout, which was not
in the retriable exception-name set, so the call failed after 1
attempt and killed the whole run. Add ReadTimeout/ConnectTimeout/
WriteTimeout/PoolTimeout/TimeoutError/ConnectError to the transient
set in LLMErrorHandlingMiddleware (retry with backoff, then a
transient fallback message instead of generic), and add ReadTimeout/
ConnectTimeout to ai_writing's classify_exception for model
switching.
2026-08-30 15:53:51 +08:00
董宏凯 4964967295 fix: writing mode first-report allows only the pipeline to write the report
- Strip write_file/str_replace/bash from the model binding in the
  first-report run: after_model rewrites only fire after a full model
  response, so any bound write tool let weak models draft the whole
  essay into tool args (write_file content or bash cat>/python
  heredocs) for tens of invisible seconds before interception
- Hallucinated hand-writes are still handled: after_model rewrites
  bash cat >/python open('w')/tee and leftover write calls to
  deep_research_report; wrap_tool_call rejects whatever reaches the
  tool layer; post-success writes are dropped, nudged to a 2-3
  sentence summary, and patched with a fallback closing message so
  the turn never ends silently
- The rewritten pipeline call carries topic = original request
  (clarification answers skipped) plus focus = clarification answers,
  so the pipeline writes the clarified subject instead of the raw
  prompt text; prompts updated to match (tools unavailable, call the
  tool directly, do not draft first)
- Frontend write-step interception and streaming preview scoping now
  applies only in writing mode; normal mode shows/previews writes
  exactly as before
2026-08-30 15:53:43 +08:00
董宏凯andCursor 49c01cb72b fix: writing mode uses chat search then pipeline write only
Keep deep_research_report as a write-only deliverable from conversation materials, block post-success rewrite loops with a summary nudge, fill thin/empty pools from model knowledge, and open the sandbox only on real report.md content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 12:41:04 +08:00
董宏凯andCursor 9afb9750e5 Merge branch 'master' into master-nj
Keep Win7 flowchart SVG labels and bring over Mermaid syntax repair plus HTML chart color downleveling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:28:52 +08:00
董宏凯 17cf849361 代码提交 2026-08-30 01:25:20 +08:00
董宏凯andCursor 4ac7bc21cd feat: 优化对话历史与会商引导体验
列表引导改为手动触发且不跳转创建页;历史 Tab 展示会商步骤/链条与深度研究模板;Step2 增加暂停与内容高度跟随滚动,并修复旧草稿恢复兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:25:21 +08:00
董宏凯andCursor 832e180c52 fix: 改进流式 write_file 沙箱预览与 Mermaid 旧浏览器兼容
同轮次非末尾 write_file 也能自动打开沙箱并跳过 JSON 大文件预览;Mermaid 改用 SVG 标签;agentfx 强制先写 md 再写 json。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:21:39 +08:00
董宏凯andCursor 875001a8c0 feat: 对话历史页收纳会商/写作/深度研究 Tab,侧栏移除重复入口
ChatsPage 新增四类历史 Tab 与统一搜索筛选 UI;简洁模式侧栏不再展示会商与写作列表;深度研究页支持 ?session= 深链打开指定记录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:43:02 +08:00
董宏凯andCursor 36653c53a6 feat: 补充对话历史页深度研究 Tab 组件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:01:58 +08:00
董宏凯andCursor 37517c2778 fix: 嵌套深链改为按路由去 chrome,顶层不再清除共用 embed.session
避免全量父页清掉老版内层深链仍依赖的 embed.session;深链问答/会商/大屏绘制由 isHostNestedSurfacePath 控制 chrome,并抽取对话历史 Tab 共享行组件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:01:46 +08:00
吉鑫升 1b43205c1b feat: improve skill uploads and agent runtime stability 2026-08-27 11:28:03 +08:00
313 changed files with 55290 additions and 2887 deletions

No files matched your search

+48 -26
View File
@@ -87,39 +87,61 @@ offline-backend-20260512/backend/skill_evolution.json
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/*
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py
!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 generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.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).
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
deploy
# Roundtable coordinator/seat capability isolation regressions.
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
!offline-backend-20260512/backend/tests/test_workflow_engine.py
!offline-backend-20260512/backend/tests/test_workflow_runs.py
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
# Writing-mode first-report pipeline enforcement regressions.
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
# LLM call timeout retry classification regression coverage.
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
# Writing-mode pipeline conversation-material regressions.
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
deploy
.history
# Cloned third-party repos (local only)
+2
View File
@@ -80,6 +80,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `AGENTS.md`/`CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
### Frontend
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
+2
View File
@@ -73,6 +73,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
### Frontend
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
@@ -5,6 +5,9 @@ const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
let pollTimer = null;
let isPolling = false;
let startupPromise = null;
const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]);
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -12,6 +15,14 @@ function delay(ms) {
async function getSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
const discovered = await chrome.storage.local.get({
discoveredApiBaseUrl: "",
discoveredWebBaseUrl: "",
});
if (settings.autoDiscoverServiceUrl) {
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
settings.webBaseUrl = discovered.discoveredWebBaseUrl || settings.webBaseUrl;
}
const updates = {};
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
@@ -69,10 +80,7 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
}
};
add(currentApiBase);
add(webBase);
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
add(LEGACY_LOCAL_API_BASE_URL);
try {
const url = new URL(normalizeServiceBase(webBase));
@@ -85,6 +93,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
// Ignore invalid candidates.
}
add(currentApiBase);
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
add(LEGACY_LOCAL_API_BASE_URL);
return candidates;
}
@@ -93,6 +105,12 @@ async function browserContextStatus(apiBaseUrl) {
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
credentials: "include",
});
// The gateway may enforce authentication globally before this otherwise
// public discovery endpoint is reached. A 401/403 still proves that this
// candidate is the zncm API paired with the open frontend tab.
if (response.status === 401 || response.status === 403) {
return { enabled: true, authenticationRequired: true };
}
if (!response.ok) {
return null;
}
@@ -201,9 +219,12 @@ async function discoverServiceBases(settings, force = false) {
settings.webBaseUrl !== next.webBaseUrl ||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
) {
await chrome.storage.sync.set({
apiBaseUrl: next.apiBaseUrl,
webBaseUrl: next.webBaseUrl,
// Discovered endpoints are runtime state, not user-authored preferences.
// Keep them in storage.local so Chrome sync write quotas cannot break task
// polling. The options page may still persist explicit user choices.
await chrome.storage.local.set({
discoveredApiBaseUrl: next.apiBaseUrl,
discoveredWebBaseUrl: next.webBaseUrl,
lastServiceSyncAt: next.lastServiceSyncAt,
lastServiceSyncSource: next.lastServiceSyncSource,
});
@@ -826,19 +847,50 @@ async function pollOnce() {
}
}
async function startup() {
async function initializePolling() {
let settings = await getSettings();
settings = await discoverServiceBases(settings);
settings = await syncAuthToken(settings);
try {
settings = await discoverServiceBases(settings);
} catch (error) {
// Service discovery persists diagnostic metadata in storage.sync. Chrome can
// temporarily reject those writes after its quota is reached; polling must
// still start with the last known settings.
console.warn("zncm service discovery failed during startup", error);
}
try {
settings = await syncAuthToken(settings);
} catch (error) {
// A failed status/token write must not prevent alarms from being installed.
// pollOnce() will retry token synchronization before claiming each task.
console.warn("zncm token sync failed during startup", error);
}
await refreshAlarm(settings);
await scheduleNextPoll(1000);
}
chrome.runtime.onInstalled.addListener(() => void startup());
chrome.runtime.onStartup.addListener(() => void startup());
function startup() {
if (!startupPromise) {
startupPromise = initializePolling().finally(() => {
startupPromise = null;
});
}
return startupPromise;
}
async function restartPollingSchedule() {
const settings = await getSettings();
await refreshAlarm(settings);
await scheduleNextPoll(0);
}
chrome.runtime.onInstalled.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
chrome.runtime.onStartup.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === "sync") {
void startup();
if (
areaName === "sync" &&
Object.keys(changes || {}).some((key) => POLLING_SCHEDULE_SETTING_KEYS.has(key))
) {
void restartPollingSchedule().catch((error) => console.warn("zncm polling reschedule failed", error));
}
});
chrome.alarms.onAlarm.addListener((alarm) => {
@@ -902,4 +954,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
});
void startup();
void startup().catch((error) => console.warn("zncm startup failed", error));
@@ -0,0 +1,130 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const vm = require("node:vm");
const BACKGROUND_PATH = path.join(__dirname, "background.js");
function createHarness() {
let storageChangedListener;
let alarmCreateCount = 0;
const settings = {
apiBaseUrl: "http://localhost:8001",
webBaseUrl: "http://localhost:5174",
autoDiscoverServiceUrl: false,
accessToken: "",
tokenSyncEnabled: false,
taskPollingEnabled: true,
workerId: "test-worker",
taskPollIntervalSeconds: 5,
};
const chrome = {
alarms: {
clear: async () => {},
create: async () => {
alarmCreateCount += 1;
},
onAlarm: { addListener: () => {} },
},
cookies: { get: async () => null },
runtime: {
onInstalled: { addListener: () => {} },
onMessage: { addListener: () => {} },
onStartup: { addListener: () => {} },
},
storage: {
local: {
get: async (defaults) => ({ ...defaults }),
set: async () => {},
},
session: { get: async () => ({ actSessions: {} }), set: async () => {} },
sync: {
get: async () => ({ ...settings }),
set: async (updates) => Object.assign(settings, updates),
},
onChanged: {
addListener: (listener) => {
storageChangedListener = listener;
},
},
},
tabs: { query: async () => [] },
};
const context = {
chrome,
console,
fetch: async () => {
throw new Error("offline test");
},
importScripts: () => {},
setTimeout: () => 1,
clearTimeout: () => {},
URL,
DEER_CLIPPER_DEFAULTS: settings,
deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""),
deerMakeWorkerId: () => "test-worker",
deerAuthHeaders: () => ({}),
deerExtractAuthTokenFromPage: () => ({ token: "" }),
};
context.globalThis = context;
vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, {
filename: BACKGROUND_PATH,
});
return {
emitStorageChange(changes) {
storageChangedListener(changes, "sync");
},
getAlarmCreateCount: () => alarmCreateCount,
resetAlarmCreateCount: () => {
alarmCreateCount = 0;
},
getApiCandidates: (webBase, currentApiBase) =>
vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context),
};
}
const settle = () => new Promise((resolve) => setImmediate(resolve));
test("self-written sync metadata does not restart browser task polling", async () => {
const harness = createHarness();
await settle();
harness.resetAlarmCreateCount();
harness.emitStorageChange({
lastTokenSyncAt: { oldValue: "before", newValue: "after" },
});
await settle();
assert.equal(harness.getAlarmCreateCount(), 0);
});
test("polling configuration changes restart browser task polling", async () => {
const harness = createHarness();
await settle();
harness.resetAlarmCreateCount();
harness.emitStorageChange({
taskPollingEnabled: { oldValue: false, newValue: true },
});
await settle();
assert.equal(harness.getAlarmCreateCount(), 1);
});
test("service discovery prefers the API paired with the open zncm tab", async () => {
const harness = createHarness();
assert.deepEqual(
Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")),
[
"http://localhost:5174",
"http://localhost:8001",
"http://47.94.209.59:2026",
"http://localhost:8001",
].filter((value, index, values) => values.indexOf(value) === index),
);
});
+45 -5
View File
@@ -7,6 +7,8 @@
const HOST_ID = "zncm-page-assistant-host";
const state = {
enabled: true,
taskPollingEnabled: true,
webBaseUrl: "",
open: false,
busy: false,
threadId: "",
@@ -709,20 +711,58 @@
async function loadSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
state.enabled = Boolean(settings.pageAssistantEnabled);
state.taskPollingEnabled = Boolean(settings.taskPollingEnabled);
state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
ensureWidget();
}
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync" || !changes.pageAssistantEnabled) {
if (areaName !== "sync") {
return;
}
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
ensureWidget();
if (state.enabled) {
setStatus("页面问答框已开启。");
if (changes.pageAssistantEnabled) {
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
ensureWidget();
if (state.enabled) {
setStatus("页面问答框已开启。");
}
}
if (changes.taskPollingEnabled) {
state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue);
}
if (changes.webBaseUrl) {
state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl);
}
});
function isZncmPage() {
const appPath = `${location.pathname}${location.hash}`.toLowerCase();
if (
appPath.includes("/page/workspace") ||
appPath.includes("/chats/") ||
appPath.includes("#/login")
) {
return true;
}
try {
const configured = new URL(state.webBaseUrl);
return configured.origin === location.origin;
} catch {
return false;
}
}
// MV3 service workers may be suspended between their short polling timers,
// while chrome.alarms cannot reliably run more often than every 30 seconds.
// A visible zncm tab provides a cheap wake-up signal so browser_act requests
// are normally claimed within the backend/model timeout window.
setInterval(() => {
if (!state.taskPollingEnabled || !isZncmPage()) {
return;
}
chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {});
}, 3000);
setInterval(() => {
if (!state.enabled || !ui.pageTitle) {
return;
@@ -0,0 +1,703 @@
# AgentScope 2.0 多智能体报告协作工作台——前端实施计划
> 文档状态:待评审、尚未开始编码
> 编写日期:2026-09-02
> 目标项目:`F:\react01\deeflow-code\deerflow-server\frontend-web`
> 视觉与交互参考:`F:\react01\coze-frontend\frontend\apps\coze-studio`
> 参考页面:`http://localhost:3000/work_flow?space_id=1&workflow_id=wf_88cb9f4c6642`
> 配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`
> 本文只规划新增页面,不修改现有多智能体会商页面,不复用旧 Workflow Studio 执行状态。
## 1. 前端目标
新增独立的「报告协作」工作台。用户可以通过对话提出任意报告需求,查看系统生成的多个协作方案,点击方案卡片在流程图中预览协作结构,确认后观察多智能体执行,并在全过程通过统一对话框提出修改意见。
前端必须满足:
1. 页面采用“左侧对话、右侧流程画布”的固定两栏布局,画布、工具栏、弹窗和抽屉参考 Coze Studio 页面风格。
2. 消息列表必须复用 DeerFlow 现有 `MessageList`,不复制 Coze 的会话消息实现。
3. 所有执行过程、AgentScope 团队消息、智能体回复、工具调用及其原始返回、错误、协助卡、返工和最终报告入口都通过现有消息协议按时间顺序显示在左侧对话区,不设置独立执行结果面板,也不另做“阶段摘要”替代实时消息。
4. 候选方案、流程节点、对话进度和报告使用同一份后端事实,不各自推断状态。
5. 用户可在规划中、运行中、等待输入和报告完成后继续对话。
6. 用户协助卡的出现和提交都不能在前端被解释为“节点已经完成”。
7. 页面刷新、SSE 断线重连、历史会话切换后能恢复到一致状态。
暂定:
- 页面名称:`报告协作`。
- 路由:`/page/workspace/report-collaboration`。
- 会话路由:`/page/workspace/report-collaboration/:sessionId`。
- 默认候选方案数量:3。
- 点击画布节点时打开覆盖式节点说明抽屉;抽屉只展示节点职责和合同,不展示节点执行结果。
---
## 2. 范围与非目标
### 2.1 本期范围
- 新建、查看、重命名、删除协作报告会话。
- 使用对话澄清报告需求。
- 显示 2~3 个候选方案卡片。
- 卡片与右侧流程图预览联动。
- 节点说明抽屉:职责、所依据的信息类型、预期产出、工具、资料要求和验收条件。
- 实时展示节点排队、运行、校验、等待用户、完成、返工、失败和取消。
- 在左侧对话时间线中直接展示每个智能体的流式文本、团队对话、工具调用和工具返回,并展示审稿意见、报告文件和报告版本。
- 并行阶段用横向的智能体选择器切换当前查看对象;选择器只控制同一份消息流的聚焦范围,不创建另一套执行结果视图。
- 运行期间发送补充要求、重新检索、重新分析、节点重跑等指令。
- 完成后进行报告问答、章节重写、全文润色和版本恢复。
- 报告模板选择、Markdown 预览、Markdown/Word 下载。
- 响应式布局、键盘操作、加载反馈和错误恢复。
### 2.2 明确不做
- 不引入 `@coze-workflow/playground` 或 `@coze-arch/coze-design`。
- 不加载 Coze workflow definition、run 或 node run。
- 不允许前端拖拽连线后直接改变执行逻辑。
- 不新增第二套消息气泡、Markdown、引用和文件卡渲染器。
- 不展示模型原始 chain-of-thought、系统/内部 prompt、密钥和被标记为内部控制信息的字段。
- 不把用户有权查看的 AgentScope TeamSay、智能体文本、工具调用或工具返回压缩成“正在检索”等摘要卡;这些原始业务消息必须进入复用的 `MessageList`。敏感字段只能按统一脱敏策略替换,不能以摘要替代。
- 不增加第三栏、常驻详情栏、节点执行结果栏或独立执行时间线。
- 节点抽屉不展示节点生成正文、工具返回、证据列表、错误堆栈或 attempt 历史;这些过程统一在左侧 `MessageList` 的原生消息/工具步骤中查看。
- 不改造现有圆桌、多智能体会商、深度研究和 AI 写作页面。
- 不把浏览器状态作为业务事实源。
---
## 3. 参考页面行为映射
| 目标行为 | Coze 参考文件 | DeerFlow 目标组件 | 说明 |
|---|---|---|---|
| 两栏骨架 | `workflow-studio-layout.tsx` 的面板比例与分隔条 | `ReportCollaborationLayout.tsx` | 左对话、右画布;只保留一条可调整分隔线 |
| 顶栏 | `workflow-studio-header.tsx` | `ReportCollaborationHeader.tsx` | 替换为会话、模板、报告、停止等操作 |
| 候选卡片 | `workflow-proposal-cards.tsx` | `ReportPlanCards.tsx` | 点击预览,明确确认后才执行 |
| 右侧画布 | `workflow-canvas.tsx` | `ReportFlowCanvas.tsx` | 使用 `@xyflow/react` 重建,不引入 Coze Playground |
| 左侧消息区 | `run-conversation-panel.tsx` | `ReportConversationPanel.tsx` | 外层风格参考 Coze,内部复用 DeerFlow `MessageList` |
| 节点说明抽屉 | Coze 节点配置面板的视觉与抽屉行为 | `NodeInspectorDrawer.tsx` | 点击画布节点打开,只展示职责与输入输出合同 |
| 全程执行反馈 | 参考其进度和状态表达 | `AgentStageSelector` + DeerFlow `MessageList` | 选择智能体后直接显示其流式消息、TeamSay、工具调用与返回;不再合成为阶段摘要 |
| 状态栏 | `connection-status.tsx` | `ConnectionStatusBar.tsx` | 展示连接、重连和事件游标 |
| 对话历史 | `left-panel-tabs.tsx` | `ConversationSidebarTabs.tsx` | 对话与历史,不增加“节点资源”Tab |
| 异步反馈 | 规划进度、运行状态 | MessageList 步骤条 + 画布状态 | 操作超过 300ms 必须有反馈 |
主要参考源:
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-layout.tsx`
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-composition.tsx`
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-header.tsx`
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\run-conversation\workflow-proposal-cards.tsx`
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\styles.module.less`
若直接复制 Apache-2.0 源码片段或样式,必须保留版权头并维护来源说明;默认做行为映射和视觉重建。
---
## 4. 页面结构
```text
┌──────────────────────────────────────────────────────────────────────────┐
│ 返回 报告协作 / 会话标题 保存状态 模板 报告 停止/继续 更多 │
├────────────────────────────────┬─────────────────────────────────────────┤
│ 对话 / 历史 │ 协作流程图 │
│ │ │
│ DeerFlow MessageList │ @xyflow/react │
│ · 并行时:[协调者][市场][政策] │ · 节点状态与活动连线 │
│ · 用户与协调者/成员消息 │ · 缩放、适配视图、小地图 │
│ · 原生 ToolCall / ToolMessage │ · 点击节点打开覆盖式说明抽屉 │
│ · 协助卡、报告文件和版本 │ │
│ │ │
│ 统一输入框 │ │
├────────────────────────────────┴─────────────────────────────────────────┤
│ 已连接 · run_xxx · 事件 128 │
└──────────────────────────────────────────────────────────────────────────┘
```
节点抽屉覆盖在右侧画布之上,不占用第三栏:
```text
┌─────────────────────────────────────────┐
│ 节点名称 关闭 × │
│ 角色 / 当前状态 │
├─────────────────────────────────────────┤
│ 节点职责 │
│ 要根据什么:所需输入、上游类型、资料要求 │
│ 要产出什么:成果类型、格式、验收条件 │
│ 可用工具与边界 │
│ 上下游关系 │
├─────────────────────────────────────────┤
│ 在对话中对此节点提出意见 │
└─────────────────────────────────────────┘
```
抽屉明确不展示实际执行结果、生成正文、工具返回、来源明细、错误堆栈或 attempt 时间线;这些信息只在左侧原生消息流中展示。
布局默认值:
- 顶栏高度:56px。
- 左侧面板:默认 480px,最小 360px,最大 720px。
- 右侧画布:最小 480px,使用剩余空间。
- 节点说明抽屉:覆盖画布右侧,建议宽度 420px;关闭后完整归还画布空间。
- 状态栏:24~28px。
- 低于 960px:左侧对话和右侧画布改为顶部按钮切换的单面板模式;节点说明仍使用覆盖式 Drawer。
- 两栏分隔条:支持 pointer 拖动、方向键、Home、End。
### 4.1 原生消息流与并行智能体切换
“复用 `MessageList`”在本页面的含义是复用它现有的 assistant 文本、`tool_calls`、`tool` 消息、引用、文件和协助卡渲染语义;不把 AgentScope 的运行过程先解析成专用步骤条、进度摘要或阶段结果卡。
单个智能体:
- `agent.reply.started` 立即创建一条稳定的 assistant 消息;`message.delta` 持续更新同一 `messageId`,所以用户会先看到流式状态、再看到逐字回复,而不是等待节点结束。
- 工具调用创建/更新该 assistant 消息的 `tool_calls`;工具返回创建匹配 `tool_call_id` 的原生 `tool` 消息。现有 `MessageGroup` 负责把两者配对显示,不再另写工具过程渲染器。
- AgentScope TeamSay 作为带 `agentRunId` 的普通 assistant 消息进入同一流,按到达顺序显示。它不是节点完成的证据,节点状态仍由右侧画布和后端状态机决定。
并行智能体:
```text
多角度并行研究 3/4 进行中
[ 协调者 ] [ 市场规模 ✓ ] [ 品牌竞争 ● ] [ 海外市场 ● ] [ 政策风险 ! ]
└──────────────────── 当前选中智能体的原生 MessageList ────────────────────┘
```
- `AgentStageSelector` 只在并行阶段出现,横向放置在左侧对话区顶部;使用现有图标体系的头像/角色图标、名称、状态文字和非纯颜色状态标识。默认选中第一位已开始运行的成员;用户手动切换后保持选择,不因其他成员返回而抢走阅读焦点。
- 所有成员的事件都持续接收、按每个 `agentRunId` 持久化和更新,即使当前未选中;切回即可看到完整历史与正在更新的同一条消息,不丢 token、不重新请求、不把结果拼成摘要。
- 当前选择只将共享用户消息、协调者广播消息和目标成员消息组成一个 `BaseStream<AgentThreadState>` 交给同一个 `MessageList`。切换标签仅切换该视图的消息范围,不改变 run、节点或消息的后端事实。
- 某成员请求用户协助时,其标签显示“待回复”徽标,选择器显示待处理数;点击徽标切到该成员,原生协助工具消息仍由现有 `MessageList` 协助卡渲染。其他成员可继续运行。
- 节点抽屉打开某并行成员时,同步聚焦对应标签;抽屉仍只展示合同。用户从抽屉或输入框发送反馈时带 `targetNodeId`/`agentRunId`,后端在安全点生效。
这不是把完整日志另做成调试面板:对话区展示的是模型和工具的真实业务消息;只有 chain-of-thought、内部提示词及按权限必须脱敏的字段不进入该消息流。
### 4.2 页面主要状态
| 状态 | 左侧对话区 | 右侧流程画布 | 主要操作 |
|---|---|---|---|
| `empty` | 欢迎语和输入框 | 空态 | 输入需求、选模板 |
| `clarifying` | 澄清问题和协助卡 | 需求理解节点 | 回答或补充 |
| `planning` | 用户消息和规划进度 | 骨架或需求图 | 继续补充,等待安全点生效 |
| `proposal_ready` | 2~3 张方案卡 | 点击卡片预览对应图 | 切换、修改、采用 |
| `running` | 协调者与成员原生消息、工具过程和用户反馈 | 实时节点状态 | 干预、停止、在对话中要求重跑 |
| `awaiting_input` | 等待用户卡片、原因和影响 | 对应节点显示等待 | 回答、取消 |
| `reviewing` | 审稿意见和返工过程 | 评审/返工节点状态 | 接受或补充要求 |
| `completed` | 总结、报告文件卡、版本和后续对话 | 完整执行图 | 问答、改写、导出 |
| `failed` | 错误说明和恢复建议 | 失败节点 | 通过对话重试或取消 |
| `cancelled` | 已保留成果说明 | 未完成节点取消 | 从已有成果继续或新建方案 |
---
## 5. 前端目录与组件边界
建议新增:
```text
frontend-web/src/report-collaboration/
api/
client.ts
sessions.ts
plans.ts
runs.ts
reports.ts
types.ts
components/
ReportCollaborationLayout.tsx
ReportCollaborationHeader.tsx
ConversationSidebarTabs.tsx
ReportConversationPanel.tsx
AgentStageSelector.tsx
ReportComposer.tsx
ReportPlanCards.tsx
ReportFlowCanvas.tsx
ReportFlowNode.tsx
NodeInspectorDrawer.tsx
ReportPreviewDialog.tsx
InterventionCard.tsx
SessionHistoryPanel.tsx
ConnectionStatusBar.tsx
hooks/
useReportCollaborationSession.ts
useReportCollaborationStream.ts
useReportCollaborationMessages.ts
useReportPlanSelection.ts
useReportPanelLayout.ts
state/
event-normalizer.ts
event-reducer.ts
selectors.ts
message-adapter.ts
pages/
ReportCollaborationPage.tsx
styles/
report-collaboration.css
tests/
```
修改范围:
- `frontend-web/src/pages/WorkspaceRoutes.tsx`:增加页面和会话路由。
- `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx`:增加导航入口。
- `frontend-web/src/components/workspace/messages/message-list.tsx`:仅允许增加一个向后兼容的通用业务卡片插槽。
- `frontend-web/src/core/messages/utils.ts`:如确需识别通用业务卡类型,只做集中式扩展。
不得把新功能放进:
- `frontend-web/src/roundtable-planning/`
- `frontend-web/src/open-canvas/`
- Coze 项目的 `workflow-standalone/`
---
## 6. 数据来源与前端状态
### 6.1 三层状态
前端状态分为:
1. 服务器快照:session、原生 messages、plans、run、nodes、artifacts、report versions;每条消息带稳定的 `agentRunId`、`nodeRunId`、`phaseId` 和可见性标记,其中 artifacts 只用于生成左侧消息卡和报告文件,不形成独立结果面板。
2. durable SSE reducer:从 `seq` 连续应用事件,更新服务器快照投影。
3. 纯展示状态:预览中的方案、选中的节点、聚焦的并行 `agentRunId`、两栏宽度、节点抽屉开关、窄屏当前视图和画布位置。
纯展示状态不能改变执行结果。点击卡片只改变 `previewPlanId`;只有点击“采用方案”才改变服务器 `selectedPlanId`。
### 6.2 前端事件包
```ts
interface CollaborationEvent<T = unknown> {
eventId: string;
sessionId: string;
runId?: string;
seq: number;
type: string;
timestamp: string;
data: T;
}
```
必须处理:
- 快照先到、事件后到。
- 事件重复。
- 事件短暂乱序。
- SSE 断线后从 `after_seq` 重放。
- 页面切换时旧连接晚到事件。
- token delta 多次更新同一消息。
- TeamSay、assistant 文本、tool-call 参数 delta、tool 返回 delta 分别更新各自稳定消息,不得等节点完成后批量生成摘要。
- 并行成员的消息可交错到达;Reducer 必须按 `agentRunId + messageId` 更新,而不是按当前被选中的标签丢弃未聚焦成员的事件。
- 节点重新打开后出现新 attempt。
Reducer 以 `sessionId + runId + seq` 去重;消息行以 `messageId`、工具结果以 `toolCallId`、标签以 `agentRunId` 使用后端稳定 ID,禁止使用数组下标。
---
## 7. 分步实施计划
### FE-0:冻结参考视觉与行为基线
任务:
1. 在 1440×900、1280×800、1024×768 打开参考页面。
2. 保存初始态、方案态、运行态和节点说明抽屉四组截图。
3. 记录顶栏、两栏比例、间距、圆角、边框、阴影、画布工具栏和状态色。
4. 形成“参考组件—目标组件—允许差异”表。
5. 明确消息列表保持 DeerFlow 原样,不纳入像素对齐。
交付物:
- 视觉基线截图。
- 视觉 token 表。
- 页面行为清单。
验收:参考的每个用户可见状态都有截图或明确说明不存在。
### FE-1:路由、菜单和静态页面骨架
任务:
1. 增加两个路由和菜单项。
2. 创建 `ReportCollaborationPage`。
3. 创建顶栏、左侧对话栏、右侧画布和底部连接栏。
4. 实现可调整宽度的两栏布局,以及“专注对话/专注画布”切换。
5. 实现窄屏单面板切换与覆盖式节点 Drawer。
验收:
- 不连接后端也能呈现完整空态。
- 375~1440px 不出现页面级横向滚动。
- 对话/画布切换不会卸载会话状态或清空输入内容。
- 键盘可以调整两栏分隔条。
### FE-2:页面设计系统与 Coze 风格重建
建立页面专用语义变量:
```text
--rc-bg-page
--rc-bg-panel
--rc-bg-canvas
--rc-border
--rc-text-primary
--rc-text-secondary
--rc-accent
--rc-running
--rc-waiting
--rc-success
--rc-danger
--rc-panel-shadow
--rc-card-radius
```
规则:
- 从 DeerFlow 主题 token 派生,不在组件中散落十六进制颜色。
- 使用 Lucide SVG 图标,不使用 emoji。
- hover/focus/展开动画控制在 150~300ms。
- 支持 `prefers-reduced-motion`。
- 交互状态不能只靠颜色表达。
- 正文对比度达到 4.5:1。
- 操作超过 300ms 显示 spinner、骨架或流式状态。
验收:除 MessageList 内部外,两栏布局、密度、卡片、画布工具栏、弹窗和节点抽屉达到参考基线。
### FE-3:API 类型和 TanStack Query 层
实现:
- session CRUD。
- messages 查询与发送。
- plan request、plans 查询、select。
- run 创建、查询、停止、重试。
- command 创建、确认、取消。
- artifacts、sources、reports、versions 查询。
每个写请求携带:
- `idempotencyKey`
- `expectedRevision`
冲突行为:
- `409`:刷新当前快照并提示用户重新确认。
- `422`:在对应操作附近显示具体错误。
- `429`:显示预算/并发限制,不自动重试。
- `5xx`:保留用户输入,允许使用同一幂等键重试。
验收:API 层不包含页面展示逻辑,类型与后端 OpenAPI 一致。
### FE-4:SSE、快照和事件 Reducer
实现 `useReportCollaborationStream`:
1. 先拉 session snapshot。
2. 使用 snapshot 的 `lastSeq` 建立 SSE。
3. 校验每个事件 session/run 身份。
4. 按 seq 应用事件并去重。
5. 出现序号缺口时暂停 live apply,补拉缺失事件。
6. 断线指数退避并支持 `Last-Event-ID`。
7. 终态后完成最后一次事件补拉再关闭连接。
Reducer 必须覆盖:
- 消息流增量。
- 计划候选和 revision。
- node/node attempt 状态。
- artifact 创建和校验。
- command 分类和执行。
- report delta 和正式版本。
- 错误、取消、接管恢复。
- AgentScope 原始可见消息和工具事件:`message.created/delta/completed`、`tool_call.created/delta/completed`、`tool_result.created/delta/completed`、`team_message.created/delta/completed`。
验收:重复事件、乱序事件和重连都不产生重复消息、重复节点或状态倒退。
### FE-5:复用 DeerFlow `MessageList`
复用:
- `frontend-web/src/components/workspace/messages/message-list.tsx`
- `frontend-web/src/components/workspace/messages/message-list-item.tsx`
- `frontend-web/src/components/workspace/messages/message-group.tsx`
- `frontend-web/src/components/workspace/messages/context.ts`
实现无损协议适配 `message-adapter.ts`:
1. 将已持久化的 `CollaborationMessage` 一对一映射为 LangGraph SDK `Message[]`,不将文本或工具结果二次概括。
2. 构造最小 `BaseStream<AgentThreadState>` 外观:`messages`、`isLoading`、`isThreadLoading`、`values`。
3. 协调者、成员 AgentScope TeamSay 和成员回复都映射为普通 assistant 消息;保留 `messageId`、文本 chunk、角色/成员名称、`agentRunId`、`nodeRunId`、`phaseId`、来源事件 ID 和完成态。
4. AgentScope 工具调用映射为该 assistant 消息的标准 `tool_calls`;调用结果映射为标准 `tool` 消息,并严格复用原有 `tool_call_id` 配对。工具参数、返回正文及增量保持原文,只有后端明确标注的脱敏字段被替换。
5. 将来源、文件、报告 Markdown delta 和正式版本继续映射为既有引用、文件卡和普通 assistant 消息;规划卡、用户确认卡和报告替换卡仍通过一个通用业务卡片插槽渲染。
6. 错误、恢复建议、命令影响范围和报告版本变化也必须进入同一时间线;错误堆栈以可读错误消息替代,但不能吞掉已发生的工具/成员消息。
7. `selectMessagesForAgentRun(allMessages, focusedAgentRunId)` 只在客户端从已同步消息中选出共享消息与目标成员消息;并行选择器不得重新拉取、重新排序或改写消息。
约束:
- 不使用 LangGraph `useThreadStream` 发送消息。
- 新页面输入框调用报告协作 API。
- 不增加多个页面专用 MessageList props。
- 普通聊天、AI 写作、深度研究等未传插槽时必须零变化。
- 不另建执行结果列表、节点结果面板或成果时间线。
- 不用“运行中”“已检索 N 条”等专用摘要替代真实消息。`MessageList` 本身的流式状态和原生工具步骤是唯一过程视图。
验收:
- 历史和流式消息使用同一行稳定更新。
- 单成员首次开始回复、每次工具启动和工具返回均在 300ms 内出现可见的原生消息行或原生工具步骤;不允许持续数秒只有静态加载圈。
- 并行标签切换后显示目标成员的完整已持久化消息和实时 delta;未选中成员持续接收,标签上的运行/待回复状态及时更新。
- 用户上翻阅读时不强制滚底。
- 用户发送新消息后滚到最新位置。
- 卡片显示成功不改变后端节点状态。
### FE-6:需求澄清和候选方案卡片
`ReportPlanCards` 展示:
- 推荐标签、策略、标题、摘要和推荐理由。
- 分析角度和参与角色。
- 关键步骤与质量门。
- 预计时间、成本等级和资料范围。
- 方案验证错误。
交互:
- 点击卡片或“查看流程”只切换预览。
- “采用此方案”调用 select API。
- “开始协作”再次校验并创建 run。
- 方案调整后显示新 revision;旧 revision 只读。
- 过期 revision 返回 409 后不静默覆盖。
验收:连续快速切换三张卡时,画布最终显示最后一次选择,不闪回旧图。
### FE-7:协作流程画布
技术:`@xyflow/react`。可以借鉴但不直接耦合:
- `frontend-web/src/components/ai-elements/canvas.tsx`
- `frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx`
节点显示:
- 节点名、角色名和分析角度。
- 状态文字、图标和颜色。
- 等待用户、返工、失败和 superseded 标记。
画布能力:
- 自动布局。
- 拖动节点只保存浏览器展示位置。
- 缩放、适配视图、方向切换、小地图。
- 活动边低强度流动动画。
- 点击节点打开覆盖在画布上的节点说明抽屉。
- 节点重跑、补充或纠偏一律通过左侧对话完成;抽屉只提供“在对话中对此节点提出意见”入口。
- 方案切换后清理不属于新方案的本地坐标。
验收:
- 30 个节点内流畅操作。
- 单个 node event 不导致整个 React Flow 重挂。
- `awaiting_input`、`reopened`、`superseded` 不与 `completed` 混淆。
### FE-8:节点说明抽屉与对话定位
点击画布节点打开 `NodeInspectorDrawer`。抽屉只展示计划合同:
1. 节点名称、角色、分析角度和当前状态标签。
2. 节点职责:负责解决什么问题。
3. 要根据什么:所需输入类型、上游节点类型、资料范围和时效要求。
4. 要产出什么:成果类型、结构要求和验收条件。
5. 可用工具、禁止事项、预算上限。
6. 上游与下游关系。
抽屉不展示:
- 节点实际生成内容。
- 工具调用返回值。
- EvidenceBundle 或其他成果物正文。
- attempt 执行时间线。
- 错误堆栈和调试信息。
- 报告正文。
交互:
- “在对话中对此节点提出意见”关闭抽屉、聚焦左侧 Composer,并绑定 `targetNodeId` 上下文。
- 若该节点对应正在并行运行的成员,同时绑定该成员的 `agentRunId` 并切换左侧选择器,使用户发送前能先看到该成员的真实对话与工具过程。
- 用户仍需在对话区输入“重新检索”“重新分析”等要求;抽屉不直接发起业务变更。
- 状态标签来自后端节点状态,但不加载节点结果接口。
- 关闭抽屉不清空 selected node;再次点击同一节点可恢复说明。
报告预览不使用常驻第三栏。最终报告、章节候选和版本先作为左侧 MessageList 中的普通消息/文件卡出现;点击文件卡时复用 `ArtifactFileDetail` 或现有 Markdown 能力打开大尺寸 Dialog,关闭后回到两栏工作台。
验收:
- 抽屉宽度不改变两栏布局,不挤压左侧对话区。
- 抽屉只读取节点合同和图结构,不请求节点执行结果。
- 所有原生成员消息、工具过程、错误和报告入口都能在左侧对话时间线找到。
### FE-9:全过程对话干预
`ReportComposer` 根据状态显示不同提示:
- 空闲:输入报告需求。
- 规划中:补充要求将在规划安全点应用。
- 运行中:协调者正在判断影响范围。
- 等待用户:优先回答等待问题。
- 完成后:可问答、重写、润色或重新研究。
发送流程:
1. 生成 idempotency key。
2. 插入 pending 用户消息;带 `targetNodeId`/`agentRunId` 时在相关成员消息流和共享上下文中引用同一条用户消息,不重复持久化。
3. 显示“正在识别修改范围”。
4. `command.classified` 后展示意图和影响节点。
5. 高成本或低置信度命令显示确认卡。
6. `command.accepted` 后显示排队或等待安全点。
7. 完成后关联新 node attempt 或 report version。
8. 失败时保留原消息和重试入口。
典型映射:
| 用户输入 | 预期操作 |
|---|---|
| “增加政策风险角度” | 新增角度节点和受影响下游 |
| “资料太旧,重新找” | 重新检索指定证据包 |
| “竞争格局分析不到位” | 复用证据,重新分析 |
| “重写第二章” | 只生成章节替换候选 |
| “语气更正式” | 新报告版本,不重新研究 |
| “为什么得出这个结论” | 只读问答,不修改报告 |
验收:
- 双击发送不创建重复命令。
- 选中“品牌竞争分析师”后发送补充要求,只影响该成员及依赖分析,其他并行成员仍保持消息流和运行状态。
- 运行中反馈不会让 run 或 node 误变 completed。
- “重写第二章”不会触发全量检索。
- “资料太旧”不会只做文字润色。
### FE-10:会话历史、报告版本与导出
- History Tab 显示标题、状态、更新时间、当前版本。
- 新建会话不删除旧会话。
- 会话切换时先关闭旧 SSE,再加载新快照。
- 报告修改先生成候选,确认后创建新版本。
- 版本恢复创建新的头版本,不删除历史。
- 下载复用现有 Markdown/Word 能力。
- 文件名来自报告标题,不暴露宿主机或沙箱路径。
验收:历史会话可完整恢复左侧消息流程、右侧节点状态和报告版本;不额外恢复节点结果面板。
### FE-11:测试、视觉回归和性能
单元测试:
- event normalizer/reducer。
- message adapter。
- plan revision 和选择状态。
- node status 和 available actions。
- command pending/idempotency。
组件测试:
- 空态、澄清、规划、候选、运行、等待、完成、失败、取消。
- 卡片选择、节点点击、只读说明 Drawer、确认弹窗和报告预览 Dialog。
- MessageList 通用业务卡插槽零回归。
- 单成员流式文本、TeamSay、工具调用与工具返回的原样重放;并行成员交错到达、切换标签、切回后不中断 delta。
- 非当前标签成员发起协助时显示待回复徽标,点击后使用原生协助卡回复到正确节点。
E2E:
1. 新建会话并输入新能源汽车报告要求。
2. 选择候选并开始。
3. 运行中增加风险角度。
4. 重新检索海外最新资料。
5. 完成后重写第二章。
6. 刷新、断网重连、切换会话。
7. 停止和重试失败节点。
性能门:
- 30 节点画布不卡顿。
- 长消息列表使用现有优化策略。
- SSE 高频 delta 合帧后再 setState。
- 画布和消息区使用独立 selector,避免互相触发整树重渲染。
- 节点抽屉只订阅节点合同和状态,不能因阶段产出更新而反复重渲染。
- 只渲染当前聚焦成员和共享消息的可视窗口;非当前成员的消息仍写入 store,但不因 token delta 重渲染当前 `MessageList`。
---
## 8. 后端接口依赖
前端开发前,后端必须冻结以下接口:
```text
POST /api/report-collaboration/sessions
GET /api/report-collaboration/sessions
GET /api/report-collaboration/sessions/{session_id}
PATCH /api/report-collaboration/sessions/{session_id}
DELETE /api/report-collaboration/sessions/{session_id}
POST /api/report-collaboration/sessions/{session_id}/messages
GET /api/report-collaboration/sessions/{session_id}/messages
POST /api/report-collaboration/sessions/{session_id}/plan-requests
GET /api/report-collaboration/sessions/{session_id}/plans
POST /api/report-collaboration/sessions/{session_id}/plans/{plan_id}/select
POST /api/report-collaboration/sessions/{session_id}/runs
GET /api/report-collaboration/runs/{run_id}
GET /api/report-collaboration/runs/{run_id}/events
GET /api/report-collaboration/runs/{run_id}/stream
POST /api/report-collaboration/runs/{run_id}/commands
POST /api/report-collaboration/runs/{run_id}/cancel
GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract
GET /api/report-collaboration/sessions/{session_id}/reports
POST /api/report-collaboration/sessions/{session_id}/report-rewrites
POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/apply
```
前后端共享枚举:
- session status。
- run status。
- node/node attempt status。
- command intent/status。
- event type/version。
- artifact type/schema version。
- report version/change type。
---
## 9. 前端开发顺序
1. `RC-FE-001`:视觉截图与行为基线。
2. `RC-FE-002`:路由、菜单和左对话/右画布两栏空壳。
3. `RC-FE-003`:页面 token、弹窗、抽屉和响应式。
4. `RC-FE-004`:API 类型和 mock server。
5. `RC-FE-005`:SSE reducer 与快照恢复。
6. `RC-FE-006`:MessageList adapter 和通用卡片插槽。
7. `RC-FE-007`:需求澄清与候选方案卡。
8. `RC-FE-008`:流程画布和自定义节点。
9. `RC-FE-009`:只读节点说明抽屉与对话目标定位。
10. `RC-FE-010`:统一 Composer 和命令确认。
11. `RC-FE-011`:报告预览、版本和导出。
12. `RC-FE-012`:历史、重连和异常恢复。
13. `RC-FE-013`:组件/E2E/视觉/性能验收。
依赖规则:
- `RC-FE-004` 必须等待前后端契约冻结。
- 页面骨架和视觉可与后端 BE-0~BE-3 并行。
- 真实 SSE 联调必须等待后端 durable event store。
- 报告版本操作必须等待后端候选应用语义完成。
---
## 10. 前端完成定义
前端只有同时满足以下条件才算完成:
1. 页面结构与 Coze 参考页面逐状态验收通过。
2. 消息区复用 DeerFlow `MessageList`,其他聊天页面无回归。
3. 候选卡、左侧对话流程、右侧流程图和报告来自同一 session/run/event 状态。
4. 点击候选只预览,明确确认后才执行。
5. 用户可以在规划、运行、完成后三个阶段继续对话。
6. 协助卡显示或提交不会导致前端误标完成。
7. 节点点击只打开说明抽屉,可查看职责、所需输入、预期输出、工具和验收条件,不展示执行结果。
8. 节点重开、返工和 superseded 状态表达正确。
9. SSE 重连、刷新和历史切换无重复消息和状态倒退。
10. 报告修改以候选和版本形式完成,不直接覆盖正式报告。
11. 页面始终只有对话与画布两个常驻区域,所有执行过程和产出入口都能在对话时间线中找到。
12. 响应式、键盘、对比度、加载反馈和错误恢复通过验收。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,304 @@
# OpenVibeCoding 内网可视化应用生成平台:分步开发方案
> 文档状态:待实施
>
> 目标环境:现有 Ubuntu 22.04 Docker 服务器;对外可用端口限定为 `8020-8030`。
>
> 本文基于 2026-08-31 的只读巡检编写。未在目标服务器上安装、停止或修改任何服务。
## 1. 目标与范围
将 OpenVibeCoding 改造成一个使用公司内网模型、在公司服务器 Docker 沙箱中生成并运行可视化应用的工作台。用户通过对话生成项目,查看并修改文件,在浏览器中预览运行结果。
第一期只服务以下项目类型:
- 数据大屏、数据看板、海报和展示型页面;
- React + Vite + TypeScript 前端;
- ECharts 图表、流程图;
- 可选的轻量 Python FastAPI 接口;
- 项目文件浏览、编辑、运行日志和实时预览。
第一期明确不做:
- 腾讯云 CloudBase、SCF、TCR、CodeBuddy、云端一键部署;
- 任意语言、任意 npm/PyPI 包的无限制安装;
- 用户自定义 Dockerfile、Docker Socket 挂载、宿主机命令执行;
- 直接将沙箱暴露为独立公网端口;
- 对不可信代码提供“绝对安全”的隔离承诺。
## 2. 已确认的服务器基线
目标服务器当前具备:
| 项目 | 巡检结果 | 判断 |
|---|---:|---|
| 操作系统 | Ubuntu 22.04.5 LTS | 支持 Docker 方案 |
| CPU | 32 vCPU | 足够第一期 |
| 内存 | 125 GiB;巡检时可用约 106 GiB | 足够第一期 |
| 根磁盘 | 2 TB;巡检时可用约 1.6 TB | 足够 Harbor、镜像与工作区 |
| Docker | 29.1.3,Docker Compose 2.40.3 | 已满足 |
| 负载 | 约 0.5 | 当前有余量 |
| `8020-8030` | 巡检时无监听、无 Docker 端口映射 | 可规划使用 |
该服务器已承载 DeerFlow、Coze、LiteLLM、数据库和其他容器;`80`、`8000`、`8005`、`8006`、`3306`、`5432` 等端口已经被使用。因此,本项目不能占用现有端口、网络、数据库或容器名称。
该服务器有公网 IP,且已有多项服务绑定在 `0.0.0.0`。本方案将它视作“公网可达的共享主机”处理;云安全组和宿主机防火墙必须额外核验,不能仅凭端口监听结果假设安全。
## 3. 总体架构
```mermaid
flowchart LR
U[用户浏览器] -->|:8020| G[App Builder Gateway]
U -->|:8022 /preview/<sandboxId>/| P[Preview Router]
G --> O[OpenVibe Web / Agent Server]
O --> M[现有内网模型网关]
O --> DB[(独立 PostgreSQL)]
O --> R[(独立 Redis)]
O --> SM[Local Sandbox Manager]
SM --> D[Docker Engine]
D --> S1[visual-ui Sandbox]
D --> S2[visual-fullstack Sandbox]
P --> S1
P --> S2
D --> H[Harbor :8023]
S1 --> N[内网 pnpm/npm 源或预置离线依赖]
S2 --> N
```
设计原则:
1. 保留 OpenVibe 的对话、任务、文件树、工具卡片和 OpenCode ACP 代码代理体验。
2. 模型请求仅由 OpenVibe 服务端发往既有内网模型网关;API Key 不进入沙箱。
3. 每个项目运行于独立 Docker 容器和独立工作区;沙箱只运行项目代码,不承担平台控制逻辑。
4. 浏览器预览只经过统一预览路由,不为每个项目映射新的公网端口。
5. 先用固定模板和固定依赖清单保证稳定,后续再按白名单增加依赖。
## 4. 端口规划
端口虽然可用,但应尽量少暴露。除 8020、8022 和按需开放的 8023 外,其余服务只加入 Docker 内部网络。
| 端口 | 服务 | 绑定范围 | 用途 |
|---:|---|---|---|
| 8020 | `appbuilder-gateway` | 公网访问面或 VPN 白名单 | 控制台页面、`/api`、SSE/WebSocket |
| 8021 | 预留 | 仅 `127.0.0.1` | 平台调试;第一期不对外开放 |
| 8022 | `preview-router` | 公网访问面或 VPN 白名单 | 统一预览入口:`/preview/<sandboxId>/` |
| 8023 | Harbor | 仅沙箱节点、构建节点和管理员网段 | 私有镜像仓库 HTTPS Registry |
| 8024 | 预留 | 不对外开放 | 后续 npm 私服或运维服务 |
| 8025-8030 | 预留 | 不对外开放 | 扩展、迁移或故障切换 |
### 4.1 预览路由规则
暂时没有可用泛域名时,使用路径型预览:
```text
http(s)://<服务器地址>:8022/preview/<sandboxId>/
```
项目模板的 Vite `base` 和 HMR 路径必须由启动脚本注入为该前缀,确保图片、JS/CSS 资源及热更新不会错误请求到根路径。后续若获得 `*.preview.company.local` 内网泛域名,可切换为子域名预览,用户体验更好。
## 5. 镜像、模板与离线依赖策略
### 5.1 标准镜像
第一期维护两种版本化镜像,不允许用户自行构建 Dockerfile:
| 镜像 | 用途 | 预置内容 |
|---|---|---|
| `appbuilder/visual-ui:<version>` | 大屏、海报、纯前端 | Node.js 20、pnpm、React、Vite、TypeScript、ECharts、流程图库、统一 UI 库、Tailwind、图标与中文字体 |
| `appbuilder/visual-fullstack:<version>` | 前端加接口 | 上述前端依赖 + Python 3.12、FastAPI、Uvicorn、Pydantic |
流程图库第一期统一选择一种(建议 React Flow),避免同时生成多种流程图语法和样式。地图、背景图、字体等资源也应作为受版本控制的本地资产打包,不能依赖 Google Fonts 或公网 CDN。
### 5.2 项目模板
每次创建项目时从镜像中的模板初始化到 `/workspace`:
```text
templates/
visual-screen/ # React + ECharts + React Flow + Mock 数据
visual-screen-api/ # visual-screen + FastAPI + /api 反向代理
poster-page/ # 海报、营销展示、静态动效
```
`visual-screen-api` 约定:前端只请求相对路径 `/api/*`;预览网关将其转发到同一沙箱的 FastAPI `8000` 端口。没有后端时保留 Mock 数据模块,生成的页面仍可运行。
### 5.3 依赖预置方式
不能仅把安装命令写进 Dockerfile 后在用户创建项目时再联网执行。正确做法是:
1. 在受控构建环境中锁定 `package.json`、`pnpm-lock.yaml`、`requirements.lock`。
2. 构建镜像时执行 `pnpm fetch`,在镜像保存只读 pnpm store;Python 包保存到 `/opt/wheelhouse`。
3. 沙箱初始化使用 `pnpm install --offline --frozen-lockfile` 和 `pip install --no-index --find-links=/opt/wheelhouse`。
4. 第一期开启“固定依赖白名单”:AI 只能使用镜像已安装的依赖;新依赖先由管理员审核、加入锁文件和下一版基础镜像。
5. 第二期部署 Verdaccio/Nexus 等内部制品源,作为受控补充而非允许访问公网 npm/PyPI。
## 6. OpenVibe 改造边界
### 6.1 保留
- 前端交互:对话、任务状态、文件浏览与修改、预览、日志;
- OpenCode ACP 代码代理;
- 项目与会话领域模型;
- 代码代理的流式事件协议。
### 6.2 替换或关闭
| OpenVibe 当前能力 | 第一期开源/内网实现 |
|---|---|
| CodeBuddy | 只启用 OpenCode,配置内网 OpenAI 兼容模型网关 |
| CloudBase、SCF 沙箱 | 新增 `LocalDockerSandboxManager` |
| TCR | Harbor |
| CloudBase 环境、用户环境池 | Docker 容器、工作区卷、沙箱记录 |
| CloudBase 消息与事件持久化 | 独立 PostgreSQL;Redis 用于临时队列与状态 |
| CloudBase 存储、函数、MCP、资源管理后台 | 第一期开关关闭或移除入口 |
| CloudBase 项目模板和系统提示词 | 替换为大屏/海报/FastAPI 模板与受控依赖提示词 |
重点改造点是将 OpenVibe 的 `scf-sandbox-manager` 和 CloudBase 生命周期调用替换为本地 Docker 生命周期。为降低前端和 OpenCode 工具改动范围,新管理器应适配现有的文件、命令、日志、健康检查与工作区信息契约,而不是改写所有 Agent 工具。
## 7. 沙箱安全基线
每个沙箱容器必须满足:
- 非 root 用户运行;禁止 `privileged`,禁止 Docker Socket,禁止宿主机目录挂载;
- `--cap-drop=ALL`、`no-new-privileges`、`pids-limit`;
- 首期默认限制:`1 vCPU`、`3 GB 内存`、`10 GB 工作区`、60 分钟空闲回收;
- 使用独立 Docker network;默认无公网出口,只允许访问预置依赖、必要的内网 DNS 和平台回调;
- 数据库、Redis、Harbor 管理接口、宿主机管理端口不在沙箱网络可访问范围;
- 运行命令、容器生命周期、文件变更和预览访问写入审计日志;
- 平台服务由受限服务账号调用 Docker API;用户代码永远不直接获得 Docker 权限。
第一期为“内部可信用户的容器隔离”。若后续允许大量非可信代码或文件上传,应迁移为 Kubernetes 节点,并评估 gVisor/Kata 等更强运行时隔离。
## 8. 分步实施计划
### 阶段 0:部署前隔离与基线确认
**工作项**
1. 为本项目建立独立目录、Docker Compose Project、卷和网络,命名统一为 `appbuilder-*`。
2. 确认云安全组、iptables/nftables 规则:仅允许管理员 SSH;8020、8022、8023 仅向 VPN 或指定办公出口开放。
3. 不复用当前服务器上已有的 MySQL、PostgreSQL、Redis、Nginx 配置;建立独立实例或独立命名空间。
4. 创建内部 CA 证书或确定可信 HTTPS 证书方案;Docker 构建/运行节点信任 Harbor 证书。
5. 轮换目标服务器 root 密码,改用 SSH 密钥和受限运维账号;关闭 root 密码远程登录。
**验收**
- `8020-8030` 无端口冲突;
- 新容器、网络和数据卷均不影响既有 DeerFlow 等服务;
- 业务数据库和沙箱端口无法从非授权公网地址访问。
### 阶段 1:内网基础设施与镜像供应链
**工作项**
1. 在独立 Compose 项目中部署 Harbor,主入口为 `8023`,镜像数据使用独立持久化目录。
2. 构建并推送 `visual-ui`、`visual-fullstack` 两种基础镜像;记录镜像版本、SBOM 和依赖锁文件。
3. 完成三个模板及离线依赖初始化脚本。
4. 先不启动漏洞扫描;严格断网环境下后续通过离线包维护漏洞库。
5. 预留内部 npm/PyPI 源接入方式;第一期仅使用预置依赖。
**验收**
- Docker daemon 能从 Harbor 拉取指定版本镜像;
- 在禁止公网访问的测试网络中,三个模板均能启动并构建;
- 一个 ECharts 页面、一个流程图页面和一个 FastAPI `/api/health` 均可运行。
### 阶段 2:OpenVibe 去 CloudBase 化与内网模型接入
**工作项**
1. Fork 固定版本的 OpenVibeCoding,建立 `local-platform` 改造分支,记录上游 commit 与许可证。
2. 只保留 OpenCode Agent;将 provider 指向现有内网模型网关,模型凭证只保存在服务端环境变量或密钥服务。
3. 将基础实体、任务、消息、流式事件的持久化迁移为独立 PostgreSQL;不再调用 CloudBase 集合。
4. 关闭 CloudBase 环境、函数、存储、数据库、资源仪表盘和相关 MCP 入口。
5. 将系统提示词和项目创建默认项改为“可视化页面 + 受控模板/依赖”。
**验收**
- 不设置任何腾讯云、CloudBase、CodeBuddy、TCR 环境变量时,平台可启动;
- 用户发起“生成 ECharts 大屏”后,OpenCode 能调用内网模型并生成项目文件;
- 服务端日志和沙箱环境中均没有模型 API Key。
### 阶段 3:本地沙箱管理器与预览路由
**工作项**
1. 实现 `LocalDockerSandboxManager`:创建、启动、停止、超时回收、查询状态和删除容器。
2. 每个沙箱分配工作区卷、随机内部容器地址、模板类型和资源限制;在数据库记录 `sandbox_id`、项目、用户、镜像版本、过期时间和状态。
3. 实现或适配沙箱内 runner:文件读写、受限命令执行、日志流、健康检查、前端端口和 FastAPI 端口发现。
4. 实现 `preview-router`:由平台根据 `sandboxId` 维护路由,将 `/preview/<sandboxId>/` 代理到对应内部容器;支持 WebSocket/HMR。
5. 增加容器异常退出、端口未就绪、构建失败、超时与手动重启的可见状态。
**验收**
- 两个用户创建的项目存在于不同容器和不同工作区;
- 修改 ECharts 配置文件后,预览地址显示修改结果;
- FastAPI 模板的 `/api/health` 可通过前端相对路径访问;
- 沙箱不能访问 Docker Socket、宿主机数据库和非白名单网络;
- 空闲超时后容器被回收,项目文件仍可恢复。
### 阶段 4:产品能力收口
**工作项**
1. 在文件面板支持安全的文件查看、编辑、保存、目录树刷新与下载;限制文件大小和可编辑扩展名。
2. 在对话中增加模板选择和能力提示:大屏、流程图、海报、可选 FastAPI。
3. 为 Agent 增加依赖白名单、图表主题、中文字体、Mock 数据、接口契约等明确提示词。
4. 增加“运行/停止/重启/删除沙箱”和日志面板;仅项目所有者或管理员有相应权限。
5. 增加项目快照、模板版本记录和基础审计日志。
**验收**
- 从一句自然语言需求到可预览项目的完整链路可完成;
- 用户可查看并手动修改文件,刷新后改动保留;
- 页面覆盖 ECharts、流程图、响应式大屏和可选 FastAPI 接口;
- 非拥有者不能读取、修改或预览另一用户的项目。
### 阶段 5:上线前安全与运行验收
**工作项**
1. 压测 6 个并发沙箱,并记录 CPU、内存、磁盘、容器启动时间、首屏预览时间和模型等待时间。
2. 验证镜像保留策略、工作区配额、容器回收、数据库备份和 Harbor 备份恢复。
3. 执行越权、路径穿越、命令注入、网络探测、资源耗尽和模型密钥泄露测试。
4. 建立日常运维操作:新增基础依赖、构建镜像、发布模板、回滚镜像、清理工作区和审计查询。
**验收**
- 并发运行时既有 DeerFlow 等服务不受明显影响;
- 任一沙箱异常退出不会影响其他项目或平台控制服务;
- 备份可恢复项目元数据、工作区和 Harbor 镜像;
- 仅开放计划中的入口端口,内部组件无不必要公网监听。
## 9. 首期资源与并发建议
由于该主机已有多项业务,首期不应按理论最大值分配:
| 组件 | 首期限制 |
|---|---|
| Harbor | 8 vCPU、16 GB 内存、500 GB 数据盘配额 |
| OpenVibe 控制服务 | 4 vCPU、8-16 GB 内存 |
| 单个 UI 沙箱 | 1 vCPU、3 GB 内存、10 GB 工作区 |
| 单个 Fullstack 沙箱 | 1.5 vCPU、4 GB 内存、12 GB 工作区 |
| 沙箱并发 | 先限制为 6;稳定后再逐步上调 |
当并发超过 10 个,或要面向更广泛用户开放时,应优先增加独立沙箱节点,而不是继续在本共享主机上加容器。
## 10. 实施顺序与交付物
建议按以下提交/交付顺序推进,每一步都可独立验收和回退:
1. `docs: confirm appbuilder network, ports and security baseline`
2. `infra: add Harbor and versioned visual sandbox images`
3. `infra: add offline templates and dependency locks`
4. `feat: replace OpenVibe CloudBase persistence and model provider`
5. `feat: add local Docker sandbox manager`
6. `feat: add sandbox runner and path-based preview router`
7. `feat: add visual-screen and FastAPI project workflows`
8. `test: add sandbox isolation, preview and recovery coverage`
9. `ops: add backup, cleanup, monitoring and rollout runbook`
达到阶段 3 即可交付“内网模型驱动、生成并预览大屏/海报页面”的 PoC;达到阶段 5 后再作为受控的内部多人平台发布。
@@ -0,0 +1,7 @@
# 技能知识归纳与“知识梳理”助手知识库
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。
File diff suppressed because it is too large. Load diff
@@ -56,12 +56,11 @@ import {
type ChangeEventHandler,
Children,
type ClipboardEventHandler,
type ComponentProps,
createContext,
type FormEvent,
type FormEventHandler,
Fragment,
type HTMLAttributes,
type ComponentProps,
createContext,
type FormEvent,
type FormEventHandler,
type HTMLAttributes,
type KeyboardEventHandler,
type PropsWithChildren,
type ReactNode,
@@ -124,9 +123,11 @@ export const usePromptInputController = () => {
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components.
const useOptionalPromptInputController = () =>
useContext(PromptInputController);
// Optional variants (do NOT throw). Useful for dual-mode components and for
// Fast Refresh transitions where a page can briefly retain the previous
// provider context instance.
export const useOptionalPromptInputController = () =>
useContext(PromptInputController);
export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
@@ -403,14 +404,14 @@ export function PromptInputAttachments({
return (
<div
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
{...props}
>
{attachments.files.map((file) => (
<Fragment key={file.id}>
<div className="max-w-60">{children(file)}</div>
</Fragment>
))}
</div>
{...props}
>
{attachments.files.map((file) => (
<div key={file.id} className="max-w-60">
{children(file)}
</div>
))}
</div>
);
}
@@ -428,11 +428,15 @@ export function ArtifactFileDetail({
if (streamPerfEnabled && isLiveStreaming) return raw;
return desensitize(raw);
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
const streamingWriteContentPending =
useWriteFileStreaming && writeFileInFlight && !content;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
@@ -1766,10 +1770,10 @@ export function ArtifactFileDetail({
activeCursor={isApplyingEdit}
/>
)}
{artifactContentPending && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
正在加载文档内容…
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
</div>
)}
{artifactContentUnavailable && viewMode === "code" && (
@@ -1787,7 +1791,7 @@ export function ArtifactFileDetail({
</button>
</div>
)}
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
@@ -1,7 +1,7 @@
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 { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
@@ -15,9 +15,12 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiFetch } from "@/core/api";
import {
artifactPathname,
listThreadArtifacts,
mergeArtifactPaths,
} from "@/core/artifacts/listing";
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
@@ -92,7 +95,13 @@ const ChatBox: React.FC<{
knowledgeReferencesEnabled = false,
} = useThread();
const pathname = usePathname();
const searchParams = useSearchParams();
const requestedArtifact = useMemo(() => {
const value = searchParams.get("artifact")?.trim();
return value ? artifactPathname(value) : null;
}, [searchParams]);
const threadIdRef = useRef(threadId);
const openedRequestedArtifactRef = useRef<string | null>(null);
const artifactsCountRef = useRef(0);
const layoutRef = useRef<GroupImperativeHandle>(null);
const latestReferenceBatchIdRef = useRef<string | null>(null);
@@ -104,6 +113,9 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
@@ -130,7 +142,7 @@ const ChatBox: React.FC<{
const { skills } = useSkills();
const canScanArtifactDirectory =
artifactDirectoryModeEnabled &&
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
!disableArtifacts &&
!isMock &&
!!threadId &&
@@ -148,17 +160,21 @@ const ChatBox: React.FC<{
);
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
const availableArtifacts = useMemo(
() =>
artifactDirectoryModeEnabled
? mergeArtifactPaths(
checkpointArtifacts,
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
)
: checkpointArtifacts,
() => {
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
return checkpointArtifacts;
}
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
(file) => file.path,
);
if (requestedArtifact) discoveredArtifacts.push(requestedArtifact);
return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts);
},
[
artifactDirectoryModeEnabled,
artifactListingQuery.data?.files,
checkpointArtifacts,
requestedArtifact,
],
);
@@ -184,6 +200,7 @@ const ChatBox: React.FC<{
deselect,
selectedArtifact,
selectedArtifactKey,
openStreamedArtifact,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
@@ -201,6 +218,29 @@ const ChatBox: React.FC<{
thread.isLoading,
);
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
useEffect(() => {
if (
disableArtifacts ||
!requestedArtifact ||
!threadId ||
threadId === "new"
) {
return;
}
const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`;
if (openedRequestedArtifactRef.current === requestKey) return;
openedRequestedArtifactRef.current = requestKey;
selectArtifact(requestedArtifact);
setArtifactsOpen(true);
setSideTab("document");
}, [
disableArtifacts,
requestedArtifact,
selectArtifact,
setArtifactsOpen,
threadId,
]);
useEffect(() => {
if (!shouldFetchBackendReferenceBatches) {
setBackendReferenceBatches([]);
@@ -398,9 +438,12 @@ const ChatBox: React.FC<{
((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
setSideTab("document");
const newest = nextArtifacts[nextArtifacts.length - 1]!;
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
selectArtifact(newest);
setArtifactsOpen(true);
setSideTab("document");
}
}
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
@@ -432,8 +475,30 @@ const ChatBox: React.FC<{
setArtifacts,
availableArtifacts,
disableArtifacts,
thread.messages,
writingModeEnabled,
]);
// Live sandbox: open as soon as any in-flight write_file has a path or
// the first content bytes — not only when that tool is the last CoT step.
// Agentfx (and other agents) often emit write_file + present_files/bash in
// the same turn; the collapsed earlier write would otherwise never auto-open.
const streamingWritePreview = useMemo(
() =>
disableArtifacts || !thread.isLoading
? null
: latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled),
[disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled],
);
useEffect(() => {
if (!streamingWritePreview) return;
openStreamedArtifact(
streamingWritePreview.artifactPath,
streamingWritePreview.previewKey,
);
setSideTab("document");
}, [openStreamedArtifact, streamingWritePreview]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current;
@@ -1,7 +1,7 @@
import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef } from "react";
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
import { useOptionalPromptInputController } from "@/components/ai-elements/prompt-input";
import { useI18n } from "@/core/i18n/hooks";
/**
@@ -11,7 +11,11 @@ export function useSpecificChatMode() {
const { t } = useI18n();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const promptInputController = usePromptInputController();
// A regular history item can navigate here while Vite Fast Refresh is
// reconciling the previous page tree. In that short window the page may see
// a stale provider context. This hook only pre-fills the skill-mode draft,
// so it must not make an ordinary chat page fail to render.
const promptInputController = useOptionalPromptInputController();
const inputInitialValue = useMemo(() => {
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
return undefined;
@@ -19,16 +23,21 @@ export function useSpecificChatMode() {
return t.inputBox.createSkillPrompt;
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
const lastInitialValueRef = useRef<string | undefined>(undefined);
const setInputRef = useRef(promptInputController.textInput.setInput);
setInputRef.current = promptInputController.textInput.setInput;
const setInputRef = useRef<((value: string) => void) | undefined>(
promptInputController?.textInput.setInput,
);
setInputRef.current = promptInputController?.textInput.setInput;
useEffect(() => {
if (
inputInitialValue &&
inputInitialValue !== lastInitialValueRef.current
) {
lastInitialValueRef.current = inputInitialValue;
setTimeout(() => {
setInputRef.current(inputInitialValue);
setTimeout(() => {
const setInput = setInputRef.current;
if (!setInput) return;
setInput(inputInitialValue);
const textarea = document.querySelector("textarea");
if (textarea) {
textarea.focus();
@@ -19,17 +19,16 @@ const TOOLBOX_ICON_TONES = [
* 简洁模式右侧工具箱。
*
* 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目
* 放到 ``toolbox`` 泳道。点击右侧竖排按钮后展开单列竖向抽屉。
* 放到 ``toolbox`` 泳道。默认展开常显(不再藏进右侧按钮后面);点 X 或 Esc
* 收起后右侧出现竖排按钮,可再次展开。
*/
export function CompactToolbox() {
const { toolboxItems } = useWorkspaceCompactNavItems();
const { pathname } = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(true);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => setOpen(false), [pathname]);
useEffect(() => {
if (!open) return;
@@ -41,7 +40,6 @@ export function CompactToolbox() {
};
document.addEventListener("keydown", handleKeyDown);
closeRef.current?.focus();
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
@@ -73,22 +71,8 @@ export function CompactToolbox() {
<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}
@@ -102,10 +86,9 @@ export function CompactToolbox() {
<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="关闭"
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"
>
@@ -131,10 +114,7 @@ export function CompactToolbox() {
? "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");
}}
onClick={() => navigate(item.path ?? "/page/workspace/chats/new")}
>
<span
className={cn(
@@ -7,7 +7,8 @@
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
*
* 三块结构(自上而下):
* 可挂在 workspace / strategy 两个普通问答路由下;导航目标由 RouteBaseProvider
* 决定。三块结构(自上而下):
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
@@ -85,6 +86,7 @@ import {
titleOfThread,
} from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { useRouteBase } from "@/core/route-base";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
@@ -93,11 +95,6 @@ import { cn } from "@/lib/utils";
import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header";
const NEW_CHAT_PATH = "/page/workspace/chats/new";
/** 「更多」常用智能体跳转目标:智能体管理页。 */
const MORE_AGENTS_HREF = "/page/workspace/agents";
/** 「更多最近对话」跳转目标。 */
const MORE_CHATS_HREF = "/page/workspace/chats";
/** 常用智能体最多展示个数。 */
const MAX_FREQUENT = 4;
@@ -142,6 +139,7 @@ function SectionLabel({
export function GeneralQaSidebar() {
const { t } = useI18n();
const routeBase = useRouteBase();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
@@ -188,9 +186,14 @@ export function GeneralQaSidebar() {
const searchParams = useSearchParams();
const activeAgentId = searchParams.get("agent");
const currentSearch = searchParams.toString();
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch);
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch);
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch);
const chatsBasePath = `${routeBase}/chats`;
const newChatPath = `${chatsBasePath}/new`;
const newChatHref = buildFullChatNavigationPath(newChatPath, currentSearch);
const moreAgentsHref = buildFullChatNavigationPath(
`${routeBase}/agents`,
currentSearch,
);
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
@@ -199,19 +202,18 @@ export function GeneralQaSidebar() {
(agentId: string) => {
navigate(
buildFullChatNavigationPath(
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
routeBase === "/page/workspace"
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
currentSearch,
),
);
},
[currentSearch, navigate],
[currentSearch, navigate, newChatPath, routeBase],
);
// ── 最近的对话取数 + 搜索 ──
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
thread_id: string;
agent_id?: string;
}>();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
@@ -224,6 +226,14 @@ export function GeneralQaSidebar() {
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
const pathForThread = useCallback(
(thread: AgentThread) =>
isBootstrapThread(thread)
? listPathOfThread(thread)
: pathOfThread(thread, undefined, routeBase),
[routeBase],
);
const visibleThreads = useMemo(
() =>
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
@@ -254,21 +264,22 @@ export function GeneralQaSidebar() {
deleteThread({ threadId });
if (threadId === threadIdFromPath) {
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
let nextThreadPath = newChatPath;
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
nextThreadPath = pathForThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
nextThreadPath = pathForThread(threads[threadIndex - 1]!);
}
}
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
}
},
[
agentIdFromPath,
currentSearch,
deleteThread,
newChatPath,
pathForThread,
router,
threadIdFromPath,
threads,
@@ -467,7 +478,7 @@ export function GeneralQaSidebar() {
) : (
filteredThreads.map((thread) => {
const href = buildFullChatNavigationPath(
listPathOfThread(thread),
pathForThread(thread),
currentSearch,
);
const isActive = href === pathname;
@@ -143,6 +143,7 @@ export function InputBox({
hideAdminRecommendedQuestions,
compact,
hideContextTools,
hideMemoryTool = true,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -153,6 +154,11 @@ export function InputBox({
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -752,7 +758,7 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && !hideContextTools && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -807,7 +813,7 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && !hideContextTools && <Tooltip
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -0,0 +1,57 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen, Database } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth";
import {
loadKnowledgeBaseProductVersion,
type KnowledgeBaseProductVersion,
updateKnowledgeBaseProductVersion,
} from "@/core/system-settings/api";
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
export function useKnowledgeBaseProductVersion() {
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
}
export function KnowledgeProductNav() {
const location = useLocation();
const navigate = useNavigate();
const client = useQueryClient();
const version = useKnowledgeBaseProductVersion();
const isAdmin = getStoredAuth()?.system_role === "admin";
const save = useMutation({
mutationFn: updateKnowledgeBaseProductVersion,
onSuccess: (value) => {
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
toast.success(`默认知识库体验已切换为 ${value}`);
},
});
const assistant = location.pathname.includes("/knowledge/assistant");
return (
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
{isAdmin && (
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
默认产品版本
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
</Select>
</div>
)}
</div>
);
}
@@ -22,6 +22,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
type AssistantKnowledgeBase,
useAssistantKnowledgeBases,
} from "@/strategy-components/api/assistant-knowledge";
import {
type LlmWikiKnowledgeBase,
useAgentLlmWikiKnowledgeBases,
@@ -66,10 +70,26 @@ function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
);
}
function AssistantSpaceIcon({ item }: { item: AssistantKnowledgeBase }) {
return (
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
item.is_global ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-sky-500/10 text-sky-600 dark:text-sky-400",
)}
>
<Database className="size-3.5" />
</span>
);
}
function SelectionContent({
rows,
assistantRows,
value,
assistantValue,
onChange,
onAssistantChange,
search,
onSearchChange,
loading,
@@ -79,8 +99,11 @@ function SelectionContent({
readOnly,
}: {
rows: LlmWikiKnowledgeBase[];
assistantRows: AssistantKnowledgeBase[];
value: string[];
assistantValue: string[];
onChange: (value: string[]) => void;
onAssistantChange: (value: string[]) => void;
search: string;
onSearchChange: (value: string) => void;
loading: boolean;
@@ -90,6 +113,7 @@ function SelectionContent({
readOnly: boolean;
}) {
const selected = useMemo(() => new Set(value), [value]);
const selectedAssistant = useMemo(() => new Set(assistantValue), [assistantValue]);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return rows;
@@ -97,11 +121,28 @@ function SelectionContent({
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
);
}, [rows, search]);
const filteredAssistant = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return assistantRows;
return assistantRows.filter((item) =>
`${item.name} ${item.description ?? ""}`.toLocaleLowerCase().includes(query),
);
}, [assistantRows, 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]);
};
const toggleAssistant = (id: string) => {
if (readOnly) return;
onAssistantChange(
selectedAssistant.has(id)
? assistantValue.filter((item) => item !== id)
: [...assistantValue, id],
);
};
const selectedCount = value.length + assistantValue.length;
const hasRows = groups.length > 0 || filteredAssistant.length > 0;
return (
<>
@@ -127,7 +168,7 @@ function SelectionContent({
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{error instanceof Error ? error.message : "知识空间读取失败"}
</div>
) : groups.length === 0 ? (
) : !hasRows ? (
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
{search
? "没有匹配的知识空间"
@@ -136,91 +177,181 @@ function SelectionContent({
: "当前账号暂无可用知识空间"}
</div>
) : (
groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</DropdownMenuLabel>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</div>
)}
{group.rows.map((item) => {
const rowContent = (
<>
<SpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
</div>
</div>
{item.publication_status === "published" && (
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
)}
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
<>
{groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</DropdownMenuLabel>
) : (
<button
key={item.id}
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",
selected.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selected.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
))
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</div>
)}
{group.rows.map((item) => {
const rowContent = (
<>
<SpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
</div>
</div>
{item.publication_status === "published" && (
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
)}
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<button
key={item.id}
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",
selected.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selected.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
))}
{filteredAssistant.length > 0 && (
<div>
{(groups.length > 0) && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
<Database className="size-3" />助手知识库
</DropdownMenuLabel>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
<Database className="size-3" />助手知识库
</div>
)}
{filteredAssistant.map((item) => {
const rowContent = (
<>
<AssistantSpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体
</div>
</div>
<Badge variant="outline" className="h-4 px-1 text-[9px]">
{item.is_global ? "总库" : "助手"}
</Badge>
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={`assistant:${item.id}`}
checked={selectedAssistant.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggleAssistant(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<button
key={`assistant:${item.id}`}
type="button"
role="checkbox"
aria-checked={selectedAssistant.has(item.id)}
disabled={readOnly}
onClick={() => toggleAssistant(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",
selectedAssistant.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selectedAssistant.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selectedAssistant.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
)}
</>
)}
</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">
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</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
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
onChange(filtered.map((item) => item.id));
onAssistantChange(filteredAssistant.map((item) => item.id));
}}
>
全选
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
onChange([]);
onAssistantChange([]);
}}
>
清空
</Button>
</div>
@@ -232,7 +363,9 @@ function SelectionContent({
export function LlmWikiKnowledgeSelector({
value,
assistantValue = [],
onChange,
onAssistantChange = () => undefined,
source = "all",
agentId,
autoSelectAll = false,
@@ -243,7 +376,9 @@ export function LlmWikiKnowledgeSelector({
variant = "popover",
}: {
value: string[];
assistantValue?: string[];
onChange: (value: string[]) => void;
onAssistantChange?: (value: string[]) => void;
source?: SelectorSource;
agentId?: string | null;
autoSelectAll?: boolean;
@@ -257,28 +392,39 @@ export function LlmWikiKnowledgeSelector({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora";
const all = useLlmWikiKnowledgeBases(
"all",
runtime.data?.provider === "weknora",
weknoraEnabled,
);
const agent = useAgentLlmWikiKnowledgeBases(
agentId,
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
weknoraEnabled && source === "agent" && !!agentId,
);
const rows = all.data ?? [];
const assistantBases = useAssistantKnowledgeBases();
const rows = weknoraEnabled ? (all.data ?? []) : [];
const assistantRows = assistantBases.data ?? [];
const defaultIds = useMemo(() => {
if (source !== "agent") {
return rows.map((item) => item.id);
}
return (agent.data ?? []).map((item) => item.id);
}, [agent.data, rows, source]);
const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = all.error ?? (source === "agent" ? agent.error : null);
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
const assistantAvailableIds = useMemo(
() => new Set(assistantRows.map((item) => item.id)),
[assistantRows],
);
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null);
useEffect(() => {
if (!runtime.isLoading && !weknoraEnabled) {
if (value.length > 0) onChange([]);
return;
}
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
initialized.current = initializationKey;
const valid = value.filter((id) => availableIds.has(id));
@@ -288,19 +434,30 @@ export function LlmWikiKnowledgeSelector({
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
onChange(next);
}
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]);
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
useEffect(() => {
if (!assistantBases.data) return;
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
onAssistantChange(valid);
}
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
const content = (
<SelectionContent
rows={rows}
assistantRows={assistantRows}
value={value}
assistantValue={assistantValue}
onChange={onChange}
onAssistantChange={onAssistantChange}
search={search}
onSearchChange={setSearch}
loading={loading}
@@ -323,7 +480,7 @@ export function LlmWikiKnowledgeSelector({
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
</div>
{content}
</div>
@@ -333,10 +490,15 @@ export function LlmWikiKnowledgeSelector({
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} 个知识空间`
const selectedAssistantNames = assistantValue
.map((id) => assistantRows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
const selectedCount = value.length + assistantValue.length;
const selectedLabel = allSelectedNames.length > 0
? allSelectedNames.join("、")
: selectedCount > 0
? `已选 ${selectedCount} 个知识空间`
: label;
return (
@@ -348,20 +510,20 @@ export function LlmWikiKnowledgeSelector({
size="sm"
className={cn(
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
className,
)}
disabled={disabled || loading}
title={value.length > 0 ? selectedLabel : undefined}
title={selectedCount > 0 ? selectedLabel : undefined}
>
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
<span className="truncate text-xs font-normal">{selectedLabel}</span>
{value.length > 0 && (
{selectedCount > 0 && (
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
{value.length}
{selectedCount}
</span>
)}
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
{selectedCount > 0 && selectedCount === rows.length + assistantRows.length && <Check className="size-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
@@ -40,8 +40,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
@@ -51,12 +53,13 @@ import {
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 {
applyInlineCitations,
buildSkillDisplayMap,
@@ -67,6 +70,7 @@ import {
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -92,7 +96,7 @@ export function MessageGroup({
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
}) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
@@ -100,12 +104,19 @@ export function MessageGroup({
const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
const { skills } = useSkills();
const [settings] = useLocalSettings();
// 写作模式的「拦截 outputs/*.md 手写」只在写作模式生效;普通模式的
// write_file / read_file 步骤条必须照旧显示。
const writingModeEnabled = !!settings.context.writing_mode;
const steps = useMemo(
() => convertToSteps(messages, writingModeEnabled),
[messages, writingModeEnabled],
);
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];
@@ -197,11 +208,11 @@ export function MessageGroup({
<ToolCall
key={step.id}
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled}
/>
),
/>
),
)}
{lastToolCallStep && (
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
@@ -209,10 +220,10 @@ export function MessageGroup({
key={lastToolCallStep.id}
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={ragModeEnabled}
/>
/>
</FlipDisplay>
)}
</ChainOfThoughtContent>
@@ -754,7 +765,6 @@ function ArtifactWriteToolCall({
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
@@ -793,19 +803,21 @@ function ArtifactWriteToolCall({
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
result !== undefined ||
!sourcePath ||
!languageOfStreamingArtifact(sourcePath) ||
!isAutoPreviewableArtifactPath(sourcePath)
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
sourcePath,
]);
return (
@@ -847,7 +859,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[]): CoTStep[] {
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
for (const message of messages) {
if (message.type === "ai") {
@@ -865,6 +877,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
if (tool_call.name === "task") {
continue;
}
const writePath =
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
? (tool_call.args as { path: string }).path
: "";
if (
(
tool_call.name === "write_file" ||
tool_call.name === "str_replace" ||
tool_call.name === "read_file" ||
tool_call.name === "read_multiple_files" ||
tool_call.name === "present_files"
) &&
isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode)
) {
continue;
}
const step: CoTToolCallStep = {
id: tool_call.id,
messageId: message.id,
@@ -1,267 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
import { isIMEComposing } from "@/lib/ime";
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
function openDraft(
draft: DraftMeta,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。
window.dispatchEvent(
new CustomEvent("roundtable:open-draft", { detail: draft.id }),
);
} else {
// 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。
sessionStorage.setItem("roundtable:pending-draft", draft.id);
router.push(ROUNDTABLE_PATH);
}
}
/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */
function StatusBadge({ draft }: { draft: DraftMeta }) {
if (draft.status === "running") {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
进行中
</span>
);
}
if (draft.status === "awaiting_input") {
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
}
if (draft.status === "error") {
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
}
// 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。
return (
<span className="text-[10px] font-medium text-muted-foreground">
{draft.chainTitle || "自由模式"}
</span>
);
}
export function RecentRoundtableList() {
const router = useRouter();
const pathname = usePathname();
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true",
);
// 重命名对话框状态。
const [renameId, setRenameId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// 分享对话框 —— 正在分享的草稿 id。
const [shareId, setShareId] = useState<string | null>(null);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
const handleRenameSubmit = useCallback(async () => {
const id = renameId;
const title = renameValue.trim();
if (!id || !title) {
setRenameId(null);
return;
}
try {
await rename(id, title);
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
setRenameValue("");
}
}, [renameId, renameValue, rename]);
const handleDelete = useCallback(
async (draft: DraftMeta) => {
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
try {
await remove(draft.id);
toast.success("已删除会商记录");
} catch {
toast.error("删除失败,请重试");
}
},
[remove],
);
if (!loading && drafts.length === 0) return null;
const isOnPage = pathname === ROUNDTABLE_PATH;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>多智能体会商</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && drafts.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
drafts.slice(0, 10).map((draft) => (
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openDraft(draft, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{draft.title || "未命名草稿"}
</span>
<div className="flex items-center gap-1 mt-0.5">
<StatusBadge draft={draft} />
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background top-2"
>
<MoreHorizontal />
<span className="sr-only">更多</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-44 rounded-lg"
side="right"
align="start"
>
<DropdownMenuItem
onSelect={() => {
setRenameId(draft.id);
setRenameValue(draft.title || "");
}}
>
<Pencil className="text-muted-foreground" />
<span>重命名</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
<Share2 className="text-muted-foreground" />
<span>分享</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
<Trash2 className="text-muted-foreground" />
<span>删除</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* 重命名对话框 */}
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>重命名会商</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameId(null)}>
取消
</Button>
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分享对话框 */}
<ShareRoundtableDraftDialog
draftId={shareId}
open={shareId !== null}
onOpenChange={(o) => {
if (!o) setShareId(null);
}}
/>
</>
);
}
@@ -1,122 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
function openSession(
session: AIWritingSessionSummary,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在 AI 写作页面,直接通过自定义事件通知面板
window.dispatchEvent(
new CustomEvent("ai-writing:open-session", { detail: session }),
);
} else {
// 不在该页面,把 session 存入 sessionStorage 后跳转
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
router.push(AI_WRITING_PATH);
}
}
export function RecentWritingList() {
const router = useRouter();
const pathname = usePathname();
const { sessions, loading, refresh } = useAIWritingSessions();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
if (!loading && sessions.length === 0) return null;
const isOnPage = pathname === AI_WRITING_PATH;
return (
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>最近写作</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && sessions.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
sessions.slice(0, 10).map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<SidebarMenuItem key={session.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openSession(session, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{session.title}
</span>
<div className="flex items-center gap-1 mt-0.5">
<span className={cn("text-[10px] font-medium", color)}>
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(session.updated_at)}
</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -93,6 +93,7 @@ import {
useViewMode,
} from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type";
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
import { formatDateTime } from "@/core/utils/datetime";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
@@ -601,6 +602,7 @@ export function SkillRow({
isAdmin,
layout = "row",
onPublisherClick,
knowledgeBindings = [],
}: {
skill: Skill;
currentUserId: string | null;
@@ -610,6 +612,8 @@ export function SkillRow({
/** Click the 发布者 line to filter the list to that publisher. Receives the
* owner_user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
/** Admin list can inject its single batched binding query here (never N+1). */
knowledgeBindings?: SkillKnowledgeBinding[];
}) {
const isCard = layout === "card";
const { t } = useI18n();
@@ -858,6 +862,20 @@ export function SkillRow({
常驻
</Badge>
)}
{knowledgeBindings.slice(0, 3).map((binding) => (
<Badge
key={binding.id}
variant="outline"
className="border-indigo-500/30 text-indigo-700 dark:text-indigo-300 text-xs"
title={`${binding.target_name} · ${binding.status}${binding.remote_business_key ? ` · ${binding.remote_business_key}` : ""}`}
>
知识库:{binding.target_name || binding.target_id}
{binding.status === "stale" ? "(待更新)" : ""}
</Badge>
))}
{knowledgeBindings.length > 3 && (
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
)}
</div>
);
@@ -0,0 +1,261 @@
import { Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
type SkillKnowledgeTarget,
useSkillKnowledgeActions,
useSkillKnowledgeBindings,
useSkillKnowledgeJobs,
} from "@/strategy-components/api/skill-knowledge";
const PHASE_LABELS: Record<string, string> = {
scanning: "扫描知识文件",
parsing: "解析资料",
extracting: "抽取实体关系",
awaiting_review: "等待审核",
merging: "合并实体关系",
writing_wiki: "写入 Wiki",
activating: "激活新版本",
completed: "完成",
};
export function SkillKnowledgePanel() {
const { skills, isLoading } = useAllSkillsAdmin(true);
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [skillQuery, setSkillQuery] = useState("");
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("auto_high_confidence");
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const bindings = useSkillKnowledgeBindings();
const jobs = useSkillKnowledgeJobs();
const actions = useSkillKnowledgeActions();
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
return (knowledgeBases.data ?? [])
.filter((kb) => {
const strategy = kb.indexing_strategy ?? {};
const capabilities = kb.capabilities ?? {};
const wikiEnabled =
kb.type === "wiki" ||
kb.wiki_index_enabled === true ||
strategy.wiki_enabled === true ||
capabilities.wiki_enabled === true ||
(kb.wiki_config && Object.keys(kb.wiki_config).length > 0);
return kb.can_write && kb.remote_status !== "missing" && !kb.is_conversation_deposit && kb.type !== "faq" && wikiEnabled;
})
.map((kb) => ({
target_type: "weknora",
target_mode: "wiki",
target_id: kb.id,
target_name: kb.name,
remote_business_key: `weknora:kb:${kb.id}`,
}));
}, [knowledgeBases.data]);
const filteredSkills = useMemo(() => {
const needle = skillQuery.trim().toLowerCase();
if (!needle) return liveSkills;
return liveSkills.filter((skill) => {
const tagText = (skill.tags ?? []).map((tag) => tag.name).join(" ");
const haystack = [
skill.name,
skill.name_zh,
skill.description,
skill.category,
tagText,
]
.filter(Boolean)
.join("\n")
.toLowerCase();
return haystack.includes(needle);
});
}, [liveSkills, skillQuery]);
const filteredSkillNames = useMemo(() => filteredSkills.map((skill) => skill.name), [filteredSkills]);
const allFilteredSelected = filteredSkillNames.length > 0 && filteredSkillNames.every((name) => selectedSkills.includes(name));
const toggleFilteredSkills = () => {
if (!filteredSkillNames.length) return;
setSelectedSkills((current) => {
if (allFilteredSelected) {
const filtered = new Set(filteredSkillNames);
return current.filter((name) => !filtered.has(name));
}
return [...current, ...filteredSkillNames.filter((name) => !current.includes(name))];
});
};
useEffect(() => {
if (selectedTargetKey && !targets.some((target) => `${target.target_type}:${target.target_id}` === selectedTargetKey)) {
setSelectedTargetKey(null);
}
}, [selectedTargetKey, targets]);
const toggle = (values: string[], value: string, setter: (next: string[]) => void) =>
setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
const submit = async () => {
const selectedTargets = targets.filter((target) =>
selectedTargetKey === `${target.target_type}:${target.target_id}`,
);
if (!selectedSkills.length || !selectedTargets.length) {
toast.error("请至少选择一个技能和一个目标知识库");
return;
}
try {
const job = await actions.createJob.mutateAsync({
skill_names: selectedSkills,
targets: selectedTargets,
review_mode: reviewMode,
});
toast.success(`已创建 ${job.total_count} 个归纳项`);
setSelectedSkills([]);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
return (
<div className="grid size-full min-h-0 grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] gap-4 overflow-hidden p-6">
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
<div className="border-b p-4">
<h2 className="font-semibold">技能到知识库管理</h2>
<p className="mt-1 text-xs text-muted-foreground">一次选择多个技能归纳到一个 WeKnora Wiki 知识库;代码与脚本不会被读取。</p>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-4">
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">1. 选择技能({selectedSkills.length})</span>
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
</Button>
</div>
<div className="relative mb-2">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={skillQuery}
onChange={(event) => setSkillQuery(event.target.value)}
placeholder="按技能中文名、英文名、描述、分类或标签搜索"
className="pl-8"
/>
</div>
<div className="max-h-60 space-y-1 overflow-y-auto rounded-md border p-2">
{isLoading ? <Loader2 className="m-4 size-5 animate-spin" /> : filteredSkills.map((skill) => (
<label key={skill.name} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-muted">
<Checkbox checked={selectedSkills.includes(skill.name)} onCheckedChange={() => toggle(selectedSkills, skill.name, setSelectedSkills)} />
<span className="truncate text-sm">{skill.name_zh || skill.name}</span>
<span className="ml-auto truncate text-xs text-muted-foreground">{skill.name}</span>
</label>
))}
{!isLoading && !filteredSkills.length && (
<p className="p-3 text-xs text-muted-foreground">没有匹配的技能,请换个关键词。</p>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">2. 选择目标知识库({selectedTargetKey ? 1 : 0})</span>
</div>
<div className="space-y-1 rounded-md border p-2">
{targets.map((target) => {
const key = `${target.target_type}:${target.target_id}`;
return (
<label key={key} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 hover:bg-muted">
<Checkbox
checked={selectedTargetKey === key}
onCheckedChange={() => setSelectedTargetKey((current) => (current === key ? null : key))}
/>
<Database className="size-4 text-muted-foreground" />
<span className="text-sm">{target.target_name}</span>
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
</label>
);
})}
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
<p className="p-3 text-xs text-destructive">
读取 WeKnora 普通知识库失败:
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
</p>
)}
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora"
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">审核策略</span>
<Select value={reviewMode} onValueChange={(value) => setReviewMode(value as typeof reviewMode)}>
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto_high_confidence">高置信自动通过</SelectItem>
<SelectItem value="off">不阻塞写入</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" disabled={actions.createJob.isPending} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
开始归纳({selectedSkills.length * (selectedTargetKey ? 1 : 0)} 项)
</Button>
</div>
</ScrollArea>
</section>
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
<div className="flex items-center justify-between border-b p-4">
<div><h2 className="font-semibold">归纳任务</h2><p className="text-xs text-muted-foreground">后台执行、失败可单项重试</p></div>
<Button size="icon" variant="ghost" onClick={() => void jobs.refetch()}><RefreshCw className="size-4" /></Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3 p-4">
{(jobs.data ?? []).map((job) => {
const done = job.success_count + job.partial_failed_count + job.failed_count;
const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0;
return (
<div key={job.id} className="rounded-md border p-3">
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
<Progress className="my-2" value={progress} />
{job.items?.map((item) => (
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || item.phase}</span>
<span className="ml-auto">{item.progress}%</span>
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
</div>
))}
</div>
);
})}
{!jobs.data?.length && <p className="py-10 text-center text-sm text-muted-foreground">暂无归纳任务</p>}
</div>
</ScrollArea>
</div>
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
<div className="mb-2 text-muted-foreground">当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。</div>
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-32 truncate font-medium">{binding.skill_name}</span><span className="max-w-40 truncate text-muted-foreground">→ {binding.target_name}</span><Badge variant="outline">{binding.status}</Badge><code className="max-w-40 truncate text-[10px] text-muted-foreground">{binding.remote_business_key}</code><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解绑并保留</Button><Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>解绑并删除贡献</Button></div>)}</div>
</div>
</section>
</div>
);
}
@@ -3,16 +3,18 @@ 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 { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
export function ThreadTitle({
threadId,
thread,
}: {
export function ThreadTitle({
className,
threadId,
thread,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
@@ -47,9 +49,9 @@ export function ThreadTitle({
if (!thread.values?.title) {
return null;
}
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className="min-w-0 w-full">
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
<span className="block truncate" title={visibleTitle}>
{visibleTitle}
</span>
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -65,6 +65,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats",
},
{
id: "workspace-library",
label: "资料库",
icon: Library,
path: "/page/workspace/library",
match: (pathname) => pathname === "/page/workspace/library",
},
{
id: "workspace-agents",
label: "智能体",
@@ -254,6 +254,14 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills?tab=knowledge")}
>
<DatabaseZapIcon />
技能到知识库管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
@@ -14,21 +14,19 @@ 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,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
}) {
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
@@ -36,26 +34,28 @@ export function WorkspaceSidebar({
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
if (forceCollapsed) {
return (
<Sidebar
variant="sidebar"
collapsible="icon"
className={cn(
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
className,
)}
{...props}
>
<SidebarContent>
<WorkspaceNavChatList />
</SidebarContent>
</Sidebar>
);
}
const isGeneralQa =
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats");
if (forceCollapsed) {
return (
<Sidebar
variant="sidebar"
collapsible="icon"
className={cn(
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
className,
)}
{...props}
>
<SidebarContent>
<WorkspaceNavChatList />
</SidebarContent>
</Sidebar>
);
}
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
@@ -123,10 +123,10 @@ export function WorkspaceSidebar({
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
{isSidebarOpen && !compactChrome && <RecentWritingList />}
{/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
(/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
全量模式本就不展示。 */}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
+24 -5
View File
@@ -1,5 +1,6 @@
import { apiFetch } from "../api";
import { getFileName } from "../utils/files";
import { apiFetch } from "../api";
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
import { getFileName } from "../utils/files";
import { urlOfArtifact } from "./utils";
@@ -39,7 +40,7 @@ async function errorMessageFromResponse(res: Response): Promise<string> {
return res.statusText || "Request failed";
}
export async function downloadArtifactFile({
export async function downloadArtifactFile({
filepath,
threadId,
isMock,
@@ -73,8 +74,26 @@ export async function downloadArtifactFile({
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
export async function downloadArtifactMarkdownAsWord({
filepath,
threadId,
}: {
filepath: string;
threadId: string;
}): Promise<void> {
const url = urlOfArtifact({ filepath, threadId, download: false });
const res = await apiFetch(url);
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
const fileName = getFileName(filepath);
const title = fileName.replace(/\.(md|markdown)$/i, "");
await downloadMarkdownAsDocx(await res.text(), fileName, title);
}
export async function openArtifactInNewTab({
filepath,
+113
View File
@@ -0,0 +1,113 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export type ArtifactLibraryKind =
| "all"
| "document"
| "spreadsheet"
| "text_data"
| "archive"
| "other";
export type ArtifactLibrarySource = "all" | "general_qa" | "agent";
export interface ArtifactLibraryItem {
threadId: string;
threadTitle: string;
agentId: string | null;
agentName: string | null;
threadType: string | null;
sourceType: Exclude<ArtifactLibrarySource, "all">;
path: string;
name: string;
sizeBytes: number;
mimeType: string | null;
modifiedAt: string;
fileKind: Exclude<ArtifactLibraryKind, "all">;
}
export interface ArtifactLibraryResponse {
total: number;
offset: number;
limit: number;
items: ArtifactLibraryItem[];
}
export async function fetchArtifactLibrary({
query,
fileKind,
sourceType,
offset,
limit,
refresh = false,
signal,
}: {
query?: string;
fileKind: ArtifactLibraryKind;
sourceType: ArtifactLibrarySource;
offset: number;
limit: number;
refresh?: boolean;
signal?: AbortSignal;
}): Promise<ArtifactLibraryResponse> {
const search = new URLSearchParams({
file_kind: fileKind,
source_type: sourceType,
offset: String(offset),
limit: String(limit),
});
const normalizedQuery = query?.trim();
if (normalizedQuery) search.set("query", normalizedQuery);
if (refresh) search.set("refresh", "true");
const response = await apiFetch(
`${getBackendBaseURL()}/api/artifact-library?${search.toString()}`,
{ method: "GET", signal },
);
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Failed to load artifact library (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
const data = (await response.json()) as {
total?: number;
offset?: number;
limit?: number;
items?: Array<{
thread_id: string;
thread_title: string;
agent_id?: string | null;
agent_name?: string | null;
thread_type?: string | null;
source_type?: Exclude<ArtifactLibrarySource, "all">;
path: string;
name: string;
size_bytes: number;
mime_type?: string | null;
modified_at: string;
file_kind: Exclude<ArtifactLibraryKind, "all">;
}>;
};
return {
total: data.total ?? 0,
offset: data.offset ?? offset,
limit: data.limit ?? limit,
items: (data.items ?? []).map((item) => ({
threadId: item.thread_id,
threadTitle: item.thread_title,
agentId: item.agent_id ?? null,
agentName: item.agent_name ?? item.agent_id ?? null,
threadType: item.thread_type ?? null,
sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"),
path: item.path,
name: item.name,
sizeBytes: item.size_bytes,
mimeType: item.mime_type ?? null,
modifiedAt: item.modified_at,
fileKind: item.file_kind,
})),
};
}
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
isAutoPreviewableArtifactPath,
isArtifactApiPath,
isProvisionalStreamingArtifactPath,
provisionalWriteFilePath,
@@ -30,3 +31,10 @@ test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
});
test("isAutoPreviewableArtifactPath limits automatic previews to outputs and provisional drafts", () => {
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/outputs/report.html"), true);
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), true);
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/scan_structure.py"), false);
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), false);
});
@@ -59,6 +59,23 @@ export function isArtifactApiPath(path: string): boolean {
);
}
/**
* Whether a streamed write is a user-facing deliverable that may open the
* artifact panel automatically. Agents often use ``workspace/`` for helper
* scripts and intermediate data; previewing those files is noisy and can show
* an empty sandbox before the real output exists.
*/
export function isAutoPreviewableArtifactPath(path: string): boolean {
if (isProvisionalStreamingArtifactPath(path)) {
return true;
}
const normalized = normalizeStreamingArtifactPath(path)
.split(/[?#]/, 1)[0]
?.replace(/^\/+/, "")
.replace(/\\/g, "/");
return normalized === "mnt/user-data/outputs" || normalized?.startsWith("mnt/user-data/outputs/");
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
@@ -1,7 +1,274 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
import {
isWriteFileInFlight,
latestInFlightWriteFilePreview,
resolveWriteFilePath,
} from "./write-file-in-flight";
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 标题\n",
},
},
{ id: "p1", name: "present_files", args: { paths: ["/mnt/user-data/outputs/report.md"] } },
],
},
]);
assert.ok(preview);
assert.equal(preview?.previewKey, "m1:w1");
assert.match(preview?.artifactPath ?? "", /write-file:/);
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview keeps markdown when a later json write is in flight", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 标题\n",
},
},
{
id: "w2",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/task-reports.json",
content: "[{",
},
},
],
},
]);
assert.ok(preview);
assert.equal(preview?.previewKey, "m1:w1");
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown in writing mode", () => {
const messages = [
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
content: "# 草稿\n",
},
},
],
},
];
assert.equal(latestInFlightWriteFilePreview(messages, true), null);
});
test("latestInFlightWriteFilePreview keeps custom outputs markdown live in normal mode", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
content: "# 草稿\n",
},
},
],
},
]);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /ai-agent-architecture-paper\.md/);
});
test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => {
const preview = latestInFlightWriteFilePreview(
[
{ type: "human", content: "写一篇论文" },
{
id: "m-pipe",
type: "ai",
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
},
{
id: "m-write",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 我自己写\n",
},
},
],
},
],
true,
);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview still opens the injected research stub", () => {
const preview = latestInFlightWriteFilePreview(
[
{
id: "research-report-j1",
type: "ai",
tool_calls: [
{
id: "research-report-j1-write",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 报告\n",
},
},
],
},
],
true,
);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("writing mode still previews follow-up-turn writes after a later user message", () => {
// 续写轮(成功之后用户又发了新消息):真实 write_file 编辑照常预览。
const preview = latestInFlightWriteFilePreview(
[
{ type: "human", content: "写一篇论文" },
{
id: "m-pipe",
type: "ai",
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
},
{
id: "m-result",
type: "tool",
tool_call_id: "p1",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md 并展示给用户。",
},
{ type: "human", content: "把第二章改写得更学术一些" },
{
id: "m-edit",
type: "ai",
tool_calls: [
{
id: "w2",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 修订后的报告\n",
},
},
],
},
],
true,
);
assert.ok(preview);
assert.equal(preview?.previewKey, "m-edit:w2");
});
test("normal mode keeps previewing pathless streaming drafts", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { content: "# 没有路径的流式草稿\n" } },
],
},
]);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /\.md/);
});
test("latestInFlightWriteFilePreview ignores workspace helper files", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/workspace/scan_structure.py",
content: "print('scan')",
},
},
{
id: "w2",
name: "write_file",
args: {
path: "/mnt/user-data/workspace/draft.html",
content: "<!doctype html><html></html>",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/task-reports.json",
content: "[{}]",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview skips write_file that already has a tool result", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } },
],
},
{ type: "tool", tool_call_id: "w1" },
]);
assert.equal(preview, null);
});
test("resolveWriteFilePath reads the virtual URL pathname", () => {
assert.equal(
@@ -1,5 +1,15 @@
import { isInterceptedWritingReportWrite } from "../threads/writing-mode";
import { isImageFile } from "../utils/files";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
export function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
@@ -27,8 +37,12 @@ export function resolveWriteFilePath(filepath: string): string {
type WriteFileThreadLike = {
messages: Array<{
id?: string;
type?: string;
name?: string;
content?: unknown;
tool_call_id?: string;
additional_kwargs?: Record<string, unknown>;
tool_calls?: Array<{
id?: string;
name?: string;
@@ -75,3 +89,70 @@ export function isWriteFileInFlight(
}
return false;
}
/**
* Latest in-flight `write_file` that the sandbox can live-preview.
* Scans all messages (not just the last CoT step) so a write that is
* followed by `present_files` / bash in the same turn still opens the panel
* while arguments stream in — same idea as position-roundtable's
* `latestStreamingWriteFile`.
*/
export function latestInFlightWriteFilePreview(
messages: WriteFileThreadLike["messages"],
writingMode = false,
): { artifactPath: string; previewKey: string } | null {
const thread = { messages };
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex];
if (message?.type !== "ai" || !message.id || !message.tool_calls) {
continue;
}
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "write_file" || !toolCall.id) {
continue;
}
if (hasToolResult(thread, toolCall.id)) {
continue;
}
const args = toolCall.args as { path?: unknown; content?: unknown } | undefined;
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
: content.trim()
? provisionalWriteFilePath(content)
: null;
if (!sourcePath) {
continue;
}
// Writing mode rewrites custom outputs/*.md to deep_research_report.
// Opening that filename would 404-poll until report.md lands. Normal
// mode never skips: the write is a real deliverable, not a rewrite.
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages, writingMode)) {
continue;
}
// JSON (e.g. agentfx task-reports.json) is too large to live-preview:
// switching the sandbox onto it freezes the main thread, then the rest
// of the turn appears to dump at once. Keep showing markdown instead.
if (!languageOfStreamingArtifact(sourcePath)) {
continue;
}
if (!isAutoPreviewableArtifactPath(sourcePath)) {
continue;
}
return {
artifactPath: buildWriteFileVirtualUrl({
path: sourcePath,
messageId: message.id,
toolCallId: toolCall.id,
}),
previewKey: `${message.id}:${toolCall.id}`,
};
}
}
return null;
}
@@ -1,6 +1,5 @@
/**
* embed-session.ts 浏览上下文隔离单测。
* 运行方式(项目无 vitest):
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs
* node --test .tmp-tests/embed-session.test.mjs
@@ -82,14 +81,11 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
asTop();
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
asIframe();
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
assert.equal(isEmbedActive(), true);
assert.equal(isChromeHidden(), true);
assert.ok(storage.getItem("embed.session.nested"));
assert.equal(storage.getItem("embed.session"), null);
asTop();
assert.equal(isEmbedActive(), false);
@@ -97,11 +93,9 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
});
});
test("top parent reclaims pollution from an old nested iframe (no topOwned)", () => {
test("top ignores old nested embed.session without clearing it", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
syncEmbedSessionFromSearch("");
// 模拟未升级的老版内层:仍写共用键且没有 topOwned。
storage.setItem(
"embed.session",
JSON.stringify({
@@ -113,15 +107,14 @@ test("top parent reclaims pollution from an old nested iframe (no topOwned)", ()
isolate: false,
}),
);
// 外层点菜单重算布局时会读 session —— 必须立刻自愈。
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
// 内层老版仍要读到 embed=true
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
});
});
test("top sync after old nested pollution does not mark owner", () => {
test("top sync does not wipe unowned embed.session", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
@@ -137,12 +130,11 @@ test("top sync after old nested pollution does not mark owner", () => {
);
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
});
});
test("nested iframe reclaims a polluted shared embed.session key", () => {
test("nested iframe leaves the shared embed.session for old inner pages", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
@@ -159,11 +151,11 @@ test("nested iframe reclaims a polluted shared embed.session key", () => {
asIframe();
syncEmbedSessionFromSearch("?embed=1");
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
assert.ok(storage.getItem("embed.session.nested"));
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
@@ -184,31 +176,7 @@ test("nested iframe keeps the top window's own embed session", () => {
});
});
test("nested reclaim strips embed but keeps top isolate", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: true,
}),
);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
});
});
test("top reclaim strips embed but keeps isolate without nested help", () => {
test("top ignore strips chrome in memory but keeps isolate", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
@@ -224,5 +192,6 @@ test("top reclaim strips embed but keeps isolate without nested help", () => {
);
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
});
});
+98 -199
View File
@@ -16,34 +16,17 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
* 录不同账号**而**不覆盖**当前标签页(localStorage)的登录态——`embed` 已隐含此隔离,`isolate`
* 则是把同一套隔离单独提供给**普通(带 chrome)标签页**,不去 chrome、不强制主题、不动跳转栏。
*
* 三者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏;
* `isolate` 只切换鉴权存储,不影响 chrome / 主题 / 跳转栏。
*
* 隔离要点(核心诉求:同一浏览器另开咱们正常系统页,**绝不能**被这些开关影响):
* - 用 **sessionStorage**(按标签页隔离、绝不跨标签)持有这些 flag,而**不用** localStorage
* (跨标签共享 → 会污染正常页)。
* - 主题走 next-themes 的 `forcedTheme`(见 App.tsx `AppProviders`)——只在本标签页生效、
* 不写 localStorage `strategy-theme`;嵌入侧绝不调 `setTheme`。
* - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等
* localStorage 偏好。
*
* 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」
* 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署)
* **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层
* 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉,
* 路由看起来却没变。因此:
* - 顶层窗口写 `embed.session`,嵌套 iframe(新版)写 `embed.session.nested`;
* - 顶层只信任带 `topOwned` 的会话(本窗自己从 URL 置位);老版嵌套页仍写共用键时,
* 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。
* 同源陷阱:sessionStorage 按「顶层标签页 × origin」分区,同一标签里所有同源文档共用一份。
* 全量父页套外部系统、外部再套咱们老版 `embed=1` 深链时,内层会写共用的 `embed.session`。
* **禁止**在顶层父页清掉这份键——内层老版还靠它藏导航栏。顶层只在「本窗自己从 URL 认领」时
* 才把 embed 当成自己的;否则读取时忽略 chrome 标志、且不写回存储。
* 嵌套新版写 `embed.session.nested`,与顶层键分开。深链问答/会商/大屏绘制另外按路由去 chrome
* (见 `isHostNestedSurfacePath`),不依赖这份会话。
*/
const EMBED_SESSION_KEY = "embed.session";
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
/**
* 顶层窗口认领「本页自己的去 chrome 会话」的内存标记(配合 session 里的 `topOwned`)。
* 嵌套同源 iframe 看到此标记就不会清掉顶层合法的 embed/hideChrome 持久化。
*/
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
@@ -61,7 +44,6 @@ function getSessionStorage(): Storage | null {
}
}
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
function isTopLevelWindow(): boolean {
const w = getWindow();
if (!w) return true;
@@ -78,28 +60,14 @@ function embedSessionKey(): string {
export type EmbedTheme = "light" | "dark";
/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */
export interface EmbedSession {
/** 嵌入模式:去全局 chrome + 强制主题。 */
embed: boolean;
/** 嵌入模式下强制的主题(仅 embed 为真时有意义)。 */
theme: EmbedTheme;
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
hideJump: boolean;
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
hideHeader: boolean;
/**
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
* 一个干净的全屏页面(如「大屏绘制」`?hideChrome=1`),其余行为与正常标签页完全一致。
*/
hideChrome: boolean;
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
isolate: boolean;
/**
* 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe
* (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。
*/
/** 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省表示可能是同源嵌套页写的共用键。 */
topOwned?: boolean;
}
@@ -142,49 +110,12 @@ function emptySession(): EmbedSession {
};
}
function stripChromeFlags(session: EmbedSession): EmbedSession {
return {
...session,
embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
topOwned: false,
};
}
function persistTopSession(next: EmbedSession): void {
const store = getSessionStorage();
if (!store) return;
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(EMBED_SESSION_KEY);
return;
}
const payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
hideHeader: next.hideHeader,
hideChrome: next.hideChrome,
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
store.setItem(EMBED_SESSION_KEY, JSON.stringify(payload));
}
function isTopChromeOwned(session?: EmbedSession | null): boolean {
const w = getWindow();
if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true;
return session?.topOwned === true;
}
/** 顶层窗口从自己的 URL 置位去 chrome 后打标(内存 + session.topOwned)。 */
function markTopChromeOwner(session: EmbedSession): void {
const w = getWindow();
if (!w || !isTopLevelWindow()) return;
@@ -192,31 +123,25 @@ function markTopChromeOwner(session: EmbedSession): void {
w[TOP_CHROME_OWNER_FLAG] = true;
}
/**
* 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。
* 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。
*/
function reclaimUnownedTopChrome(): void {
if (!isTopLevelWindow()) return;
function readStoredSession(): EmbedSession | null {
try {
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur || !sessionHidesChrome(cur)) return;
if (isTopChromeOwned(cur)) {
const w = getWindow();
if (w) w[TOP_CHROME_OWNER_FLAG] = true;
return;
}
persistTopSession(stripChromeFlags(cur));
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
} catch {
/* noop */
return null;
}
}
function readSharedTopSession(): EmbedSession | null {
try {
return parseEmbedSession(getSessionStorage()?.getItem(EMBED_SESSION_KEY) ?? null);
} catch {
return null;
}
}
/**
* 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。
* 旧版嵌套页写过共用键时,新版嵌套页复制一份到 nested 键自己用;**不删除**共用键,
* 避免老版内层丢掉 embed 后把导航栏放出来。
*/
function migrateLegacySharedSessionIfNested(): void {
if (isTopLevelWindow()) return;
@@ -230,30 +155,12 @@ function migrateLegacySharedSessionIfNested(): void {
}
}
function reclaimPollutedTopChrome(): void {
if (isTopLevelWindow()) return;
try {
const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined;
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur) return;
// 顶层已认领(内存标记或 session.topOwned)则不动。
if (top?.[TOP_CHROME_OWNER_FLAG] === true || cur.topOwned === true) return;
persistTopSession(stripChromeFlags(cur));
} catch {
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
}
}
/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */
type FlagParam = "on" | "off" | null;
function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
}
/** 读某个布尔型 query 参数的三态。`1/true/yes` → on;`0/false/no` → off;缺省/其它 → null。 */
function readFlagParam(search: string, key: string): FlagParam {
const v = (toSearchParams(search).get(key) ?? "").trim().toLowerCase();
if (!v) return null;
@@ -262,32 +169,26 @@ function readFlagParam(search: string, key: string): FlagParam {
return null;
}
/** 读 URL 里的 `embed` 三态。 */
export function readEmbedParam(search: string): FlagParam {
return readFlagParam(search, "embed");
}
/** 读 URL 里的 `hideJump` 三态。 */
export function readHideJumpParam(search: string): FlagParam {
return readFlagParam(search, "hideJump");
}
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
export function readHideHeaderParam(search: string): FlagParam {
return readFlagParam(search, "hideHeader");
}
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
export function readHideChromeParam(search: string): FlagParam {
return readFlagParam(search, "hideChrome");
}
/** 读 URL 里的 `isolate` 三态(独立会话登录)。 */
export function readIsolateParam(search: string): FlagParam {
return readFlagParam(search, "isolate");
}
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
export function readEmbedThemeParam(search: string): string {
return (toSearchParams(search).get("theme") ?? "").trim();
}
@@ -301,34 +202,32 @@ function searchEnablesChromeHide(search: string): boolean {
);
}
/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */
/**
* 读本浏览上下文的嵌入会话。顶层若看到无主(非 topOwned)的 chrome 标志,视为同源
* 嵌套页写的,**读取时忽略、不写回**,以免清掉内层老版仍在用的 `embed.session`。
*/
export function readEmbedSession(): EmbedSession | null {
try {
// 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。
reclaimUnownedTopChrome();
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
} catch {
return null;
const s = readStoredSession();
if (!s) return null;
if (isTopLevelWindow() && sessionHidesChrome(s) && !isTopChromeOwned(s)) {
return {
...s,
embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
topOwned: false,
};
}
return s;
}
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void {
try {
const store = getSessionStorage();
if (!store) return;
const key = embedSessionKey();
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(key);
return;
}
const payload: EmbedSession = {
let payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
@@ -337,37 +236,56 @@ function writeEmbedSession(next: EmbedSession): void {
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
// 顶层未认领时,写 isolate 等字段不得覆盖内层老版仍占用的 chrome 标志。
if (
isTopLevelWindow() &&
key === EMBED_SESSION_KEY &&
!payload.topOwned &&
getWindow()?.[TOP_CHROME_OWNER_FLAG] !== true
) {
const shared = readSharedTopSession();
if (shared && sessionHidesChrome(shared) && !shared.topOwned) {
payload = {
...payload,
embed: shared.embed,
theme: shared.theme,
hideJump: shared.hideJump,
hideHeader: shared.hideHeader,
hideChrome: shared.hideChrome,
};
}
}
if (
!payload.embed &&
!payload.hideJump &&
!payload.hideHeader &&
!payload.hideChrome &&
!payload.isolate
) {
store.removeItem(key);
return;
}
store.setItem(key, JSON.stringify(payload));
} catch {
/* noop */
}
}
/** 本标签页是否处于嵌入模式(驱动去全局 chrome + 强制主题)。 */
export function isEmbedActive(): boolean {
return readEmbedSession()?.embed === true;
}
/** 本标签页是否要隐藏深链底部「快捷跳转」栏(TaskJumpBar)。 */
export function isJumpBarHidden(): boolean {
return readEmbedSession()?.hideJump === true;
}
/**
* Whether this tab should hide its global Header.
* `embed` and `hideChrome` also hide the Header, while `hideHeader` alone
* deliberately leaves PageSidebar visible.
*/
export function isHeaderHidden(): boolean {
const session = readEmbedSession();
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
}
/**
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
* 与 `isEmbedActive()` 的差别:`hideChrome` 不强制主题、不隔离鉴权,只去 chrome。
*/
export function isChromeHidden(): boolean {
const s = readEmbedSession();
return s?.embed === true || s?.hideChrome === true;
@@ -377,28 +295,38 @@ export function isHideChromeActive(): boolean {
return readEmbedSession()?.hideChrome === true;
}
/**
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
* 供需要区分「纯独立会话(带 chrome)」与「嵌入态(去 chrome)」的场合使用。
*/
export function isIsolatedSession(): boolean {
return readEmbedSession()?.isolate === true;
}
/**
* 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。
* **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时,
* 之前置位的开关不会被抹掉。
*
* 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的
* chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。
*/
export function syncEmbedSessionFromSearch(search: string): void {
migrateLegacySharedSessionIfNested();
if (isTopLevelWindow()) reclaimUnownedTopChrome();
if (!isTopLevelWindow()) {
migrateLegacySharedSessionIfNested();
applySearchToSession(search, readStoredSession() ?? emptySession(), false);
return;
}
const cur = readEmbedSession() ?? emptySession();
const stored = readStoredSession() ?? emptySession();
if (searchEnablesChromeHide(search)) {
applySearchToSession(search, stored, true);
return;
}
if (isTopChromeOwned(stored)) {
applySearchToSession(search, stored, true);
return;
}
// 无主共用键:只处理 isolate URL,绝不把 chrome 标志写成 false 覆盖内层。
const isolateParam = readIsolateParam(search);
if (isolateParam === null) return;
const isolate = isolateParam === "on";
writeEmbedSession({ ...stored, isolate, topOwned: false });
}
function applySearchToSession(
search: string,
cur: EmbedSession,
topOwned: boolean,
): void {
const next: EmbedSession = { ...cur };
const embedParam = readEmbedParam(search);
@@ -425,27 +353,11 @@ export function syncEmbedSessionFromSearch(search: string): void {
if (isolateParam === "on") next.isolate = true;
else if (isolateParam === "off") next.isolate = false;
if (isTopLevelWindow()) {
if (searchEnablesChromeHide(search)) {
next.topOwned = true;
} else if (!isTopChromeOwned(next)) {
// 未认领:丢掉可能由老版内层写脏的 chrome 标志,保留 isolate。
Object.assign(next, stripChromeFlags(next), { isolate: next.isolate });
} else {
next.topOwned = true;
}
}
next.topOwned = topOwned;
writeEmbedSession(next);
if (isTopLevelWindow()) markTopChromeOwner(next);
else reclaimPollutedTopChrome();
if (topOwned) markTopChromeOwner(next);
}
/**
* 从当前 HashRouter URL(`#/path?a=b`)取出 query 串。
* 供「在 React 渲染之前」同步启用嵌入会话(main.tsx)——必须早于任何鉴权写入,
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
*/
function currentHashSearch(): string {
const w = getWindow();
if (!w) return "";
@@ -454,18 +366,10 @@ function currentHashSearch(): string {
return i >= 0 ? hash.slice(i) : "";
}
/** main.tsx 在渲染前同步调用一次:据当前 URL 启用/关闭本标签页嵌入会话。 */
export function syncEmbedSessionFromCurrentUrl(): void {
syncEmbedSessionFromSearch(currentHashSearch());
}
/**
* 解析「应当强制的嵌入主题」供 `AppProviders` 的 `forcedTheme` 用:
* - URL 显式 `embed=1` → 用 URL `theme`(首帧即生效,登录页转场不闪烁);
* - URL 显式 `embed=0` → 不强制(undefined);
* - URL 未带 → 回退本标签页嵌入会话主题(仅 embed 置位时)。
* 非嵌入标签页恒返回 undefined → 完全不影响正常系统页的主题。
*/
export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined {
const param = readEmbedParam(search);
if (param === "on") return resolveEmbedThemeMode(readEmbedThemeParam(search));
@@ -474,12 +378,7 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
return session?.embed ? session.theme : undefined;
}
/**
* 运行时直接置位/清除本标签页的独立会话标志(供「外观」设置里的开关用,区别于 URL 参数驱动)。
* 仅切换标志;登录态的「localStorage 快照搬进 sessionStorage」由 [[scoped-storage]]
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
*/
export function setIsolatedSession(on: boolean): void {
const cur = readEmbedSession() ?? emptySession();
writeEmbedSession({ ...cur, isolate: on });
const stored = readStoredSession() ?? emptySession();
writeEmbedSession({ ...stored, isolate: on });
}
@@ -149,7 +149,7 @@ export function buildWikiIndexCatalogMarkdown(
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
indexGroups: WikiIndexGroup[] = [],
): string {
if (indexGroups.length > 0) {
if (indexGroups.some((group) => group.items.length > 0)) {
const sections: string[] = [];
for (const group of indexGroups) {
if (group.items.length === 0) continue;
@@ -56,6 +56,61 @@ test("write_file tool after 用户协助 opens a processing group instead of err
]);
});
test("writing-mode progress merges into the deep_research_report bar so the summary sits below", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "写一篇论文" } as Message,
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: { query: "多智能体" } }],
} as Message,
{
type: "tool",
id: "t1",
name: "deep_research_report",
content: "ok",
tool_call_id: "c1",
} as Message,
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "complete" } },
],
} as Message,
{
type: "ai",
id: "write",
content: "",
tool_calls: [{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } }],
} as Message,
{
type: "ai",
id: "summary",
content: "已生成多智能体论文,详见 report.md。",
} as Message,
]);
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
});
test("a lone deep-research progress bar still opens its own group", () => {
const types = groupTypes([
{
type: "ai",
id: "progress",
content: "",
tool_calls: [
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "active" } },
],
} as Message,
]);
assert.deepEqual(types, ["assistant:processing"]);
});
test("tool messages still attach to an open processing group", () => {
const types = groupTypes([
{
+21 -8
View File
@@ -166,14 +166,19 @@ export function groupMessages<T>(
messages: [message],
});
} else if (hasOnlyDeepResearchProgress(message)) {
// Each deep-research writing bar is its own group. Merging consecutive
// processing messages would pin a new report's in-progress steps onto
// the parent bar, which sits above the original file card.
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
// 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
// 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
// 会有 human/assistant 隔开,不会误并到上一轮的条上。
const lastGroup = groups[groups.length - 1];
if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) {
lastGroup.messages.push(message);
} else {
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
}
} else if (hasSubagent(message)) {
groups.push({
id: message.id,
@@ -426,6 +431,14 @@ export function hasOnlyDeepResearchProgress(message: Message) {
);
}
function groupHasDeepResearchReport(group: MessageGroup): boolean {
return group.messages.some(
(message) =>
message.type === "ai" &&
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report")),
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
@@ -0,0 +1,70 @@
/**
* host-nested-surface 深链嵌套页路径判定。
* npx esbuild src/core/page-layout/host-nested-surface.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/host-nested-surface.test.mjs
* node --test .tmp-tests/host-nested-surface.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import { isHostNestedSurfacePath } from "./host-nested-surface";
test("matches deeplink single-agent workspaces", () => {
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task-sentiment/agent/abc/chats",
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task/plan-task-fx/agent/abc/chats/thread-1",
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task/plan-task-action/agent/abc/chats/new",
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task-agentfx/agent/agentfx-analyst/chats",
),
true,
);
});
test("matches deeplink roundtable and dashboard-agent", () => {
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task/plan-task-fx/roundtable",
),
true,
);
assert.equal(
isHostNestedSurfacePath("/page/strategy/dashboard-agent"),
true,
);
});
test("does not match full-mode pages we use ourselves", () => {
assert.equal(
isHostNestedSurfacePath("/page/strategy/task-management/xk-task-jc"),
false,
);
assert.equal(
isHostNestedSurfacePath("/page/strategy/task-management/plan-task-sentiment"),
false,
);
assert.equal(
isHostNestedSurfacePath("/page/strategy/qa/multi-agent-roundtable"),
false,
);
assert.equal(
isHostNestedSurfacePath("/page/workspace/roundtable/planning"),
false,
);
assert.equal(isHostNestedSurfacePath("/page/workspace/chats/new"), false);
});
@@ -0,0 +1,21 @@
/**
* 外部系统 iframe 深链套用的页面:单智能体问答、多智能体会商、大屏绘制。
* 这些路由只给宿主嵌套,不走全量 Header / PageSidebar;chrome 由路径决定,
* 不依赖(也不改写)同源 iframe 共用的 `embed.session`。
*/
/** 任务管理下的深链会商(goPath=`*-A`)。 */
const TASK_ROUNDTABLE_RE =
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action)\/roundtable(?:\/|$)/;
/** 任务管理下的深链单智能体问答(含 agentfx)。 */
const TASK_AGENT_CHATS_RE =
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action|plan-task-agentfx)\/agent\/[^/]+\/chats/;
export function isHostNestedSurfacePath(pathname: string): boolean {
const path = (pathname || "").split("?")[0] || "";
if (path.includes("/strategy/dashboard-agent")) return true;
if (TASK_ROUNDTABLE_RE.test(path)) return true;
if (TASK_AGENT_CHATS_RE.test(path)) return true;
return false;
}
@@ -61,6 +61,7 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
{
id: "multi-agent-roundtable",
label: "多智能体会商",
@@ -73,12 +74,12 @@ export const sidebarMenuItems: MenuItem[] = [
// icon: Briefcase,
// path: "/page/strategy/qa/position-roundtable",
// },
{
id: "dashboard-agent",
label: "大屏绘制",
icon: Gauge,
path: "/page/strategy/dashboard-agent",
},
// {
// id: "dashboard-agent",
// label: "大屏绘制",
// icon: Gauge,
// path: "/page/strategy/dashboard-agent",
// },
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
@@ -88,12 +89,12 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
{
id: "qa-research-chat",
label: "课题研究(对话版)",
icon: FileText,
path: "/page/canvas/ai-writing-chat",
},
// {
// id: "qa-research-chat",
// label: "课题研究(对话版)",
// icon: FileText,
// path: "/page/canvas/ai-writing-chat",
// },
{
id: "qa-deep-research",
label: "深度研究",
@@ -9,6 +9,31 @@ export interface PromptPrefixSettings {
ordinary_qa_markdown_format_enabled: boolean;
}
export type KnowledgeBaseProductVersion = "v1" | "v2";
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
);
if (!response.ok) throw new Error(`Failed to load knowledge base product version: ${response.status}`);
return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version;
}
export async function updateKnowledgeBaseProductVersion(
version: KnowledgeBaseProductVersion,
): Promise<KnowledgeBaseProductVersion> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version }),
},
);
if (!response.ok) throw new Error(`Failed to update knowledge base product version: ${response.status}`);
return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version;
}
export interface PluginDomainAgentMappingDto {
id: string;
domain: string;
+9 -3
View File
@@ -1512,10 +1512,16 @@ export function useThreadStream({
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const selectedAssistantKnowledgeIds = Array.isArray(
mergedContext.assistant_knowledge_base_ids,
)
? mergedContext.assistant_knowledge_base_ids
: [];
const ragModeEnabled =
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0;
selectedLlmWikiIds.length > 0 ||
selectedAssistantKnowledgeIds.length > 0;
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
@@ -1693,7 +1699,7 @@ export function useThreadStream({
? undefined
: thread.error;
const mergedThread = {
const mergedThread = {
...thread,
error: visibleThreadError,
values: mergedValues,
@@ -1703,7 +1709,7 @@ export function useThreadStream({
: mergedMessages,
isLoading: thread.isLoading || streamHoldOpen,
stop: handleThreadStop,
} as typeof thread;
} as typeof thread;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
+2
View File
@@ -27,6 +27,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
agent_id?: string;
@@ -6,6 +6,7 @@ import {
buildResearchFeedMessages,
buildResearchProgressToolCalls,
buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
clearResearchReportStream,
finalizeResearchReportStream,
ingestResearchReportEvent,
@@ -139,15 +140,33 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
sourceCount: 3,
});
const calls = buildResearchProgressToolCalls(active);
assert.equal(calls.length, 2); // 拆解已完成 + 检索进行中
assert.equal(calls.length, 2); // 梳理思路已完成 + 整理资料进行中
assert.equal(calls[0].args.status, "complete");
assert.equal(calls[0].args.label, "梳理写作思路");
assert.equal(calls[1].args.status, "active");
assert.equal(calls[1].args.label, "整理对话资料");
assert.match(calls[1].args.detail, /1\/2/);
const writing = streamOf({ jobId: "j7", phase: "writing", reportText: "# x" });
assert.equal(buildResearchProgressToolCalls(writing).length, 3);
assert.equal(buildResearchProgressToolCalls(writing)[2].args.status, "active");
const completedByPhase = streamOf({ jobId: "j7b", phase: "done", reportText: "# x" });
const completedCalls = buildResearchProgressToolCalls(completedByPhase);
assert.equal(completedCalls.length, 3);
assert.ok(completedCalls.every((call) => call.args.status === "complete"));
assert.match(completedCalls[2].args.detail, /report\.md/);
const completedByStatus = streamOf({
jobId: "j7c",
phase: "writing",
status: "success",
reportText: "# x",
});
assert.ok(
buildResearchProgressToolCalls(completedByStatus).every((call) => call.args.status === "complete"),
);
const failed = streamOf({ jobId: "j8", phase: "writing", status: "error", error: "模型超时" });
const failedCalls = buildResearchProgressToolCalls(failed);
assert.match(failedCalls.at(-1)?.args.label ?? "", /已中断/);
@@ -157,15 +176,73 @@ test("progress tool calls reveal stages stepwise and mark interruption", () => {
test("feed messages attach the write_file stub only once report text exists", () => {
const noText = streamOf({ jobId: "j9", phase: "collecting" });
assert.equal(buildResearchFeedMessages(noText).length, 1);
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls[0].name, "deep_research_progress");
assert.equal(buildResearchFeedMessages(noText)[0].tool_calls?.[0]?.name, "deep_research_progress");
const writingEmpty = streamOf({ jobId: "j9b", phase: "writing" });
assert.equal(buildResearchFeedMessages(writingEmpty).length, 1);
const withText = streamOf({ jobId: "j10", phase: "writing", reportText: "# 报告正文" });
const feed = buildResearchFeedMessages(withText);
assert.equal(feed.length, 2);
const stub = feed[1].tool_calls[0];
assert.equal(stub.name, "write_file");
assert.equal(stub.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub.args.content, "# 报告正文");
const stub = feed[1].tool_calls?.[0];
assert.equal(stub?.name, "write_file");
assert.equal(stub?.args.path, "/mnt/user-data/outputs/report.md");
assert.equal(stub?.args.content, "# 报告正文");
const done = streamOf({ jobId: "j10b", phase: "done", reportText: "# 报告正文" });
const doneFeed = buildResearchFeedMessages(done);
assert.equal(doneFeed.length, 3);
assert.equal(doneFeed[2].type, "tool");
assert.equal(doneFeed[2].tool_call_id, "research-report-j10b-write");
});
test("report_completed flips phase to done and write step completes before tool_finished", () => {
const tid = nextThread();
ingestAll(tid, [
{ type: "report_delta", jobId: "jdone", payload: { delta: "正文", offset: 0 } },
{ type: "report_completed", jobId: "jdone", payload: { sourceCount: 2 } },
]);
const state = peekResearchReportStream(tid);
assert.equal(state?.phase, "done");
assert.equal(state?.status, "active");
const calls = buildResearchProgressToolCalls(state!);
assert.ok(calls.every((call) => call.args.status === "complete"));
});
test("composeResearchDisplayMessages inserts the bar before the summary and drops leaked prose", () => {
const feed = buildResearchFeedMessages(streamOf({
jobId: "jsplice",
phase: "done",
reportText: "# 报告",
}));
const composed = composeResearchDisplayMessages(
[
{ id: "h", type: "human", content: "写论文" },
{
id: "ai-tool",
type: "ai",
content: "",
tool_calls: [{ id: "c1", name: "deep_research_report", args: {} }],
},
{ id: "leak", type: "ai", content: "# 报告\n\n泄漏到消息列表的正文" },
{ id: "tool", type: "tool", tool_call_id: "c1", content: "ok" },
{ id: "summary", type: "ai", content: "已生成论文,详见 report.md。" },
],
feed,
);
assert.deepEqual(
composed.map((message) => message.id),
[
"h",
"ai-tool",
"tool",
"research-progress-jsplice",
"research-report-jsplice",
"research-report-jsplice-write-result",
"summary",
],
);
});
test("virtual URL matches the message-list ArtifactWriteToolCall URL (one suppression key)", () => {
@@ -84,8 +84,8 @@ function getStore(threadId: string): StoreEntry {
* 通知若逐帧发出,会让聊天页整棵树(长消息列表 + 沙箱 Markdown 预览)按
* token 频率重渲染,页面直接卡死。工作台用 requestAnimationFrame 批处理
* (DeepResearchWorkbench),聊天这里等效地按固定间隔合并——每秒最多
* 10 次通知,流式观感不受影响。终态帧(tool_finished)与
* finalize/clear 立即 flush,结束态不滞后。
* 10 次通知,流式观感不受影响。终态帧(tool_finished /
* report_completed)与 finalize/clear 立即 flush,结束态不滞后。
*/
const EMIT_COALESCE_MS = 100;
@@ -193,7 +193,8 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
case "report_completed":
next = {
...base,
phase: "writing",
// 文件已落盘:步骤条应立刻标完成,不必等随后的 tool_finished。
phase: "done",
sourceCount: typeof payload.sourceCount === "number" && payload.sourceCount > 0
? payload.sourceCount
: base.sourceCount,
@@ -223,7 +224,7 @@ export function ingestResearchReportEvent(threadId: string, frame: ResearchRepor
if (next !== previous) {
store.state = next;
// 状态同步更新(peek/快照立即可见);通知按 token 帧频率合并,终态立即。
if (frame.type === "tool_finished") {
if (frame.type === "tool_finished" || frame.type === "report_completed") {
emitNow(store);
} else {
emitCoalesced(store);
@@ -280,33 +281,41 @@ export type ResearchProgressToolCall = {
export function buildResearchProgressToolCalls(state: ResearchReportStreamState): ResearchProgressToolCall[] {
const rank = phaseRank(state.phase);
const finished = state.status !== "active";
const effectiveRank = finished && state.status === "success" ? PHASE_ORDER.length - 1 : rank;
// success 时必须用 length(越过 write 的 boundary),length-1 永远完不成最后一步。
// report_completed 会先把 phase 置 done,此时工具可能还没返回,也视为写完。
const succeeded =
state.status === "success" || (state.status === "active" && state.phase === "done");
const effectiveRank = succeeded ? PHASE_ORDER.length : rank;
const stages = [
{
id: "plan",
label: "拆解检索问题",
// 写作管线不检索:只按课题拆论述角度、汇总对话已收集的资料再成稿。
// 措辞避免「拆解检索问题/检索参考资料」,用户会误会管线在重新检索。
label: "梳理写作思路",
boundary: phaseRank("collecting"),
detail: state.queries.length > 0
? `已拆出 ${state.queries.length} 个检索子问题`
: "正在围绕课题拆解检索子问题",
? `已梳理出 ${state.queries.length} 个论述角度`
: "正在围绕课题梳理写作思路",
},
{
id: "collect",
label: "检索参考资料",
label: "整理对话资料",
boundary: phaseRank("compressing"),
detail: state.queries.length > 0
? `已检索 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个子问题 · 累计 ${state.sourceCount} 条资料`
? `已整理 ${Math.min(state.completedQueries, state.queries.length)}/${state.queries.length} 个论述角度的资料 · 累计 ${state.sourceCount} 条`
: state.sourceCount > 0
? `已检索到 ${state.sourceCount} 条资料`
: "正在逐个子问题检索资料",
? `已汇总 ${state.sourceCount} 条对话资料`
: "正在汇总对话中已收集的资料",
},
{
id: "write",
label: "撰写研究报告",
boundary: PHASE_ORDER.length,
detail: state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
detail: succeeded
? "已按「通用分析结构」写完并保存为 report.md"
: state.reportText
? "正在按「通用分析结构」逐字撰写,右侧沙箱同步预览"
: "正在整理资料并开始撰写报告",
},
];
const activeIndex = stages.findIndex((stage) => effectiveRank < stage.boundary);
@@ -330,19 +339,25 @@ export function buildResearchProgressToolCalls(state: ResearchReportStreamState)
export type ResearchFeedMessage = {
id: string;
type: "ai";
type: "ai" | "tool";
content: string;
tool_calls: {
name?: string;
tool_call_id?: string;
tool_calls?: {
id: string;
name: string;
args: Record<string, unknown>;
}[];
};
function isResearchWriteFinished(state: ResearchReportStreamState): boolean {
return state.status === "success" || state.phase === "done";
}
/**
* 组装聊天页要注入的两条虚拟 ai 消息:进度步骤条 + report.md 的 write_file
* stub(沙箱 write-file 虚拟 URL 预览的数据源)。仅在报告尚无正文或仍在
* 流式时输出 stub —— 完成后由真实 artifacts 接管。
* 组装聊天页要注入的虚拟消息:进度步骤条 + report.md 的 write_file stub
* (沙箱 write-file 虚拟 URL 预览的数据源)。写完后补一条 tool result,
* 让沙箱的「正在生成」立刻收起,不必等真实 artifacts 交接。
*/
export function buildResearchFeedMessages(state: ResearchReportStreamState): ResearchFeedMessage[] {
const messages: ResearchFeedMessage[] = [
@@ -353,23 +368,127 @@ export function buildResearchFeedMessages(state: ResearchReportStreamState): Res
tool_calls: buildResearchProgressToolCalls(state),
},
];
if (state.reportText.trim()) {
// 必须等正文首 token:思考阶段挂 stub 会提前显示「写入文件 report.md」。
if (!state.reportText.trim()) {
return messages;
}
const writeCallId = `research-report-${state.jobId}-write`;
messages.push({
id: `research-report-${state.jobId}`,
type: "ai",
content: "",
tool_calls: [
{
id: writeCallId,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
});
if (isResearchWriteFinished(state)) {
messages.push({
id: `research-report-${state.jobId}`,
type: "ai",
content: "",
tool_calls: [
{
id: `research-report-${state.jobId}-write`,
name: "write_file",
args: { path: RESEARCH_REPORT_OUTPUT_PATH, content: state.reportText },
},
],
id: `research-report-${state.jobId}-write-result`,
type: "tool",
name: "write_file",
tool_call_id: writeCallId,
content: "ok",
});
}
return messages;
}
function isDeepResearchReportAi(message: { type?: string; tool_calls?: Array<{ name?: string; id?: string }> }): boolean {
return (
message.type === "ai" &&
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report"))
);
}
function deepResearchReportCallIds(message: { tool_calls?: Array<{ name?: string; id?: string }> }): Set<string> {
return new Set(
(message.tool_calls ?? [])
.filter((toolCall) => toolCall.name === "deep_research_report" && toolCall.id)
.map((toolCall) => toolCall.id as string),
);
}
function messageHasVisibleText(message: { type?: string; content?: unknown; tool_calls?: unknown[] }): boolean {
if (message.type !== "ai") return false;
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return false;
return typeof message.content === "string" && message.content.trim().length > 0;
}
/**
* 丢掉管线内部 LLM 泄漏到父图 messages 流的正文:它们夹在
* `deep_research_report` 调用与其 tool result 之间,会让左侧气泡和沙箱一起写。
*/
export function omitLeakedReportTokens<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
): T[] {
const hidden = new Set<number>();
let pendingIds: Set<string> | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (isDeepResearchReportAi(message)) {
pendingIds = deepResearchReportCallIds(message);
continue;
}
if (!pendingIds) continue;
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
pendingIds.has(message.tool_call_id)
) {
pendingIds = null;
continue;
}
if (messageHasVisibleText(message)) {
hidden.add(i);
}
}
return hidden.size === 0 ? messages : messages.filter((_, index) => !hidden.has(index));
}
/**
* 把进度条 / write_file stub 插到 `deep_research_report` 及其结果之后、
* 模型总结之前,这样总结气泡永远在步骤条下面。
*/
export function insertResearchFeedMessages<T>(messages: T[], feed: T[]): T[] {
if (feed.length === 0) return messages;
let insertAt = -1;
let callIds = new Set<string>();
for (let i = 0; i < messages.length; i++) {
const message = messages[i] as { type?: string; tool_calls?: Array<{ name?: string; id?: string }> };
if (isDeepResearchReportAi(message)) {
insertAt = i + 1;
callIds = deepResearchReportCallIds(message);
}
}
if (insertAt === -1) {
return [...messages, ...feed];
}
while (insertAt < messages.length) {
const message = messages[insertAt] as { type?: string; tool_call_id?: string };
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
callIds.has(message.tool_call_id)
) {
insertAt += 1;
continue;
}
break;
}
return [...messages.slice(0, insertAt), ...feed, ...messages.slice(insertAt)];
}
export function composeResearchDisplayMessages<T extends { type?: string; tool_calls?: Array<{ name?: string; id?: string }>; tool_call_id?: string; content?: unknown }>(
messages: T[],
feed: T[],
): T[] {
return insertResearchFeedMessages(omitLeakedReportTokens(messages), feed);
}
/** write-file 虚拟 URL(ArtifactFileDetail 用它从 stub 消息读流式内容)。 */
export function buildResearchReportVirtualUrl(state: ResearchReportStreamState): string {
return new URL(
@@ -3,8 +3,8 @@
/**
* 聊天页「写作模式」深度研究进度注入。
*
* 把 `useResearchReportStream` 的流式状态转成 displayThread 尾部追加的
* 虚拟消息(进度步骤条 + report.md 的 write_file stub):
* 把 `useResearchReportStream` 的流式状态插到 `deep_research_report`
* 工具结果之后、模型总结之前(进度步骤条 + report.md 的 write_file stub):
*
* - 进行中 / 中断:始终注入(中断态展示「已中断」,下一轮 jobId 变化自然淘汰);
* - 成功:注入到真实 report.md 产物落地为止(artifacts 出现该路径即撤出),
@@ -26,13 +26,16 @@ import {
artifactPreviewKey,
useArtifactsOptional,
} from "@/components/workspace/artifacts/context";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
import {
buildResearchFeedMessages,
buildResearchReportVirtualUrl,
composeResearchDisplayMessages,
RESEARCH_REPORT_OUTPUT_PATH,
useResearchReportStream,
} from "./use-research-report-stream";
import { isInterceptedWritingReportWrite } from "./writing-mode";
type FeedThreadLike = {
messages: Message[];
@@ -55,10 +58,15 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
[thread.values?.artifacts],
);
// 报告首帧正文到达且仍在写入 → 主动展开右侧沙箱并选中 write-file 虚拟
// 预览,报告在沙箱里边写边流式渲染。
// 正文首 token 到达且仍在写入 → 主动展开右侧沙箱。思考阶段不打开,
// 避免「写入文件 report.md」出现在模型还在想的时候。
useEffect(() => {
if (!research || research.status !== "active" || !research.reportText.trim()) {
if (
!research ||
research.status !== "active" ||
research.phase !== "writing" ||
!research.reportText.trim()
) {
return;
}
if (autoOpenedJobRef.current === research.jobId) return;
@@ -77,7 +85,15 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
if (!artifacts) return;
handedOverJobRef.current = research.jobId;
const current = artifacts.selectedArtifact;
if (current !== null && current !== buildResearchReportVirtualUrl(research)) {
const virtualUrl = buildResearchReportVirtualUrl(research);
const currentPath = current ? resolveWriteFilePath(current) : "";
const keepUserSelection =
current !== null &&
current !== virtualUrl &&
current !== RESEARCH_REPORT_OUTPUT_PATH &&
// research 流存在即本线程跑过写作模式管线,这里恒按写作模式判定。
!isInterceptedWritingReportWrite(currentPath, undefined, undefined, true);
if (keepUserSelection) {
return;
}
artifacts.select(RESEARCH_REPORT_OUTPUT_PATH);
@@ -91,11 +107,11 @@ export function useResearchWritingFeed<T extends FeedThreadLike>(
}, [hasRealArtifact, research]);
const displayThread = useMemo(() => {
if (feedMessages.length === 0) return thread;
const messages = [
...thread.messages,
...(feedMessages as unknown as Message[]),
];
const messages = composeResearchDisplayMessages(
thread.messages,
feedMessages as unknown as Message[],
);
if (messages === thread.messages) return thread;
return {
...thread,
values: { ...thread.values, messages },
+13 -1
View File
@@ -27,6 +27,18 @@ function pickAgentRef(
return undefined;
}
/**
* Return the agent that owns a thread when that ownership was persisted on
* either its run context or its metadata. Keeping this in one place ensures
* sidebars and route recovery make the same decision for older deep links.
*/
export function agentRefOfThread(thread: {
context?: ThreadAgentRef | null;
metadata?: Record<string, unknown> | null;
}): string | undefined {
return pickAgentRef(thread.context, thread.metadata);
}
export function pathOfThread(
thread: ThreadRouteTarget,
context?: ThreadAgentRef | null,
@@ -36,7 +48,7 @@ export function pathOfThread(
const agentRef =
typeof thread === "string"
? pickAgentRef(context, null)
: pickAgentRef(thread.context, thread.metadata);
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isInterceptedWritingReportWrite,
isWritingFirstReportTurn,
shouldAutoOpenWritingArtifact,
} from "./writing-mode";
test("custom outputs markdown is always intercepted except the research stub", () => {
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md", undefined, undefined, true),
true,
);
assert.equal(
isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md",
"research-report-j1",
undefined,
true,
),
false,
);
});
test("normal mode never intercepts outputs markdown writes", () => {
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/paper.md"),
false,
);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m0", [
{ type: "human", content: "写一篇论文" },
]),
false,
);
});
test("report.md is intercepted for the whole first-report turn", () => {
const beforePipeline = [{ type: "human", content: "写一篇论文" }];
assert.equal(isWritingFirstReportTurn(beforePipeline), true);
assert.equal(
isInterceptedWritingReportWrite(
"/mnt/user-data/outputs/report.md",
"m1",
beforePipeline,
true,
),
true,
);
const inFlight = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(isWritingFirstReportTurn(inFlight), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m2", inFlight, true),
true,
);
const succeeded = [
...inFlight,
{
type: "tool",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md。",
},
];
assert.equal(isWritingFirstReportTurn(succeeded), true);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m3", succeeded, true),
true,
);
});
test("sandbox auto-open waits for report.md on the first-report turn", () => {
const firstTurn = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn, true),
false,
);
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn, true),
true,
);
});
test("normal mode auto-opens every new artifact like before writing mode existed", () => {
const firstTurn = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
];
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/research-notes.md", firstTurn),
true,
);
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/report.md", firstTurn),
true,
);
});
test("a later user turn releases report.md live edits", () => {
const followUp = [
{ type: "human", content: "写一篇论文" },
{
type: "ai",
tool_calls: [{ name: "deep_research_report" }],
},
{
type: "tool",
name: "deep_research_report",
content: "研究报告已完成:已写入 report.md。",
},
{ type: "human", content: "把第二节改短一点" },
];
assert.equal(isWritingFirstReportTurn(followUp), false);
assert.equal(
isInterceptedWritingReportWrite("/mnt/user-data/outputs/report.md", "m4", followUp, true),
false,
);
});
@@ -0,0 +1,127 @@
/** Only the writing-mode deliverable counts as a follow-up edit target. */
const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
export function normalizeWritingPath(path: string | null | undefined): string {
if (!path) return "";
return path.replace(/\\/g, "/").split("?")[0]?.split("#")[0]?.replace(/\/+$/, "") ?? "";
}
export function isWritingModeReportPath(
path: string | null | undefined,
): boolean {
const normalized = normalizeWritingPath(path);
return /(?:^|\/)report\.md$/i.test(normalized);
}
/** Markdown under the sandbox outputs dir — writing mode intercepts these as the report. */
export function isOutputsMarkdownPath(path: string | null | undefined): boolean {
const normalized = normalizeWritingPath(path);
if (!normalized || !/\.(?:md|markdown)$/i.test(normalized)) return false;
return (
normalized === OUTPUTS_PREFIX ||
normalized.startsWith(`${OUTPUTS_PREFIX}/`) ||
normalized === "outputs" ||
normalized.startsWith("outputs/")
);
}
type WritingPipelineMessage = {
type?: string;
name?: string;
content?: unknown;
additional_kwargs?: Record<string, unknown>;
tool_calls?: Array<{ name?: string }>;
};
/**
* First-report turn: no successful pipeline yet, or the latest visible user
* message is still the one that triggered that pipeline. Follow-up edits
* (a later visible human after success) keep live `report.md` writes visible.
*/
export function isWritingFirstReportTurn(
messages: readonly WritingPipelineMessage[] | null | undefined,
): boolean {
if (!messages?.length) return false;
let lastVisibleHuman = -1;
let lastSuccess = -1;
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (
message?.type === "human" &&
message.additional_kwargs?.hide_from_ui !== true
) {
lastVisibleHuman = index;
}
if (message?.type === "tool" && message.name === "deep_research_report") {
const content = typeof message.content === "string" ? message.content : "";
if (content && !content.toLowerCase().startsWith("error")) {
lastSuccess = index;
}
}
}
return lastVisibleHuman >= 0 && (lastSuccess < 0 || lastVisibleHuman <= lastSuccess);
}
/**
* Hand-written outputs/*.md writes the backend rewrites or drops:
* custom filenames always, and `report.md` while the first-turn pipeline is
* in-flight or just succeeded. The injected research stub stays visible.
*
* Everything above only exists in writing mode — normal mode writes md files
* by hand as always, so `writingMode=false` (the default) never intercepts.
*/
export function isInterceptedWritingReportWrite(
path: string | null | undefined,
messageId?: string | null,
messages?: readonly WritingPipelineMessage[] | null,
writingMode = false,
): boolean {
if (!writingMode) {
return false;
}
if (typeof messageId === "string" && messageId.startsWith("research-report-")) {
return false;
}
if (!isOutputsMarkdownPath(path)) {
return false;
}
if (!isWritingModeReportPath(path)) {
return true;
}
return isWritingFirstReportTurn(messages);
}
/**
* Auto-open the sandbox only for the real report during the first-report
* turn. Normal mode keeps the pre-writing-mode behavior: always auto-open
* the newest artifact.
*/
export function shouldAutoOpenWritingArtifact(
path: string | null | undefined,
messages?: readonly WritingPipelineMessage[] | null,
writingMode = false,
): boolean {
if (!path) {
return false;
}
if (!writingMode) {
return true;
}
if (isWritingFirstReportTurn(messages)) {
return isWritingModeReportPath(path);
}
return !isInterceptedWritingReportWrite(path, undefined, messages, true);
}
export function resolveWritingModeReportPath(
artifacts: readonly string[] | null | undefined,
selectedArtifact?: string | null,
): string | undefined {
if (isWritingModeReportPath(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(artifacts ?? [])]
.reverse()
.find((path) => isWritingModeReportPath(path));
}
@@ -273,6 +273,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
hideHeaderSaveDraft
hideHeaderSettings
newTaskLabel="新建会话"
step3Mode="report-only"
hideDefaultStep1RightSidebar
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
+32 -1
View File
@@ -3,11 +3,13 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DatabaseZap,
Replace,
SearchIcon,
SparklesIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,17 +20,32 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { getStoredAuth } from "@/core/auth";
import { useAllSkillsAdmin } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { useSkillKnowledgeBindings } from "@/strategy-components/api/skill-knowledge";
import { SkillAddressesPanel } from "./SkillAddressesPage";
const PAGE_SIZE = 10;
export default function AdminSkillsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const { skills, isLoading, error } = useAllSkillsAdmin(true);
const knowledgeBindings = useSkillKnowledgeBindings();
const activeTab = useMemo(() => {
const tab = searchParams.get("tab");
return tab === "addresses" || tab === "knowledge" ? tab : "skills";
}, [searchParams]);
const changeTab = (value: string) => {
const next = new URLSearchParams(searchParams);
if (value === "skills") next.delete("tab");
else next.set("tab", value);
setSearchParams(next, { replace: true });
};
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -56,7 +73,8 @@ export default function AdminSkillsPage() {
<WorkspaceHeader />
<WorkspaceBody>
<Tabs
defaultValue="skills"
value={activeTab}
onValueChange={changeTab}
className="flex size-full min-h-0 flex-col gap-0"
>
<div className="flex items-center justify-between gap-4 border-b px-6 pt-4">
@@ -71,6 +89,10 @@ export default function AdminSkillsPage() {
<Replace />
技能地址管理
</TabsTrigger>
<TabsTrigger value="knowledge">
<DatabaseZap />
技能到知识库管理
</TabsTrigger>
</TabsList>
</div>
</div>
@@ -126,6 +148,9 @@ export default function AdminSkillsPage() {
skill={skill}
currentUserId={currentUserId}
isAdmin
knowledgeBindings={(knowledgeBindings.data ?? []).filter(
(binding) => binding.skill_name === skill.name,
)}
/>
))}
</>
@@ -164,6 +189,12 @@ export default function AdminSkillsPage() {
>
<SkillAddressesPanel />
</TabsContent>
<TabsContent
value="knowledge"
className="mt-0 min-h-0 flex-1 overflow-hidden"
>
<SkillKnowledgePanel />
</TabsContent>
</Tabs>
</WorkspaceBody>
</WorkspaceContainer>
+143 -43
View File
@@ -3,6 +3,8 @@
import {
BotIcon,
Compass,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PanelRightOpenIcon,
PlusSquare,
} from "lucide-react";
@@ -36,6 +38,7 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
import { Tooltip } from "@/components/workspace/tooltip";
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useTheme } from "@/contexts/ThemeContext";
import { useI18n } from "@/core/i18n/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useModels } from "@/core/models/hooks";
@@ -45,6 +48,7 @@ import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStrea
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
Select,
@@ -84,10 +88,13 @@ import {
AGENTFX_REPORT_JSON_OUTPUT_PATH,
notifyTaskReportSaved,
} from "@/strategy-components/lib/agentfx-report-schema";
import { cn } from "@/lib/utils";
import type { Message } from "@langchain/langgraph-sdk";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
/** 实验性页面研报智能体:发送当前界面明暗模式供其选择同色系 HTML 骨架。 */
const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
@@ -111,6 +118,26 @@ function readPendingKnowledgeSelection(agentId: string | undefined): string[] |
}
}
function readPendingAssistantKnowledgeSelection(agentId: string | undefined): string[] | null {
if (typeof window === "undefined" || !agentId) return null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (!raw) return null;
const parsed = JSON.parse(raw) as {
agentId?: string;
assistantKnowledgeBaseIds?: unknown;
};
if (parsed.agentId !== agentId || !Array.isArray(parsed.assistantKnowledgeBaseIds)) {
return null;
}
return parsed.assistantKnowledgeBaseIds
.map((item) => String(item).trim())
.filter(Boolean);
} catch {
return null;
}
}
function stringifyForHiddenContext(value: unknown): string {
if (typeof value === "string") return value;
try {
@@ -222,8 +249,12 @@ export default function AgentChatPage({
} = {}) {
const routeBase = useRouteBase();
const { t } = useI18n();
const { baseMode } = useTheme();
const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter();
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
// 由本页维护其收展状态,避免顶部按钮切到了不可见的另一层侧栏。
const [agentSidebarOpen, setAgentSidebarOpen] = useState(true);
const { agent_id: agentIdFromPath } = useParams<{
agent_id: string;
@@ -323,11 +354,17 @@ export default function AgentChatPage({
const pendingKnowledgeSelection = useRef(
readPendingKnowledgeSelection(agent_id),
);
const pendingAssistantKnowledgeSelection = useRef(
readPendingAssistantKnowledgeSelection(agent_id),
);
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>(
() => pendingKnowledgeSelection.current ?? [],
);
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
() => pendingAssistantKnowledgeSelection.current ?? [],
);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null,
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
@@ -338,6 +375,10 @@ export default function AgentChatPage({
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedAssistantKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
useEffect(() => {
activeThreadIdRef.current = threadId;
@@ -347,17 +388,23 @@ export default function AgentChatPage({
if (knowledgeSelectionAgent.current === agent_id) return;
knowledgeSelectionAgent.current = agent_id;
const pending = readPendingKnowledgeSelection(agent_id);
const pendingAssistant = readPendingAssistantKnowledgeSelection(agent_id);
pendingKnowledgeSelection.current = pending;
pendingAssistantKnowledgeSelection.current = pendingAssistant;
knowledgeSelectionThread.current = null;
setSelectedKnowledgeBaseIds(pending ?? []);
setKnowledgeSelectionReady(pending !== null);
setSelectedAssistantKnowledgeBaseIds(pendingAssistant ?? []);
setKnowledgeSelectionReady(pending !== null || pendingAssistant !== null);
}, [agent_id]);
useEffect(() => {
if (isNewThread) {
if (knowledgeSelectionThread.current !== "__new__") {
setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []);
setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null);
setSelectedAssistantKnowledgeBaseIds(pendingAssistantKnowledgeSelection.current ?? []);
setKnowledgeSelectionReady(
pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
);
}
knowledgeSelectionThread.current = "__new__";
setKnowledgeSelectionLoading(false);
@@ -372,18 +419,29 @@ export default function AgentChatPage({
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
const assistantRaw = metadata?.assistant_knowledge_base_ids;
const llmwikiIds = Array.isArray(raw)
? raw.map((item) => String(item).trim()).filter(Boolean)
: [];
const assistantIds = Array.isArray(assistantRaw)
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
: [];
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
setSelectedKnowledgeBaseIds(llmwikiIds);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
if (Array.isArray(assistantRaw)) {
setSelectedAssistantKnowledgeBaseIds(assistantIds);
} else {
setSelectedAssistantKnowledgeBaseIds([]);
}
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
@@ -406,10 +464,13 @@ export default function AgentChatPage({
() => ({
...(taskId ? taskThreadMetadata(taskContext) : {}),
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
}),
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
[knowledgeSelectionReady, selectedAssistantKnowledgeBaseIds, selectedKnowledgeBaseIds, taskContext, taskId],
);
// 任务工作区(taskBind):每次运行都带隐藏任务上下文(任务id/名称/内容),
// 新建对话的消息历史里没有开场信息时模型也能拿到任务id(见 taskBindRunContext)。
@@ -425,7 +486,10 @@ export default function AgentChatPage({
...settings.context,
agent_id,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
...(taskBindRunContextValue ?? {}),
},
@@ -612,16 +676,13 @@ export default function AgentChatPage({
} as typeof displayThread;
}, [displayThread, showReportImportCard, taskId, threadId]);
// 写作模式续写轮:已有 md 产物(report.md 已生成)时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线(与
// ChatPage 的回传逻辑对齐)。
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [thread.values.artifacts]);
// 写作模式续写轮:仅当已有写作模式产物 report.md 时回传 writing_artifact_path,
// 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线。其它 leftover
// .md 不得跳过管线(与 ChatPage 对齐)。
const writingArtifactPath = useMemo(
() => resolveWritingModeReportPath(thread.values.artifacts),
[thread.values.artifacts],
);
// ── 「模板数据转换器」对话页专属调试入口(仅此 agent)──────────────────────────
// 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板,
@@ -673,13 +734,18 @@ export default function AgentChatPage({
settings.context.writing_mode && writingArtifactPath
? { writing_artifact_path: writingArtifactPath }
: {};
const pageReportThemeContext =
agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
? { presentation_theme: baseMode }
: {};
void sendMessage(threadId, message, {
...writingExtraContext,
...(extraContext ?? {}),
agent_id,
...pageReportThemeContext,
});
},
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath],
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
);
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -753,9 +819,14 @@ export default function AgentChatPage({
async (answer: string) => {
setHasStartedConversation(true);
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
await sendMessage(threadId, { text: answer, files: [] }, {
agent_id,
...(agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
? { presentation_theme: baseMode }
: {}),
});
},
[sendMessage, thread, threadId, agent_id],
[sendMessage, thread, threadId, agent_id, baseMode],
);
const handleStop = useCallback(async () => {
@@ -796,7 +867,11 @@ export default function AgentChatPage({
{showAgentList && (
<div
data-agent-guide="chat-sidebar"
className="flex h-full shrink-0"
aria-hidden={!agentSidebarOpen}
className={cn(
"flex h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
agentSidebarOpen ? "w-(--sidebar-width)" : "pointer-events-none w-0",
)}
>
<AgentChatSidebar activeAgentId={agent_id} />
</div>
@@ -808,6 +883,25 @@ export default function AgentChatPage({
<main className="relative flex size-full min-h-0 min-w-0 flex-col">
{/* Header in normal flow, fixed height. */}
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
{/* 智能体列表栏的本地收展入口。 */}
{showAgentList && !isEmbedded && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setAgentSidebarOpen((open) => !open)}
title={agentSidebarOpen ? "收起左侧栏" : "展开左侧栏"}
aria-label={agentSidebarOpen ? "收起左侧栏" : "展开左侧栏"}
aria-expanded={agentSidebarOpen}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
{agentSidebarOpen ? (
<PanelLeftCloseIcon className="size-4" />
) : (
<PanelLeftOpenIcon className="size-4" />
)}
</Button>
)}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span
@@ -883,23 +977,6 @@ export default function AgentChatPage({
开始回答
</Button>
)}
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
{/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示
(展开态的「收起」按钮在面板右上角)。桌面端专用(lg+),
窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */}
@@ -921,6 +998,23 @@ export default function AgentChatPage({
enabled={tokenUsageEnabled}
messages={displayThread.messages}
/>
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
@@ -980,9 +1074,13 @@ export default function AgentChatPage({
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
autoSelectAll={
isNewThread && pendingKnowledgeSelection.current === null
isNewThread &&
pendingKnowledgeSelection.current === null &&
pendingAssistantKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
label="知识空间"
@@ -1067,7 +1165,9 @@ export default function AgentChatPage({
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
readOnly
label="知识空间"
@@ -0,0 +1,559 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
ArrowDownWideNarrow,
Bot,
ChevronLeft,
ChevronRight,
Download,
FileText,
FileX2,
Library,
Loader2,
MessagesSquare,
MessageSquareShare,
RefreshCw,
Search,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import {
downloadArtifactFile,
downloadArtifactMarkdownAsWord,
} from "@/core/artifacts/download";
import {
fetchArtifactLibrary,
type ArtifactLibraryItem,
type ArtifactLibraryKind,
type ArtifactLibrarySource,
} from "@/core/artifacts/library";
import { useI18n } from "@/core/i18n/hooks";
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
import { formatDateTime } from "@/core/utils/datetime";
import {
getFileExtensionDisplayName,
getFileIcon,
} from "@/core/utils/files";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 50;
const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [
{ value: "all", label: "全部" },
{ value: "document", label: "文档" },
{ value: "spreadsheet", label: "表格" },
{ value: "text_data", label: "文本与数据" },
{ value: "archive", label: "压缩包" },
{ value: "other", label: "其他" },
];
const SOURCE_FILTERS: Array<{
value: ArtifactLibrarySource;
label: string;
}> = [
{ value: "all", label: "全部来源" },
{ value: "general_qa", label: "通用问答" },
{ value: "agent", label: "智能体" },
];
type DownloadFormat = "original" | "word";
function isMarkdownArtifact(name: string): boolean {
return /\.(md|markdown)$/i.test(name);
}
function artifactDownloadKey(
item: ArtifactLibraryItem,
format: DownloadFormat,
): string {
return `${item.threadId}:${item.path}:${format}`;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** index;
const digits = index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(digits)} ${units[index]}`;
}
function sourceConversationPath(item: ArtifactLibraryItem): string {
const basePath =
item.threadType === "bootstrap"
? bootstrapResumePath(item.threadId)
: pathOfThread({
thread_id: item.threadId,
metadata: item.agentId ? { agent_id: item.agentId } : undefined,
});
const search = new URLSearchParams({ artifact: item.path });
return `${basePath}?${search.toString()}`;
}
function FileRow({
item,
downloading,
onDownload,
}: {
item: ArtifactLibraryItem;
downloading: DownloadFormat | null;
onDownload: (item: ArtifactLibraryItem, format: DownloadFormat) => void;
}) {
const sourcePath = sourceConversationPath(item);
const displayName = desensitize(item.name);
const sourceTitle = desensitize(item.threadTitle || "新对话");
const sourceLabel = item.sourceType === "agent" ? "智能体" : "通用问答";
const sourceName =
item.sourceType === "agent"
? desensitize(item.agentName || item.agentId || "未知智能体")
: sourceLabel;
const SourceIcon = item.sourceType === "agent" ? Bot : MessagesSquare;
const busy = downloading !== null;
return (
<tr className="group border-b transition-colors hover:bg-muted/35">
<td className="min-w-0 py-3 pr-4 pl-1">
<Link
href={sourcePath}
className="flex min-w-0 items-center gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={`打开原对话并查看 ${displayName}`}
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground/75">
{getFileIcon(item.name, "size-5")}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground group-hover:text-primary">
{displayName}
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground lg:hidden">
{getFileExtensionDisplayName(item.name)} · {sourceName} · 来自「
{sourceTitle}」
</span>
<span className="mt-1 hidden text-xs text-muted-foreground lg:block">
{getFileExtensionDisplayName(item.name)} 文件
</span>
</span>
</Link>
</td>
<td className="hidden min-w-0 px-3 py-3 lg:table-cell">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<SourceIcon className="size-4" />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{sourceName}
</span>
<Link
href={sourcePath}
className="block truncate text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
title={`打开原对话:${sourceTitle}`}
>
{item.sourceType === "agent" ? "智能体 · " : ""}
{sourceTitle}
</Link>
</span>
</div>
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-muted-foreground">
{formatDateTime(item.modifiedAt)}
</td>
<td className="hidden whitespace-nowrap px-3 py-3 text-sm text-muted-foreground md:table-cell">
{formatBytes(item.sizeBytes)}
</td>
<td className="py-3 pr-1 pl-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
type="button"
size="icon"
variant="ghost"
className="size-8"
disabled={busy}
onClick={() => onDownload(item, "original")}
title={`下载原文件:${displayName}`}
aria-label={`下载原文件:${displayName}`}
>
{downloading === "original" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
</Button>
{isMarkdownArtifact(item.name) && (
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 gap-1 px-2 text-xs"
disabled={busy}
onClick={() => onDownload(item, "word")}
title={`下载为 Word:${displayName}`}
aria-label={`下载为 Word:${displayName}`}
>
{downloading === "word" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileText className="size-4" />
)}
Word
</Button>
)}
<Button
asChild
size="icon"
variant="ghost"
className="size-8"
title={`打开原对话:${sourceTitle}`}
>
<Link href={sourcePath} aria-label={`打开原对话:${sourceTitle}`}>
<MessageSquareShare className="size-4" />
</Link>
</Button>
</div>
</td>
</tr>
);
}
function LoadingRows() {
return Array.from({ length: 5 }, (_, index) => (
<tr className="border-b" key={index}>
<td className="py-3 pr-4 pl-1">
<div className="flex items-center gap-3">
<Skeleton className="size-10 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
</td>
<td className="hidden px-3 lg:table-cell"><Skeleton className="h-4 w-3/4" /></td>
<td className="px-3"><Skeleton className="h-4 w-24" /></td>
<td className="hidden px-3 md:table-cell"><Skeleton className="h-4 w-16" /></td>
<td className="pr-1 pl-3"><Skeleton className="ml-auto size-8" /></td>
</tr>
));
}
export default function ArtifactLibraryPage() {
const { t } = useI18n();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
const [sourceType, setSourceType] = useState<ArtifactLibrarySource>("all");
const [page, setPage] = useState(1);
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const forceRefreshRef = useRef(false);
useEffect(() => {
document.title = `资料库 - ${t.pages.appName}`;
}, [t.pages.appName]);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => window.clearTimeout(timer);
}, [search]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, fileKind, sourceType]);
const offset = (page - 1) * PAGE_SIZE;
const libraryQuery = useQuery({
queryKey: ["artifact-library", debouncedSearch, fileKind, sourceType, page],
queryFn: async ({ signal }) => {
const refresh = forceRefreshRef.current;
try {
return await fetchArtifactLibrary({
query: debouncedSearch,
fileKind,
sourceType,
offset,
limit: PAGE_SIZE,
refresh,
signal,
});
} finally {
forceRefreshRef.current = false;
}
},
retry: false,
});
const total = libraryQuery.data?.total ?? 0;
const items = libraryQuery.data?.items ?? [];
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const hasPrevious = page > 1;
const hasNext = page < totalPages;
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const itemKeys = useMemo(
() =>
new Set(
items.flatMap((item) => [
artifactDownloadKey(item, "original"),
artifactDownloadKey(item, "word"),
]),
),
[items],
);
useEffect(() => {
if (downloadingKey && !itemKeys.has(downloadingKey)) setDownloadingKey(null);
}, [downloadingKey, itemKeys]);
const handleDownload = async (
item: ArtifactLibraryItem,
format: DownloadFormat,
) => {
const key = artifactDownloadKey(item, format);
if (downloadingKey) return;
setDownloadingKey(key);
try {
if (format === "word") {
await downloadArtifactMarkdownAsWord({
filepath: item.path,
threadId: item.threadId,
});
toast.success("Word 文件已生成");
} else {
await downloadArtifactFile({
filepath: item.path,
threadId: item.threadId,
});
}
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : "下载失败");
} finally {
setDownloadingKey(null);
}
};
const handleRefresh = () => {
forceRefreshRef.current = true;
void libraryQuery.refetch();
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody className="items-stretch">
<div className="flex min-h-0 w-full flex-1 flex-col">
<header className="shrink-0 border-b bg-card/40 px-5 py-6 sm:px-8">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div>
<div className="flex items-center gap-2">
<Library className="size-6 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">资料库</h1>
</div>
<p className="mt-2 text-sm text-muted-foreground">
汇总当前账号在对话中生成的文件,不包含上传文件、图片、音频和视频。
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={libraryQuery.isFetching}
>
<RefreshCw className={cn("size-4", libraryQuery.isFetching && "animate-spin")} />
刷新
</Button>
</div>
<div className="relative max-w-xl">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
className="h-11 pl-9"
placeholder="搜索文件名、来源对话或智能体名称"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-1" aria-label="文件类型筛选">
{FILTERS.map((filter) => (
<Button
key={filter.value}
type="button"
size="sm"
variant={fileKind === filter.value ? "secondary" : "ghost"}
aria-pressed={fileKind === filter.value}
onClick={() => setFileKind(filter.value)}
>
{filter.label}
</Button>
))}
</div>
<Select
value={sourceType}
onValueChange={(value) =>
setSourceType(value as ArtifactLibrarySource)
}
>
<SelectTrigger
size="sm"
className="w-32"
aria-label="筛选文件来源"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOURCE_FILTERS.map((filter) => (
<SelectItem key={filter.value} value={filter.value}>
{filter.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ArrowDownWideNarrow className="size-4" />
最近生成
</div>
</div>
</div>
</header>
<ScrollArea className="min-h-0 flex-1">
<main className="mx-auto w-full max-w-6xl px-5 py-5 sm:px-8">
<div className="mb-2 text-xs text-muted-foreground tabular-nums">
{libraryQuery.isLoading ? "正在统计文件…" : `共 ${total} 个文件`}
</div>
<div className="overflow-x-auto">
<table className="w-full table-fixed border-collapse">
<thead>
<tr className="border-b text-left text-xs font-normal text-muted-foreground">
<th className="w-[48%] py-3 pr-4 pl-1 font-normal lg:w-[35%]">文件名</th>
<th className="hidden w-[25%] px-3 py-3 font-normal lg:table-cell">来源</th>
<th className="w-[34%] px-3 py-3 font-normal md:w-[22%] lg:w-[16%]">生成时间</th>
<th className="hidden w-[13%] px-3 py-3 font-normal lg:w-[10%] md:table-cell">大小</th>
<th className="w-[18%] py-3 pr-1 pl-3 text-right font-normal md:w-[17%] lg:w-[14%]">
<span className="sr-only">操作</span>
</th>
</tr>
</thead>
<tbody>
{libraryQuery.isLoading ? (
<LoadingRows />
) : (
items.map((item) => {
const key = `${item.threadId}:${item.path}`;
const activeDownload = downloadingKey?.startsWith(`${key}:`)
? downloadingKey.endsWith(":word")
? "word"
: "original"
: null;
return (
<FileRow
key={key}
item={item}
downloading={activeDownload}
onDownload={(target, format) =>
void handleDownload(target, format)
}
/>
);
})
)}
</tbody>
</table>
</div>
{!libraryQuery.isLoading && libraryQuery.isError && (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<FileX2 className="size-9 text-muted-foreground" />
<div>
<p className="text-sm font-medium">资料库加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">
{libraryQuery.error instanceof Error
? libraryQuery.error.message
: "请稍后重试"}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
重新加载
</Button>
</div>
)}
{!libraryQuery.isLoading && !libraryQuery.isError && items.length === 0 && (
<div className="flex flex-col items-center gap-3 py-20 text-center">
<FileX2 className="size-9 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{debouncedSearch || fileKind !== "all" || sourceType !== "all"
? "没有找到匹配的文件"
: "还没有生成文件"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
对话中生成的文档、表格和数据文件会自动出现在这里。
</p>
</div>
</div>
)}
{!libraryQuery.isLoading && !libraryQuery.isError && totalPages > 1 && (
<div className="flex items-center justify-center gap-3 py-7">
<Button
variant="outline"
size="sm"
disabled={!hasPrevious}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
<ChevronLeft className="size-4" />
上一页
</Button>
<span className="text-sm text-muted-foreground tabular-nums">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasNext}
onClick={() => setPage((current) => current + 1)}
>
下一页
<ChevronRight className="size-4" />
</Button>
</div>
)}
</main>
</ScrollArea>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
@@ -0,0 +1,448 @@
import { BookOpen, Database, Download, Loader2, Plus, RefreshCw, Search, ShieldCheck, Trash2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getStoredAuth } from "@/core/auth";
import {
type AssistantKnowledgeBase,
type AssistantWikiPage,
useAssistantImportJobs,
useAssistantKnowledgeActions,
useAssistantKnowledgeBases,
useAssistantKnowledgeSources,
useAssistantWikiPages,
} from "@/strategy-components/api/assistant-knowledge";
function baseSubtitle(base: AssistantKnowledgeBase) {
if (base.is_global) return "全库沉淀总库 · 所有 WeKnora 导入数据都会同步进入这里并做实体对齐";
return base.description || "自建助手知识库 · 可上传普通知识库导出的 Wiki 向量包";
}
function EmptyInitializeCard({
isAdmin,
onInitialize,
pending,
}: {
isAdmin: boolean;
onInitialize: () => void;
pending: boolean;
}) {
return (
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-lg rounded-xl border bg-card p-8 text-center">
<Database className="mx-auto mb-4 size-10 text-primary" />
<h1 className="text-xl font-semibold">初始化助手知识库</h1>
<p className="mt-2 text-sm text-muted-foreground">
未配置 WeKnora 时会默认展示助手知识库。首次使用需要创建“知识梳理总库”,后续从 WeKnora 导入的数据都会沉淀到这里。
</p>
{isAdmin ? (
<Button className="mt-5" disabled={pending} onClick={onInitialize}>
{pending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Database className="mr-2 size-4" />}
创建知识梳理总库
</Button>
) : (
<p className="mt-5 text-sm">请联系管理员初始化。</p>
)}
</div>
</div>
);
}
export default function AssistantKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [query, setQuery] = useState("");
const [selectedBaseId, setSelectedBaseId] = useState<string | null>(null);
const [selected, setSelected] = useState<AssistantWikiPage | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createDraft, setCreateDraft] = useState({ name: "", description: "" });
const packageInputRef = useRef<HTMLInputElement | null>(null);
const bases = useAssistantKnowledgeBases();
const actions = useAssistantKnowledgeActions();
useEffect(() => {
const values = bases.data ?? [];
if (!values.length) {
setSelectedBaseId(null);
return;
}
if (selectedBaseId && values.some((base) => base.id === selectedBaseId)) return;
setSelectedBaseId((values.find((base) => base.is_global) ?? values[0]).id);
}, [bases.data, selectedBaseId]);
useEffect(() => {
setSelected(null);
}, [selectedBaseId]);
const selectedBase = useMemo(
() => (bases.data ?? []).find((base) => base.id === selectedBaseId) ?? null,
[bases.data, selectedBaseId],
);
const pages = useAssistantWikiPages(query, selectedBaseId);
const sources = useAssistantKnowledgeSources(selectedBaseId);
const jobs = useAssistantImportJobs(selectedBaseId);
const activePage = selected ?? pages.data?.[0] ?? null;
const counts = useMemo(
() => ({
pages: pages.data?.length ?? selectedBase?.page_count ?? 0,
sources: sources.data?.length ?? selectedBase?.source_count ?? 0,
jobs: jobs.data?.length ?? 0,
}),
[jobs.data, pages.data, selectedBase?.page_count, selectedBase?.source_count, sources.data],
);
const initializeGlobal = () => {
actions.initialize.mutate("知识梳理总库", {
onSuccess: (base) => {
setSelectedBaseId(base.id);
toast.success("知识梳理总库已创建");
},
onError: (error) => toast.error(error.message),
});
};
const createBase = async () => {
const name = createDraft.name.trim();
if (!name) {
toast.error("请输入助手知识库名称");
return;
}
try {
const base = await actions.createBase.mutateAsync({
name,
description: createDraft.description.trim(),
});
setSelectedBaseId(base.id);
setCreateDraft({ name: "", description: "" });
setCreateOpen(false);
toast.success("助手知识库已创建");
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
const uploadPackage = async (file: File | null | undefined) => {
if (!file) return;
try {
await actions.uploadWeKnoraPackage.mutateAsync({
file,
assistantBaseId: selectedBaseId,
});
toast.success("导出包已上传,后台正在异步导入");
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
if (packageInputRef.current) packageInputRef.current.value = "";
}
};
if (bases.isLoading) {
return (
<div className="flex size-full flex-col">
<KnowledgeProductNav />
<div className="flex flex-1 items-center justify-center">
<Loader2 className="animate-spin" />
</div>
</div>
);
}
if (!bases.data?.length) {
return (
<div className="flex size-full flex-col">
<KnowledgeProductNav />
<EmptyInitializeCard
isAdmin={isAdmin}
pending={actions.initialize.isPending}
onInitialize={initializeGlobal}
/>
</div>
);
}
return (
<div className="flex size-full min-h-0 flex-col bg-background">
<KnowledgeProductNav />
<div className="grid min-h-0 flex-1 grid-cols-[280px_1fr]">
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold">助手知识库</h2>
<p className="mt-1 text-xs text-muted-foreground">总库置顶,自建库可上传 WeKnora 导出包</p>
</div>
{isAdmin && (
<Button size="icon" variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
</Button>
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 p-3">
{(bases.data ?? []).map((base) => (
<button
key={base.id}
type="button"
onClick={() => setSelectedBaseId(base.id)}
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
selectedBaseId === base.id ? "border-primary bg-primary/10 text-primary" : "bg-card hover:bg-muted"
}`}
>
<div className="flex items-center gap-2">
<Database className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{base.name}</span>
{base.is_global && <Badge variant="outline" className="text-[10px]">总库</Badge>}
</div>
<div className="mt-2 flex gap-3 text-[11px] text-muted-foreground">
<span>{base.page_count ?? 0} 页</span>
<span>{base.source_count ?? 0} 来源</span>
<span>{base.entity_count ?? 0} 实体</span>
</div>
</button>
))}
</div>
</ScrollArea>
</aside>
<main className="flex min-h-0 flex-col">
<header className="flex items-center gap-4 border-b px-6 py-4">
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold">{selectedBase?.name}</h1>
<p className="mt-1 truncate text-xs text-muted-foreground">
{selectedBase ? baseSubtitle(selectedBase) : "请选择助手知识库"}
</p>
</div>
<Badge variant="outline">
<ShieldCheck className="mr-1 size-3" />
实体对齐
</Badge>
{isAdmin && (
<div className="ml-auto flex items-center gap-2">
<input
ref={packageInputRef}
type="file"
accept=".jsonl,.ndjson,application/x-ndjson,application/json"
className="hidden"
onChange={(event) => void uploadPackage(event.target.files?.[0])}
/>
<Button
variant="outline"
disabled={actions.uploadWeKnoraPackage.isPending || !selectedBaseId}
onClick={() => packageInputRef.current?.click()}
>
{actions.uploadWeKnoraPackage.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
)}
上传 WeKnora 导出包
</Button>
</div>
)}
</header>
<Tabs defaultValue="wiki" className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-4 border-b px-6">
<TabsList className="my-2">
<TabsTrigger value="wiki">Wiki({counts.pages})</TabsTrigger>
<TabsTrigger value="sources">来源({counts.sources})</TabsTrigger>
<TabsTrigger value="jobs">导入记录({counts.jobs})</TabsTrigger>
</TabsList>
<div className="relative ml-auto w-72">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索标题、实体别名和正文"
className="pl-8"
/>
</div>
</div>
<TabsContent value="wiki" className="mt-0 grid min-h-0 flex-1 grid-cols-[300px_1fr]">
<ScrollArea className="border-r">
<div className="space-y-1 p-3">
{pages.isLoading && <Loader2 className="m-4 size-5 animate-spin" />}
{pages.data?.map((page) => (
<button
key={page.id}
type="button"
onClick={() => setSelected(page)}
className={`w-full rounded-md px-3 py-2 text-left ${
activePage?.id === page.id ? "bg-primary/10 text-primary" : "hover:bg-muted"
}`}
>
<div className="truncate text-sm font-medium">{page.title}</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{page.page_type}</span>
<span>{new Date(page.updated_at).toLocaleString()}</span>
</div>
</button>
))}
{!pages.isLoading && !pages.data?.length && (
<div className="p-4 text-xs text-muted-foreground">
暂无 Wiki 页面。请先到普通知识库详情页下载向量化数据包,再在当前助手知识库上传。
</div>
)}
</div>
</ScrollArea>
<ScrollArea>
<article className="mx-auto max-w-5xl p-8">
{activePage ? (
<>
<h1 className="mb-1 flex items-center gap-2 text-2xl font-semibold">
<BookOpen className="size-5 text-primary" />
{activePage.title}
</h1>
<p className="mb-6 text-xs text-muted-foreground">{activePage.slug}</p>
<MarkdownContent content={activePage.content_markdown} isLoading={false} rehypePlugins={[]} />
</>
) : (
<p className="text-sm text-muted-foreground">暂无 Wiki 页面。</p>
)}
</article>
</ScrollArea>
</TabsContent>
<TabsContent value="sources" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{sources.data?.map((source) => (
<div key={source.id} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Database className="size-4" />
<span className="truncate font-medium">{source.source_name}</span>
<Badge variant="outline" className="ml-auto">{source.source_type}</Badge>
</div>
<p className="mt-3 text-xs text-muted-foreground">业务键:{source.source_key}</p>
{isAdmin && (
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={source.source_type !== "weknora" || actions.reimportSource.isPending}
onClick={() =>
actions.reimportSource.mutate(source.id, {
onSuccess: () => toast.success("已发起重新导入"),
onError: (error) => toast.error(error.message),
})
}
>
<RefreshCw className="mr-2 size-4" />
重新导入
</Button>
<Button
size="sm"
variant="destructive"
disabled={actions.deleteSource.isPending}
onClick={() => {
if (window.confirm(`确认删除来源“${source.source_name}”的贡献?仍被其他来源引用的知识会保留。`)) {
actions.deleteSource.mutate(source.id, {
onSuccess: () => toast.success("来源贡献已删除"),
onError: (error) => toast.error(error.message),
});
}
}}
>
<Trash2 className="mr-2 size-4" />
删除来源
</Button>
</div>
)}
</div>
))}
{!sources.data?.length && <p className="text-sm text-muted-foreground">暂无来源。</p>}
</div>
</TabsContent>
<TabsContent value="jobs" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
<div className="space-y-3">
{jobs.data?.map((job) => (
<div key={job.id} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-3">
<Badge>{job.status}</Badge>
<span className="text-sm">{job.trigger}</span>
<span className="text-xs text-muted-foreground">阶段:{job.phase}</span>
<span className="ml-auto text-xs">{new Date(job.created_at).toLocaleString()}</span>
{isAdmin && job.package_download_url && (
<Button asChild size="sm" variant="outline">
<a href={job.package_download_url} target="_blank" rel="noreferrer">
<Download className="mr-2 size-4" />
下载导出包
</a>
</Button>
)}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ") || "暂无统计"}
</div>
{job.last_error_message && (
<div className="mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{job.last_error_message}
</div>
)}
</div>
))}
{!jobs.data?.length && <p className="text-sm text-muted-foreground">暂无导入记录。</p>}
</div>
</TabsContent>
</Tabs>
</main>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>新建助手知识库</DialogTitle>
<DialogDescription>
自建助手知识库用于承接从 WeKnora 普通知识库下载的 Wiki 向量包;导入时同一份数据也会沉淀到“知识梳理总库”。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">名称</label>
<Input
value={createDraft.name}
onChange={(event) => setCreateDraft((prev) => ({ ...prev, name: event.target.value }))}
placeholder="例如:政策资料库"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">描述</label>
<Textarea
value={createDraft.description}
onChange={(event) => setCreateDraft((prev) => ({ ...prev, description: event.target.value }))}
placeholder="可选,用于说明这个助手知识库的范围"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
取消
</Button>
<Button disabled={actions.createBase.isPending} onClick={() => void createBase()}>
{actions.createBase.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Plus className="mr-2 size-4" />}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+104 -35
View File
@@ -38,12 +38,16 @@ import { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import {
buildFullChatNavigationPath,
isChatsIframeWorkspacePath,
} from "@/core/page-layout/chats-iframe-route";
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import { isWujieEnv } from "@/core/embed/wujie";
import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { PanelLeftOpen } from "lucide-react";
import { MessageSquarePlus, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
@@ -63,6 +67,7 @@ import {
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -113,6 +118,7 @@ function clearBrowserContextParamsFromHash() {
export default function ChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const navigate = useNavigate();
const searchParams = useSearchParams();
const pathname = usePathname();
// `chatMode=full` is used by an embedded standard chat. It intentionally
@@ -136,6 +142,7 @@ export default function ChatPage() {
// per-thread header toggle so stale localStorage can't re-enable it.
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
@@ -144,18 +151,26 @@ export default function ChatPage() {
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedAssistantKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const streamContext = useMemo(
() => ({
...settings.context,
knowledge_rag_enabled: knowledgeRagEnabled,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
}),
[
settings.context,
knowledgeRagEnabled,
knowledgeSelectionReady,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
],
);
@@ -194,6 +209,7 @@ export default function ChatPage() {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
setKnowledgeSelectionLoading(false);
knowledgeSelectionThread.current = "__new__";
@@ -219,18 +235,29 @@ export default function ChatPage() {
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
const assistantRaw = metadata?.assistant_knowledge_base_ids;
const llmwikiIds = Array.isArray(raw)
? raw.map((item) => String(item).trim()).filter(Boolean)
: [];
const assistantIds = Array.isArray(assistantRaw)
? assistantRaw.map((item) => String(item).trim()).filter(Boolean)
: [];
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
setSelectedKnowledgeBaseIds(llmwikiIds);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
if (Array.isArray(assistantRaw)) {
setSelectedAssistantKnowledgeBaseIds(assistantIds);
} else {
setSelectedAssistantKnowledgeBaseIds([]);
}
setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw));
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
@@ -253,6 +280,7 @@ export default function ChatPage() {
if (!known.some((a) => a.id === selectedAgentId)) {
setSelectedAgentId(null);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
@@ -263,7 +291,10 @@ export default function ChatPage() {
threadId: isNewThread ? undefined : threadId,
context: streamContext,
threadMetadata: knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: undefined,
isMock,
onStart: (createdThreadId) => {
@@ -354,16 +385,11 @@ export default function ChatPage() {
researchDisplayThread,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const writingArtifactPath = useMemo(
() =>
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
[selectedArtifact, thread.values.artifacts],
);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
@@ -442,7 +468,10 @@ export default function ChatPage() {
metadata: {
agent_id: selectedAgentId,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
},
});
@@ -453,7 +482,10 @@ export default function ChatPage() {
threadId: created.thread_id,
text,
...(knowledgeSelectionReady
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
? {
knowledgeBaseIds: selectedKnowledgeBaseIds,
assistantKnowledgeBaseIds: selectedAssistantKnowledgeBaseIds,
}
: {}),
}),
);
@@ -515,6 +547,7 @@ export default function ChatPage() {
hideAdminPrompt,
preferencesLoading,
selectedAgentId,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
agentHandoffPending,
@@ -618,6 +651,7 @@ export default function ChatPage() {
const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId);
setSelectedKnowledgeBaseIds([]);
setSelectedAssistantKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
saveLastAgentId(agentId);
}, []);
@@ -675,6 +709,20 @@ export default function ChatPage() {
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
const compactActionsHidden = isHideChromeActive();
const headerNewChatPath = useMemo(
() =>
buildFullChatNavigationPath(
`${routeBase}/chats/new`,
searchParams.toString(),
),
[routeBase, searchParams],
);
const handleHeaderNewChat = useCallback(() => {
navigate(headerNewChatPath, {
replace: true,
state: { resetAt: Date.now() },
});
}, [headerNewChatPath, navigate]);
// 简洁模式新手引导:仅面向「无全局导航栏/侧边栏」的正常简洁布局。iframe 嵌入
// (embed=1)、去 chrome(hideChrome=1)、强制收起侧栏(无界部署)等环境同样没有
// 全局 chrome,但不该在外部系统里弹引导。
@@ -713,27 +761,47 @@ export default function ChatPage() {
)}
>
<div className="flex min-w-0 flex-1 items-center overflow-hidden text-sm font-medium">
<ThreadTitle threadId={threadId} thread={displayThread} />
</div>
<div className="flex shrink-0 items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
{compactChrome && !historySidebarOpen && !compactActionsHidden && (
{/* 左上角始终提供左栏切换;展开态显示“收起”,收起态显示“展开”。 */}
{compactChrome && !compactActionsHidden && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={toggleHistorySidebar}
title="打开历史记录"
aria-label="打开历史记录"
className="text-muted-foreground hover:text-foreground"
title={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
aria-label={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
className="mr-1 shrink-0 text-muted-foreground hover:text-foreground"
>
<PanelLeftOpen className="size-4" />
{historySidebarOpen ? (
<PanelLeftClose className="size-4" />
) : (
<PanelLeftOpen className="size-4" />
)}
</Button>
)}
<ThreadTitle
className="-translate-y-1"
threadId={threadId}
thread={displayThread}
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={displayThread.messages}
/>
{compactChrome && !compactActionsHidden && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleHeaderNewChat}
className="text-muted-foreground hover:text-foreground"
>
<MessageSquarePlus className="size-4" />
新建对话
</Button>
)}
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} />
<ArtifactTrigger />
@@ -801,11 +869,10 @@ export default function ChatPage() {
isLandingState &&
!compactLanding &&
"-translate-y-[calc(50vh-96px)]",
compactLanding
? "max-w-[calc(var(--container-width-xl)+150px)]"
: isLandingState
? "max-w-[calc(var(--container-width-md)+150px)]"
: "max-w-(--container-width-lg)",
// 新对话落地态在全量与简洁模式使用同一输入框宽度。
isLandingState
? "max-w-[calc(var(--container-width-md)+150px)]"
: "max-w-(--container-width-lg)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
@@ -873,7 +940,9 @@ export default function ChatPage() {
source={selectedAgentId ? "agent" : "all"}
agentId={selectedAgentId}
value={selectedKnowledgeBaseIds}
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
autoSelectAll={isLandingState && !!selectedAgentId}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
+447 -119
View File
@@ -3,16 +3,24 @@
import {
ChevronLeft,
ChevronRight,
Compass,
Clock,
DownloadIcon,
MessageSquare,
PenTool,
Pin,
PinOff,
Search,
Star,
Users2,
type LucideIcon,
X,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import {
@@ -42,9 +50,70 @@ import {
import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import { useResearchSessions } from "@/strategy-components/api/deep-research";
import { RoundtableHistoryTab } from "./chats-history/RoundtableHistoryTab";
import { WritingHistoryTab } from "./chats-history/WritingHistoryTab";
import { ResearchHistoryTab } from "./chats-history/ResearchHistoryTab";
const PAGE_SIZE = 20;
/**
* 「对话历史」页的四个历史 Tab:对话(通用问答/智能体会话)之外,把多智能体
* 会商、AI写作、深度研究的记录也收纳进来(简洁模式侧栏不再展示后两者的入口,
* 统一从这里查看)。
*/
type HistoryTab = "chats" | "roundtable" | "writing" | "research";
type ChatFilter = "all" | "favorites" | "pinned";
const HISTORY_TABS: {
id: HistoryTab;
label: string;
description: string;
icon: LucideIcon;
}[] = [
{
id: "chats",
label: "对话",
description: "普通问答与智能体会话",
icon: MessageSquare,
},
{
id: "roundtable",
label: "多智能体会商",
description: "多角色协作研讨",
icon: Users2,
},
{
id: "writing",
label: "AI写作",
description: "写作任务与草稿",
icon: PenTool,
},
{
id: "research",
label: "深度研究",
description: "研究任务与报告",
icon: Compass,
},
];
/** 非对话 Tab 的搜索占位文案(对话 Tab 沿用 i18n 的 t.chats.searchChats)。 */
const TAB_SEARCH_PLACEHOLDER: Record<Exclude<HistoryTab, "chats">, string> = {
roundtable: "搜索会商记录…",
writing: "搜索写作记录…",
research: "搜索研究记录…",
};
const TAB_TOTAL_UNIT: Record<HistoryTab, string> = {
chats: "条对话",
roundtable: "条会商记录",
writing: "篇写作",
research: "项研究",
};
export default function ChatsPage({
compactChrome = false,
}: {
@@ -54,16 +123,28 @@ export default function ChatsPage({
const { t } = useI18n();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [activeTab, setActiveTab] = useState<HistoryTab>("chats");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [onlyFavorites, setOnlyFavorites] = useState(false);
const [chatFilter, setChatFilter] = useState<ChatFilter>("all");
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
const [tabTotal, setTabTotal] = useState<number | null>(null);
// 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。
const { drafts: roundtableTabDrafts, refresh: refreshRoundtableTabDrafts } = useRoundtableDrafts();
const { sessions: writingTabSessions, refresh: refreshWritingTabSessions } = useAIWritingSessions();
const { data: researchTabData } = useResearchSessions(100);
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
useEffect(() => {
void refreshRoundtableTabDrafts();
void refreshWritingTabSessions();
}, [refreshRoundtableTabDrafts, refreshWritingTabSessions]);
// Debounce the search box so each keystroke doesn't fire a backend query.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
@@ -73,26 +154,41 @@ export default function ChatsPage({
// Reset to the first page whenever the filtered result set changes.
useEffect(() => {
setPage(1);
}, [debouncedSearch, onlyFavorites]);
}, [debouncedSearch, chatFilter]);
// 切换 Tab 时回到第一页并清空旧 Tab 上报的计数。
useEffect(() => {
setPage(1);
setTabTotal(null);
}, [activeTab]);
const handleTabTotalChange = useCallback((total: number) => {
setTabTotal(total);
}, []);
// Conversations are paginated on the backend: each page is one bounded
// query (title search + favorites filter included), so a long history is
// never fetched in one go. Pinned conversations come from their own small
// query and are surfaced in a dedicated section on the first page only,
// and dropped from the paginated rows so they don't repeat on every page.
const favoriteFilter = useMemo(
() => (onlyFavorites ? { is_favorite: true } : {}),
[onlyFavorites],
const threadFilter = useMemo(
() =>
chatFilter === "favorites"
? { is_favorite: true }
: chatFilter === "pinned"
? { is_pinned: true }
: {},
[chatFilter],
);
const pinnedFilter = useMemo(
() => ({ ...favoriteFilter, is_pinned: true }),
[favoriteFilter],
() => ({ ...threadFilter, is_pinned: true }),
[threadFilter],
);
const { data: pageData } = useThreadsPage({
page,
pageSize: PAGE_SIZE,
search: debouncedSearch,
metadata: favoriteFilter,
metadata: threadFilter,
});
const { data: pinnedData } = useThreadsPage({
page: 1,
@@ -102,13 +198,28 @@ export default function ChatsPage({
});
const { data: totalCount } = useThreadsCount({
search: debouncedSearch,
metadata: favoriteFilter,
metadata: threadFilter,
});
const { data: allThreadsCount } = useThreadsCount({
search: debouncedSearch,
metadata: {},
});
const { data: favoriteThreadsCount } = useThreadsCount({
search: debouncedSearch,
metadata: { is_favorite: true },
});
const { data: pinnedThreadsCount } = useThreadsCount({
search: debouncedSearch,
metadata: { is_pinned: true },
});
const pinnedThreads = pinnedData?.threads ?? [];
const pinnedThreads = chatFilter === "pinned" ? [] : (pinnedData?.threads ?? []);
const pagedThreads = useMemo(
() => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
[pageData?.threads],
() =>
(pageData?.threads ?? []).filter(
(thread) => chatFilter === "pinned" || !isThreadPinned(thread),
),
[chatFilter, pageData?.threads],
);
const currentPage = page;
const hasMore = pageData?.hasMore ?? false;
@@ -123,46 +234,67 @@ export default function ChatsPage({
return (
<div
key={thread.thread_id}
className="group/chat-row relative flex flex-col gap-2 border-b"
className={cn(
"group/chat-row relative flex min-w-0 items-start gap-3 rounded-[10px] border bg-card p-3 transition-[border-color,box-shadow,background-color] duration-150 sm:items-center sm:p-3.5",
pinned
? "border-red-200 bg-gradient-to-r from-red-50/50 via-white to-white shadow-xs hover:border-red-300 hover:shadow-sm"
: "border-neutral-200/80 bg-white hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs",
)}
>
<Link href={pathOfThread(thread)} className="block p-4 pr-24">
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon-sm"
className={cn(
"mt-0.5 shrink-0 rounded-lg sm:mt-0",
favorited
? "bg-amber-50 text-amber-500 hover:bg-amber-100 hover:text-amber-600"
: "text-muted-foreground/45 hover:bg-muted hover:text-muted-foreground",
)}
title={favorited ? t.common.unfavorite : t.common.favorite}
aria-label={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star className={cn("size-4", favorited && "fill-current")} />
</Button>
<Link
href={pathOfThread(thread)}
className="min-w-0 flex-1 rounded-lg p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex min-w-0 items-center gap-2">
{pinned && (
<Pin className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="inline-flex items-center gap-1 rounded border !border-red-200 bg-red-100/80 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
<Pin className="size-3 rotate-45 text-red-600" />
置顶
</span>
)}
<span className="truncate">
<span className="inline-flex items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<MessageSquare className="size-3 text-red-600" />
对话
</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-neutral-900 transition-colors group-hover/chat-row:text-red-700 sm:text-base">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</div>
{thread.updated_at && (
<div className="text-muted-foreground mt-2 text-sm">
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground sm:hidden">
<Clock className="size-3" />
{formatTimeAgo(thread.updated_at)}
</div>
)}
</Link>
{/* Row actions — kept out of the Link so clicks don't navigate. */}
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover/chat-row:opacity-100 focus-within:opacity-100">
{thread.updated_at && (
<div className="hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
<Clock className="size-3" />
<span>{formatTimeAgo(thread.updated_at)}</span>
</div>
)}
<div className="flex shrink-0 items-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/chat-row:opacity-100 sm:focus-within:opacity-100">
<Button
variant="ghost"
size="icon"
className="size-8"
title={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star
className={cn(
"size-4",
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
size="icon-sm"
className="size-8"
title={pinned ? t.common.unpin : t.common.pin}
aria-label={pinned ? t.common.unpin : t.common.pin}
onClick={() => togglePin(thread.thread_id, !pinned)}
>
{pinned ? (
@@ -177,6 +309,15 @@ export default function ChatsPage({
};
const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0;
// 头部计数:对话 Tab 用后端总数,其余 Tab 用组件回传的过滤后条数。
const displayTotal = activeTab === "chats" ? totalCount : tabTotal;
const activeTabConfig = HISTORY_TABS.find((tab) => tab.id === activeTab)!;
const historyTabCounts: Record<HistoryTab, number> = {
chats: allThreadsCount ?? 0,
roundtable: roundtableTabDrafts.length,
writing: writingTabSessions.length,
research: researchTabData?.total ?? 0,
};
return (
<WorkspaceContainer>
@@ -192,93 +333,280 @@ export default function ChatsPage({
}
/>
<WorkspaceBody>
<div className="flex size-full flex-col">
<div className="flex size-full min-w-0 flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="flex w-full max-w-(--container-width-md) items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
variant={onlyFavorites ? "outline" : "secondary"}
size="sm"
onClick={() => setOnlyFavorites(false)}
<div className="relative w-full max-w-5xl px-4 sm:px-6">
<div className="flex w-full flex-col items-center">
<div
id="primary-tabs-nav"
className="inline-flex rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
role="tablist"
aria-label="历史记录类型"
>
{t.chats.allChats}
</Button>
<Button
variant={onlyFavorites ? "secondary" : "outline"}
size="sm"
onClick={() => setOnlyFavorites(true)}
>
<Star className="size-4" />
{t.chats.favorites}
</Button>
{HISTORY_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
const count = historyTabCounts[tab.id];
return (
<button
key={tab.id}
id={`history-tab-${tab.id}`}
type="button"
className={cn(
"relative flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
isActive
? "font-semibold text-red-700 shadow-xs"
: "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
)}
role="tab"
aria-selected={isActive}
aria-controls={`history-panel-${tab.id}`}
title={tab.description}
onClick={() => setActiveTab(tab.id)}
>
{isActive && (
<motion.div
layoutId="activeTabBadge"
className="absolute inset-0 rounded-xl border !border-red-200 bg-red-50/90 shadow-xs"
transition={{ type: "spring", stiffness: 450, damping: 35 }}
aria-hidden="true"
/>
)}
<span
className={cn(
"relative z-10 flex items-center gap-2",
isActive
? "font-semibold text-red-700"
: "text-neutral-600",
)}
>
<Icon className="size-4" aria-hidden="true" />
<span>{tab.label}</span>
</span>
{count > 0 && (
<span
className={cn(
"relative z-10 rounded-full px-1.5 py-[0.05rem] font-mono text-[11px] transition-colors",
isActive
? "bg-red-600 font-bold text-white"
: "bg-neutral-100 text-neutral-500",
)}
>
{count}
</span>
)}
</button>
);
})}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
{activeTab === "chats" && (
<Button
variant="outline"
size="sm"
className="absolute right-4 top-1/2 -translate-y-1/2 sm:right-6"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
)}
</div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{totalCount != null && (
<div className="text-muted-foreground w-full max-w-(--container-width-md) text-right text-xs tabular-nums">
共 {totalCount} 条对话
</div>
)}
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{currentPage === 1 && pinnedThreads.length > 0 && (
<>
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
{t.chats.pinnedSection}
<ScrollArea className="size-full pb-8 pt-5 sm:pt-7">
<div className="mx-auto flex w-full max-w-5xl flex-col px-4 sm:px-6">
<section
id={`history-panel-${activeTab}`}
role="tabpanel"
aria-labelledby={`history-tab-${activeTab}`}
className="space-y-4 rounded-2xl border border-red-200 bg-white p-4 shadow-xs sm:p-5"
>
{activeTab !== "roundtable" && <div className="w-full space-y-3.5">
<div className="flex items-center justify-between gap-3">
{activeTab === "chats" ? (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0" role="group" aria-label="对话筛选">
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
chatFilter === "all"
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700",
)}
aria-pressed={chatFilter === "all"}
onClick={() => setChatFilter("all")}
>
<span>全部</span>
{allThreadsCount != null && (
<span
className={cn(
"rounded-full px-1.5 py-px font-mono text-[11px]",
chatFilter === "all"
? "bg-red-700 text-white"
: "bg-neutral-100 text-neutral-500",
)}
>
{allThreadsCount}
</span>
)}
</button>
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30",
chatFilter === "favorites"
? "border-amber-300 bg-amber-50 font-semibold text-amber-700 shadow-xs"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900",
)}
aria-pressed={chatFilter === "favorites"}
onClick={() => setChatFilter("favorites")}
>
<Star className={cn("size-3.5", chatFilter === "favorites" ? "fill-amber-500 text-amber-500" : "text-neutral-400")} />
<span>收藏</span>
{favoriteThreadsCount != null && (
<span
className={cn(
"rounded-full px-1.5 py-px font-mono text-[11px]",
chatFilter === "favorites"
? "bg-amber-200 text-amber-900"
: "bg-neutral-100 text-neutral-500",
)}
>
{favoriteThreadsCount}
</span>
)}
</button>
{pinnedThreadsCount != null && pinnedThreadsCount > 0 && (
<button
type="button"
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
chatFilter === "pinned"
? "border-red-200 bg-red-50 font-semibold text-red-700 shadow-xs"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-red-50/50 hover:text-red-700",
)}
aria-pressed={chatFilter === "pinned"}
onClick={() => setChatFilter("pinned")}
>
<Pin className="size-3.5 rotate-45 text-red-600" />
<span>置顶</span>
<span className="rounded-full bg-red-100 px-1.5 py-px font-mono text-[11px] font-medium text-red-800">
{pinnedThreadsCount}
</span>
</button>
)}
</div>
) : (
<div className="min-w-0 truncate text-sm font-medium text-neutral-500">
{activeTabConfig.description}
</div>
)}
<label className="relative min-w-0 flex-1 md:max-w-md">
<Search
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400"
aria-hidden="true"
/>
<span className="sr-only">
{activeTab === "chats"
? t.chats.searchChats
: TAB_SEARCH_PLACEHOLDER[activeTab]}
</span>
<input
type="search"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50/80 py-2 pl-9 pr-16 text-sm text-neutral-900 transition-all placeholder:text-neutral-400 hover:bg-white focus:border-red-500 focus:bg-white focus:outline-hidden focus:ring-2 focus:ring-red-500/20"
placeholder={
activeTab === "chats"
? "搜索对话标题、关键词或标签…"
: TAB_SEARCH_PLACEHOLDER[activeTab]
}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30"
aria-label="清空搜索"
onClick={() => setSearch("")}
>
<X className="size-3.5" />
</button>
)}
</label>
</div>
{pinnedThreads.map(renderThread)}
</>
)}
{pagedThreads.map(renderThread)}
{isEmpty && (
<div className="text-muted-foreground py-16 text-center text-sm">
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
</div>
)}
{(currentPage > 1 || hasMore) && (
<div className="flex items-center justify-center gap-3 py-6">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
{t.chats.prevPage}
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
{totalPages != null
? `${currentPage} / ${totalPages}`
: currentPage}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
{t.chats.nextPage}
<ChevronRight className="size-4" />
</Button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
{displayTotal != null && (
<span className="font-medium">
共 <strong className="font-bold text-red-700">{displayTotal}</strong>{" "}
{TAB_TOTAL_UNIT[activeTab]}
</span>
)}
</div>
</div>}
{activeTab === "chats" && (
<div className="space-y-3 pt-5">
{currentPage === 1 && pinnedThreads.map(renderThread)}
{pagedThreads.map(renderThread)}
{isEmpty && (
<div className="text-muted-foreground py-16 text-center text-sm">
{chatFilter === "favorites"
? t.chats.noFavorites
: chatFilter === "pinned"
? "暂无置顶对话"
: t.chats.noChats}
</div>
)}
{(currentPage > 1 || hasMore) && (
<div className="flex items-center justify-center gap-3 border-t border-primary/10 pt-5">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
{t.chats.prevPage}
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
{totalPages != null
? `${currentPage} / ${totalPages}`
: currentPage}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
{t.chats.nextPage}
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
)}
{activeTab === "roundtable" && (
<RoundtableHistoryTab onTotalChange={handleTabTotalChange} />
)}
{activeTab === "writing" && (
<div className="pt-5">
<WritingHistoryTab
query={search}
onTotalChange={handleTabTotalChange}
/>
</div>
)}
{activeTab === "research" && (
<div className="pt-5">
<ResearchHistoryTab
query={search}
onTotalChange={handleTabTotalChange}
/>
</div>
)}
</section>
</div>
</ScrollArea>
</main>
+6 -10
View File
@@ -48,6 +48,7 @@ import {
useThreadStream,
} from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
@@ -291,16 +292,11 @@ export default function IframeChatPage() {
threadMetadataAgentId,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const writingArtifactPath = useMemo(
() =>
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
[selectedArtifact, thread.values.artifacts],
);
const handleSubmit = useCallback(
(
@@ -3,6 +3,11 @@ import { useState } from "react";
import { Navigate, useParams, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
KNOWLEDGE_NORMAL_VIEW_VALUE,
KnowledgeProductNav,
useKnowledgeBaseProductVersion,
} from "@/components/workspace/knowledge-product-nav";
import {
Dialog,
DialogContent,
@@ -70,7 +75,9 @@ export default function LlmWikiKnowledgePage({
const { noteId = "" } = useParams();
const [searchParams] = useSearchParams();
const runtime = useLlmWikiRuntime();
const productVersion = useKnowledgeBaseProductVersion();
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
const explicitNormalView = searchParams.get("view") === KNOWLEDGE_NORMAL_VIEW_VALUE;
if (mode === "detail" && noteId && wikiSlug) {
return <Navigate to={buildWeKnoraWikiPagePath(noteId, wikiSlug)} replace />;
@@ -80,7 +87,14 @@ export default function LlmWikiKnowledgePage({
return <RuntimeLoading />;
}
if (mode === "list" && productVersion.data === "v2" && !explicitNormalView) {
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}
if (runtime.isError || runtime.data?.provider !== "weknora") {
if (mode === "list" || mode === "detail") {
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
}
if (mode === "graph") {
return <KnowledgeGraphPage />;
}
@@ -95,5 +109,5 @@ export default function LlmWikiKnowledgePage({
return <WeKnoraKnowledgeDetailPage />;
}
return <WeKnoraKnowledgeWorkspace />;
return <div className="flex size-full min-h-0 flex-col"><KnowledgeProductNav /><div className="min-h-0 flex-1"><WeKnoraKnowledgeWorkspace /></div></div>;
}
+8 -10
View File
@@ -16,6 +16,7 @@ import {
readHideHeaderParam,
readHideChromeParam,
} from "@/core/embed/embed-session";
import { isHostNestedSurfacePath } from "@/core/page-layout/host-nested-surface";
import { useTheme } from "@/contexts/ThemeContext";
import { useLocation } from "react-router-dom";
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
@@ -91,23 +92,20 @@ function PageLayout() {
location.pathname.startsWith("/page/workspace/chats/")
);
}, [location.pathname, location.search]);
// 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页,
// 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
const forceChromeForDashboardAgent =
!forceCollapsedSidebar && location.pathname.includes("/strategy/dashboard-agent");
// 深链嵌套页(单智能体问答 / 多智能体会商 / 大屏绘制)只给宿主 iframe 套用,
// 路径上就不带全局 Header / PageSidebar,不靠改写共用 embed.session。
const hostNestedSurface = isHostNestedSurfacePath(location.pathname);
const pageSidebarAllowed =
(forceChromeForDashboardAgent || chromeByPreference) &&
chromeByPreference &&
!isChatsIframeWorkspacePath(location.pathname) &&
!embedActive &&
// `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。
!chromeHidden;
!chromeHidden &&
!hostNestedSurface;
const compactToolboxAllowed =
!chromeByPreference &&
!forceChromeForDashboardAgent &&
!embedActive &&
!chromeHidden &&
!hostNestedSurface &&
!isChatsIframeWorkspacePath(location.pathname);
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
+21 -6
View File
@@ -1,7 +1,8 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { RouteBaseProvider } from "@/core/route-base";
@@ -79,6 +80,24 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes";
/**
* Strategy's legacy `/chats/:thread_id` route shares ChatPage's header.
* Unlike WorkspaceRoutes it did not render a WorkspaceSidebar, leaving the
* header's expand/collapse control with no visible panel to operate.
*/
function StrategyChatThreadLayout() {
return (
<div className="flex size-full min-h-0 overflow-hidden">
<WorkspaceSidebar compactChrome />
<SidebarInset className="min-w-0">
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</SidebarInset>
</div>
);
}
export default function StrategyRoutes() {
const storedAuth = getStoredAuth();
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
@@ -342,11 +361,7 @@ export default function StrategyRoutes() {
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
element={<StrategyChatThreadLayout />}
/>
<Route path="agents" element={<AgentsPage />} />
+102 -3
View File
@@ -1,7 +1,8 @@
import type { CSSProperties, ReactNode } from 'react';
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { getAPIClient } from '@/core/api';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import {
resolveChatsIframeEntryRedirect,
@@ -25,9 +26,11 @@ import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
import { RouteBaseProvider } from '@/core/route-base';
import { agentRefOfThread } from '@/core/threads/utils';
import ChatsPage from './ChatsPage';
import ChatPage from './ChatPage';
import ArtifactLibraryPage from './ArtifactLibraryPage';
import IframeChatPage from './IframeChatPage';
import AgentsPage from './AgentsPage';
import AgentChatPage from './AgentChatPage';
@@ -39,6 +42,7 @@ import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import AssistantKnowledgePage from './AssistantKnowledgePage';
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
@@ -93,6 +97,7 @@ function shouldShowCompactTopActions(pathname: string): boolean {
pathname === '/page/workspace/agents' ||
pathname === '/page/workspace/skills' ||
pathname === '/page/workspace/memory' ||
pathname === '/page/workspace/library' ||
pathname === '/page/workspace/knowledge' ||
pathname.startsWith('/page/workspace/knowledge/')
);
@@ -180,7 +185,11 @@ export function WorkspaceLayout({
'--header-height': headerHidden ? '0rem' : '4rem',
} as CSSProperties
}
key={String(compactWorkspaceSidebar)}
// Agent and general-chat routes share this layout component. Keep their
// sidebar state separate: otherwise React preserves the collapsed agent
// state when a history item switches to `/chats/:thread_id`, while the
// new page's header button is meant to control GeneralQaSidebar.
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? 'general' : 'workspace'}`}
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={
forceWorkspaceSidebarCollapsed
@@ -341,12 +350,80 @@ function ChatThreadEntryRoute({
return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
<AgentAwareChatThreadContent />
</ChatRuntime>
</WorkspaceLayout>
);
}
/**
* Older bookmarks and a few historic list entries use the generic
* ``/chats/:thread_id`` path even for an agent-owned conversation. Recover the
* owning agent from the persisted thread record before rendering so those
* links keep the agent workspace (agent list and detail panel) instead of
* falling back to the general Q&A page. Task workspace threads intentionally
* remain on their explicit task routes because their surrounding layout is
* part of the task context.
*/
function AgentAwareChatThreadContent() {
const { thread_id: threadId } = useParams<{ thread_id: string }>();
const location = useLocation();
const [agentId, setAgentId] = useState<string | null>(null);
const [resolved, setResolved] = useState(false);
useEffect(() => {
let cancelled = false;
setAgentId(null);
setResolved(false);
if (!threadId) {
setResolved(true);
return () => {
cancelled = true;
};
}
void getAPIClient()
.threads.get(threadId)
.then((thread) => {
if (cancelled) return;
const record = thread as {
context?: { agent_id?: string; agent_name?: string } | null;
metadata?: Record<string, unknown> | null;
};
// Task-bound threads need their task host to preserve the purpose gate,
// action partitioning, and other task-specific controls.
const isTaskThread = typeof record.metadata?.taskId === 'string';
setAgentId(isTaskThread ? null : agentRefOfThread(record) ?? null);
})
.catch(() => {
if (!cancelled) setAgentId(null);
})
.finally(() => {
if (!cancelled) setResolved(true);
});
return () => {
cancelled = true;
};
}, [threadId]);
if (!resolved) {
return <div className="size-full bg-background" aria-busy="true" />;
}
if (threadId && agentId) {
return (
<Navigate
replace
to={`/page/workspace/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
/>
);
}
return <ChatPage />;
}
/**
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
@@ -444,6 +521,20 @@ export default function WorkspaceRoutes({
path="chats/:thread_id"
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
/>
<Route
path="library"
element={
<WorkspaceLayout
{...layoutProps}
workspaceSidebarHidden={
layoutProps.workspaceSidebarHidden ||
!layoutProps.pageSidebarHidden
}
>
<ArtifactLibraryPage />
</WorkspaceLayout>
}
/>
<Route
path="chats_iframe/new"
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
@@ -679,6 +770,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge/assistant"
element={
<WorkspaceLayout {...layoutProps}>
<AssistantKnowledgePage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId/wiki/view"
element={
@@ -0,0 +1,209 @@
"use client";
/**
* 「对话历史」页 —— 深度研究 Tab。
*
* 数据来自 deep-research 会话列表(useResearchSessions)。点击跳转深度研究页并
* 带 ``?session=<id>``,由 DeepResearchWorkbench 挂载时读取该参数直接选中并
* 打开这条研究记录。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Search } from "lucide-react";
import { toast } from "sonner";
import {
useResearchActions,
useResearchSessions,
type DeepResearchSession,
} from "@/strategy-components/api/deep-research";
import { useReportStructures } from "@/strategy-components/api/report-structures";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { desensitize } from "@/lib/desensitize";
import {
DeleteIcon,
HistoryRenameDialog,
HistoryRow,
HistoryRowIconButton,
HistoryStatusPill,
HistoryTypeBadge,
RenameIcon,
} from "./history-tab-shared";
const DEEP_RESEARCH_PATH = "/page/workspace/deep-research";
/** 状态文案与配色对齐 DeepResearchWorkbench 的研究记录侧栏。 */
const RESEARCH_STATUS: Record<
DeepResearchSession["status"],
{ label: string; className: string }
> = {
draft: { label: "待开始", className: "text-muted-foreground" },
collecting: { label: "收集中", className: "text-primary" },
awaiting_report: { label: "待撰写", className: "text-amber-500" },
running: { label: "进行中", className: "text-primary" },
awaiting_input: { label: "等待确认", className: "text-amber-500" },
completed: { label: "已完成", className: "text-emerald-600" },
failed: { label: "失败", className: "text-rose-500" },
cancelled: { label: "已取消", className: "text-muted-foreground" },
};
/**
* 深度研究会话把选中的报告模板快照为 custom_outline;用结构库里的内容反查标题。
* 内容不再存在或用户输入了自定义大纲时,明确展示为「自定义报告结构」,避免冒充模板名。
*/
function reportTemplateLabel(
session: DeepResearchSession,
structures: { title: string; content: string }[],
): string {
const config = session.config ?? {};
const outline = typeof config.custom_outline === "string"
? config.custom_outline.trim()
: typeof config.customOutline === "string"
? config.customOutline.trim()
: "";
if (!outline) return "默认报告结构";
return structures.find((structure) => structure.content.trim() === outline)?.title ?? "自定义报告结构";
}
export function ResearchHistoryTab({
query,
onTotalChange,
}: {
query: string;
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { data, isLoading } = useResearchSessions(100);
const structuresQuery = useReportStructures();
const actions = useResearchActions();
const [renameId, setRenameId] = useState<string | null>(null);
const [renameTitle, setRenameTitle] = useState("");
const sessions = data?.items ?? [];
const keyword = query.trim().toLowerCase();
const filtered = keyword
? sessions.filter((session) =>
(session.title || session.query).toLowerCase().includes(keyword),
)
: sessions;
useEffect(() => {
onTotalChange?.(filtered.length);
}, [filtered.length, onTotalChange]);
const openSession = (session: DeepResearchSession) => {
navigate(`${DEEP_RESEARCH_PATH}?session=${encodeURIComponent(session.id)}`);
};
const handleRename = async (id: string, title: string) => {
try {
await actions.updateTitle.mutateAsync({ id, title });
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
}
};
const handleDelete = async (session: DeepResearchSession) => {
if (!window.confirm("确定删除这条研究记录吗?此操作不可恢复。")) return;
try {
await actions.delete.mutateAsync(session.id);
toast.success("已删除研究记录");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败,请重试");
}
};
if (isLoading && sessions.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的研究记录" : "暂无研究记录"}
</div>
);
}
return (
<>
<div>
{filtered.map((session) => {
const status = RESEARCH_STATUS[session.status] ?? {
label: session.status,
className: "text-muted-foreground",
};
const timestamp = session.updatedAt ?? session.createdAt ?? null;
const template = reportTemplateLabel(session, structuresQuery.data ?? []);
return (
<HistoryRow
key={session.id}
badge={<HistoryTypeBadge icon={Search} label="深度研究" />}
title={desensitize(session.title || session.query || "未命名研究")}
meta={
<>
<HistoryStatusPill
label={status.label}
className={status.className}
/>
{session.sourceCount > 0 && (
<>
<span className="text-muted-foreground/60">·</span>
<span>{session.sourceCount} 条来源</span>
</>
)}
<span className="text-muted-foreground/60">·</span>
<span className="max-w-56 truncate" title={`模板:${template}`}>
模板:{template}
</span>
{timestamp && (
<>
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(timestamp)}</span>
</>
)}
</>
}
onOpen={() => openSession(session)}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(session.id);
setRenameTitle(session.title || session.query || "");
}}
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名研究"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
</>
);
}
@@ -0,0 +1,466 @@
"use client";
/**
* 「对话历史」页 —— 多智能体会商 Tab。
*
* 卡片与筛选区按最新「智能工作区管理 (1)」的 ConversationRow /
* FilterAndSearchBar 适配。会商步骤、状态、模式、互动数和模型都来自实际草稿快照。
*/
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ArrowUpDown,
Clock,
Cpu,
ExternalLink,
Search,
Tag,
Trash2,
Users2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import {
getDraft,
type DraftMeta,
type DraftRecord,
} from "@/roundtable-planning/api/drafts";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import { desensitize } from "@/lib/desensitize";
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
type SortType = "updated_desc" | "updated_asc" | "messages_desc";
interface RoundtableItem {
draft: DraftMeta;
record?: DraftRecord;
status: ReturnType<typeof progressOf>;
businessChainName: string;
messageCount: number;
model?: string;
tags: string[];
}
function progressOf(draft: DraftMeta): {
label: string;
stepLabel: string;
tone: "completed" | "active" | "waiting" | "failed" | "cancelled";
} {
const stepLabel = `第 ${draft.furthestStep} 步`;
if (draft.status === "done" || (!draft.status && draft.furthestStep === 3)) {
return { label: "已完成", tone: "completed", stepLabel };
}
if (draft.status === "error") {
return { label: "执行失败", tone: "failed", stepLabel: `停在${stepLabel}` };
}
if (draft.status === "cancelled") {
return { label: "已取消", tone: "cancelled", stepLabel: `停在${stepLabel}` };
}
if (draft.status === "awaiting_input") {
return { label: "等待确认", tone: "waiting", stepLabel: `当前${stepLabel}` };
}
return { label: "进行中", tone: "active", stepLabel: `当前${stepLabel}` };
}
function modeOf(record?: DraftRecord): string {
switch (record?.step2?.orchestrationMode) {
case "chain":
return "业务链会商";
case "dag":
return "DAG 编排";
default:
return "自由模式";
}
}
function businessChainNameOf(draft: DraftMeta, record?: DraftRecord): string {
return record?.step2?.chain?.title ?? draft.chainTitle ?? modeOf(record);
}
function interactionCount(record?: DraftRecord): number {
if (!record) return 0;
return (
(record.step1?.interactiveMessages?.length ?? 0) +
(record.step2?.step2RoundtableDialogues?.length ?? 0) +
(record.step3?.summaryReport?.dialogues?.length ?? 0) +
(record.step3?.reportDialogues?.length ?? 0) +
(record.step3?.dashboardDialogues?.length ?? 0)
);
}
function modelOf(record?: DraftRecord): string | undefined {
return record?.step3?.summaryReport?.model ?? record?.step3?.model;
}
function tagsOf(draft: DraftMeta, record?: DraftRecord): string[] {
const chain = record?.step2?.chain?.title ?? draft.chainTitle;
return chain ? [chain] : [];
}
function timestamp(value: string): number {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function renderHighlightedTitle(text: string, query: string) {
if (!query.trim()) return text;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const parts = text.split(new RegExp(`(${escaped})`, "gi"));
return parts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={index} className="rounded-xs bg-red-100 px-0.5 font-semibold text-red-900">
{part}
</mark>
) : (
part
),
);
}
function RoundtableHistoryRow({
item,
searchQuery,
onDelete,
onOpen,
}: {
item: RoundtableItem;
searchQuery: string;
onDelete: () => void;
onOpen: () => void;
}) {
const { draft, status } = item;
const statusClass = {
completed: "border-emerald-200/80 bg-emerald-50 text-emerald-700",
active: "border-red-200/80 bg-red-50 text-red-600",
waiting: "border-amber-200/80 bg-amber-50 text-amber-700",
failed: "border-rose-200/80 bg-rose-50 text-rose-600",
cancelled: "border-neutral-200/80 bg-neutral-50 text-neutral-500",
}[status.tone];
const statusDotClass = {
completed: "bg-emerald-500",
active: "bg-red-500",
waiting: "bg-amber-500",
failed: "bg-rose-500",
cancelled: "bg-neutral-400",
}[status.tone];
return (
<div
id={`conversation-item-${draft.id}`}
onClick={(event) => {
if ((event.target as HTMLElement).closest("button, input")) return;
onOpen();
}}
className="group relative flex cursor-pointer items-start justify-between rounded-xl border border-neutral-200/80 bg-white p-4 transition-all duration-150 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs sm:items-center"
>
<div className="flex min-w-0 flex-1 items-start gap-3.5 pr-4 sm:items-center">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border !border-red-200 bg-red-50/90 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<span className="relative flex size-1.5">
{status.tone === "active" && (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
)}
<span className="relative inline-flex size-1.5 rounded-full bg-red-600" />
</span>
<Users2 className="size-3 text-red-600" />
会商
</span>
<h3 className="truncate text-sm font-semibold text-neutral-900 transition-colors group-hover:text-red-700">
{renderHighlightedTitle(desensitize(draft.title || "未命名会商"), searchQuery)}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2 pt-0.5 text-[11px] text-neutral-400">
<span className={`inline-flex items-center gap-1 rounded border !border-red-200 px-1.5 py-0.5 text-[10px] font-medium ${statusClass}`}>
<span className={`size-1.5 rounded-full ${statusDotClass}`} />
{status.label}
</span>
<span className="inline-flex items-center rounded border border-neutral-200/60 bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-600">
{status.stepLabel}
</span>
<span className="inline-flex items-center gap-1 rounded border border-neutral-200/40 bg-neutral-50 px-1.5 py-0.5 text-[10px] text-neutral-500">
<Cpu className="size-2.5 text-neutral-400" />
{item.businessChainName}
</span>
<span className="inline-flex items-center gap-1 text-neutral-400">
<Clock className="size-3 text-neutral-400" />
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="hidden items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 sm:flex">
<button
id={`delete-btn-${draft.id}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
className="rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
title="删除会商"
aria-label="删除会商"
>
<Trash2 className="size-3.5" />
</button>
<button
id={`open-preview-btn-${draft.id}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-700"
title="打开会商详情"
aria-label="打开会商详情"
>
<ExternalLink className="size-3.5" />
</button>
</div>
</div>
</div>
);
}
export function RoundtableHistoryTab({
onTotalChange,
}: {
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { drafts, loading, refresh, remove } = useRoundtableDrafts();
const [records, setRecords] = useState<Record<string, DraftRecord>>({});
const [searchQuery, setSearchQuery] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [sortType, setSortType] = useState<SortType>("updated_desc");
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
let cancelled = false;
if (drafts.length === 0) {
setRecords({});
return () => {
cancelled = true;
};
}
void Promise.all(
drafts.map(async (draft) => {
try {
return await getDraft(draft.id);
} catch {
return null;
}
}),
).then((fullDrafts) => {
if (cancelled) return;
setRecords(
Object.fromEntries(
fullDrafts
.filter((record): record is DraftRecord => record !== null)
.map((record) => [record.id, record]),
),
);
});
return () => {
cancelled = true;
};
}, [drafts]);
const allItems = useMemo<RoundtableItem[]>(
() =>
drafts.map((draft) => {
const record = records[draft.id];
return {
draft,
record,
status: progressOf(draft),
businessChainName: businessChainNameOf(draft, record),
messageCount: interactionCount(record),
model: modelOf(record),
tags: tagsOf(draft, record),
};
}),
[drafts, records],
);
const availableTags = useMemo(
() => [...new Set(allItems.flatMap((item) => item.tags))],
[allItems],
);
const filteredItems = useMemo(() => {
const keyword = searchQuery.trim().toLowerCase();
return allItems
.filter((item) => {
if (selectedTag && !item.tags.includes(selectedTag)) return false;
return !keyword || (item.draft.title || "未命名会商").toLowerCase().includes(keyword);
})
.sort((left, right) => {
if (sortType === "messages_desc") return right.messageCount - left.messageCount;
const delta = timestamp(right.draft.updatedAt) - timestamp(left.draft.updatedAt);
return sortType === "updated_desc" ? delta : -delta;
});
}, [allItems, searchQuery, selectedTag, sortType]);
useEffect(() => {
onTotalChange?.(filteredItems.length);
}, [filteredItems.length, onTotalChange]);
const openDraft = (draft: DraftMeta) => {
sessionStorage.setItem("roundtable:pending-draft", draft.id);
navigate(ROUNDTABLE_PATH);
};
const deleteDraft = async (draft: DraftMeta) => {
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
try {
await remove(draft.id);
toast.success("已删除会商记录");
} catch {
toast.error("删除失败,请重试");
}
};
return (
<div className="w-full space-y-4">
<div className="w-full space-y-3.5">
<div className="flex flex-col items-stretch justify-between gap-3 md:flex-row md:items-center">
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0">
<button
id="filter-all-btn"
type="button"
onClick={() => setSelectedTag(null)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all ${
!selectedTag
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700"
}`}
>
<span>全部</span>
<span className={`rounded-full px-1.5 py-0.2 font-mono text-[11px] ${!selectedTag ? "bg-red-700 font-semibold text-white" : "bg-neutral-100 text-neutral-500"}`}>
{allItems.length}
</span>
</button>
{selectedTag && (
<div className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs">
<Tag className="size-3 text-red-100" />
<span>{selectedTag}</span>
<button
type="button"
onClick={() => setSelectedTag(null)}
className="ml-1 hover:text-red-200"
title="清除标签筛选"
aria-label="清除标签筛选"
>
<X className="size-3" />
</button>
</div>
)}
</div>
<div className="relative flex-1 md:max-w-md">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<input
id="conversation-search-input"
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索会商记录…"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50/80 py-2 pl-9 pr-16 text-sm text-neutral-900 transition-all placeholder:text-neutral-400 hover:bg-white focus:border-red-500 focus:bg-white focus:outline-hidden focus:ring-2 focus:ring-red-500/20"
/>
{searchQuery ? (
<button
id="clear-search-query-btn"
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600"
aria-label="清空搜索"
>
<X className="size-3.5" />
</button>
) : (
<div className="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 rounded bg-neutral-200/60 px-1.5 py-0.5 font-mono text-[10px] text-neutral-400 sm:flex">
⌘K
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
<div className="flex items-center gap-3 overflow-x-auto">
<span className="whitespace-nowrap font-medium text-neutral-500">
共 <strong className="font-bold text-red-600">{filteredItems.length}</strong> 条会商记录
{searchQuery && <span className="ml-1 text-neutral-400">(匹配 “{searchQuery}”)</span>}
</span>
{availableTags.length > 0 && (
<div className="hidden items-center gap-1.5 text-xs lg:flex">
<span className="text-neutral-400">快捷标签:</span>
{availableTags.slice(0, 4).map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`rounded-md px-2 py-0.5 transition-colors ${
selectedTag === tag
? "bg-red-600 font-medium text-white shadow-2xs"
: "border !border-red-200 bg-red-50 text-red-700 hover:bg-red-100 hover:text-red-800"
}`}
>
#{tag}
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2 py-1 shadow-2xs">
<ArrowUpDown className="size-3 text-neutral-400" />
<select
id="sort-select-dropdown"
value={sortType}
onChange={(event) => setSortType(event.target.value as SortType)}
className="cursor-pointer bg-transparent text-xs text-neutral-700 focus:outline-hidden"
>
<option value="updated_desc">最近活跃优先</option>
<option value="updated_asc">最早创建优先</option>
<option value="messages_desc">消息数量最多</option>
</select>
</div>
</div>
</div>
</div>
{loading && allItems.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">加载中…</div>
) : filteredItems.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{searchQuery || selectedTag ? "没有匹配的会商记录" : "暂无会商记录"}
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<RoundtableHistoryRow
key={item.draft.id}
item={item}
searchQuery={searchQuery}
onDelete={() => void deleteDraft(item.draft)}
onOpen={() => openDraft(item.draft)}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,154 @@
"use client";
/**
* 「对话历史」页 —— AI写作 Tab。
*
* 数据与原侧栏「最近写作」列表同源(useAIWritingSessions);点击行为一致:
* 暂存 sessionStorage「ai-writing:pending-session」后跳转 AI 写作页,由
* AIWritingPanel 挂载时消费并打开该会话。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { FilePenLine } from "lucide-react";
import { toast } from "sonner";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
import { desensitize } from "@/lib/desensitize";
import {
DeleteIcon,
HistoryRenameDialog,
HistoryRow,
HistoryRowIconButton,
HistoryStatusPill,
HistoryTypeBadge,
RenameIcon,
} from "./history-tab-shared";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
export function WritingHistoryTab({
query,
onTotalChange,
}: {
query: string;
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { sessions, loading, refresh, rename, remove } = useAIWritingSessions();
const [renameId, setRenameId] = useState<string | null>(null);
const [renameTitle, setRenameTitle] = useState("");
useEffect(() => {
void refresh();
}, [refresh]);
const keyword = query.trim().toLowerCase();
const filtered = keyword
? sessions.filter((session) => session.title.toLowerCase().includes(keyword))
: sessions;
useEffect(() => {
onTotalChange?.(filtered.length);
}, [filtered.length, onTotalChange]);
const openSession = (session: AIWritingSessionSummary) => {
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
navigate(AI_WRITING_PATH);
};
const handleRename = async (id: string, title: string) => {
try {
await rename(id, title);
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
}
};
const handleDelete = async (session: AIWritingSessionSummary) => {
if (!window.confirm("确定删除这条写作记录吗?此操作不可恢复。")) return;
try {
await remove(session.id);
toast.success("已删除写作记录");
} catch {
toast.error("删除失败,请重试");
}
};
if (loading && sessions.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的写作记录" : "暂无写作记录"}
</div>
);
}
return (
<>
<div>
{filtered.map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<HistoryRow
key={session.id}
badge={<HistoryTypeBadge icon={FilePenLine} label="AI写作" />}
title={desensitize(session.title)}
meta={
<>
<HistoryStatusPill label={label} className={color} />
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(session.updated_at)}</span>
</>
}
onOpen={() => openSession(session)}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(session.id);
setRenameTitle(session.title);
}}
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名写作"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
</>
);
}
@@ -0,0 +1,181 @@
"use client";
/**
* 「对话历史」页(/page/workspace/chats)业务历史 Tab(多智能体会商 / AI写作 /
* 深度研究)的共享小组件。行样式与「对话」Tab 保持一致:标题行 + 次行元信息 +
* 悬浮操作按钮,让四个 Tab 在同一页里观感统一。
*/
import { useEffect, useState, type ReactNode } from "react";
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
/** 次行状态小标签(对齐侧栏列表的状态药丸配色)。 */
export function HistoryStatusPill({
label,
className,
}: {
label: string;
className?: string;
}) {
return (
<span className={cn("text-[10px] font-medium", className)}>{label}</span>
);
}
/** 历史卡片左上角的类型标签,与对话卡片的「对话」标签保持同一视觉层级。 */
export function HistoryTypeBadge({
icon: Icon,
label,
}: {
icon: LucideIcon;
label: string;
}) {
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<Icon className="size-3 text-red-600" />
{label}
</span>
);
}
/**
* 单条历史记录行。onOpen 由各 Tab 提供(多数是暂存 sessionStorage 后跳转
* 对应工作台,由页面挂载时消费),操作按钮经 children 传入。
*/
export function HistoryRow({
badge,
title,
meta,
onOpen,
children,
}: {
badge?: ReactNode;
title: ReactNode;
meta: ReactNode;
onOpen: () => void;
children?: ReactNode;
}) {
return (
<div className="group/chat-row relative mb-3 flex min-w-0 rounded-[10px] border border-neutral-200/80 bg-white transition-[border-color,box-shadow,background-color] duration-150 last:mb-0 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs">
<button
type="button"
onClick={onOpen}
className="block w-full p-3 pr-28 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:p-3.5 sm:pr-32"
>
<div className="flex min-w-0 items-center gap-2">
{badge}
<span className="min-w-0 flex-1 truncate text-sm font-semibold transition-colors group-hover/chat-row:text-red-700 sm:text-base">
{title}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{meta}
</div>
</button>
{/* Row actions — kept out of the open button so clicks don't trigger it. */}
<div className="absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-1 rounded-lg bg-white/90 opacity-100 shadow-sm backdrop-blur-sm transition-opacity sm:opacity-0 sm:group-hover/chat-row:opacity-100 sm:focus-within:opacity-100">
{children}
</div>
</div>
);
}
/** 重命名 / 删除图标操作按钮(与「对话」Tab 的收藏/置顶按钮同规格)。 */
export function HistoryRowIconButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<Button
variant="ghost"
size="icon"
className="size-8"
title={title}
aria-label={title}
onClick={onClick}
>
{children}
</Button>
);
}
export function RenameIcon() {
return <Pencil className="text-muted-foreground size-4" />;
}
export function DeleteIcon() {
return <Trash2 className="text-muted-foreground size-4" />;
}
/**
* 自包含重命名弹窗:打开时用 initialTitle 初始化输入框,确认时回调新标题。
* 三个 Tab 的重命名接口都是「(id, title) → Promise」,由调用方闭包绑定 id。
*/
export function HistoryRenameDialog({
open,
initialTitle,
dialogTitle,
onCancel,
onConfirm,
}: {
open: boolean;
initialTitle: string;
dialogTitle: string;
onCancel: () => void;
onConfirm: (title: string) => void;
}) {
const [value, setValue] = useState(initialTitle);
useEffect(() => {
if (open) setValue(initialTitle);
}, [open, initialTitle]);
return (
<Dialog open={open} onOpenChange={(o) => (o ? undefined : onCancel())}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
if (value.trim()) onConfirm(value.trim());
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button disabled={!value.trim()} onClick={() => onConfirm(value.trim())}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -20,6 +20,7 @@
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk";
import ReactMarkdown from "react-markdown";
import { Select as TSelect } from "tdesign-react";
@@ -444,7 +445,12 @@ export default function DeepResearchWorkbench() {
}
function DeepResearchWorkbenchInner() {
const [selectedId, setSelectedId] = useState<string | null>(null);
// 「?session=<id>」深链:对话历史页「深度研究」Tab 等外部入口直接定位到某条
// 研究记录。仅作初始选中(首帧读取一次);之后的切换仍走页内状态,不回写 URL。
const [searchParams] = useSearchParams();
const [selectedId, setSelectedId] = useState<string | null>(() =>
searchParams.get("session"),
);
const [mode, setMode] = useState<ResearchMode>("basic");
const [generateImages, setGenerateImages] = useState(false);
const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS);
@@ -155,12 +155,12 @@ export function ResearchSandboxPanel({
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
"bg-[#fffdfa] transition-all duration-300 ease-in-out dark:bg-[#0b1426]",
!open && "opacity-0",
)}
id="artifacts"
>
<div className="relative h-full bg-[#fffdfa] p-4">
<div className="relative h-full bg-[#fffdfa] p-4 dark:bg-[#0b1426]">
{selectedArtifact && previewThreadId && stubMessages.length > 0 ? (
<>
{documentRewriteActive && onCancelDocumentRewrite && (
@@ -114,7 +114,7 @@ const outlineMarkdownClassName =
"[&_p]:my-2.5 [&_ul]:my-2.5 [&_ol]:my-2.5 [&_li]:my-1 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_strong]:font-semibold [&_strong]:text-neutral-900 dark:[&_strong]:text-slate-100 " +
"[&_blockquote]:my-3 [&_blockquote]:border-l-4 [&_blockquote]:border-rose-200 [&_blockquote]:pl-4 [&_blockquote]:text-neutral-500 dark:[&_blockquote]:border-rose-900/50 dark:[&_blockquote]:text-slate-400 " +
"[&_blockquote]:my-3 [&_blockquote]:border-l-4 [&_blockquote]:border-rose-200 [&_blockquote]:pl-4 [&_blockquote]:text-neutral-500 dark:[&_blockquote]:border-[#2b8ffe]/45 dark:[&_blockquote]:text-slate-400 " +
"[&_code]:rounded [&_code]:border [&_code]:bg-neutral-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs dark:[&_code]:border-slate-700 dark:[&_code]:bg-slate-800 " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-neutral-50 [&_pre]:p-4 dark:[&_pre]:border-slate-700 dark:[&_pre]:bg-slate-800 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
@@ -309,17 +309,17 @@ export function WorkflowConfigDialog({
overlayClassName="bg-slate-900/60 backdrop-blur-xs"
className={
!detail
? "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-3xl border border-slate-200/80 bg-white p-0 shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:max-w-6xl"
: "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-2xl border border-neutral-200/90 bg-white p-0 shadow-2xl shadow-neutral-950/20 dark:border-slate-800 dark:bg-slate-900 sm:max-w-6xl"
? "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-3xl border border-slate-200/80 bg-white p-0 shadow-2xl dark:border-[#263b5c] dark:bg-[#0f172a] sm:max-w-6xl"
: "flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-2xl border border-neutral-200/90 bg-white p-0 shadow-2xl shadow-neutral-950/20 dark:border-[#263b5c] dark:bg-[#0f172a] sm:max-w-6xl"
}
>
{/* header:列表视图=标题+筛选+新增;详情视图=结构标题 + 返回结构库 */}
{!detail ? (
<>
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-slate-200/80 bg-white px-6 py-5 dark:border-slate-800/80 dark:bg-slate-900">
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-slate-200/80 bg-white px-6 py-5 dark:border-[#263b5c]/80 dark:bg-[#0f172a]">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 rounded-full bg-[#F5222D]" aria-hidden />
<div className="h-2.5 w-2.5 rounded-full bg-[#F5222D] dark:bg-[#4096ff]" aria-hidden />
<h2 className="truncate text-xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
工作流配置 · 报告结构
</h2>
@@ -331,7 +331,7 @@ export function WorkflowConfigDialog({
<Button
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0 rounded-xl text-slate-400 hover:bg-slate-100 hover:text-[#CF1322] dark:hover:bg-slate-800 dark:hover:text-slate-200"
className="h-10 w-10 shrink-0 rounded-xl text-slate-400 hover:bg-slate-100 hover:text-[#CF1322] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
aria-label="关闭"
onClick={() => onOpenChange(false)}
>
@@ -339,7 +339,7 @@ export function WorkflowConfigDialog({
</Button>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200/80 bg-white px-6 py-3.5 dark:border-slate-800/60 dark:bg-slate-900">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200/80 bg-white px-6 py-3.5 dark:border-[#263b5c]/70 dark:bg-[#0f172a]">
<div className="flex min-w-[280px] flex-1 flex-wrap items-center gap-2.5">
<div className="relative min-w-[200px] max-w-xs flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-slate-400" />
@@ -347,14 +347,14 @@ export function WorkflowConfigDialog({
value={titleFilter}
onChange={(event) => setTitleFilter(event.target.value)}
placeholder="按标题或检索词筛选..."
className="h-10 rounded-xl border-slate-200/90 bg-slate-50/60 pr-8 pl-9 text-xs text-slate-800 shadow-2xs placeholder:text-slate-400 focus-visible:border-[#F5222D] focus-visible:ring-[#F5222D]/20 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 sm:text-sm"
className="h-10 rounded-xl border-slate-200/90 bg-slate-50/60 pr-8 pl-9 text-xs text-slate-800 shadow-2xs placeholder:text-slate-400 focus-visible:border-[#F5222D] focus-visible:ring-[#F5222D]/20 dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-100 dark:focus-visible:border-[#2b8ffe] dark:focus-visible:ring-[#2b8ffe]/25 sm:text-sm"
/>
{titleFilter && (
<button
type="button"
aria-label="清空筛选"
onClick={() => setTitleFilter("")}
className="absolute top-1/2 right-2.5 -translate-y-1/2 text-slate-400 hover:text-[#CF1322] dark:hover:text-slate-200"
className="absolute top-1/2 right-2.5 -translate-y-1/2 text-slate-400 hover:text-[#CF1322] dark:hover:text-[#9bc7ff]"
>
<X className="h-3.5 w-3.5" />
</button>
@@ -366,7 +366,7 @@ export function WorkflowConfigDialog({
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as ReportStructureStatusFilter)}
aria-label="启用状态筛选"
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 sm:text-sm"
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:focus:border-[#2b8ffe] dark:focus:ring-[#2b8ffe]/25 sm:text-sm"
>
<option value="all">全部状态</option>
<option value="enabled">已启用</option>
@@ -380,7 +380,7 @@ export function WorkflowConfigDialog({
value={modeFilter}
onChange={(event) => setModeFilter(event.target.value as "all" | ReportStructureMode)}
aria-label="大纲类型筛选"
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 sm:text-sm"
className="h-10 cursor-pointer appearance-none rounded-xl border border-slate-200/90 bg-slate-50/60 py-2 pr-8 pl-3 text-xs font-medium text-slate-700 shadow-2xs transition-all hover:border-slate-300 focus:border-[#F5222D] focus:ring-2 focus:ring-[#F5222D]/20 focus:outline-hidden dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:focus:border-[#2b8ffe] dark:focus:ring-[#2b8ffe]/25 sm:text-sm"
>
<option value="all">全部大纲</option>
<option value="adaptive">弹性大纲</option>
@@ -391,11 +391,11 @@ export function WorkflowConfigDialog({
</div>
<div className="flex shrink-0 items-center gap-2.5">
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300">
{visible.length} 套
</span>
<Button
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] sm:text-sm"
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
onClick={() => {
onOpenChange(false);
const base = location.pathname.replace(/\/+$/, "");
@@ -409,9 +409,9 @@ export function WorkflowConfigDialog({
</div>
</>
) : (
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-neutral-100 bg-white px-6 py-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-neutral-100 bg-white px-6 py-4 dark:border-[#263b5c] dark:bg-[#0f172a]">
<div className="flex min-w-0 items-center gap-3.5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-b from-[#b91c1c] to-[#991b1b] text-white shadow-xs shadow-rose-900/20">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-b from-[#b91c1c] to-[#991b1b] text-white shadow-xs shadow-rose-900/20 dark:from-[#2b8ffe] dark:to-[#0958d9] dark:shadow-blue-950/30">
<FileText className="h-5 w-5 text-white" strokeWidth={1.9} aria-hidden />
</div>
<div className="min-w-0">
@@ -420,8 +420,8 @@ export function WorkflowConfigDialog({
{detail.title}
</h2>
{detail.id === appliedStructureId && (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-rose-200/70 bg-rose-50 px-2.5 py-0.5 text-xs font-medium text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600" aria-hidden />
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-rose-200/70 bg-rose-50 px-2.5 py-0.5 text-xs font-medium text-rose-700 dark:border-[#2b8ffe]/45 dark:bg-[#0b3064]/60 dark:text-[#9bc7ff]">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600 dark:bg-[#69b1ff]" aria-hidden />
当前使用
</span>
)}
@@ -440,7 +440,7 @@ export function WorkflowConfigDialog({
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3.5 py-1.5 text-xs font-medium text-neutral-700 shadow-2xs transition-all duration-150 hover:border-neutral-300 hover:bg-neutral-50 active:bg-neutral-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3.5 py-1.5 text-xs font-medium text-neutral-700 shadow-2xs transition-all duration-150 hover:border-neutral-300 hover:bg-neutral-50 active:bg-neutral-100 dark:border-[#2a4164] dark:bg-[#17243a] dark:text-slate-200 dark:hover:border-[#2b8ffe]/70 dark:hover:bg-[#1c2d4a]"
onClick={handleBackToList}
>
返回结构库
@@ -448,7 +448,7 @@ export function WorkflowConfigDialog({
<button
type="button"
aria-label="关闭弹窗"
className="rounded-lg p-2 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-slate-800 dark:hover:text-slate-200"
className="rounded-lg p-2 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
@@ -460,7 +460,7 @@ export function WorkflowConfigDialog({
{/* body */}
{!detail ? (
<>
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/60 dark:bg-slate-900/40">
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/60 dark:bg-[#0b1426]">
<div className="p-6">
{listQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-slate-500 dark:text-slate-400">
@@ -488,7 +488,7 @@ export function WorkflowConfigDialog({
setStatusFilter("all");
setModeFilter("all");
}}
className="inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3.5 py-1.5 text-xs font-medium text-[#CF1322] transition-colors hover:bg-slate-200 dark:bg-rose-950/50 dark:text-[#FA9196]"
className="inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3.5 py-1.5 text-xs font-medium text-[#CF1322] transition-colors hover:bg-slate-200 dark:bg-[#17345c] dark:text-[#9bc7ff] dark:hover:bg-[#1d467a]"
>
<RotateCcw className="h-3.5 w-3.5" />
重置所有筛选
@@ -528,12 +528,12 @@ export function WorkflowConfigDialog({
</div>
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200/80 bg-white px-6 py-3.5 text-xs dark:border-slate-800 dark:bg-slate-900">
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200/80 bg-white px-6 py-3.5 text-xs dark:border-[#263b5c] dark:bg-[#0f172a]">
<div className="flex min-w-0 items-center gap-2 truncate text-slate-500 dark:text-slate-400">
<span className="shrink-0">当前已选用:</span>
{appliedStructureId ? (
<span className="flex min-w-0 items-center gap-1 font-semibold text-[#CF1322] dark:text-[#FA9196]">
<Check className="h-3.5 w-3.5 shrink-0 text-[#F5222D]" />
<span className="flex min-w-0 items-center gap-1 font-semibold text-[#CF1322] dark:text-[#9bc7ff]">
<Check className="h-3.5 w-3.5 shrink-0 text-[#F5222D] dark:text-[#4096ff]" />
<span className="truncate">
{structures.find((item) => item.id === appliedStructureId)?.title ?? "当前结构"}
({reportStructureModeLabel(structures.find((item) => item.id === appliedStructureId)?.structureMode ?? "adaptive")})
@@ -546,7 +546,7 @@ export function WorkflowConfigDialog({
<Button
variant="ghost"
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
onClick={() => onOpenChange(false)}
>
完成
@@ -554,11 +554,11 @@ export function WorkflowConfigDialog({
</div>
</>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white md:flex-row dark:bg-slate-900">
<aside className="report-structure-detail-scrollbar flex w-full shrink-0 flex-col gap-4 overflow-y-auto border-b border-neutral-100 bg-[#fafbfc] p-[22px] md:w-[320px] md:border-r md:border-b-0 lg:w-[340px] dark:border-slate-800 dark:bg-slate-950/40">
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-slate-800 dark:bg-slate-900">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white md:flex-row dark:bg-[#0b1426]">
<aside className="report-structure-detail-scrollbar flex w-full shrink-0 flex-col gap-4 overflow-y-auto border-b border-neutral-100 bg-[#fafbfc] p-[22px] md:w-[320px] md:border-r md:border-b-0 lg:w-[340px] dark:border-[#263b5c] dark:bg-[#0b1426]">
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-[#263b5c] dark:bg-[#111c31]">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<Info className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div>
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
@@ -570,9 +570,9 @@ export function WorkflowConfigDialog({
</p>
</section>
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-slate-800 dark:bg-slate-900">
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-[#263b5c] dark:bg-[#111c31]">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<Compass className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div>
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
@@ -584,9 +584,9 @@ export function WorkflowConfigDialog({
{detail.retrievalDirections.map((direction) => (
<div
key={direction}
className="flex items-center gap-1.5 rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300"
className="flex items-center gap-1.5 rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300"
>
<Tag className="h-3 w-3 shrink-0 text-rose-500" strokeWidth={2} aria-hidden />
<Tag className="h-3 w-3 shrink-0 text-rose-500 dark:text-[#69b1ff]" strokeWidth={2} aria-hidden />
<span className="leading-tight">{direction}</span>
</div>
))}
@@ -598,17 +598,17 @@ export function WorkflowConfigDialog({
)}
</section>
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-slate-800 dark:bg-slate-900">
<section className="space-y-2.5 rounded-xl border border-neutral-200/75 bg-white p-4 shadow-2xs dark:border-[#263b5c] dark:bg-[#111c31]">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<Database className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div>
<span className="text-xs font-bold tracking-tight text-neutral-900 dark:text-slate-100">
推荐检索来源
</span>
</div>
<span className="shrink-0 rounded-full border border-neutral-200/60 bg-neutral-100 px-2 py-0.5 text-[11px] font-medium text-neutral-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
<span className="shrink-0 rounded-full border border-neutral-200/60 bg-neutral-100 px-2 py-0.5 text-[11px] font-medium text-neutral-500 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-400">
{detail.retrievalSkills.length} 个渠道
</span>
</div>
@@ -617,7 +617,7 @@ export function WorkflowConfigDialog({
{detail.retrievalSkills.map((skill) => (
<span
key={skill}
className="rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1 text-xs leading-tight text-neutral-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
className="rounded-lg border border-neutral-200/60 bg-neutral-50 px-2.5 py-1 text-xs leading-tight text-neutral-600 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300"
>
{skill}
</span>
@@ -631,10 +631,10 @@ export function WorkflowConfigDialog({
</section>
</aside>
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-white dark:bg-slate-900">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-neutral-100 bg-white px-6 py-3.5 dark:border-slate-800 dark:bg-slate-900">
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-white dark:bg-[#0f172a]">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-neutral-100 bg-white px-6 py-3.5 dark:border-[#263b5c] dark:bg-[#0f172a]">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-rose-50 text-rose-700 dark:bg-rose-950/40 dark:text-rose-300">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-rose-50 text-rose-700 dark:bg-[#0b3064]/60 dark:text-[#69b1ff]">
<FileText className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</div>
<span className="truncate text-sm font-semibold text-neutral-900 dark:text-slate-100">
@@ -646,7 +646,7 @@ export function WorkflowConfigDialog({
<button
type="button"
onClick={() => void handleCopyOutline()}
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 shadow-2xs transition-all hover:bg-neutral-50 hover:text-neutral-900 active:scale-95 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 shadow-2xs transition-all hover:bg-neutral-50 hover:text-neutral-900 active:scale-95 dark:border-[#2a4164] dark:text-slate-300 dark:hover:border-[#2b8ffe]/70 dark:hover:bg-[#1c2d4a] dark:hover:text-slate-100"
title="复制大纲文本"
>
{copiedOutlineId === detail.id ? (
@@ -661,15 +661,15 @@ export function WorkflowConfigDialog({
</>
)}
</button>
<span className="inline-flex items-center gap-1.5 rounded-lg border border-rose-200/80 bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 shadow-2xs dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600" aria-hidden />
<span className="inline-flex items-center gap-1.5 rounded-lg border border-rose-200/80 bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 shadow-2xs dark:border-[#2b8ffe]/45 dark:bg-[#0b3064]/60 dark:text-[#9bc7ff]">
<span className="h-1.5 w-1.5 rounded-full bg-rose-600 dark:bg-[#69b1ff]" aria-hidden />
{reportStructureModeLabel(detail.structureMode)}
</span>
</div>
</div>
<div className="report-structure-detail-scrollbar flex-1 space-y-6 overflow-y-auto bg-white p-6 md:p-8 dark:bg-slate-900">
<div className="flex items-center justify-between gap-4 border-b border-neutral-100 pb-3 dark:border-slate-800">
<div className="report-structure-detail-scrollbar flex-1 space-y-6 overflow-y-auto bg-white p-6 md:p-8 dark:bg-[#0f172a]">
<div className="flex items-center justify-between gap-4 border-b border-neutral-100 pb-3 dark:border-[#263b5c]">
<h3 className="min-w-0 truncate text-base font-bold tracking-tight text-neutral-900 dark:text-slate-100">
{detailOutlineTitle}
</h3>
@@ -784,10 +784,10 @@ function StructureCard({
}}
className={`group relative flex cursor-pointer select-none flex-col justify-between rounded-2xl border p-5 text-left transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 ${
applied
? "border-[#F5222D] bg-gradient-to-b from-[#FEEDED]/90 via-[#FEFAFA] to-white shadow-sm ring-2 ring-[#F5222D]/20 dark:from-[#5C0011]/35 dark:via-slate-900 dark:to-slate-900"
? "border-[#F5222D] bg-gradient-to-b from-[#FEEDED]/90 via-[#FEFAFA] to-white shadow-sm ring-2 ring-[#F5222D]/20 dark:border-[#2b8ffe]/70 dark:from-[#0c3266]/70 dark:via-[#122347] dark:to-[#0e1c33] dark:ring-[#2b8ffe]/25"
: structure.enabled
? "border-slate-200/90 bg-white hover:border-[#FA9196] hover:bg-gradient-to-b hover:from-[#FEFAFA]/80 hover:to-white hover:shadow-md dark:border-slate-800/90 dark:bg-slate-900 dark:hover:border-[#A8071A]/70"
: "border-slate-200/60 bg-slate-50/80 opacity-75 hover:border-slate-300 hover:opacity-100 dark:border-slate-800/50 dark:bg-slate-900/40"
? "border-slate-200/90 bg-white hover:border-[#FA9196] hover:bg-gradient-to-b hover:from-[#FEFAFA]/80 hover:to-white hover:shadow-md dark:border-[#263b5c]/90 dark:bg-[#111c31] dark:hover:border-[#2b8ffe]/70 dark:hover:bg-gradient-to-b dark:hover:from-[#152b4b] dark:hover:to-[#111c31]"
: "border-slate-200/60 bg-slate-50/80 opacity-75 hover:border-slate-300 hover:opacity-100 dark:border-[#263b5c]/50 dark:bg-[#10192b]/70 dark:hover:border-[#38527a]"
}`}
>
<div>
@@ -799,7 +799,7 @@ function StructureCard({
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{applied && (
<span className="inline-flex items-center gap-1 rounded-full bg-[#F5222D] px-2.5 py-0.5 text-xs font-semibold text-white shadow-xs">
<span className="inline-flex items-center gap-1 rounded-full bg-[#F5222D] px-2.5 py-0.5 text-xs font-semibold text-white shadow-xs dark:bg-[#1677ff]">
<Check className="h-3 w-3 stroke-[3]" />
当前使用
</span>
@@ -807,18 +807,18 @@ function StructureCard({
<span
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${
structure.structureMode === "adaptive"
? "border border-[#FDDADC] bg-[#FEEDED] text-[#BC0D1E] dark:border-[#820014] dark:bg-[#5C0011]/40 dark:text-[#FA9196]"
: "border border-rose-200/70 bg-rose-50/80 text-rose-700 dark:border-rose-800/60 dark:bg-rose-950/40 dark:text-rose-300"
? "border border-[#FDDADC] bg-[#FEEDED] text-[#BC0D1E] dark:border-[#2b8ffe]/45 dark:bg-[#0b3064]/60 dark:text-[#9bc7ff]"
: "border border-rose-200/70 bg-rose-50/80 text-rose-700 dark:border-[#38527a] dark:bg-[#15243d] dark:text-[#b8d4ff]"
}`}
>
<Layers className="h-3 w-3 text-[#F74750]" />
<Layers className="h-3 w-3 text-[#F74750] dark:text-[#69b1ff]" />
{reportStructureModeLabel(structure.structureMode)}
</span>
<span
className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium ${
structure.enabled
? "border border-emerald-200/70 bg-emerald-50 text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-950/40 dark:text-emerald-300"
: "border border-slate-200 bg-slate-100 text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400"
? "border border-emerald-200/70 bg-emerald-50 text-emerald-700 dark:border-emerald-700/55 dark:bg-emerald-950/35 dark:text-emerald-300"
: "border border-slate-200 bg-slate-100 text-slate-500 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-400"
}`}
>
{structure.enabled ? "已启用" : "已停用"}
@@ -841,7 +841,7 @@ function StructureCard({
type="button"
title="查看大纲结构"
aria-label={`查看 ${structure.title} 的大纲结构`}
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-slate-800 dark:hover:text-[#FA9196]"
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
onClick={onPreview}
>
<Eye className="h-4 w-4" />
@@ -850,7 +850,7 @@ function StructureCard({
type="button"
title="复制为新结构"
aria-label={`复制 ${structure.title}`}
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-slate-800 dark:hover:text-[#FA9196]"
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
onClick={onDuplicate}
disabled={toggling}
>
@@ -860,7 +860,7 @@ function StructureCard({
type="button"
title="编辑结构"
aria-label={`编辑 ${structure.title}`}
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-slate-800 dark:hover:text-[#FA9196]"
className="rounded-lg p-1.5 transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D] dark:hover:bg-[#1c2d4a] dark:hover:text-[#9bc7ff]"
onClick={onEdit}
>
<Pencil className="h-4 w-4" />
@@ -884,7 +884,7 @@ function StructureCard({
{directions.length > 0 && (
<div className="mb-4">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-slate-500 dark:text-slate-400">
<Compass className="h-3.5 w-3.5 text-[#F74750]" />
<Compass className="h-3.5 w-3.5 text-[#F74750] dark:text-[#69b1ff]" />
<span>检索方向</span>
</div>
<div className="flex flex-wrap gap-1.5">
@@ -892,13 +892,13 @@ function StructureCard({
<span
key={direction}
title={direction}
className="inline-flex max-w-[200px] truncate rounded-md border border-[#FDDADC] bg-[#FEFAFA] px-2 py-0.5 text-xs text-slate-700 dark:border-slate-700/60 dark:bg-slate-800/90 dark:text-slate-300"
className="inline-flex max-w-[200px] truncate rounded-md border border-[#FDDADC] bg-[#FEFAFA] px-2 py-0.5 text-xs text-slate-700 dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-slate-300"
>
{direction}
</span>
))}
{directions.length > 3 && (
<span className="inline-flex items-center rounded-md border border-[#FCB5B9] bg-[#FEEDED] px-1.5 py-0.5 text-[11px] font-medium text-[#F86C73] dark:border-slate-700/40 dark:bg-slate-800/70">
<span className="inline-flex items-center rounded-md border border-[#FCB5B9] bg-[#FEEDED] px-1.5 py-0.5 text-[11px] font-medium text-[#F86C73] dark:border-[#38527a]/70 dark:bg-[#17243a] dark:text-[#9bc7ff]">
+{directions.length - 3}
</span>
)}
@@ -908,7 +908,7 @@ function StructureCard({
</div>
<div
className="mt-auto flex items-center justify-between gap-2 border-t border-slate-100 pt-3 dark:border-slate-800/80"
className="mt-auto flex items-center justify-between gap-2 border-t border-slate-100 pt-3 dark:border-[#263b5c]/80"
onClick={(event) => event.stopPropagation()}
role="presentation"
>
@@ -934,8 +934,8 @@ function StructureCard({
disabled={toggling}
aria-label={`${structure.enabled ? "停用" : "启用"} ${structure.title}`}
onClick={() => onToggleEnabled(!structure.enabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60 ${
structure.enabled ? "bg-[#F5222D]" : "bg-slate-300 dark:bg-slate-700"
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 dark:focus-visible:ring-[#2b8ffe]/45 disabled:cursor-not-allowed disabled:opacity-60 ${
structure.enabled ? "bg-[#F5222D] dark:bg-[#1677ff]" : "bg-slate-300 dark:bg-[#2a4164]"
}`}
>
<span
@@ -4,7 +4,7 @@ import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly.
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
test("includes the context compression toggle in the iframe chat toolbar", () => {
test("hides the memory toggle while retaining the other iframe chat toolbar controls", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
@@ -20,7 +20,6 @@ test("includes the context compression toggle in the iframe chat toolbar", () =>
buttons.map((button) => button.key),
[
"writing",
"memory",
"references",
"contextCompression",
"pro",
@@ -29,6 +28,22 @@ test("includes the context compression toggle in the iframe chat toolbar", () =>
);
});
test("can restore the iframe memory toggle when the feature is re-enabled", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
hideMemoryButton: false,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.some((button) => button.key === "memory"), true);
});
test("hides the references toggle when reference mode is disabled globally", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
@@ -15,6 +15,8 @@ type BuildIframeModeButtonsInput = {
agentSelected: boolean;
writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
hideMemoryButton?: boolean;
referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean;
ragModeLocked: boolean;
@@ -26,6 +28,7 @@ export function buildIframeModeButtons({
agentSelected,
writingModeEnabled,
memoryInjectionEnabled,
hideMemoryButton = true,
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
@@ -65,7 +68,9 @@ export function buildIframeModeButtons({
active: agentSelected,
},
];
return referenceModeEntryEnabled
? buttons
: buttons.filter((button) => button.key !== "references");
return buttons.filter(
(button) =>
(referenceModeEntryEnabled || button.key !== "references") &&
(!hideMemoryButton || button.key !== "memory"),
);
}
@@ -28,8 +28,9 @@ import type { JobStatus } from "@/roundtable-planning/lib/job-types";
export interface DraftStep1Snapshot {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: unknown[];
timelineEntries: unknown[];
/** 早期草稿可能缺这两个数组字段 —— hydrate 按空数组兜底。 */
interactiveMessages?: unknown[];
timelineEntries?: unknown[];
}
export interface DraftStep2Snapshot {
@@ -4,6 +4,7 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/stream-reasoning";
export type ThreadIdMap = Record<string, string>;
@@ -254,8 +255,7 @@ export interface StreamMultiAgentRequest {
*/
synthesisMode?: boolean;
/**
* 仅 leader 用。置 true 抑制「把总控本轮输入广播给各席位 thread」的预广播。DAG「每 stage
* 派活轮」置真——否则总控的派活 prompt 会进席位历史,弱模型据此模仿派活。后端缺省 false。
* 仅为兼容旧协议保留。总控 prompt 属于总控私有上下文,前端恒传 true,后端也会无条件隔离。
*/
suppressPreBroadcast?: boolean;
/**
@@ -291,12 +291,14 @@ export interface StreamMultiAgentRequest {
* show progressive status messages during the inevitable first-token wait.
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
* 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。
* 帧中当前 tool call 的 args),供 UI 渲染路径 chip / 代码块等子节点。
* `toolCallId` 可将同名工具的连续调用与同一次调用的增量参数区分开。
*/
onPhaseChange?: (
phase: StreamPhase,
name?: string,
args?: Record<string, unknown>,
toolCallId?: string,
) => void;
/**
* 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个
@@ -468,7 +470,9 @@ function isFinalStatusFrame(value: unknown): value is {
* structured content blocks. Returns null when the frame is not an AI text
* chunk (e.g., tool calls, tool results, value snapshots).
*/
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
function extractAiTextDelta(
parsed: unknown,
): { content: string; reasoning: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null;
@@ -491,9 +495,18 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
}
}
}
if (!text) return null;
const additionalKwargs = obj.additional_kwargs;
const directReasoning = obj.reasoning_content ?? obj.reasoning;
const nestedReasoning =
additionalKwargs && typeof additionalKwargs === "object"
? (additionalKwargs as Record<string, unknown>).reasoning_content ??
(additionalKwargs as Record<string, unknown>).reasoning
: undefined;
const rawReasoning = nestedReasoning ?? directReasoning;
const reasoning = typeof rawReasoning === "string" ? rawReasoning : "";
if (!text && !reasoning) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { text, id };
return { content: text, reasoning, id };
}
/**
@@ -532,8 +545,8 @@ export async function streamMultiAgent(
skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false,
// 仅 leader 用;DAG 派活轮抑制预广播,避免总控派活 prompt 污染席位 thread。省略 → false。
suppress_pre_broadcast: req.suppressPreBroadcast ?? false,
// 总控 prompt 永不进入席位 thread;后端同样强制执行,避免旧调用方破坏角色隔离。
suppress_pre_broadcast: req.suppressPreBroadcast ?? true,
// 仅 special 用;DAG 并行批次禁写文件(seat_parallel policy)。省略 → 后端按 false 处理。
parallel_no_file: req.parallelNoFile ?? false,
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
@@ -620,27 +633,36 @@ export async function streamMultiAgent(
let currentPhase: StreamPhase = "connecting";
let currentPhaseName = "";
let currentPhaseArgsSignature = "";
let currentPhaseToolCallId = "";
req.onPhaseChange?.("connecting");
// The normal chat renders provider reasoning from a distinct message field.
// Roundtable's bespoke SSE reader keeps that field inline so its existing
// `<think>` renderer remains the single display path for Steps 2 and 3.
const inlineAiDelta = createInlineReasoningDeltaFormatter();
const setPhase = (
next: StreamPhase,
name?: string,
args?: Record<string, unknown>,
toolCallId?: string,
) => {
const nextName = name ?? "";
const nextArgsSignature = phaseArgsSignature(args);
const nextToolCallId = toolCallId ?? "";
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
if (
next === currentPhase &&
nextName === currentPhaseName &&
nextArgsSignature === currentPhaseArgsSignature
nextArgsSignature === currentPhaseArgsSignature &&
nextToolCallId === currentPhaseToolCallId
) {
return;
}
currentPhase = next;
currentPhaseName = nextName;
currentPhaseArgsSignature = nextArgsSignature;
req.onPhaseChange?.(next, name, args);
currentPhaseToolCallId = nextToolCallId;
req.onPhaseChange?.(next, name, args, toolCallId);
};
/**
@@ -782,12 +804,25 @@ export async function streamMultiAgent(
const toolCalls = chunkObj.tool_calls;
const toolCallChunks = chunkObj.tool_call_chunks;
const content = chunkObj.content;
const streamDelta = extractAiTextDelta(parsed);
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
ingestToolCallChunks(toolCallChunks);
// Some providers serialize `tool_calls` before their `args` have
// been decoded, leaving args as the raw JSON string. Treating that
// as a complete call suppresses the accumulated tool_call_chunks,
// so write_file reaches the sandbox with a path but no content.
const hasCompleteToolCalls =
Array.isArray(toolCalls) && toolCalls.length > 0;
Array.isArray(toolCalls) &&
toolCalls.length > 0 &&
toolCalls.every(
(toolCall) =>
!!toolCall &&
typeof toolCall === "object" &&
!!(toolCall as Record<string, unknown>).args &&
typeof (toolCall as Record<string, unknown>).args === "object",
);
let synthetic: SyntheticToolCall[] = [];
if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
synthetic = buildSyntheticToolCalls();
@@ -812,27 +847,34 @@ export async function streamMultiAgent(
if (chunkType === "tool") {
setPhase("tool_result");
} else if (hasToolCall) {
// Pick the most informative name + args we have so far.
// Pick the active (latest) call's name + args. A leader can dispatch
// several seats with the same `agent_orchestration` tool in one run;
// choosing the first accumulated call would leave the UI stuck on an
// earlier assignment until the final response arrives.
// 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候,
// 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。
const firstCall =
const activeCall =
effectiveToolCalls && effectiveToolCalls.length > 0
? (effectiveToolCalls[0] as Record<string, unknown>)
? (effectiveToolCalls[effectiveToolCalls.length - 1] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>)
? (toolCallChunks[toolCallChunks.length - 1] as Record<string, unknown>)
: null;
const toolName =
firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
activeCall && typeof activeCall.name === "string" ? activeCall.name : "";
const args =
firstCall &&
typeof firstCall.args === "object" &&
firstCall.args !== null
? (firstCall.args as Record<string, unknown>)
activeCall &&
typeof activeCall.args === "object" &&
activeCall.args !== null
? (activeCall.args as Record<string, unknown>)
: undefined;
setPhase("tool_calling", toolName, args);
const toolCallId =
activeCall && typeof activeCall.id === "string" ? activeCall.id : undefined;
setPhase("tool_calling", toolName, args, toolCallId);
} else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
(Boolean(streamDelta) ||
(typeof content === "string" && content.length > 0) ||
(Array.isArray(content) && content.length > 0))
) {
setPhase("streaming");
}
@@ -900,8 +942,9 @@ export async function streamMultiAgent(
const delta = extractAiTextDelta(parsed);
if (delta) {
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
req.onTextDelta?.(delta.text, delta.id);
const text = inlineAiDelta(delta);
if (debugEnabled) console.debug(debugLabel, `→ delta (${text.length} chars)`);
if (text) req.onTextDelta?.(text, delta.id);
}
req.onUpstreamFrame?.(parsed);
@@ -9,7 +9,7 @@
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
*/
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowRightIcon,
ClockIcon,
@@ -593,8 +593,9 @@ function ChainSeatsFlow({
return (
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
{/* 同 DispatchChainModal:div.contents 代替 <Fragment>,规避 dev tagger 注入 data-lov-id 的警告。 */}
{stages.map((stageIds, stageIdx) => (
<Fragment key={stageIdx}>
<div className="contents" key={stageIdx}>
{stageIdx > 0 && (
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
@@ -609,7 +610,7 @@ function ChainSeatsFlow({
) : (
seatTag(stageIds[0])
)}
</Fragment>
</div>
))}
</div>
);
@@ -6,7 +6,7 @@
* 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用
* `stripReportFence` 替换成一句提示。
*/
import { useMemo, useRef, useState, type FormEvent } from "react";
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import {
CheckCircle,
Loader2,
@@ -64,7 +64,19 @@ export function DashboardChatPanel({
}: DashboardChatPanelProps) {
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll(
scrollRef,
[dialogues, phase],
{ contentRef },
);
const previousDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > previousDialogueCountRef.current) {
forceScrollToBottom();
}
previousDialogueCountRef.current = dialogues.length;
}, [dialogues.length, forceScrollToBottom]);
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
@@ -156,7 +168,8 @@ export function DashboardChatPanel({
{/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white">
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -209,6 +222,7 @@ export function DashboardChatPanel({
)}
</div>
)}
</div>
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
@@ -234,6 +248,16 @@ export function DashboardChatPanel({
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
{busy && (
<button
type="button"
onClick={onPause}
title="中止当前绘制(之后可输入修改要求继续)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
<button
type="submit"
disabled={inputLocked || text.trim().length === 0}
@@ -14,7 +14,7 @@
* (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段
* (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。
*/
import { Fragment, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -229,9 +229,10 @@ export function DispatchChainModal({
state={coordinatorState}
/>
{/* 席位节点 */}
{/* 席位节点(div.contents 而非 <Fragment>:dev 期 tagger 会往具名 Fragment 注入
data-lov-id 触发 React 警告,contents 不生成盒子、布局不变)。 */}
{nodes.map((node) => (
<Fragment key={node.agentId}>
<div className="contents" key={node.agentId}>
<Connector
active={node.state === "active"}
done={node.state === "done"}
@@ -242,7 +243,7 @@ export function DispatchChainModal({
state={node.state}
order={node.order}
/>
</Fragment>
</div>
))}
{/* 共识里程碑 */}
@@ -61,6 +61,8 @@ interface SeatDelivery {
interface HighFidelityReportProps {
useStep1Chrome?: boolean;
headerTitle?: string;
/** `report-only` is used by 8BF; normal roundtables retain every Step 3 view. */
step3Mode?: "full" | "report-only";
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
/** The most recent leader-agent text — rendered as the final consensus. */
@@ -121,9 +123,26 @@ function formatGeneratedAt(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** 最终成果区打开的是已落盘文件,不走实时流;独立给工具栏一个稳定的文件类型徽标。 */
function artifactTypeLabel(fileName: string): string {
const normalized = fileName.trim().toLowerCase();
if (/\.(md|markdown)$/.test(normalized)) return "MD";
if (/\.html?$/.test(normalized)) return "HTML";
if (/\.(mmd|mermaid)$/.test(normalized)) return "MMD";
if (/\.json$/.test(normalized)) return "JSON";
if (/\.csv$/.test(normalized)) return "CSV";
if (/\.ya?ml$/.test(normalized)) return "YAML";
if (/\.tsx?$/.test(normalized)) return "TS";
if (/\.js$/.test(normalized)) return "JS";
if (/\.py$/.test(normalized)) return "PY";
if (/\.txt$/.test(normalized)) return "TXT";
return "FILE";
}
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
useStep1Chrome = false,
headerTitle,
step3Mode = "full",
threadIds,
coordinatorName,
consensusText,
@@ -156,7 +175,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
// 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。
const hasFullStep3Outputs = step3Mode === "full";
// 第 3 步主页为总结报告。普通会商可继续打开最终方案、经典可视化和大屏多页;
// 8BF 明确停在报告工作台,不启动或露出这些后续交付。
const [summaryMode, setSummaryMode] = useState(true);
const [drawMode, setDrawMode] = useState(false);
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
@@ -398,6 +419,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
const fileSandboxVirtualUrl = fileSandboxTarget
? `write-file:${fileSandboxStubPath}?message_id=hfr-file-stub&tool_call_id=tc-hfr-0`
: "";
const fileSandboxTypeLabel = fileSandboxTarget
? artifactTypeLabel(fileSandboxTarget.file.name)
: "FILE";
const fileSandboxThreadStub = useMemo(() => {
const messages =
fileSandboxTarget && fileSandboxContent != null
@@ -414,6 +438,14 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
},
],
},
// 最终产物仅借用 write-file 虚拟 URL 来复用预览器;内容已经通过下载接口完整读回,
// 不是流式写入。因此要同时提供 tool result,避免 ArtifactFileDetail 误显示「正在生成」。
{
id: "hfr-file-tool-result",
type: "tool",
content: "",
tool_call_id: "tc-hfr-0",
},
]
: [];
return {
@@ -434,7 +466,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[],
);
if (summaryMode) {
if (summaryMode || !hasFullStep3Outputs) {
return (
<Step3SummaryMode
useStep1Chrome={useStep1Chrome}
@@ -448,16 +480,19 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
intentReady={intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary}
summary={summaryFlow}
enableFlow={hasFullStep3Outputs}
onViewStatic={hasFullStep3Outputs ? () => setSummaryMode(false) : undefined}
taskArtifactSubmission={taskArtifactSubmission}
/>
);
}
if (drawMode) {
if (drawMode && hasFullStep3Outputs) {
return (
<Step3ReportMode
threadIds={threadIds}
@@ -1043,21 +1078,26 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
allowVirtualMarkdownEdit
headerLeadingActionBare
headerLeadingAction={(
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: fileSandboxTarget.threadId,
path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}) ?? {
threadId: fileSandboxTarget.threadId,
path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}}
/>
<div className="flex items-center gap-1.5">
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileSandboxTypeLabel}
</span>
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: fileSandboxTarget.threadId,
path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}) ?? {
threadId: fileSandboxTarget.threadId,
path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}}
/>
</div>
)}
/>
</ThreadContext.Provider>
@@ -22,6 +22,7 @@ import {
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { Button } from "@/components/ui/button";
import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts";
import { cn } from "@/lib/utils";
import {
getStepDisplay,
@@ -177,13 +178,16 @@ function ToolStep({
const display = getStepDisplay(step.key, step.label, step.args);
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
const [, toolName] = step.key.split(":");
// 只让「自己生成/展示的文件」可点(write_file / str_replace / present_files),
// 与主聊天页一致。read_file 不可点 —— 否则智能体调用技能时读取的 SOUL.md、
// 执行的 .py 脚本等也会被渲染成可点击文件名,点开却没有意义。
// 步骤条是工具活动的实时视图:所有 write_file 都应能打开沙箱,包括为后续执行
// 而创建的 .py/.txt 等过程文件。消息气泡下的最终文件卡片才收紧为用户可交付类型。
// read_file 仍不可点,避免技能/SOUL 等输入文件被误当作产物。
// str_replace 沿用原有的文档预览范围(它没有完整写入内容可供虚拟沙箱读取)。
const stepPath = typeof step.args?.path === "string" ? step.args.path : "";
const isArtifactStep =
toolName === "present_files" ||
toolName === "write_file" ||
toolName === "str_replace" ||
toolName === "present_files";
(toolName === "str_replace" &&
collectRoundtableDeliverablePaths([stepPath]).length > 0);
return (
<ChainOfThoughtStep
@@ -130,6 +130,11 @@ export interface Step1PanelProps {
footerAction?: React.ReactNode;
/** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */
hideEnterRoundtable?: boolean;
/**
* 隐藏「继续追问」「跳过此项」两个快捷动作(多智能体会商页不需要;
* 岗位会商 / 8BF 不传保持原样)。
*/
hideFollowupActions?: boolean;
/**
* 仅替换输入区的渲染插槽。消息列表、意图状态及附件状态仍由 Step1Panel
* 统一管理,因此岗位会商定制样式时不会分叉或复制 Step 1 的业务逻辑。
@@ -171,6 +176,7 @@ export function Step1Panel(props: Step1PanelProps) {
enterButtonLabel = "进入多智能体研讨 >",
footerAction,
hideEnterRoundtable = false,
hideFollowupActions = false,
inputArea,
inputDraftKey,
initialInputText = "",
@@ -425,23 +431,27 @@ export function Step1Panel(props: Step1PanelProps) {
</button>
)}
<button
id="suggest-followup-btn"
onClick={onFollowUp}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<Repeat className="w-3 h-3 text-slate-500" />
继续追问
</button>
{!hideFollowupActions && (
<button
id="suggest-followup-btn"
onClick={onFollowUp}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<Repeat className="w-3 h-3 text-slate-500" />
继续追问
</button>
)}
<button
id="skip-question-btn"
onClick={onSkipQuestion}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<X className="w-3 h-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
{!hideFollowupActions && (
<button
id="skip-question-btn"
onClick={onSkipQuestion}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition"
>
<X className="w-3 h-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
)}
{footerAction ??
(!hideEnterRoundtable && (
@@ -43,6 +43,7 @@ const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import { useLocalSettings } from "@/core/settings";
import { collectRoundtableDeliverablePaths } from "@/roundtable-planning/lib/streaming-artifacts";
import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
@@ -221,8 +222,42 @@ export function Step2Panel(props: Step2PanelProps) {
} = props;
const step2ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// contentRef 与 Step1Panel 同款:消息内容(markdown/代码高亮/折叠卡/write_file 预览)在
// 状态更新之后才继续长高,deps 效果抓不到,靠 ResizeObserver 跟随实际内容高度。
const step2ContentRef = useRef<HTMLDivElement>(null);
const {
atBottom: step2AtBottom,
scrollToBottom: scrollStep2ToBottom,
forceScrollToBottom: forceStep2ToBottom,
} =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues], {
contentRef: step2ContentRef,
});
const latestUserDialogueId = useMemo(() => {
for (let index = step2RoundtableDialogues.length - 1; index >= 0; index -= 1) {
const dialogue = step2RoundtableDialogues[index];
if (dialogue.avatarType === "human") {
return dialogue.id;
}
}
return null;
}, [step2RoundtableDialogues]);
const previousUserDialogueIdRef = useRef<number | null>(latestUserDialogueId);
// 用户消息入列后立即定位到底部,不等待后续模型流式消息触发滚动。
useEffect(() => {
if (
latestUserDialogueId === null ||
latestUserDialogueId === previousUserDialogueIdRef.current
) {
return;
}
previousUserDialogueIdRef.current = latestUserDialogueId;
const frameId = window.requestAnimationFrame(forceStep2ToBottom);
return () => window.cancelAnimationFrame(frameId);
}, [forceStep2ToBottom, latestUserDialogueId]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
@@ -264,8 +299,8 @@ export function Step2Panel(props: Step2PanelProps) {
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
useEffect(() => {
scrollStep2ToBottom();
}, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]);
forceStep2ToBottom();
}, [forceStep2ToBottom, isParallelRunning, selectedParallelSeats]);
const resolveAgentName = useCallback(
(idOrName: string) =>
@@ -601,14 +636,15 @@ export function Step2Panel(props: Step2PanelProps) {
// 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内
// 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。
className={cn(
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4",
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden",
useStep1InputAppearance
? "eightbf-roundtable-step2-log"
: "bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner",
)}
>
<div ref={step2ContentRef} className="flex min-h-full flex-col space-y-4 p-6">
{isInitializing && step2RoundtableDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div>
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
@@ -704,6 +740,7 @@ export function Step2Panel(props: Step2PanelProps) {
)}
</>
)}
</div>
</div>
{!step2AtBottom && (
<ScrollToBottomButton
@@ -848,6 +885,8 @@ export function Step2Panel(props: Step2PanelProps) {
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
onSend={handleInject}
busy={isStep2Responding}
onPause={onPauseRoundtable}
hasConsensus={hasConsensus}
onProceed={onProceedToStep3}
/>
@@ -888,6 +927,8 @@ function Step2LegacyComposer({
onAddFiles,
onRemoveFile,
onSend,
busy,
onPause,
hasConsensus,
onProceed,
}: {
@@ -897,6 +938,9 @@ function Step2LegacyComposer({
onAddFiles: (files: File[]) => void;
onRemoveFile: (index: number) => void;
onSend: () => void;
/** 编排中或席位仍在流式输出 —— 此时显示输入框「暂停」键(与第一步输入框一致)。 */
busy: boolean;
onPause: () => void;
hasConsensus: boolean;
onProceed: () => void;
}) {
@@ -958,14 +1002,19 @@ function Step2LegacyComposer({
>
<Paperclip className="size-4" />
</button>
<button
type="button"
onClick={onSend}
title="记录干预指令并立即恢复会商执行"
className="shrink-0 cursor-pointer rounded-xl bg-primary px-4 py-2.5 text-xs font-bold text-white shadow-md shadow-primary/10 transition hover:bg-[var(--brand-900)] active:scale-95"
>
干预并发送
</button>
{busy && (
<button
type="button"
onClick={onPause}
title="暂停当前会商编排,可稍后恢复"
className="shrink-0 cursor-pointer rounded-xl bg-amber-100 px-3 py-2.5 text-xs font-bold text-amber-800 shadow-sm transition hover:bg-amber-200 active:scale-95"
>
<span className="flex items-center gap-1.5">
<Pause className="size-3" />
暂停
</span>
</button>
)}
<button
type="button"
onClick={onProceed}
@@ -1037,7 +1086,7 @@ export function Step2Message({
const rawContent = msg.content ?? "";
const { content, reasoning } = splitInlineReasoning(rawContent);
const hasContent = content.length > 0;
const presentedFiles = collectPresentedFiles(msg.steps);
const presentedFiles = collectPresentedFiles(msg.steps, msg.artifactPaths);
const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0");
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
@@ -1136,36 +1185,48 @@ function RoundtableSubtaskCard({
/**
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
*
* 三类来源都算:
* - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个);
* 三类来源:
* - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个)。后端
* present_files 已强制"必须在 /mnt/user-data/outputs 下且真实存在",与主聊天
* `chats/:threadId` 的产物面板同源,原样放行;
* - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files,
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
* 文件就出可点的卡片",而不是只有 present_files 才出。
* 成品文件就出可点的卡片"。
*
* write_file/str_replace 的 path 需过同一套交付物门槛(只认 .md/.html/
* .mermaid 等可预览产物,与主聊天 `ArtifactWriteToolCall` 的流式预览门槛同一套):
* 席位在沙箱里写的过程文件(fetch.py / r1.txt 等)与无扩展名的畸形路径不再出卡。
*/
function collectPresentedFiles(
steps: Step2Dialogue["steps"],
settledArtifactPaths?: string[],
): string[] {
if (!Array.isArray(steps) || steps.length === 0) return [];
const toolSteps = Array.isArray(steps) ? steps : [];
if (toolSteps.length === 0 && (!settledArtifactPaths || settledArtifactPaths.length === 0)) return [];
const out: string[] = [];
const seen = new Set<string>();
const add = (fp: unknown) => {
const add = (fp: unknown, requirePreviewable = false) => {
if (typeof fp !== "string" || !fp || seen.has(fp)) return;
if (requirePreviewable && collectRoundtableDeliverablePaths([fp]).length === 0) return;
seen.add(fp);
out.push(fp);
};
for (const step of steps) {
for (const step of toolSteps) {
if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths;
if (Array.isArray(fps)) fps.forEach(add);
if (Array.isArray(fps)) fps.forEach((fp) => add(fp));
continue;
}
if (
step.key === "tool_calling:write_file" ||
step.key === "tool_calling:str_replace"
) {
add(step.args?.path);
add(step.args?.path, true);
}
}
// A weak model may create a report through bash rather than present_files;
// the completed dialogue adds only real, user-facing document paths here.
settledArtifactPaths?.forEach((path) => add(path, true));
return out;
}
@@ -144,6 +144,25 @@ export function Step2SandboxLayout({
}, [artifacts, isStreaming, seatMessages]);
const previewThreadId = activeThreadId ?? "";
const selectedArtifactTarget = useMemo(() => {
const target = {
threadId: previewThreadId,
path: selectedArtifact ?? "",
content: selectedArtifactContent,
};
return taskArtifactSubmission?.targetFor(target) ?? target;
}, [
previewThreadId,
selectedArtifact,
selectedArtifactContent,
taskArtifactSubmission,
]);
// 8BF 等接入任务过程产物提交的页面,用提交按钮替换文件类型;其余普通/任务深链
// 会商仍保留原有文件类型徽标。非 Markdown 产物本身不能提交,也应显示类型。
const showTaskArtifactSubmit = Boolean(
taskArtifactSubmission?.enabled
&& taskArtifactSubmission.isMarkdown(selectedArtifactTarget),
);
const artifactPanelBody = (
<div
@@ -162,18 +181,14 @@ export function Step2SandboxLayout({
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
disableDesensitize
headerLeadingActionBare
headerLeadingAction={(
headerLeadingAction={showTaskArtifactSubmit ? (
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: previewThreadId,
path: selectedArtifact,
content: selectedArtifactContent,
}) ?? { threadId: previewThreadId, path: selectedArtifact }}
target={selectedArtifactTarget}
/>
)}
) : undefined}
headerLeadingActionBare={showTaskArtifactSubmit}
/>
) : (
<div className="relative flex size-full justify-center">
@@ -26,6 +26,8 @@ export interface Step3PanelProps {
/** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */
useStep1Chrome?: boolean;
headerTitle?: string;
/** 普通会商提供完整交付;8BF 仅保留 Markdown 总结报告。 */
step3Mode?: "full" | "report-only";
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
@@ -65,6 +67,7 @@ export function Step3Panel(props: Step3PanelProps) {
const {
useStep1Chrome = false,
headerTitle,
step3Mode = "full",
threadIds,
coordinatorName,
consensusText,
@@ -115,6 +118,7 @@ export function Step3Panel(props: Step3PanelProps) {
<HighFidelityReport
useStep1Chrome={useStep1Chrome}
headerTitle={headerTitle}
step3Mode={step3Mode}
threadIds={threadIds}
coordinatorName={coordinatorName}
consensusText={consensusText}
@@ -388,6 +388,7 @@ export function Step3ReportMode({
error={error}
themeMismatch={themeMismatch}
onRetry={() => redraw()}
onPause={pause}
onRevisionSubmit={interject}
onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview}
@@ -513,6 +514,7 @@ function AgentProgressPanel({
error,
themeMismatch,
onRetry,
onPause,
onRevisionSubmit,
onArtifactStepClick,
onArtifactPreview,
@@ -527,6 +529,8 @@ function AgentProgressPanel({
error: string | null;
themeMismatch: boolean;
onRetry: () => void;
/** 输入框「暂停」键 —— 绘制中显示,中断在途请求并把当前气泡收尾为「已暂停」。 */
onPause: () => void;
onRevisionSubmit: (revisionInstruction: string) => void;
onArtifactStepClick: UseStep3ReportReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3ReportReturn["openArtifactPreview"];
@@ -541,7 +545,21 @@ function AgentProgressPanel({
// 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll(
scrollRef,
[dialogues, phase],
{ contentRef },
);
// 新的绘制/修改气泡一出现就定位到最新进度;后续 Markdown、工具卡高度变化
// 由 contentRef 的 ResizeObserver 持续跟随。
const previousDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > previousDialogueCountRef.current) {
forceScrollToBottom();
}
previousDialogueCountRef.current = dialogues.length;
}, [dialogues.length, forceScrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
@@ -601,8 +619,9 @@ function AgentProgressPanel({
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white"
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white"
>
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -674,6 +693,7 @@ function AgentProgressPanel({
</button>
</div>
)}
</div>
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
<form
@@ -700,6 +720,16 @@ function AgentProgressPanel({
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
{busy && (
<button
type="button"
onClick={onPause}
title="暂停当前绘制(沙箱保留已生成内容,之后可输入修改要求继续)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
<button
type="submit"
disabled={inputLocked || revisionText.trim().length === 0}
@@ -14,6 +14,7 @@ import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowRight,
CheckCircle,
Download,
Eye,
@@ -26,6 +27,7 @@ import {
RefreshCw,
SendHorizontal,
Sparkles,
Workflow,
X,
} from "lucide-react";
@@ -42,6 +44,12 @@ import {
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import {
detectFlowIntent,
extractFlowGraph,
FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -74,6 +82,7 @@ interface Step3SummaryModeProps {
intentReady: ReportIntent | null;
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
coordinatorPrompt?: string | null;
@@ -81,6 +90,10 @@ interface Step3SummaryModeProps {
savedSummary?: DraftStep3SummarySnapshot | null;
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
summary: UseStep3SummaryReturn;
/** 普通会商保留流程图交付;8BF 为 false。 */
enableFlow?: boolean;
/** 普通会商进入完整的最终方案展示;8BF 不提供该入口。 */
onViewStatic?: () => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
@@ -98,10 +111,13 @@ export function Step3SummaryMode({
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
coordinatorPrompt,
savedSummary,
summary,
enableFlow = false,
onViewStatic,
taskArtifactSubmission,
}: Step3SummaryModeProps) {
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
@@ -135,6 +151,36 @@ export function Step3SummaryMode({
const { open: artifactsOpen } = useArtifacts();
// 普通会商的结构化流程图:优先使用后端抽取+校验的结果,兼容旧草稿里 flow-json。
const flowGraph = useMemo(() => {
if (!enableFlow) return null;
if (summary.flowPhase === "done" && summary.flowGraph) {
return { graph: summary.flowGraph, streaming: false };
}
if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") {
return null;
}
for (let index = dialogues.length - 1; index >= 0; index -= 1) {
const dialogue = dialogues[index];
if (dialogue.avatarType === "human") continue;
const graph = extractFlowGraph(dialogue.content ?? "");
if (graph && (dialogue.content ?? "").includes(FLOW_FENCE_OPEN)) {
return { graph, streaming: dialogue.streaming === true };
}
}
return null;
}, [dialogues, enableFlow, summary.flowGraph, summary.flowPhase]);
const [flowView, setFlowView] = useState(false);
const flowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!enableFlow || !flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// 自动生成的图优先打开;若用户已在报告沙箱内,保留当前阅读位置并露出切换入口。
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
}, [artifactsOpen, enableFlow, flowGraph, summary.autoFlow]);
// 沙箱里已写出的 Markdown 总结报告。
const mdArtifact = useMemo(() => {
for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
@@ -156,16 +202,22 @@ export function Step3SummaryMode({
return null;
}, [seatStubMessages]);
const handleAsk = (message: string, files?: File[]) => ask(message, files);
const handleAsk = (message: string, files?: File[]) => {
if (enableFlow && detectFlowIntent(message)) {
flowAutoOpenedRef.current = true;
setFlowView(true);
}
ask(message, files);
};
// 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染,
// 保持与第二步一致的流式过程可见性。
const displayDialogues = dialogues;
const [leftCollapsed, setLeftCollapsed] = useState(false);
// 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 右侧沙箱或流程图露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
const rightPanelVisible = artifactsOpen;
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
const prevRightPanelVisibleRef = useRef(false);
useEffect(() => {
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
@@ -195,16 +247,23 @@ export function Step3SummaryMode({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isFlowRunning =
enableFlow &&
(summary.flowPhase === "extracting" || summary.flowPhase === "validating");
const isStreaming =
phase === "creating" ||
phase === "streaming" ||
isFlowRunning ||
dialogues.some((d) => d.streaming === true);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
const statusLabel =
phase === "creating"
const statusLabel = isFlowRunning
? summary.flowPhase === "extracting"
? "结构化流程图抽取中…"
: "流程图校验 / 修复中…"
: phase === "creating"
? "正在初始化总结线程…"
: phase === "streaming"
? "方案总结报告智能体撰写中…"
@@ -225,7 +284,7 @@ export function Step3SummaryMode({
<Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && (
{isStreaming && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "paused" && <Pause className="w-3 h-3 text-amber-600" />}
@@ -237,12 +296,22 @@ export function Step3SummaryMode({
<button
type="button"
onClick={() => redraw()}
disabled={phase === "creating" || phase === "streaming"}
disabled={busy}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新生成
</button>
{onViewStatic && (
<button
type="button"
onClick={onViewStatic}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
最终方案展示
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
</button>
)}
</div>
</div>
)}
@@ -347,6 +416,24 @@ export function Step3SummaryMode({
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission}
sidePanel={
enableFlow && flowGraph && flowView ? (
<FlowGraphPanel
data={flowGraph.graph}
streaming={flowGraph.streaming}
onClose={() => setFlowView(false)}
onSwitchToReport={
mdArtifact
? () => {
setFlowView(false);
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
}
: undefined
}
onChange={summary.setFlowGraph}
/>
) : undefined
}
>
<AgentProgressPanel
useStep1Chrome={useStep1Chrome}
@@ -356,6 +443,11 @@ export function Step3SummaryMode({
error={error}
busy={busy}
mdAvailable={!!mdArtifact}
enableFlow={enableFlow}
flowPhase={summary.flowPhase}
onRegenerateFlow={summary.regenerateFlow}
pendingFlowClarification={summary.pendingFlowClarification}
onResolveFlowClarification={summary.resolveFlowClarification}
onRetry={redraw}
onAsk={handleAsk}
onPause={pause}
@@ -363,11 +455,31 @@ export function Step3SummaryMode({
onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission}
/>
</Step2SandboxLayout>
{enableFlow && flowGraph && !flowView && (
<button
type="button"
onClick={() => setFlowView(true)}
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
>
{flowGraph.streaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Workflow className="h-3.5 w-3.5" />
)}
流程图
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
{flowGraph.graph.nodes.length}
</span>
</button>
)}
</section>
</div>
@@ -481,6 +593,11 @@ function AgentProgressPanel({
error,
busy,
mdAvailable,
enableFlow,
flowPhase,
onRegenerateFlow,
pendingFlowClarification,
onResolveFlowClarification,
onRetry,
onAsk,
onPause,
@@ -488,6 +605,7 @@ function AgentProgressPanel({
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
taskArtifactSubmission,
}: {
@@ -499,6 +617,12 @@ function AgentProgressPanel({
busy: boolean;
/** 沙箱里是否已有 Markdown 总结报告。 */
mdAvailable: boolean;
/** 普通会商才会生成、展示和编辑流程图。 */
enableFlow: boolean;
flowPhase: UseStep3SummaryReturn["flowPhase"];
onRegenerateFlow: () => void;
pendingFlowClarification: string | null;
onResolveFlowClarification: UseStep3SummaryReturn["resolveFlowClarification"];
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
@@ -506,21 +630,46 @@ function AgentProgressPanel({
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}) {
// 进度面板独立根据 props 判断流程图阶段,不能引用外层 Step3SummaryMode 的局部变量。
const isFlowRunning =
enableFlow && (flowPhase === "extracting" || flowPhase === "validating");
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll(
scrollRef,
[dialogues, flowPhase],
{ contentRef },
);
// 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底,
// 让用户看到新阶段。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > prevDialogueCountRef.current) {
scrollToBottom();
forceScrollToBottom();
}
prevDialogueCountRef.current = dialogues.length;
}, [dialogues.length, scrollToBottom]);
}, [dialogues.length, forceScrollToBottom]);
// 结构化抽取可能复用前一张「调度中」气泡,导致消息条数不变;只依赖上面的
// dialogues.length 时就不会触发定位。阶段切换后等 DOM 提交一帧再强制下滑,
// 保证用户能立刻看到抽取/校验进度;后续增量仍由 useAutoScroll 按用户是否上翻决定。
useEffect(() => {
if (!enableFlow || (flowPhase !== "extracting" && flowPhase !== "validating")) return;
let followUpFrameId: number | null = null;
const frameId = window.requestAnimationFrame(() => {
forceScrollToBottom();
// 校验卡及其中的流式内容可能在第一帧后才完成布局,再确认一次最终高度。
followUpFrameId = window.requestAnimationFrame(forceScrollToBottom);
});
return () => {
window.cancelAnimationFrame(frameId);
if (followUpFrameId !== null) window.cancelAnimationFrame(followUpFrameId);
};
}, [enableFlow, flowPhase, forceScrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
@@ -598,10 +747,12 @@ function AgentProgressPanel({
id="step3-summary-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || availableModels.length === 0}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "撰写中不可切换;完成后或重新生成时生效"
: "切换撰写使用的推理模型(下次生成生效)"
@@ -609,7 +760,7 @@ function AgentProgressPanel({
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">未配置模型</option>
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
@@ -639,10 +790,11 @@ function AgentProgressPanel({
<div
ref={scrollRef}
className={cn(
"size-full overflow-y-auto overflow-x-hidden p-4 space-y-4",
"size-full overflow-y-auto overflow-x-hidden",
useStep1Chrome ? "eightbf-roundtable-step3-log" : "bg-white",
)}
>
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -663,10 +815,68 @@ function AgentProgressPanel({
/>
))}
{busy && (
{busy && !isFlowRunning && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
{mdAvailable ? "报告正文实时输出,右侧沙箱同步预览 Markdown" : "正在撰写 Markdown 总结报告"}
{mdAvailable
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
: "正在撰写 Markdown 总结报告"}
</div>
)}
{enableFlow && (flowPhase === "extracting" || flowPhase === "validating") && (
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{flowPhase === "extracting"
? "报告已完成,正在抽取结构化流程图"
: "报告已完成,正在校验 / 修复流程图"}
<button
type="button"
onClick={onPause}
title="暂停结构化流程图抽取,已完成的报告会保留"
className="-mr-1 rounded-full border border-blue-200 bg-white px-1.5 py-0.5 text-[10px] font-bold text-blue-700 transition hover:bg-blue-100"
>
<Pause className="mr-0.5 inline-block h-3 w-3" />
暂停
</button>
</div>
)}
{enableFlow && flowPhase === "error" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="font-bold mb-1">流程图生成失败</div>
<div className="leading-relaxed">可切换模型后重新生成流程图。</div>
<button
type="button"
onClick={onRegenerateFlow}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
>
<RefreshCw className="w-3 h-3" />
重新生成流程图
</button>
</div>
)}
{enableFlow && pendingFlowClarification && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="mb-2 font-bold">请确认下一步要做什么:</div>
<div className="flex flex-wrap gap-1.5">
{(
[
["regenerate_report", "重新生成报告"],
["modify_flow", "修改流程图"],
["regenerate_flow", "重新生成流程图"],
["qa", "只是提问"],
] as const
).map(([intent, label]) => (
<button
key={intent}
type="button"
onClick={() => onResolveFlowClarification(intent)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{label}
</button>
))}
</div>
</div>
)}
@@ -696,10 +906,13 @@ function AgentProgressPanel({
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
{mdAvailable
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
? enableFlow
? "已生成方案总结报告与流程图,右侧可预览 / 下载 .md 或切换流程图。"
: "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
: "已生成方案总结,可在下方继续修改报告或提问。"}
</div>
)}
</div>
</div>
{!atBottom && (
<ScrollToBottomButton
@@ -783,7 +996,9 @@ function AgentProgressPanel({
placeholder={
busy
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:改报告(如「执行路线写细些」)或提问"
: enableFlow
? "继续对话:改报告、修改流程图或提问"
: "继续对话:改报告(如「执行路线写细些」)或提问"
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/>
@@ -28,6 +28,8 @@ export function useAutoScroll<T extends HTMLElement>(
atBottom: boolean;
/** Force-scroll to the bottom and resume auto-follow. */
scrollToBottom: () => void;
/** Immediately jump to the bottom and resume auto-follow (for phase transitions). */
forceScrollToBottom: () => void;
} {
const threshold = options.threshold ?? 32;
const contentRef = options.contentRef;
@@ -51,16 +53,22 @@ export function useAutoScroll<T extends HTMLElement>(
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
// - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随
const lastObservedScrollTopRef = useRef(0);
// 思考块 / Markdown 代码块完成重排时,浏览器可能在没有用户输入的情况下
// clamp scrollTop。记录高度后可把这种布局变化与真实向上滚动区分开。
const lastObservedScrollHeightRef = useRef(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
let prevScrollTop = el.scrollTop;
lastObservedScrollTopRef.current = prevScrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
const onScroll = () => {
const currentScrollTop = el.scrollTop;
const currentScrollHeight = el.scrollHeight;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
const programmaticTarget = programmaticScrollTargetRef.current;
if (
@@ -71,6 +79,7 @@ export function useAutoScroll<T extends HTMLElement>(
programmaticScrollTargetRef.current = null;
prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
// 程序滚动后通常落在底部,把 atBottom 维持 true。
if (!atBottomRef.current && isAtBottom) {
atBottomRef.current = true;
@@ -84,7 +93,7 @@ export function useAutoScroll<T extends HTMLElement>(
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
if (currentScrollTop < prevScrollTop && atBottomRef.current) {
if (currentScrollTop < prevScrollTop && atBottomRef.current && !layoutChanged) {
atBottomRef.current = false;
setAtBottom(false);
} else if (isAtBottom && !atBottomRef.current) {
@@ -97,6 +106,7 @@ export function useAutoScroll<T extends HTMLElement>(
// 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。
prevScrollTop = currentScrollTop;
lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
};
el.addEventListener("scroll", onScroll, { passive: true });
// 初次测量
@@ -114,21 +124,25 @@ export function useAutoScroll<T extends HTMLElement>(
const el = ref.current;
if (!el) return;
const currentScrollTop = el.scrollTop;
const currentScrollHeight = el.scrollHeight;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
// 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。
const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1;
if (userScrolledUp && distanceFromBottom > threshold) {
const layoutChanged = currentScrollHeight !== lastObservedScrollHeightRef.current;
if (userScrolledUp && !layoutChanged && distanceFromBottom > threshold) {
// 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。
if (atBottomRef.current) {
atBottomRef.current = false;
setAtBottom(false);
}
lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
return;
}
if (!atBottomRef.current) {
// 之前已退出自动跟随,不要因为内容增长又把用户拽回去。
lastObservedScrollTopRef.current = currentScrollTop;
lastObservedScrollHeightRef.current = currentScrollHeight;
return;
}
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。
@@ -138,6 +152,7 @@ export function useAutoScroll<T extends HTMLElement>(
);
el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
@@ -163,6 +178,7 @@ export function useAutoScroll<T extends HTMLElement>(
);
el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
});
});
observer.observe(content);
@@ -183,9 +199,27 @@ export function useAutoScroll<T extends HTMLElement>(
el.scrollHeight - el.clientHeight,
);
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
lastObservedScrollHeightRef.current = el.scrollHeight;
atBottomRef.current = true;
setAtBottom(true);
}, [ref]);
return { atBottom, scrollToBottom };
// 阶段切换/用户刚发送消息时不能依赖 smooth 动画:下一帧可能还会插入校验卡、
// Markdown 或工具步骤,动画目标会停在旧高度。这里直接写入 scrollTop,由内容
// ResizeObserver 继续接管后续增高,保证关键阶段始终落在最新底部。
const forceScrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
programmaticScrollTargetRef.current = Math.max(
0,
el.scrollHeight - el.clientHeight,
);
el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop;
lastObservedScrollHeightRef.current = el.scrollHeight;
atBottomRef.current = true;
setAtBottom(true);
}, [ref]);
return { atBottom, scrollToBottom, forceScrollToBottom };
}
@@ -283,8 +283,8 @@ export function useDraftPersistence(
/** 当前会话是否有值得保存的内容。 */
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: DraftStep2Snapshot): boolean => {
const hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user");
return hasUserMessage || step2.step2RoundtableDialogues.length > 0;
const hasUserMessage = step1.interactiveMessages?.some((m) => m.sender === "user") ?? false;
return hasUserMessage || (step2.step2RoundtableDialogues?.length ?? 0) > 0;
}, []);
/**
@@ -32,6 +32,7 @@ import {
} from "@/roundtable-planning/components/IntentClarificationCard";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
import {
normalizeIntentReadySummary,
nowTime,
splitIntentReadyBlock,
} from "@/roundtable-planning/lib/roundtable-constants";
@@ -82,8 +83,9 @@ export type TimelineEntry = {
export type Step1Snapshot = {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: InteractiveMessage[];
timelineEntries: TimelineEntry[];
/** 早期草稿可能缺这两个数组字段 —— hydrate 按空数组兜底,避免 undefined 流进渲染层。 */
interactiveMessages?: InteractiveMessage[];
timelineEntries?: TimelineEntry[];
};
export interface UseStep1IntentOptions {
@@ -699,11 +701,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setIntentThreadId(snapshot.intentThreadId);
setIntentReady(snapshot.intentReady);
// 旧草稿存的 summary 可能缺 constraints/assumptions(实时流两条入口都过
// normalizeIntentReadySummary,唯独这里直接落 state)—— 先归一化再恢复,
// 保证下游 IntentSummaryCard 等消费者拿到的一定是数组。
setIntentReady(normalizeIntentReadySummary(snapshot.intentReady));
setIntentInitError(null);
setIntentInitErrorDetail(null);
setInteractiveMessages(snapshot.interactiveMessages);
setTimelineEntries(snapshot.timelineEntries);
setInteractiveMessages(snapshot.interactiveMessages ?? []);
setTimelineEntries(snapshot.timelineEntries ?? []);
step1InitRef.current = true;
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
step1InitGenRef.current += 1;
File diff suppressed because it is too large. Load diff
@@ -20,9 +20,10 @@ import {
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type {
Step2Dialogue,
StubAiMessage,
import {
completeAllStubWriteFiles,
type Step2Dialogue,
type StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
buildDashboardPrompt,
@@ -234,6 +235,16 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
return { messageId, toolCallId };
}, []);
/** 前端已完成模板渲染后,为虚拟 HTML 文件补齐 tool result,解除沙箱「正在生成」状态。 */
const completeDashStubWriteFiles = useCallback((threadId: string) => {
const current = stubRef.current[threadId] ?? [];
const nextMessages = completeAllStubWriteFiles(current);
if (nextMessages === current) return;
const nextMap = { ...stubRef.current, [threadId]: nextMessages };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
}, []);
// 预览开关(并行流防抢沙箱):只有用户当前所在视图的流允许**自动**打开沙箱。
const previewEnabledRef = useRef(false);
const lastAutoOpenRef = useRef<{ threadId: string; messageId: string; toolCallId: string } | null>(
@@ -274,8 +285,9 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const current = htmlRef.current;
if (!current) return;
const { messageId, toolCallId } = upsertDashStub(tid, current);
completeDashStubWriteFiles(tid);
openDashboardArtifact(tid, messageId, toolCallId, { force: true }); // 用户显式点击不受预览开关限制
}, [activeArtifactThreadId, upsertDashStub, openDashboardArtifact]);
}, [activeArtifactThreadId, upsertDashStub, completeDashStubWriteFiles, openDashboardArtifact]);
const abort = useCallback(() => {
aborterRef.current?.abort();
@@ -345,7 +357,10 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const data = extractReportData(res.content);
if (!data) {
// 解析失败:把 stub 还原成上一版可用的大屏(若有),别让沙箱停在生 JSON 上。
if (htmlRef.current) upsertDashStub(threadId, htmlRef.current);
if (htmlRef.current) {
upsertDashStub(threadId, htmlRef.current);
completeDashStubWriteFiles(threadId);
}
finalizeDialogue(
bubbleId,
(res.content || "").trim() ||
@@ -362,6 +377,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setData(data); // 同步结构化数据 → 应用内 React 大屏可直接渲染
dataRef.current = data; // 记下当前数据,供下一轮修改时喂回模型做最小改动
const { messageId, toolCallId } = upsertDashStub(threadId, rendered);
completeDashStubWriteFiles(threadId);
openDashboardArtifact(threadId, messageId, toolCallId);
finalizeDialogue(bubbleId, res.content, "大屏绘制完成");
@@ -378,6 +394,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setDialogueConfidence,
finalizeDialogue,
upsertDashStub,
completeDashStubWriteFiles,
openDashboardArtifact,
],
);
@@ -483,6 +500,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setDashThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const { messageId, toolCallId } = upsertDashStub(synthThreadId, savedHtml);
completeDashStubWriteFiles(synthThreadId);
// 记录预览锚点(不打开):进入大屏视图时若沙箱已开着,可立即接管显示本流产物。
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
// 历史完整对话(文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
@@ -495,7 +513,12 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
: null;
if (savedStubs && Object.keys(savedStubs).length) {
// 合并历史 stub(各气泡步骤卡的真线程预览),保留合成 stub(恢复后大屏预览锚点)。
const merged = { ...stubRef.current, ...savedStubs };
const merged: Record<string, StubAiMessage[]> = Object.fromEntries(
Object.entries({ ...stubRef.current, ...savedStubs }).map(([threadId, messages]) => [
threadId,
completeAllStubWriteFiles(messages as StubAiMessage[]),
]),
);
stubRef.current = merged;
setSeatStubMessages(merged);
}
@@ -522,7 +545,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setPhase("done");
// 不自动开沙箱(对齐 06-12「进第三步默认关闭」策略),用户点按钮随时可开。
},
[abort, upsertDashStub],
[abort, upsertDashStub, completeDashStubWriteFiles],
);
/** 从 dashboardJson(草稿/后台作业存的结构化数据)恢复:按当前主题渲染。 */
@@ -556,6 +579,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setData(parsed);
dataRef.current = parsed; // 续轮修改的基线
const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered);
completeDashStubWriteFiles(synthThreadId);
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
} else {
htmlRef.current = null;
@@ -567,7 +591,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setError(null);
setPhase(parsed ? "done" : "idle");
},
[abort, themeMode, upsertDashStub],
[abort, themeMode, upsertDashStub, completeDashStubWriteFiles],
);
// 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。
@@ -478,6 +478,36 @@ export function useStep3Report(
[],
);
/**
* 自定义 SSE 的 stub 不会天然带回 LangGraph 的 ToolMessage。经典绘制正常收尾时,
* 为每个 write_file 补一条等价的完成消息,让通用沙箱能结束「正在生成」状态并自动预览。
*/
const completeStubWriteFiles = useCallback((threadId: string) => {
const current = stubRef.current[threadId] ?? [];
const aiMessage = current.find((message) => message.type === "ai");
if (!aiMessage) return;
const completedIds = new Set(
current
.filter((message) => message.type === "tool" && message.tool_call_id)
.map((message) => message.tool_call_id!),
);
const toolResults: StubAiMessage[] = aiMessage.tool_calls
.filter((toolCall) => toolCall.name === "write_file" && !completedIds.has(toolCall.id))
.map((toolCall) => ({
id: `roundtable-report-tool-result-${threadId}-${toolCall.id}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: toolCall.id,
}));
if (toolResults.length === 0) return;
const nextMap = { ...stubRef.current, [threadId]: [...current, ...toolResults] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
}, []);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
@@ -825,6 +855,9 @@ export function useStep3Report(
? await reconcileStubFromRealFile(threadId, aborter.signal)
: "";
if (aborter.signal.aborted) return;
// 该轮已正常收尾:解除当前 HTML 虚拟预览的写入态。不能等其它并行的总结/大屏流,
// 它们各自有独立线程和 stub,互不影响。
completeStubWriteFiles(threadId);
finalizeDialogue(bubbleId, closing, "绘制完成");
setPhase("done");
setReportTheme(theme);
@@ -844,6 +877,7 @@ export function useStep3Report(
selectedModel,
openRealArtifactFallback,
reconcileStubFromRealFile,
completeStubWriteFiles,
finalizeDialogue,
],
);
@@ -1043,6 +1077,13 @@ export function useStep3Report(
},
],
},
{
id: `roundtable-report-tool-result-${synthThreadId}-${toolCallId}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: toolCallId,
},
],
};
// 历史完整对话(中间文字 + 步骤条) + stub —— 新版草稿存了就整段回显,像第二步一样。
@@ -34,9 +34,11 @@ import type {
ReportIntent,
ReportSeatDelivery,
} from "@/roundtable-planning/hooks/useStep3Report";
import type {
Step2Dialogue,
StubAiMessage,
import {
completeAllStubWriteFiles,
completeStubWriteFile,
type Step2Dialogue,
type StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import {
@@ -127,7 +129,7 @@ export interface UseStep3SummaryReturn {
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
activeArtifactThreadId: string | null;
/** 是否正在生成中(撰写/初始化/上传)—— 驱动暂停按钮与发送态。 */
/** 是否正在生成中(报告撰写或流程图抽取/校验)—— 驱动暂停按钮与发送态。 */
busy: boolean;
/** 启动总结(幂等:进行中重复调用会被忽略)。 */
start: () => void;
@@ -821,6 +823,34 @@ export function useStep3Summary(
[scheduleStubMessagesFlush],
);
/** 将 summary special 流的真实 tool 返回同步到虚拟 Markdown 文件。 */
const completeSummaryStubWriteFile = useCallback(
(threadId: string, toolCallId: string) => {
const current = stubRef.current[threadId] ?? [];
const nextMessages = completeStubWriteFile(current, toolCallId);
if (nextMessages === current) return;
const nextMap = { ...stubRef.current, [threadId]: nextMessages };
stubRef.current = nextMap;
pendingStubMessagesRef.current = nextMap;
scheduleStubMessagesFlush();
},
[scheduleStubMessagesFlush],
);
/** summary 一轮已完成时补齐尚未透传的 write_file tool 返回。 */
const completeSummaryStubWriteFiles = useCallback(
(threadId: string) => {
const current = stubRef.current[threadId] ?? [];
const nextMessages = completeAllStubWriteFiles(current);
if (nextMessages === current) return;
const nextMap = { ...stubRef.current, [threadId]: nextMessages };
stubRef.current = nextMap;
pendingStubMessagesRef.current = nextMap;
scheduleStubMessagesFlush();
},
[scheduleStubMessagesFlush],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
@@ -955,6 +985,11 @@ export function useStep3Summary(
(frame: unknown, threadId: string) => {
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0] as Record<string, unknown> | undefined;
if (String(chunk?.type ?? "").toLowerCase() === "tool") {
const toolCallId = typeof chunk?.tool_call_id === "string" ? chunk.tool_call_id : "";
if (toolCallId) completeSummaryStubWriteFile(threadId, toolCallId);
return;
}
const toolCalls = chunk?.tool_calls;
if (!Array.isArray(toolCalls)) return;
for (const tc of toolCalls) {
@@ -970,7 +1005,7 @@ export function useStep3Summary(
upsertStub(threadId, name, a as Record<string, unknown>);
}
},
[upsertStub],
[completeSummaryStubWriteFile, upsertStub],
);
const openArtifactPreview = useCallback(
@@ -1162,6 +1197,9 @@ export function useStep3Summary(
flowActiveRef.current = false;
}
// 报告线程本轮已收尾。即使后端没有单独透传 tool 帧,也只结束本线程的预览写入态;
// 后续自动流程图/大屏等并行分支完全不参与这个判断。
completeSummaryStubWriteFiles(threadId);
finalizeDialogue(bubbleId, res.content, "总结完成");
setPhase("done");
@@ -1187,6 +1225,7 @@ export function useStep3Summary(
handleArtifactToolPhase,
handleUpstreamFrame,
reconcileStubFromRealFile,
completeSummaryStubWriteFiles,
finalizeDialogue,
seedStubWithCurrentMd,
],
@@ -1939,6 +1978,13 @@ export function useStep3Summary(
},
],
},
{
id: `roundtable-stub-tool-result-${messageId}-${toolCallId}`,
type: "tool",
content: "",
tool_calls: [],
tool_call_id: toolCallId,
},
],
}
: {};
@@ -1953,7 +1999,15 @@ export function useStep3Summary(
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub =
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
replaceStubMessages(effectiveStub);
// 恢复的是静态完成记录;兼容旧草稿没有保存 tool result 的场景。
replaceStubMessages(
Object.fromEntries(
Object.entries(effectiveStub).map(([threadId, messages]) => [
threadId,
completeAllStubWriteFiles(messages),
]),
),
);
currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
@@ -2015,7 +2069,11 @@ export function useStep3Summary(
[abort, buildWriteFileVirtualUrl, replaceStubMessages],
);
const busy = phase === "creating" || phase === "streaming";
const busy =
phase === "creating" ||
phase === "streaming" ||
flowPhase === "extracting" ||
flowPhase === "validating";
return {
phase,
@@ -29,7 +29,7 @@ import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble"
/**
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
* 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。
* 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧中当前工具调用的 args。
*
* 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边
* 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createInlineReasoningDeltaFormatter } from "./stream-reasoning";
test("provider reasoning chunks form one live think block before answer content", () => {
const format = createInlineReasoningDeltaFormatter();
assert.equal(
format({ id: "msg-1", reasoning: "先检查约束", content: "" }),
"<think>先检查约束",
);
assert.equal(
format({ id: "msg-1", reasoning: ",再组织答案", content: "" }),
",再组织答案",
);
assert.equal(
format({ id: "msg-1", reasoning: "", content: "最终结论" }),
"</think>最终结论",
);
});
@@ -0,0 +1,30 @@
export type StreamedAiTextDelta = {
content: string;
reasoning: string;
id: string;
};
/**
* Preserve an open `<think>` block across provider reasoning-only chunks and
* close it exactly when visible content begins. The resulting text feeds the
* same Step2Message renderer used for literal `<think>` model output.
*/
export function createInlineReasoningDeltaFormatter(): (delta: StreamedAiTextDelta) => string {
const reasoningOpenByMessageId = new Set<string>();
return (delta) => {
const key = delta.id || "__roundtable-anonymous-reasoning__";
let text = "";
if (delta.reasoning) {
if (!reasoningOpenByMessageId.has(key)) {
reasoningOpenByMessageId.add(key);
text += "<think>";
}
text += delta.reasoning;
}
if (delta.content) {
if (reasoningOpenByMessageId.delete(key)) text += "</think>";
text += delta.content;
}
return text;
};
}
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
collectRoundtableDeliverablePaths,
mergeRoundtableStreamingToolArgs,
newestChangedRoundtableArtifact,
resolveRoundtableStreamingWritePreview,
roundtableArtifactDiskSnapshot,
} from "./streaming-artifacts";
test("roundtable keeps streamed file bytes when a compact progress step only carries path", () => {
assert.deepEqual(
mergeRoundtableStreamingToolArgs(
{ path: "/mnt/user-data/outputs/report.md", content: "# 已接收的报告" },
{ path: "/mnt/user-data/outputs/report.md" },
),
{ path: "/mnt/user-data/outputs/report.md", content: "# 已接收的报告" },
);
assert.deepEqual(
mergeRoundtableStreamingToolArgs(
{ content: "# 内容先到" },
{ path: "/mnt/user-data/outputs/report.md", content: "" },
),
{ path: "/mnt/user-data/outputs/report.md", content: "# 内容先到" },
);
});
test("roundtable opens a provisional Markdown preview when content arrives before path", () => {
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }),
"未命名文件.md",
);
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", { path: "/mnt/user-data/outputs/report.m", content: "# 报告" }),
"未命名文件.md",
);
});
test("roundtable live-previews every write_file type but filters final cards", () => {
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", {
path: "/mnt/user-data/outputs/report.md",
content: "# 报告",
}),
"/mnt/user-data/outputs/report.md",
);
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", {
path: "/mnt/user-data/workspace/fetch.py",
content: "print('fetch')",
}),
"/mnt/user-data/workspace/fetch.py",
);
assert.equal(
resolveRoundtableStreamingWritePreview("write_file", {
path: "/mnt/user-data/outputs/task-reports.json",
content: "[{}]",
}),
"/mnt/user-data/outputs/task-reports.json",
);
assert.equal(
resolveRoundtableStreamingWritePreview("str_replace", { content: "# 报告" }),
null,
);
assert.deepEqual(
collectRoundtableDeliverablePaths([
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/page.html",
"/mnt/user-data/outputs/chart.mermaid",
"/mnt/user-data/workspace/fetch.py",
"/mnt/user-data/workspace/notes.txt",
]),
[
"/mnt/user-data/outputs/report.md",
"/mnt/user-data/outputs/page.html",
"/mnt/user-data/outputs/chart.mermaid",
],
);
});
test("roundtable detects files created or overwritten by a skill without write_file SSE", () => {
const oldFile = {
path: "/mnt/user-data/outputs/report.md",
sizeBytes: 10,
modifiedAt: "2026-08-31T01:00:00.000Z",
};
const baseline = roundtableArtifactDiskSnapshot([oldFile]);
const overwritten = {
...oldFile,
sizeBytes: 20,
modifiedAt: "2026-08-31T01:00:05.000Z",
};
const generatedBySkill = {
path: "/mnt/user-data/outputs/chart.html",
sizeBytes: 30,
modifiedAt: "2026-08-31T01:00:06.000Z",
};
assert.deepEqual(
newestChangedRoundtableArtifact(
[overwritten, generatedBySkill],
baseline,
Date.parse("2026-08-31T01:00:04.000Z"),
),
generatedBySkill,
);
assert.equal(
newestChangedRoundtableArtifact(
[oldFile],
undefined,
Date.parse("2026-08-31T01:00:10.000Z"),
),
null,
);
assert.deepEqual(
newestChangedRoundtableArtifact(
[generatedBySkill],
undefined,
Date.parse("2026-08-31T01:00:07.000Z"),
),
generatedBySkill,
);
});
@@ -0,0 +1,144 @@
import {
hasCompleteArtifactExtension,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
/**
* Resolve the path that may be live-previewed while a roundtable tool call is
* still streaming.
*
* `write_file` is deliberately unrestricted here: the sandbox is a live tool
* activity view, not the final-deliverable list. Agents commonly write a
* temporary Python script before reading a skill or running it; suppressing
* that preview makes the page look stalled even though the backend is busy.
* The separate `collectRoundtableDeliverablePaths` gate remains responsible
* for limiting message-list file cards to Markdown, HTML, and Mermaid.
*
* `str_replace` retains its existing document-only behavior. It has no full
* content payload for a virtual preview, and is not a new-file write.
*/
export function resolveRoundtableStreamingWritePreview(
toolName: string | undefined,
args: Record<string, unknown> | undefined,
): string | null {
const rawPath = typeof args?.path === "string" ? args.path : "";
const path = normalizeStreamingArtifactPath(rawPath);
if (toolName === "write_file") {
// Weak models can emit content before the `path` JSON key. Give that same
// call a temporary name so the sandbox opens and continues receiving data.
const content = typeof args?.content === "string" ? args.content : "";
// A complete path of *any* file type is previewable. Keep an incomplete
// suffix such as `report.m` provisional while content is already flowing,
// so it upgrades cleanly to Markdown once the final `d` arrives.
if (path && (hasCompleteArtifactExtension(path) || !content.trim())) {
return path;
}
return content.trim() ? provisionalWriteFilePath(content) : null;
}
// Preserve the prior, conservative behavior for in-place replacement.
return languageOfStreamingArtifact(path) ? path : null;
}
/**
* Merge successive snapshots of one streaming tool call without losing bytes
* that arrived in an earlier SSE frame.
*
* LangChain's partial JSON snapshots frequently contain only `path` while the
* complete `content` has already been received (and the inverse can happen
* while the path key is still incomplete). A click on a progress step also
* carries the deliberately compact `{ path }` payload. Blindly replacing the
* prior args with either shape empties the virtual write_file preview.
*/
export function mergeRoundtableStreamingToolArgs(
previous: Record<string, unknown> | undefined,
incoming: Record<string, unknown>,
): Record<string, unknown> {
if (!previous) return { ...incoming };
const merged = { ...previous, ...incoming };
for (const key of ["path", "content"] as const) {
const prior = previous[key];
const next = incoming[key];
if (
typeof prior === "string" &&
prior.length > 0 &&
!(typeof next === "string" && next.length > 0)
) {
merged[key] = prior;
}
}
return merged;
}
/** Final artifact cards intentionally expose only user-facing deliverables. */
export function collectRoundtableDeliverablePaths(values: Iterable<unknown>): string[] {
const seen = new Set<string>();
const paths: string[] = [];
for (const value of values) {
if (typeof value !== "string") continue;
const path = normalizeStreamingArtifactPath(value);
if (!path || !languageOfStreamingArtifact(path) || seen.has(path)) continue;
seen.add(path);
paths.push(path);
}
return paths;
}
export interface RoundtableWatchedArtifact {
path: string;
sizeBytes: number;
modifiedAt: string;
}
/** Stable disk signature used to notice both newly created and overwritten files. */
export function roundtableArtifactDiskSnapshot(
files: Iterable<RoundtableWatchedArtifact>,
): Map<string, string> {
const snapshot = new Map<string, string>();
for (const file of files) {
const path = normalizeStreamingArtifactPath(file.path);
if (!path) continue;
snapshot.set(path, `${file.sizeBytes}:${file.modifiedAt}`);
}
return snapshot;
}
/**
* Pick the newest file created or changed since the prior directory poll.
*
* On the first poll there is no baseline yet. A small grace window catches a
* skill output that lands between mounting the streaming bubble and the first
* HTTP response, without reopening unrelated files from older roundtable runs.
*/
export function newestChangedRoundtableArtifact(
files: readonly RoundtableWatchedArtifact[],
previous: ReadonlyMap<string, string> | undefined,
watchStartedAtMs: number,
firstPollGraceMs = 2_000,
): RoundtableWatchedArtifact | null {
let newest: RoundtableWatchedArtifact | null = null;
let newestTime = Number.NEGATIVE_INFINITY;
for (const file of files) {
const path = normalizeStreamingArtifactPath(file.path);
if (!path) continue;
const signature = `${file.sizeBytes}:${file.modifiedAt}`;
const modifiedTime = Date.parse(file.modifiedAt);
const changed = previous
? previous.get(path) !== signature
: Number.isFinite(modifiedTime) &&
modifiedTime >= watchStartedAtMs - firstPollGraceMs;
if (!changed) continue;
const sortableTime = Number.isFinite(modifiedTime) ? modifiedTime : 0;
if (!newest || sortableTime > newestTime) {
newest = { ...file, path };
newestTime = sortableTime;
}
}
return newest;
}
@@ -18,7 +18,7 @@
*
* 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。
*/
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { nanoid } from "nanoid";
@@ -1238,7 +1238,8 @@ export default function BusinessChainEditorPage() {
{ n: 1 as const, label: "链条编排" },
{ n: 2 as const, label: "基本信息" },
]).map((s, i) => (
<Fragment key={s.n}>
// div.contents 代替 <Fragment>:dev tagger 注入 data-lov-id 会触发 React 警告。
<div className="contents" key={s.n}>
{i > 0 && <span className="h-px w-10 bg-border" />}
<button
type="button"
@@ -1261,7 +1262,7 @@ export default function BusinessChainEditorPage() {
</span>
{s.label}
</button>
</Fragment>
</div>
))}
</nav>
@@ -22,7 +22,6 @@
* 仍走旧实现,本页不复用它,避免互相牵连。
*/
import {
Fragment,
useCallback,
useEffect,
useMemo,
@@ -853,8 +852,9 @@ function ChainFlow({ chain }: { chain: BusinessChain }) {
style={{ maxHeight: 220 }}
>
<div className="flex min-w-max items-stretch gap-3 py-1">
{/* div.contents 代替 <Fragment>:dev tagger 注入 data-lov-id 会触发 React 警告。 */}
{stages.map((stage, sIdx) => (
<Fragment key={sIdx}>
<div className="contents" key={sIdx}>
<div
className={cn(
"flex shrink-0 flex-col rounded-xl border p-2.5",
@@ -897,7 +897,7 @@ function ChainFlow({ chain }: { chain: BusinessChain }) {
<ArrowRightIcon className="size-4 text-zinc-300 dark:text-[#4A6491]" />
</div>
)}
</Fragment>
</div>
))}
</div>
</div>
@@ -1054,8 +1054,9 @@ function TableFlowSummary({ chain }: { chain: BusinessChain }) {
const { stages, seatById } = useChainStages(chain);
return (
<div className="custom-scrollbar flex max-w-[280px] select-none items-center gap-1.5 overflow-x-auto py-1">
{/* div.contents 代替 <Fragment>:dev tagger 注入 data-lov-id 会触发 React 警告。 */}
{stages.map((stage, sIdx) => (
<Fragment key={sIdx}>
<div className="contents" key={sIdx}>
<div
className={cn(
"flex shrink-0 items-center rounded border px-2 py-1",
@@ -1073,7 +1074,7 @@ function TableFlowSummary({ chain }: { chain: BusinessChain }) {
{sIdx < stages.length - 1 && (
<span className="shrink-0 text-zinc-300 dark:text-[#4A6491]">→</span>
)}
</Fragment>
</div>
))}
</div>
);
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff. Show more