Compare commits

...
69 Commits
Author SHA1 Message Date
gozeros 8bc8b0e082 sync: 鍚屾灞€鍩熺綉鏈€鏂颁唬鐮?(source master @ b69568e4) 2026-10-21 14:51:31 +08:00
董宏凯 4f7f07504a Merge branch 'master-nj' into 'master'
fix(buttons): 行动分析宿主路由支持切换任务id/行动id

See merge request knowledge-base-bj/deerflow-code!39
2026-09-07 09:53:09 +00:00
董宏凯 1244fb1f2f 代码提交 2026-09-07 17:30:53 +08:00
吉鑫升 3ca5528640 feat(knowledge): support standalone file ingestion and configurable wiki entry
Use neutral knowledge-service labels and sanitize user-facing task errors.
2026-09-07 17:07:27 +08:00
董宏凯 250db4de04 fix(buttons): 行动分析宿主路由支持切换任务id/行动id
- TaskButtonsPage: 宿主路由追加任务id时,行动分析(xdfx) tab 下增加 idKind 下拉框可选任务id/行动id,与外部链接类型一致
- runTaskButton: host 分支按 idKind 取 ctx.actionId 或 ctx.taskId(行动id缺失回退任务id),与 url 分支同规则
- AGENTS.md: 同步更新 idKind 适用范围说明(url/host 均适用)
2026-09-07 15:56:50 +08:00
董宏凯 f9b6d7e322 Merge branch 'master-nj' into 'master'
feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底

See merge request knowledge-base-bj/deerflow-code!38
2026-09-07 06:58:42 +00:00
董宏凯 6edb6fa7aa 本地模式线上区分-v1 2026-09-07 14:57:17 +08:00
董宏凯 cf9fe58905 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
#	frontend-web/src/pages/WorkspaceRoutes.tsx
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/app/gateway/deps.py
#	offline-backend-20260512/backend/pyproject.toml
2026-09-07 14:46:36 +08:00
董宏凯 56bb4f141d 代码提交 2026-09-06 22:17:53 +08:00
董宏凯 75f288b241 代码提交 2026-09-06 20:49:35 +08:00
董宏凯 bf42a48fcc 代码提交 2026-09-06 20:25:14 +08:00
董宏凯andCursor 5e953c9fcb feat(report-collaboration): complete RC-BE-015–018 backend, eval suite, and MySQL sync
Ship report rewrite/version APIs, security budget and audit persistence, AgentScope worker wiring, and the 20-task quality eval harness so the frontend can integrate against a stable contract once enabled in config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 17:32:15 +08:00
董宏凯 814a44f0b9 代码修改 2026-09-05 11:28:25 +08:00
董宏凯 b80433f091 chore: 同步仓库执行说明与约束文档
功能说明:\n- 更新仓库根目录 AGENTS.md 为当前约定和功能说明口径\n- 主要是开发约束与架构说明的同步,供后续提交与协作流程对齐
2026-09-04 23:18:59 +08:00
董宏凯 5c75e1af16 feat(deep-research): 强化任务链路可靠性并接入 agentfx 报告助手种子
功能说明:\n- 重构 Deep Research 调度与执行链,补齐 dispatcher、executor、live hub 与依赖注入入口,提升任务下发、执行、回调的稳定性\n- 增强运行时持久化与恢复能力,接入业务映射、任务来源与任务事件日志模型,覆盖持久化与执行可靠性关键路径测试\n- 新增 agentfx 报告助手 seed 与任务报告导入脚本配置,支持离线环境快速初始化与任务流模板回填\n- 完善 deep-research 与 thread_runs 路由参数编排,兼容不同业务映射与运行入口的前后端联动\n- 补充前端 agentfx deep-link 解析,保证 deep-research 场景下的登录跳转与任务上下文一致
2026-09-04 23:18:52 +08:00
董宏凯 438772f0b7 feat(frontend): 上线报告协作与多轮 RAG 消息聚合体验
功能说明:\n- 新增报告协作页(Report Collaboration)核心状态层、消息适配与会话面板,支持按节点/阶段选择和协作上下文展示\n- 补齐 report-collaboration API 与类型定义,并接入页面路由/构建路径,形成可运行的协作链路\n- 重构多轮 RAG 与 write/artefact 消息分组逻辑,优化 AgentChat 与 DeepResearch 场景下的消息展示顺序与可读性\n- 新增/更新 DeepResearch 前端页面(Workbench、报告配置、沙箱联动)以承接报告生成与复用能力\n- 增加报表协作相关单测,覆盖事件归并、消息适配与关键 reducer 行为
2026-09-04 23:18:27 +08:00
吉鑫升 dea4697ea0 feat: improve knowledge sync and entity alignment 2026-09-04 18:43:12 +08:00
g zy 7dbac9cc56 Merge branch 'gzy_browser' into 'master'
fix

See merge request knowledge-base-bj/deerflow-code!37
2026-09-04 03:21:48 +00:00
Rocky Kwok a723a9ca02 fix 2026-09-04 11:16:38 +08:00
董宏凯 e58da3fbdf 代码提交 2026-09-03 20:06:54 +08:00
吉鑫升 60e2c72514 feat: preserve Wiki vectors across knowledge transfers and add local BGE-M3
Unify Wiki reading, preserve metadata and graph links, add asynchronous file-backed transfers, and reuse compatible vectors. Includes regression coverage and live acceptance notes.
2026-09-03 19:21:10 +08:00
董宏凯 1eae02f782 代码提交 2026-09-03 17:45:06 +08:00
董宏凯andCursor db333a4c33 feat: 重构多轮RAG展示并完善报告协作、业务链审核与 agentfx 种子
抽出专用多轮RAG消息列表并升级会话模型;强化报告协作画布与检查器;业务链普通用户改为提交审核;扩展 agentfx 报告技能种子,并将含 admin 的用户名自动提权为管理员。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 17:34:16 +08:00
董宏凯andCursor f109a95535 feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底
支持浏览器侧素材快照撰写、拒绝报告读时修复、Mermaid PNG 导出与 Word 导出加固。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 11:16:50 +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
2002 changed files with 382049 additions and 16794 deletions

No files matched your search

+57 -29
View File
@@ -87,39 +87,65 @@ 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_llmwiki_knowledge_management.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).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
deploy
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
# 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
# Report collaboration (RC-BE-000–018) regression coverage.
!offline-backend-20260512/backend/tests/test_report_collaboration_*.py
!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py
deploy
.history
# Cloned third-party repos (local only)
@@ -150,8 +176,10 @@ docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# Local-only scratch directory (not committed)
0622/
+4 -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:
@@ -94,13 +96,13 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` + `task-reports.json`(14 个 categoryType 的 `{contentJson, categoryType}` 数组,contentJson 为 JSON 字符串)→ 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "task-report:saved", taskId: String(taskId)}, "*")`(`notifyTaskReportSaved`,`agentfx-report-schema.ts`)——卡片路径在 `TaskReportImportCard` 导入成功回调里直发;技能路径由 `AgentChatPage` 从消息流检测 `task_report_import.py` 的成功 stdout(`"success": true` + `task_id`)代发——**仅在回合结束后检测**(`!thread.isLoading` 才扫描;流式过程中 messages Line truncated
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
### Configuration
+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;
+117
View File
@@ -0,0 +1,117 @@
# Wiki 数据流转与本地编码验收(2026-09-03)
## 结论
本地 BGE-M3 小批量编码已安装、配置并实际运行。已有 Wiki 的向量化、自动沉淀、
普通库文件导出、助手库异步导入、助手库文件导出、空普通库回流及两端向量检索已实测通过。
**技能自动生成 Wiki 尚未完成验收**:WeKnora 的两个生成模型都返回 HTTP 402
`Insufficient Balance`。不能把后半段通过等同于全链路通过。
本次修改运行在本地 DeerFlow 开发服务(前端 5175、后端 8001),连接用户提供的远端
WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有直接写 WeKnora 图数据库。
## 本地编码配置
- 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。
- 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。
- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。
- 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。
- 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用
`local_model_path` 指定离线权重目录,详见后端 README。
- 查询向量在本地实时编码。已有 Wiki 在内容哈希、模型指纹匹配且向量非空时直接复用。
不同模型或维度不能混用;导入不静默重新编码。
- 原始文档分段向量与模型生成后的 Wiki 文本不同,不能相互冒用。本次新建的是 Wiki
正文的 BGE-M3 索引,不是把既有 512 维文档向量改名为 1024 维。
## 实际数据验收
来源是已有普通知识库“图片测试111”,没有改写其 Wiki 内容。
| 阶段 | Wiki | Wiki 向量 | 实体 | 引用关系 |
| --- | ---: | ---: | ---: | ---: |
| 普通知识库导出 | 19 | 101 | 19 | 108 |
| 助手知识库导入后导出 | 19 | 101 | 19 | 108 |
| 新建普通知识库回流后导出 | 19 | 101 | 19 | 108 |
| 知识梳理总库导出(7 个来源记录) | 19 | 101 | 19 | 108 |
逐项断言通过:
1. 三份包的 Wiki slug、标题、正文、摘要、页面类型、别名、分类路径、出链一致。
2. 101 条向量的原始 float32 字节、分段编号、维度和模型指纹完全一致。
3. 将导入前后实体 ID 映射回原 Wiki slug 后,108 条关系的端点及谓词集合完全一致。
4. 总库导出没有因多个来源重复同步而重复导出同一当前页面的分段向量。
5. 元数据回归测试另覆盖 tags、目录恢复、原始文档片段不计入 Wiki 数量、原生技能无
ownership 记录时可被选中、短 Wiki 不漏向量、模型指纹不兼容时不假装向量检索。
普通包约 1.01 MB,助手包约 0.79 MB,回流包约 0.83 MB。普通包还带 2 个原始文档及
108 个原始片段;助手导出提供 Wiki、Wiki 向量和关系,不重新上传原始文档触发二次生成。
### 检索
实际查询:“军方用什么工具评估极端天气带来的风险?”
- 回流普通库 `/api/llmwiki/search` 返回 `mode=vector`,首条“国防气候评估工具”。
- 助手库 `/api/assistant-knowledge/search` 返回 `retrieval_mode=vector`,首条同上;
首个命中分段余弦相似度约 0.6343。
- 浏览器实际提交总库检索,显示“向量检索 · 余弦相似度”和相同命中;不是只检查返回字段。
- 普通库采用已有页面级聚合评分,因此页面最终分数与单分段余弦值不必相等。
- 回流的向量写入 **DeerFlow 管理的本地 Wiki 索引**。未写入 WeKnora 原生向量数据库,
本次结论适用于本系统问答检索,不能声称原生 WeKnora 自身的检索也已完成该向量导入。
### 页面
普通与助手库复用同一 Wiki 阅读组件:目录树、类型/标签筛选、分类面包屑、别名标签、
正文内链、出链、反向链接及邻接关系视图。窄窗口可收起目录。
浏览器验证了“索引 → 国防气候评估工具”、展开关系视图、助手 Wiki 跳回普通库来源,
以及普通 Wiki 的“在知识梳理中查看”反向跳转。
### 验收数据位置
- 原有普通库:`48f669dc-433e-4aab-b5ea-897b6f7a3d2f`
- 知识梳理总库:`c3fd9841-0718-44dc-860e-aae3020fd485`
- 最终助手测试库:`489be905-08c8-40fe-8d60-42ac21dd945a`
- 最终普通回流测试库:`428162aa-03f6-4c61-8994-360f2f39e0c9`
新建测试库名称均以“Wiki链路验收-0903-1826”开头,保留用于复核;未删除用户已有库。
本地 `.runtime/wiki-live-result.json` 保存验收结果,`.runtime/wiki_live_acceptance.py`
保存本次验收流程,均为忽略的运行产物,不包含认证口令。
## 这次实际修复的问题
- 内置技能只有磁盘目录、没有归属记录时,被错误判定为不存在。
- 技能应上传去代码后的知识正文,让 WeKnora 生成 Wiki,而不是伪造一个 `skill` 类型页面。
- Wiki 生成等待使用实际 stats 接口;零页结果明确失败,不无期限等待。
- 向量化完成后自动导出沉淀到总库,并阻断导出回调重复触发全量同步。
- 短摘要和短正文同时存在时,旧分段阈值会将整页过滤为零向量。
- 分类路径必须先创建 WeKnora folder 并绑定 folder_id;只传 category_path 会被服务端清空。
- Wiki 的标签/分类/别名/原始引用保留;图节点绑定真实 Wiki,避免生成重复的空实体页面。
- 保留归一化 float32 原始字节;导入中不能用 `section_index or index`,因为合法编号 0 会被改写。
- 总库多个来源保留审计,但检索与导出只使用当前 Wiki 版本对应的向量,避免旧贡献污染检索。
- 包解析改为同盘临时 SQLite,上传先落盘再异步导入,导出完成后才提供文件下载。
## 阻塞与未通过的范围
1. 技能 `knowledge-base-ingest` 已送入专门创建的普通测试库,但远端
`deepseek-v4-flash` 和 `qwen3.6-flash` 均因余额不足失败。WeKnora 即使失败也可能把
文档 parse_status 标为 completed。需恢复可用生成模型后重新运行技能全链路验收。
2. 已采用磁盘暂存和有界批处理,但本次只有约 1 MB 实测,**未做数 GB 压测、断点续传、
进程中断恢复验收**。大包部署仍需磁盘容量、代理 body size 和超时配置。
3. 当前实体去重主要按类型及规范化名称;这次没有完成跨别名、歧义实体的模型对齐及
多来源事实冲突合并验收,不能视为这一历史需求已全面完成。
4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端
写入失败后的无损重试、跨实例图片资源打包仍需独立验证。
5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通
知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。
6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模
分页与助手问答的最终生成回答不属于这轮通过结论。
## 自动化检查
- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、
WeKnora 接口、向量检索与知识库管理)。
- 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。
- `git diff --check` 通过(只有仓库换行提示)。
- 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown
插件类型及其他模块;本轮新建/修改的 Wiki 组件、助手 API、技能面板没有出现在错误清单中。
浏览器实际页面与跳转已验证,但不能声称全项目构建通过。
@@ -0,0 +1,719 @@
# AgentScope 2.0 多智能体报告协作工作台——前端实施计划
> 文档状态:前端代码已落地、契约冻结;联调等待后端运行中干预与报告改写
> 编写日期:2026-09-02;进度更新日期:2026-09-06
> 目标项目:`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 执行状态。
## 0. 当前进度
`frontend-web/src/report-collaboration/` 已实现两栏工作台、会话/方案/run API 客户端、mock driver、SSE reducer、`MessageList` 适配、方案卡、画布、节点抽屉、Composer、报告预览与历史。`RC-FE-001`~`013` 的页面骨架与契约层视为完成。
后端已通规划前路径(澄清 → 出方案 → 选定 → `queued` run),见后端计划 §0。前端真实执行体验仍受阻:
- `GET /runs/{id}/stream` 已挂载(`RC-BE-011`);dispatcher/租约已落地(`RC-BE-012`),默认 `worker_enabled=false` 仍不自动领取 queued run。
- 报告改写 / 恢复接口已由后端 `RC-BE-015` 挂载。
- 质量角色已由 `QualityRoleKernel` 跑通(`RC-BE-013`);真实 AgentScope 模型/`web_search` 仍待后续票。
联调顺序:先打开 `report_collaboration.enabled` 验证规划前 REST 与 `GET /runs/{id}/stream` 重放;画布自动执行需同时打开 `worker_enabled`(默认关)。
## 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. 前端开发顺序
页面与契约层已完成(代码在 `frontend-web/src/report-collaboration/`):
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/视觉/性能验收。
依赖规则:
- 契约已冻结为 `src/report-collaboration/api/types.ts`;后端 `RC-BE-001` 已对齐。
- 真实 SSE 联调:后端 `RC-BE-011` 已挂载 `GET /runs/{id}/stream`;默认无后台循环时流只会重放已入库事件。
- 报告版本操作已接后端 `RC-BE-015`(改写候选 / apply / restore)。
- 执行画布实时状态依赖打开 `worker_enabled`;质量角色内核已在 `RC-BE-013` 落地,真实成员见 `RC-BE-017`。
---
## 10. 前端完成定义
页面与 mock 联调路径已落地。真实后端可联调 `GET /runs/{id}/stream`、领取/恢复(`RC-BE-012`,默认不挂 worker)、质量角色(`RC-BE-013`)以及报告改写/恢复(`RC-BE-015`)。此前以 mock driver 覆盖交互,不以「前端未编码」计未完成。
前端只有同时满足以下条件才算完成:
1. 页面结构与 Coze 参考页面逐状态验收通过。
2. 消息区复用 DeerFlow `MessageList`,其他聊天页面无回归。
3. 候选卡、左侧对话流程、右侧流程图和报告来自同一 session/run/event 状态。
4. 点击候选只预览,明确确认后才执行。
5. 用户可以在规划、运行、完成后三个阶段继续对话。
6. 协助卡显示或提交不会导致前端误标完成。
7. 节点点击只打开说明抽屉,可查看职责、所需输入、预期输出、工具和验收条件,不展示执行结果。
8. 节点重开、返工和 superseded 状态表达正确。
9. SSE 重连、刷新和历史切换无重复消息和状态倒退。
10. 报告修改以候选和版本形式完成,不直接覆盖正式报告。
11. 页面始终只有对话与画布两个常驻区域,所有执行过程和产出入口都能在对话时间线中找到。
12. 响应式、键盘、对比度、加载反馈和错误恢复通过验收。
@@ -0,0 +1,481 @@
# 报告协作工作台 — 前端对接指南
> **适用版本:** RC-BE-000~018 已落地(2026-09-06)
> **配套文档:**
> - [前端实施计划](./agentscope-多智能体报告协作工作台-前端实施计划.md)
> - [后端实施计划](./agentscope-多智能体报告协作工作台-后端实施计划.md)
> - 模块 README:`frontend-web/src/report-collaboration/README.md`
本文说明**前端如何对接真实后端**:环境配置、API 契约、用户流程、状态机、错误处理与联调验收。前端 API 客户端与页面已实现,通常**无需再写一套 HTTP 层**。
---
## 1. 结论速览
| 问题 | 答案 |
|------|------|
| 前端还要写接口吗? | **不用**。`src/report-collaboration/api/` 已封装 REST + SSE,页面通过 `getReportCollaborationDriver()` 调用。 |
| 为什么调不通? | 最常见:`config.yaml` 里 `report_collaboration.enabled: false` → 全部 **503**;或 `worker_enabled: false` → run 不入队执行。 |
| Mock 和真后端怎么切? | **Mock 已移除**。前端永远请求真实后端(`deerflow.rc-mock` / `VITE_RC_MOCK` 开关已删除);联调需 `enabled: true`。 |
| 字段会和 MySQL 对不上吗? | 前端只认 JSON 契约(`api/types.ts`),不直连表。缺列表现为后端 500/SQL 错误,部署后需**重启 Gateway** 跑 `create_all` + 列同步。 |
| 从哪进页面? | `/page/workspace/report-collaboration`(新会话)或 `/page/workspace/report-collaboration/:sessionId`。侧边栏入口默认注释,可直接 URL 访问。 |
---
## 2. 架构:前端已有什么
```
ReportCollaborationPage
└─ useReportCollaborationSession(唯一数据入口)
├─ driver.getSnapshot() ← REST 冷启动
├─ driver.openRunStream() ← SSE 实时事件
├─ driver.sendMessage / createCommand / startRun …
└─ event-reducer ← 事件 → UI 状态
ReportConversationPanel
└─ MessageList(message-adapter 映射 CollaborationMessage → LangGraph Message)
```
### 2.1 目录结构
```text
frontend-web/src/report-collaboration/
api/
types.ts # 冻结契约(snake_case,与后端 Pydantic 一致)
client.ts # rcFetch:鉴权、幂等键、乐观锁、错误映射
sessions.ts # 会话 CRUD、消息
plans.ts # 方案请求 / 列表 / 选定
runs.ts # Run、SSE、命令、节点合同
reports.ts # 报告版本、改写、恢复
driver.ts # 真实后端统一门面(Mock 驱动已移除)
state/
event-normalizer.ts
event-reducer.ts
selectors.ts
message-adapter.ts
hooks/
useReportCollaborationSession.ts # 快照 → SSE → reducer → 动作
useReportCollaborationStream.ts # 断线重连、after_seq 续接
useReportCollaborationMessages.ts
components/ …
pages/ReportCollaborationPage.tsx
```
### 2.2 事实源原则
- **冷启动:** `GET /sessions/{id}` 返回 `SessionSnapshot`(session、messages、plans、run、nodes、agent_runs、commands、report_versions、last_seq)。
- **运行中:** `GET /runs/{id}/stream?after_seq=N` 订阅 durable SSE。
- **断线 / 序号缺口:** `GET /runs/{id}/events?after_seq=N` REST 补拉。
- **澄清 / 规划阶段(尚无 run):** 每 2.5s 轮询快照(后端事件主要在 run 流上)。
纯展示状态(预览方案、聚焦成员、面板宽度)**不**写入服务器,仅存前端。
---
## 3. 后端启用与部署
### 3.1 必改配置
文件:`offline-backend-20260512/backend/config.yaml`
```yaml
report_collaboration:
enabled: false # 联调必须改为 true
worker_enabled: false # 要看成员执行必须 true
planner_model: null # 建议填可用模型名
coordinator_model: null
research_model: null
writer_model: null
reviewer_model: null
# … token_budget、max_concurrent_runs_per_user 等见同文件
```
| 配置 | 现象 |
|------|------|
| `enabled: false` | 所有路由 **503**,`code: REPORT_COLLABORATION_DISABLED` |
| `enabled: true`,`worker_enabled: false` | REST/SSE 可用,run 入队但不自动执行(测 API 可用,无成员流式输出) |
| 两者 `true` + 模型配置完整 | 完整联调 |
### 3.2 MySQL / 表结构
生产环境 Alembic 自动升级默认关闭,启动时走 `create_all` + `_ensure_orm_columns_sync`。
- 迁移脚本(可选手动跑):`20260905_01`、`20260906_01`、`20260907_01`
- ORM:`packages/harness/deerflow/persistence/report_collaboration/model.py`(13 张表)
- **部署后务必重启 Gateway**,观察日志是否有 `Failed to auto-add column report_collaboration_*`
已有 RC-003 时代旧表会通过列同步补上 `template_snapshot_json`、`budget_snapshot_json`、`usage_json`、`budget_exhausted_reason` 等可空列,并创建 `rewrite_proposals`、`audits`。
### 3.3 前端环境变量
| 变量 | 说明 |
|------|------|
| `VITE_BACKEND_BASE_URL` | Gateway 地址,默认 `http://127.0.0.1:8001` |
请求经 `apiFetch`,自动携带登录 JWT(`credentials: "include"`)。**必须先登录 DeerFlow**。
---
## 4. API 基址与请求约定
### 4.1 Base URL
```text
{VITE_BACKEND_BASE_URL}/api/report-collaboration
```
代码常量:`REPORT_COLLABORATION_BASE()`(`api/client.ts`)。
### 4.2 请求头
| Header | 何时携带 | 说明 |
|--------|----------|------|
| `Authorization` | 全部 | 由 `apiFetch` 注入 |
| `X-Idempotency-Key` | 写操作 | 格式 `rc-{uuid}`;重复提交返回首次结果 |
| `X-Expected-Revision` | 选方案、开 run、改会话等 | 会话 `requirement_revision` 或方案 `revision` |
| `Accept: text/event-stream` | SSE | `openRunStream` |
### 4.3 错误响应格式
```json
{
"code": "REPORT_COLLABORATION_DISABLED",
"message": "报告协作工作台未启用",
"detail": { }
}
```
前端映射(`api/client.ts`):
| HTTP | 异常类 | 典型场景 |
|------|--------|----------|
| 409 | `ConflictError` | revision 过期、active run 冲突 |
| 422 | `ValidationError` | 参数/语义错误、缺幂等键 |
| 429 | `RateLimitError` | 预算耗尽、每用户并发 run 上限 |
| 5xx | `ServerError` | 可带同一幂等键重试 |
| 0 | `NetworkError` | fetch 失败 |
### 4.4 完整路由清单(与前端 client 对齐)
| 方法 | 路径 | 前端模块 |
|------|------|----------|
| POST | `/sessions` | `sessions.createSession` |
| GET | `/sessions` | `sessions.listSessions` |
| GET | `/sessions/{id}` | `sessions.getSessionSnapshot` |
| PATCH | `/sessions/{id}` | `sessions.updateSession` |
| DELETE | `/sessions/{id}` | `sessions.deleteSession` |
| GET | `/sessions/{id}/messages` | `sessions.listMessages` |
| POST | `/sessions/{id}/messages` | `sessions.sendMessage` |
| POST | `/sessions/{id}/plan-requests` | `plans.requestPlans` |
| GET | `/sessions/{id}/plans` | `plans.listPlans` |
| POST | `/sessions/{id}/plans/{planId}/select` | `plans.selectPlan` |
| POST | `/sessions/{id}/runs` | `runs.startRun` |
| GET | `/sessions/{id}/reports` | `reports.listReportVersions` |
| POST | `/sessions/{id}/report-rewrites` | `reports.createRewrite` |
| POST | `/sessions/{id}/report-rewrites/{id}/apply` | `reports.applyRewrite` |
| POST | `/sessions/{id}/report-versions/{id}/restore` | `reports.restoreReportVersion` |
| GET | `/runs/{id}` | `runs.getRun` |
| GET | `/runs/{id}/events` | `runs.listRunEvents` |
| GET | `/runs/{id}/stream` | `runs.openRunStream` |
| POST | `/runs/{id}/cancel` | `runs.cancelRun` |
| POST | `/runs/{id}/commands` | `runs.createCommand` |
| POST | `/runs/{id}/commands/{id}/confirm` | `runs.confirmCommand` |
| POST | `/runs/{id}/commands/{id}/cancel` | `runs.cancelCommand` |
| GET | `/runs/{id}/nodes/{nodeId}/contract` | `runs.getNodeContract` |
| GET | `/runs/{id}/artifacts` | (后端已暴露,前端按需接) |
| GET | `/runs/{id}/sources` | (后端已暴露,前端按需接) |
---
## 5. 用户流程与 API 时序
```mermaid
sequenceDiagram
participant U as 用户
participant FE as 前端 RC
participant API as Gateway
participant W as Worker
U->>FE: 输入需求
FE->>API: POST /sessions
FE->>API: POST /sessions/{id}/messages
API-->>FE: 澄清(快照 messages)
U->>FE: 回答澄清
FE->>API: POST /messages
FE->>API: POST /plan-requests
API-->>FE: 候选方案(快照 plans)
U->>FE: 采用方案
FE->>API: POST /plans/{id}/select
U->>FE: 开始协作
FE->>API: POST /runs
FE->>API: GET /runs/{id}/stream
W-->>API: 持久化事件
API-->>FE: SSE
U->>FE: 运行中干预
FE->>API: POST /commands → confirm
```
### 5.1 阶段 0:进入页面
| 路由 | 行为 |
|------|------|
| `/page/workspace/report-collaboration` | 空态;`POST /sessions` 后跳转带 `sessionId` |
| `/page/workspace/report-collaboration/:sessionId` | 加载快照;有 `location.state.rcInitialMessage` 时自动发首条消息 |
路由注册:`frontend-web/src/pages/WorkspaceRoutes.tsx`。
侧边栏入口已在 `workspace-nav-chat-list.tsx` 中放开(「报告协作」项),也可直接访问 URL。
### 5.2 阶段 1:需求澄清(无 run)
```http
POST /api/report-collaboration/sessions
POST /api/report-collaboration/sessions/{sessionId}/messages
Content-Type: application/json
{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null }
```
- `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。
- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。
- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。
### 5.3 阶段 2:候选方案
```http
POST /api/report-collaboration/sessions/{sessionId}/plan-requests
POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select
```
- **预览方案**(`previewPlanId`):纯前端,不调 API。
- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。
- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。
- 右栏 `ReportFlowCanvas` 使用 `PlanNode` / `PlanEdge` 渲染 DAG。
### 5.4 阶段 3:开始执行与 SSE
```http
POST /api/report-collaboration/sessions/{sessionId}/runs
{ "plan_id": "plan-xxx" }
GET /api/report-collaboration/runs/{runId}/stream?after_seq=0
Accept: text/event-stream
```
SSE 每条 `data:` 为 JSON:
```typescript
interface CollaborationEvent {
eventId: string;
sessionId: string;
runId?: string | null;
seq: number;
type: CollaborationEventType;
timestamp: string;
data: unknown;
}
```
常用 `type`(完整列表见 `api/types.ts`):
| 类型 | 含义 |
|------|------|
| `message.created` / `message.delta` / `message.completed` | 对话镜像流式 |
| `tool_call.*` / `tool_result.*` | 工具调用与返回 |
| `team_message.*` | 成员 / 协调者广播 |
| `plan.proposed` / `plan.selected` | 方案生命周期 |
| `run.status.changed` | Run 状态 |
| `node.status.changed` / `node.progress` | 节点执行 |
| `command.confirmation.required` | 需用户确认的干预 |
| `report.version.created` | 报告版本落盘 |
| `heartbeat` | 保活 |
### 5.5 阶段 4:运行中干预
```http
POST /api/report-collaboration/runs/{runId}/commands
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/confirm
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/cancel
POST /api/report-collaboration/runs/{runId}/cancel
```
- Run 活跃时 `send()` 自动走 `createCommand`(而非 `sendMessage`)。
- 可从节点抽屉 / `AgentStageSelector` 传入 `target_node_id`、`agent_run_id`。
- `InterventionCard` 展示 `confirmation_required` 命令;确认后调 `confirmCommand`。
`CommandIntent` 枚举(后端分类,前端展示 reason/impact):`answer_question`、`update_requirements`、`research_again`、`rewrite_section`、`rerun_node`、`replan`、`cancel` 等。
### 5.6 阶段 5:报告版本与改写
```http
GET /api/report-collaboration/sessions/{sessionId}/reports
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites/{rewriteId}/apply
POST /api/report-collaboration/sessions/{sessionId}/report-versions/{versionId}/restore
```
- `createRewrite` 只生成候选,不直接覆盖正式版本。
- `applyRewrite` / `restoreReportVersion` 产生新的 head 版本,历史保留。
- `ReportPreviewDialog` 展示 `ReportVersion.markdown`。
---
## 6. 前端状态机(接入方须知)
**不要**在页面里用零散 REST 字段拼 UI 状态。统一路径:
```text
SessionSnapshot → applySessionSnapshot()
CollaborationEvent[] → normalizeCollaborationEvent() → applyCollaborationEvents()
→ selectors(derivePagePhase、selectPlanForCanvas、…)
```
### 6.1 核心 Selector
| Selector | 用途 |
|----------|------|
| `derivePagePhase` | Composer 占位、方案卡、空态 |
| `selectPlanForCanvas` | 画布 DAG(含预览方案) |
| `selectAgentTabs` | 并行成员 Tab |
| `selectNodeStatusMap` | 节点颜色 / 状态 |
| `selectPendingConfirmationCommands` | 干预确认卡 |
| `selectHeadReportVersion` | 报告预览入口 |
| `selectStreamingAgentRunIds` | 流式成员指示 |
### 6.2 SSE 连接策略(`useReportCollaborationStream`)
- Run 非终态时连接;`completed` / `failed` / `cancelled` 后关流并 REST 最终补拉。
- 断线:指数退避重连(1s~30s),`after_seq` 取 reducer 内 `lastSeq`。
- `hasSeqGap`:暂停 live 应用,先 `listRunEvents` 回填。
### 6.3 乐观消息
用户发送时插入 `metadata.optimistic: true` 的 pending 消息;服务端镜像带相同 `idempotency_key` 时 reducer 原地替换。
---
## 7. 消息与 MessageList 复用
`useReportCollaborationMessages` 将 `CollaborationMessage[]` 转为 `@langchain/langgraph-sdk` 的 `Message[]`,并构造合成 `BaseStream` 供 `MessageList` 消费。
| 来源 | 渲染 |
|------|------|
| `human` / `ai` 文本 | 普通气泡 |
| `tool_calls` + `tool` | 现有工具卡 |
| `team_message` 事件 | 带 `name` 的成员发言 |
| 业务卡 | `businessCardSlot`(方案组、命令确认、报告可用) |
分组键:`core/messages/utils.ts` 中 `assistant:task-report-import` 同级扩展 `assistant:business-card`,判据 `additional_kwargs.report_collaboration_card`。
### 7.1 在其他页面嵌入 RC(最小集)
1. `useReportCollaborationSession(sessionId)`
2. `ThreadContext.Provider` + `MessageList` + `businessCardSlot`
3. 引入 `report-collaboration/styles/report-collaboration.css`
---
## 8. 单测
Mock 驱动(原 `api/mock.ts`)与 `deerflow.rc-mock` / `VITE_RC_MOCK` 开关已移除,前端只请求真实后端;本地演示请直接联调后端。
单测:
```bash
cd frontend-web
pnpm test:report-collaboration
```
---
## 9. 联调验收清单
### 9.1 准备
- [ ] `report_collaboration.enabled: true`
- [ ] `report_collaboration.worker_enabled: true`
- [ ] 五个 `*_model` 已配置且可用
- [ ] Gateway 已重启(MySQL 表/列就绪)
- [ ] 前端已登录(JWT 有效)
### 9.2 探活
```bash
curl -s -H "Authorization: Bearer <token>" \
http://127.0.0.1:8001/api/report-collaboration/sessions
```
期望:**200** + `{ "sessions": [...] }`,而非 503。
### 9.3 页面流程(浏览器 Network)
1. 打开 `/#/page/workspace/report-collaboration`
2. 输入需求 → `POST .../sessions` **200**
3. 跳转 → `GET .../sessions/{id}` **200**,响应含 `SessionSnapshot`
4. 首条消息 → `POST .../messages` **200**
5. 请求方案 → `POST .../plan-requests` **200**
6. 采用方案 → `POST .../plans/{id}/select` **200**
7. 开始协作 → `POST .../runs` **200**
8. `GET .../runs/{id}/stream` 持续收到 SSE(`message.delta`、`node.status.changed` 等)
9. 刷新页面 → 消息与 `last_seq` 续接正确
10. 运行中输入「重新检索」→ `POST .../commands` → 确认卡 → `confirm` **200**
### 9.4 常见失败
| 现象 | 原因 | 处理 |
|------|------|------|
| 全部 503 | `enabled: false` | 改配置并重启 |
| run 一直 queued | `worker_enabled: false` | 改为 true |
| 409 选方案 | revision 过期 | 刷新后重选(前端已自动 refresh) |
| 429 | 预算/并发上限 | 调大配置或取消其他 run |
| 500 + SQL | 表缺列 | 重启 Gateway,查列同步日志 |
---
## 10. 契约与类型维护
- **单一事实源:** `frontend-web/src/report-collaboration/api/types.ts`
- **后端镜像:** `offline-backend-20260512/backend/app/report_collaboration/contracts/`
- 修改字段须同步两份实施计划与本指南,并跑前后端相关测试。
`SessionSnapshot` 形状(摘要):
```typescript
interface SessionSnapshot {
session: ReportSession;
messages: CollaborationMessage[];
plans: ReportPlanCandidate[];
run: ReportRun | null;
nodes: CollaborationNodeState[];
agent_runs: AgentRun[];
commands: CollaborationCommand[];
report_versions: ReportVersion[];
last_seq: number;
}
```
`ReportRun` 可选字段(RC-BE-016):`usage`、`budget_exhausted_reason`。
---
## 11. 后续工作(非对接阻塞)
| 项 | 说明 |
|----|------|
| RC-BE-019 | 灰度发布与离线部署文档 |
| 侧边栏入口 | 已放开(`workspace-nav-chat-list.tsx` 的「报告协作」项) |
| iframe 嵌入 | RC 页尚未专门适配 `embed=1`,可参考现有深链模式扩展 |
| 真人盲评 | RC-BE-018 为确定性评测集,上线前仍需计划 §11.4 人工评审 |
---
## 12. 相关代码索引
| 用途 | 路径 |
|------|------|
| API 客户端 | `frontend-web/src/report-collaboration/api/` |
| 会话 Hook | `frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts` |
| 页面 | `frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx` |
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| 后端路由 | `offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py` |
| 配置 | `offline-backend-20260512/backend/config.yaml` → `report_collaboration` |
| 持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_collaboration/` |
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 后再作为受控的内部多人平台发布。
+3
View File
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=true
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
+6
View File
@@ -60,3 +60,9 @@
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A
# VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
# true 时,普通登录后进入同一 Vite 应用中隔离的 /official/*。
# 已指定 redirect 或 taskId/goPath 的登录仍保持原来的优先级。
# 仅 .env.development 默认 true;其余环境保持 false。
# VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
+3
View File
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
# 内网环境保持当前定制版默认入口。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
+3
View File
@@ -14,6 +14,9 @@ VITE_MOCK_SQ_REPORT=false
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 线上保持当前定制版默认入口。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
+3
View File
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
# SIT 环境保持当前定制版默认入口。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
@@ -0,0 +1,7 @@
# 技能知识归纳与“知识梳理”助手知识库
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。
File diff suppressed because it is too large. Load diff
+1
View File
@@ -11,6 +11,7 @@
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
},
+2
View File
@@ -45,6 +45,7 @@ import {
} from "@/position-roundtable/demo/position-roundtable-demo-config";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
const PUBLIC_EMBED_PREFIXES = [
"/share/",
@@ -203,6 +204,7 @@ export function App() {
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="188" height="241" viewBox="0 0 188 241"><defs><style>.a{fill:url(#a);}.b{fill:#fff;opacity:0.47;}</style><pattern id="a" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 376 482"><image width="376" height="482" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXgAAAHiCAYAAADiYNeDAAAgAElEQVR4nOy993csOZYmdoFIw6R73pav6qrqrvY9flatmdldrWa1+nv1m46OjrSr7ZnZdtW2ulxX1Xt8ho+eTBcB6CACQFzcQEQaMskkeb938iUzvP3w4cPFhdDpGK41hGg+ez3jxZm0veg69r85Vj0zzHqe/thnOmgJAIn9xh8zrQsAqwBwAwA2ybf7rNqPWbaH1nG/V+zfCZRX02y/BQBtO90gAwDz4KcAoOw0bacPAWAAAH3794n9xr/NZx99DiD8dutkdvv4k6F9Toae88Zc+LOk53umZj3fSec57/W7Imhd67NnnCc6loAdETsy7iKiXgOADQBYtyTvvjft9B4i9hW7Tfy7a6dhghcNNKBR0eYIfoRI3hH7iPw2n0NL7OZzhL7N9GNE8kO0PVdwDOw2GYyFggmesWiYZ+wmADwEgHsAcB8AbgPAHft9Cyl2R9DtyHfbbqtlCRx/5JznQMk/sftdt7+d4saf1H7G9jOKfA+Rot8FgB0AeGW/XwDASwB4BgB7dlsMxkLABM84K6zZzyr53LREPongO0t4J5yN1J5j3dEUBL9jSf6EfI7th8E4FdiDZw++wPwefMcS9+sA8BoAPAaABwDwyJL4LUT4MYsmmWIvlxlZjUXjSH3Xkv8WADwHgKcA8AQAvskLAq3ns3LYg59ve1cMrOAZs2DN2hfryCu/awk9RvC3llSZnycSVMDFMLIkHyN483sbeftH9sPqnjEVWMGzgi/QfJ7SEvYbAPCW/X7TEvl9a7GsEZume34HfyXgInSO0efAWjqG6L8CgK8B4M/2e6sxGocV/Hzbu2JggmeCLxCe5xoJVbxrSR0T/BuW4Ncu7qCvBY4twX9NCP5rq+5xiGap7Jng59veFQMTPBN8Ae2P5JFV5+8BwLcA4B077S6yZ9xn3ugVxmxQyJ5xn1fWzvkCAD4FgM+syt/K7yYT/Hzbu2JggmeCv5V/dB7tctcq9LcIwd+5kCNjTMIrQvB/tp9tEHl0zq79nC+Y4JcGTPDXl+DbVpl/GwC+CwAf2obSB6hz0Q0b9cJYXvSRRXNg7RzTQPtHAPgtAPzBqvrze9GZ4JcGTPDXi+BvothzZ8Vggn/AkVWXHqkleUzwzrrZRbH3iwMT/NKACf56ELywZP4hIvN3bPz6Lfu5sbC9My4C+8iieWmtHEf6f7Skvxj2Y4JfGjDBX12C71jv/I5V5h8AwHcswX9gY9YvsimOcX7QNrb+E0vwv7d/P7c+/qszzY3DBL80YIK/mgTfsw2kP7Af02D6trVnbrNav7bYtxaN+XxpG2Z/bT+fWj//9GCCXxowwV8dgu/ZKJh71o75DiL4tzhenUFwbCNuHMH/3to2L218/fxkzwS/NGCCvxoEv2oJ/ScA8GOr3h8ji2aeZFmMq48xsmieWhX/CwD4uW2cnS8lAhP80oAJ/vISfBcpdmO//NAS/I9suCN3QmLMAmXDK39pCf7XtmHWKfrh1Ntigl8aMMFfToJfsQ2lP7GfD23qgHuW9BmMebFtSf1rG23zc6vqP5natmGCXxowwV8egpcoIuZt6607S+ataNrd6/1sM6ZF/LnLrEf/C/txit5F3tQnOmOCXxowwV8OgheW1I398jcA8D0bx37XfkI7RgOzO2NGRJ8/ZRX9tiX33wDAz6yN82XtQ8YEvzTgXovLjRWr2E1UzPcB4C8swX9rqnS8zPGMaVBPktKmg76PQm3v2V7QH1sb55kdwISxhGAFv7wKftX67H8PAH9pOyi5wTTCqBiq2JnYGfMgeP6iin6MBiUxHab+DQD+P+vVn/ilWMEvDVjBLx82LIl/aP31v7fWzKPokeo5XyQGo/IsRX6EgqVtI7Ret436D2270GuW5J/ZkacYSwJW8Mul4Dcsmf8tAPydTQT2wPY+Jcely+Njz51x5rAK3j3O8ed6xyp6EzP/362a/xVok9WSFfwygBX8cmAzt190Tuh/Z332H+fT6QOMiR34AWYsCo6g3QMYVfS3Qefi4zXUqe6u7RW7ZVMjMC4QrOAvXsHfsSGPPwWRe+3fRjnZ64+HiZ1xnnDPdf3jfWgtmj+Chn8FgP/XhlZuz3SUrODPFKzgLw43rPL5nlXsPwUNH03MGcPEzrgI+OeullE37McFAty0ETcf20yWrOYvAKzgL0bB37Ahj39vPx/YRtTV6v6Z0BlLjPjzfuLVPMB/s5//MRXJs4I/U7CCP1/0rMIxqv1/tuT+gyixMxiXF+Z5ftdG2bgBZTZtR6knZ5aWmDERrODPT8EbcjcWzD9aYv++Jfu4JcPKnXGZUP/cH9sG199YJf9/2b/jnaNYwZ8pWMEvHm3bE/A7ltj/yWZ+vHXVT5zBsALmW2h4yFXrz5tImxfnOhj4NQQr+MUq+Jbt4m3i2v+97ZH6ejxChhU74yoh+l4d2vQGxo//P21em0/tQOEFWMGfKVjBLw63rQ9pyP0frHp/fFVPlsGYAhvWprxlLctNO+0z22mKccZgBb8YBX/bhj6ahtR/Z9MO3A+36zqOzLh9BuMywUdXVh70FzbH/H8FgP8bAP4lT0PMCv5MwQr+bNG2se0m3cB/sOT+0VSZHxmM64X7Nlx43fr0Kzbv/BP25c8OTPBnB5E3Jmn9H6wl82NryYTk7hUHSTnAYFwHhM971/bc3rD9QF6z3vwf+M04GzDBnw3MA/q+tWT+2XZiah46j6T4YDCuJCZbkCs2EMGp+Zb15//EmSlPDyb402PNhj3+bzYE8sNoCKSoYXL24BnXAqTNSVcefCOI/toSvbFv/g9r2Rzz8zE/mOBPh7vIb/9fbeclck01p/NlMCg0SpNd4pYNJe7aGSt2eMDZEpYxPJjg54fx1/8KAP5LniisGM6suJ5epWjQ+YAcPCgHgxGiYPg8uMZF2BTvSMt2CuzZTKumhvyvdhQpxoxggp8dLlLGhEH+R9uB6d18K1iMeGLX1z5Ui8GYCCFwu9SKbXztICX/M46wmR1M8LOhZcn8761y/+to56VAuTO5MxhRmPfETi/cmoov/5a1Pl2Kg/9mO0WlfEGnAxP89FixNoyJlPlfAOB/snmvS/jRlpjcGYypEH1HPNEnduzXjjVw2nbml7XJyhgBmOCnQ9fmbP+pVe4/CcjdKRGtUXw7kzuDMRWC90f44WCRon9g3701S/L/j42VH/EFbgYT/GSs2Tjdf7Ke+18FMe4Vtc7EzmDMDCyIzN9+iMCA5I0lmiHe+ozDKJvBBN+Mnm3R/0fbgemHlNwDr51j2hmMU0KXkcVVb/6OTd7Xs9xlrJvf8gAi9WCCr8ea7Z36T9Zz/4s8j7UAFNfOXjuDsRBUkvFZ30bnAsu8iwoApFX0n7CSj4MJPo5VG6b1TyDyHqo/tIMUWCDlDqzcGYwzBcrX5HnedYoq5t20HaI6dkHTGPs7OxYsA4EJvooVOwLNP1rl/qOA3B2xK0XqkWy/MxhnAvw+5danBpCymFbaNbfsuzm0S46skufoGgQm+BBda8uYbJD/2WaEDMndELvSIJxRKJjYGYwzBX6f3N+ZApCiIPqS5G/YoIcEEf0n9u9rD2CCD2Cqe++gOPcf2YE7CuTkbj9Bi/8FHzWDcdXh2ryU/ZZRJX9ifXnT0/VzDqEswARfoG3J/ac2K+Rf2Rb7Aki5c4Mqg3FBcO8hVJS8i65JLLFrS/LXPq0BE3xhsjy2oy/9J9tCf8/P9cpdlR0xGAzGxUAboSUsyQdK/p59d09sZI3x4r+67nVsJniAh1ax/2er4MuxU51iMP4fcKw7g3HhcDXoLCuiJEMlf9++wwYHVs1vXeebdt0J/o4l9/9ke8kV6Qe854eVO1szDMZSwJG8t2vAhVAK1ON11xL8f80H876muM4Ev5E3zmj9v9tG1df8HPP8qKwgeCDkzh48g7EEsFFsRsmb0aFkgg/pNftOg+0A9S/Xdfi/60rwa7bl3WWFfNvPCTx3YOXOYCwrvJK3bWMmjLKwaxL7Tpt3e8cq+Z9fx96u15HgE9tL9b/YiJl3/RyOlmEwLhmsko9H17xr33Gwja+/tA2w1wbXjeDbNjPkP9gcM98JrkHeS1UxuTMYlw35eyuKT0nwXfuOm2RkL62C/+w6hU9eN4J/3Q6Q/c8BueOqHm1QrY4yw2AwlgW006GyqQ3Kd9eN8frPtoer+XxxXe5f60p6zP6UfHIiYSNmfmzHUP2RbWQtkVfx1AUdMIPBOBsom2fS5q4psGHf+UMbNnloI2t00M52BbVc68q2IebtLi5fjLhtwyEdud8LlnPWjELqnZU7g7H8cO+pH5fBkLu1a0Bg0r5r3/0ta9n8DLQuwievcE6pq6vg89PKb/CKbWwx2SH/znZsssshYqfZIbnTKoNxORAMpqZRVI0uOkOFo0IZDjjynryGQTX3/NXBVffguzbHzN/aVAQf2lzvtvXd9lJVqmyNd+AskQzG5YEm728eHy+LmDkhHXuvWg4wETXfWKL//Cpnn7zqBH8HtOnVpv+xGDRbrBWTrcTPQyKzOJGzgmcwLgfo++uVfFa8w17BO5LXH+Q1ep178SalwZOreqevLsELWAWtv1tEzYh/VzSy2ichL+1tT1WqLine truncated

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68"><defs><style>.a{fill:#fff;stroke:#707070;stroke-width:3.75px;opacity:0;}.b{fill:url(#a);}.c{stroke:none;}.d{fill:none;}</style><linearGradient id="a" x1="0.431" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient></defs><g transform="translate(-71.077 -67.746)"><g class="a" transform="translate(71.077 67.746)"><rect class="c" width="68" height="68"/><rect class="d" x="1.875" y="1.875" width="64.25" height="64.25"/></g><path class="b" d="M114.406,106.791a7.707,7.707,0,0,0-4.354,1.353L98.7,99.937a13.3,13.3,0,0,0,1.742-6.583c0-.421-.03-.842-.06-1.263L112,85.207a4.687,4.687,0,1,0-1.952-2.345L99.691,88.995a13.678,13.678,0,0,0-6.1-7.365l2.072-10.341c.15,0,.3.03.451.03a4.66,4.66,0,1,0-4.655-4.659,4.727,4.727,0,0,0,1.412,3.367l-2.1,10.462A13.479,13.479,0,0,0,80.05,81.87l-6.4-5.712A6.129,6.129,0,1,0,71.7,78.443L77.677,83.7a13.456,13.456,0,0,0,.691,19.991l-5.316,5.622a5.948,5.948,0,0,0-3.394-1.022,6.163,6.163,0,1,0,6.156,6.162,6.343,6.343,0,0,0-.721-2.916l5.826-6.193a13.464,13.464,0,0,0,16.1-2.946l11.022,7.966a7.77,7.77,0,0,0-1.2,4.118,7.658,7.658,0,1,0,15.316,0A7.817,7.817,0,0,0,114.406,106.791Zm0,0" transform="translate(13 9.671)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68">
<defs>
<linearGradient id="ap-idea" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#f3212c"/>
<stop offset="1" stop-color="#aa071a"/>
</linearGradient>
</defs>
<g fill="url(#ap-idea)" transform="translate(12 10)">
<path d="M22 2.2A12.8 12.8 0 0 0 9.2 15c0 5.1 2.9 9.5 7.1 11.6v3.8a2.4 2.4 0 0 0 2.4 2.4h6.6a2.4 2.4 0 0 0 2.4-2.4v-3.8A12.8 12.8 0 0 0 34.8 15 12.8 12.8 0 0 0 22 2.2Zm3.3 26.8h-6.6v-2.1h6.6Zm-3.3-26.8A10.4 10.4 0 0 1 32.4 15a10.4 10.4 0 0 1-6.3 9.5 2.4 2.4 0 0 0-1.3 2.1v.3h-5.6v-.3a2.4 2.4 0 0 0-1.3-2.1A10.4 10.4 0 0 1 11.6 15 10.4 10.4 0 0 1 22 2.2Z"/>
<path d="M17.6 39.2a2 2 0 0 0 2 2h4.8a2 2 0 0 0 2-2v-1.4h-8.8Zm-1.8-3.8h12.4a1.6 1.6 0 1 0 0-3.2H15.8a1.6 1.6 0 1 0 0 3.2Z"/>
<path d="M8.4 22.6a1.6 1.6 0 0 0 0-3.2H3.8a1.6 1.6 0 1 0 0 3.2Zm31.8 0a1.6 1.6 0 0 0 0-3.2h-4.6a1.6 1.6 0 1 0 0 3.2ZM7.2 10.8a1.6 1.6 0 0 0 2.3-2.3L6.2 5.3A1.6 1.6 0 0 0 4 7.6Zm29.3-2.3a1.6 1.6 0 0 0 2.3 2.3l3.2-3.2A1.6 1.6 0 1 0 39.7 5.3Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient></defs><g transform="translate(14482.893 -23044.904)"><rect class="a" width="68" height="68" transform="translate(-14482.893 23044.904)"/><path class="b" d="M43.013,57.336h-.255l-.074,0a14.064,14.064,0,0,1-8.911-3.467,6.216,6.216,0,0,1,6.1-5.029h6.278a6.218,6.218,0,0,1,6.1,5.03A14.073,14.073,0,0,1,43.6,57.324h-.009l-.067,0H43.5l-.063,0h-.424ZM30.159,54.9H8.5a1.717,1.717,0,0,1-1.715-1.716v-10.1A1.717,1.717,0,0,1,8.5,41.363H20.6a1.717,1.717,0,0,0,1.716-1.715V37.73H11.824a1.717,1.717,0,0,1-1.716-1.715V8.8a1.718,1.718,0,0,1,1.716-1.716h8.768V3.437A3.44,3.44,0,0,1,24.029,0h6.93A3.441,3.441,0,0,1,34.4,3.437V7.089H42.87A1.718,1.718,0,0,1,44.586,8.8V26.022c-.515-.046-1.044-.07-1.574-.07A17.327,17.327,0,0,0,30.157,54.9Zm4.219-36.07a3.437,3.437,0,1,0,3.438,3.437A3.441,3.441,0,0,0,34.378,18.828Zm-13.619,0A3.437,3.437,0,1,0,24.2,22.265,3.441,3.441,0,0,0,20.758,18.828ZM24.029,3.437V7.083h6.924V3.437Zm7.256,47.59v0a14.059,14.059,0,1,1,23.456,0,9.508,9.508,0,0,0-8.589-5.455H44.686a7.193,7.193,0,1,0-3.347,0H39.873a9.5,9.5,0,0,0-8.587,5.457ZM43.013,42.5a3.923,3.923,0,1,1,3.923-3.924A3.928,3.928,0,0,1,43.013,42.5ZM6.844,31.07a1.717,1.717,0,0,1-1.716-1.715V15.176a1.715,1.715,0,1,1,3.431,0V29.355A1.717,1.717,0,0,1,6.844,31.07ZM49.8,27.327h0a17.187,17.187,0,0,0-3.429-1.051v-11.1a1.715,1.715,0,1,1,3.431,0v12.15Zm3.406-.007a1.717,1.717,0,0,1-1.715-1.715V18.724a1.715,1.715,0,0,1,3.431,0v6.881A1.717,1.717,0,0,1,53.206,27.32Zm-51.491,0A1.717,1.717,0,0,1,0,25.605V18.724a1.715,1.715,0,0,1,3.431,0v6.881A1.717,1.717,0,0,1,1.715,27.32Z" transform="translate(-14477.664 23050.15)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68">
<defs>
<linearGradient id="ap-task" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#f3212c"/>
<stop offset="1" stop-color="#aa071a"/>
</linearGradient>
</defs>
<g fill="url(#ap-task)" transform="translate(14 12)">
<path d="M28.4 2.2H15.6A3.4 3.4 0 0 0 12.2 5.6v2.8H8.6A3.4 3.4 0 0 0 5.2 11.8v28.8A3.4 3.4 0 0 0 8.6 44h22.8a3.4 3.4 0 0 0 3.4-3.4V11.8a3.4 3.4 0 0 0-3.4-3.4h-3.6V5.6A3.4 3.4 0 0 0 28.4 2.2Zm-12.8 3.4h12.8v2.8H15.6Zm19.2 35.8H8.6V11.8h3.6v2.2a1.6 1.6 0 0 0 1.6 1.6h12.8a1.6 1.6 0 0 0 1.6-1.6v-2.2h3.6Z"/>
<rect x="11.4" y="24.2" width="3.6" height="11.6" rx="1.2"/>
<rect x="18.2" y="20.4" width="3.6" height="15.4" rx="1.2"/>
<rect x="25" y="27.2" width="3.6" height="8.6" rx="1.2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 924 B

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2064"
height="2591" viewBox="0 0 2064 2591">
<path fill-rule="nonzero"
d="M 68.582031 0 C 66.890625 0 65.300781 1 64.617188 2.640625 C 49.46875 38.800781 33.71875 76.199219 33.628906 106.539062 C 33.550781 134.230469 46.445312 155.730469 59.949219 175.71875 L 59.949219 175.730469 C 52.414062 175.671875 44.992188 175.371094 37.472656 174.171875 C 27.210938 172.53125 16.585938 169.199219 5.585938 165.738281 C 5.15625 165.609375 4.722656 165.539062 4.296875 165.539062 C 2.824219 165.539062 1.417969 166.300781 0.625 167.609375 C -0.402344 169.300781 -0.140625 171.46875 1.257812 172.871094 L 1.261719 172.878906 C 11.230469 182.871094 21.8125 193.53125 36.980469 200.050781 C 51.5625 206.320312 70.167969 208.628906 88.085938 210.78125 L 88.027344 210.78125 C 127.152344 253.410156 166.617188 296.300781 215.761719 334.941406 C 230.558594 346.578125 246.292969 357.878906 261.785156 369 C 277.171875 380.050781 292.441406 391.011719 306.660156 402.191406 C 326.132812 417.511719 343.5625 433.179688 356.773438 450.019531 C 377.808594 476.828125 388.449219 506.960938 395.4375 538.628906 L 395.4375 538.640625 C 372.683594 510.710938 349.328125 483.929688 324.855469 469.339844 C 295.957031 452.121094 265.800781 452 236.960938 451.808594 L 236.933594 451.820312 C 235.792969 451.808594 234.707031 452.261719 233.898438 453.070312 C 233.085938 453.871094 232.632812 454.96875 232.636719 456.121094 C 232.722656 494.011719 232.699219 533.539062 256.304688 567.480469 C 279.351562 600.621094 324.738281 628.019531 369.191406 654.679688 L 369.132812 654.691406 C 371.476562 683.539062 373.40625 710.691406 367.109375 728.03125 C 365.828125 731.558594 364.191406 734.71875 362.058594 738.820312 C 358.910156 744.871094 354.964844 752.441406 350.417969 764.960938 C 342.890625 785.699219 333.769531 819.839844 323.648438 883.109375 C 303.347656 1010.019531 278.722656 1255.949219 308.882812 1409.269531 C 326.265625 1497.660156 361.828125 1555.210938 393.511719 1606.519531 C 417.042969 1644.632812 438.207031 1678.960938 448.835938 1720.09375 C 466.921875 1790.089844 454.648438 1880.246094 444.5 1954.386719 C 440.613281 1982.78125 437.042969 2008.839844 435.726562 2030.261719 C 433.410156 2067.929688 438.066406 2091.179688 442.304688 2112.347656 L 442.304688 2112.355469 C 445.910156 2130.347656 449.152344 2146.589844 447.757812 2169.28125 C 447.429688 2174.613281 446.835938 2180.359375 445.933594 2186.535156 L 445.933594 2186.542969 C 436.582031 2250.328125 393.613281 2362.808594 362.105469 2416.636719 C 339.761719 2454.804688 323.546875 2462.851562 309.597656 2469.925781 C 303.980469 2472.777344 298.261719 2475.660156 292.691406 2480.238281 C 273.378906 2496.113281 256.457031 2532.078125 240.183594 2566.417969 C 239.578125 2567.703125 239.640625 2569.203125 240.363281 2570.429688 C 241.082031 2571.65625 242.359375 2572.449219 243.777344 2572.542969 L 243.789062 2572.542969 L 243.882812 2572.550781 L 243.882812 2572.554688 C 261.582031 2573.746094 279.460938 2574.957031 296.386719 2574.957031 C 323.671875 2574.957031 349.332031 2571.84375 368.355469 2559.785156 C 392.640625 2544.394531 405.109375 2514.960938 414.488281 2493.074219 C 417.011719 2487.183594 419.289062 2481.871094 421.546875 2477.308594 C 432.054688 2456.039062 441.753906 2451.664062 451.855469 2446.960938 C 453.054688 2446.402344 453.929688 2445.320312 454.226562 2444.042969 L 454.238281 2443.996094 L 454.238281 2443.988281 C 476.210938 2348.800781 498.035156 2254.347656 524.300781 2196.363281 L 524.300781 2196.367188 C 536.363281 2169.730469 549.363281 2150.796875 562.695312 2131.402344 C 576.28125 2111.625 590.1875 2091.402344 603.300781 2062.445312 L 603.304688 2062.453125 C 605.183594 2058.292969 607.054688 2053.949219 608.894531 2049.421875 C 625.066406 2009.675781 639.746094 1954.289062 653.585938 1902.101562 C 663.0625 1866.367188 672.15625 1832.09375 681.085938 1805.085938 C 699.308594 1749.992188 716.863281 1725.09375 734.371094 1705.144531 L 734.421875 1705.148438 C 727.285156 1740.746094 721.597656 1776.628906 729.320312 1807.472656 C 733.351562 1823.570312 740.996094 1838.160156 749.066406 1853.613281 C 758.828125 1872.296875 769.433594 1892.636719 776.359375 1919.675781 C 783.289062 1946.71875 786.574219 1980.605469 789.527344 2011.140625 L 789.527344 2011.148438 C 791.875 2035.402344 794.023438 2057.78125 797.746094 2072.316406 L 797.746094 2072.320312 C 797.929688 2073.023438 798.128906 2073.769531 798.316406 2074.441406 L 798.320312 2074.441406 C 802.132812 2087.941406 807.617188 2093.914062 813.515625 2100.324219 C 821.132812 2108.597656 830.351562 2118.441406 839.742188 2151.671875 L 839.738281 2151.675781 C 840.105469 2152.96875 840.402344 2154.039062 840.765625 2155.398438 C 857.484375 2217.601562 874.097656 2354.589844 872.078125 2423.390625 C 870.472656 2478.15625 857.140625 2488.390625 845.339844 2498.136719 C 842.320312 2500.632812 839.085938 2503.296875 836.09375 2506.566406 C 820.847656 2523.230469 812.652344 2554.886719 804.800781 2584.753906 C 804.457031 2586.0625 804.75 2587.453125 805.589844 2588.515625 C 806.40625 Line truncated
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

@@ -18,7 +18,9 @@ import {
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import type { StreamdownProps } from "streamdown";
import { SafeStreamdown } from "@/core/streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
@@ -302,11 +304,11 @@ export const MessageBranchPage = ({
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export type MessageResponseProps = StreamdownProps;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
<SafeStreamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
@@ -34,9 +34,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { PromptInputFilePart } from "@/core/uploads";
import { splitUnsupportedUploadFiles } from "@/core/uploads";
import { isIMEComposing } from "@/lib/ime";
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
import {
DEFAULT_UPLOAD_POLICY,
splitUnsupportedUploadFiles,
useUploadPolicy,
validateChatUploadFiles,
} from "@/core/uploads";
import { imeEnterAction } from "@/lib/ime";
import { cn } from "@/lib/utils";
import type { ChatStatus } from "ai";
import {
@@ -57,10 +62,10 @@ import {
Children,
type ClipboardEventHandler,
type ComponentProps,
type CompositionEventHandler,
createContext,
type FormEvent,
type FormEventHandler,
Fragment,
type HTMLAttributes,
type KeyboardEventHandler,
type PropsWithChildren,
@@ -124,8 +129,10 @@ export const usePromptInputController = () => {
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components.
const useOptionalPromptInputController = () =>
// 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 = () => {
@@ -144,6 +151,7 @@ const usePromptInputValidation = () => useContext(PromptInputValidationContext);
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
uploadPolicy?: UploadPolicy;
}>;
/**
@@ -152,8 +160,12 @@ export type PromptInputProviderProps = PropsWithChildren<{
*/
export function PromptInputProvider({
initialInput: initialTextInput = "",
uploadPolicy,
children,
}: PromptInputProviderProps) {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []);
@@ -162,6 +174,7 @@ export function PromptInputProvider({
const [attachmentFiles, setAttachmentFiles] = useState<
(PromptInputFilePart & { id: string })[]
>([]);
const attachmentsRef = useRef(attachmentFiles);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openRef = useRef<() => void>(() => {});
@@ -171,9 +184,26 @@ export function PromptInputProvider({
return;
}
setAttachmentFiles((prev) =>
prev.concat(
incoming.map((file) => ({
const current = attachmentsRef.current;
const existing = current.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
incoming,
existing,
effectiveUploadPolicy,
);
for (const message of new Set(
validation.issues.map((issue) => issue.message),
)) {
toast.error(message);
}
if (validation.accepted.length === 0) return;
const next = current.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
@@ -181,33 +211,31 @@ export function PromptInputProvider({
filename: file.name,
file,
})),
),
);
}, []);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, [effectiveUploadPolicy]);
const remove = useCallback((id: string) => {
setAttachmentFiles((prev) => {
const found = prev.find((f) => f.id === id);
if (found?.url) {
URL.revokeObjectURL(found.url);
}
return prev.filter((f) => f.id !== id);
});
const current = attachmentsRef.current;
const found = current.find((f) => f.id === id);
if (found?.url) URL.revokeObjectURL(found.url);
const next = current.filter((f) => f.id !== id);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, []);
const clear = useCallback(() => {
setAttachmentFiles((prev) => {
for (const f of prev) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
return [];
});
}
attachmentsRef.current = [];
setAttachmentFiles([]);
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
const attachmentsRef = useRef(attachmentFiles);
attachmentsRef.current = attachmentFiles;
// Cleanup blob URLs on unmount to prevent memory leaks
@@ -406,9 +434,9 @@ export function PromptInputAttachments({
{...props}
>
{attachments.files.map((file) => (
<Fragment key={file.id}>
<div className="max-w-60">{children(file)}</div>
</Fragment>
<div key={file.id} className="max-w-60">
{children(file)}
</div>
))}
</div>
);
@@ -458,8 +486,15 @@ export type PromptInputProps = Omit<
// Minimal constraints
maxFiles?: number;
maxFileSize?: number; // bytes
uploadPolicy?: UploadPolicy;
onError?: (err: {
code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
code:
| "max_files"
| "max_file_size"
| "max_total_size"
| "unsupported_type"
| "accept"
| "unsupported_package";
message: string;
}) => void;
onSubmit: (
@@ -477,11 +512,22 @@ export const PromptInput = ({
syncHiddenInput,
maxFiles,
maxFileSize,
uploadPolicy,
onError,
onSubmit,
children,
...props
}: PromptInputProps) => {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const policyAccept = useMemo(
() =>
Object.values(effectiveUploadPolicy.groups)
.flatMap((group) => group.extensions)
.join(","),
[effectiveUploadPolicy],
);
// Try to use a provider controller if present
const controller = useOptionalPromptInputController();
const usingProvider = !!controller;
@@ -516,6 +562,9 @@ export const PromptInput = ({
.filter(Boolean);
return patterns.some((pattern) => {
if (pattern.startsWith(".")) {
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
}
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
return f.type.startsWith(prefix);
@@ -530,52 +579,67 @@ export const PromptInput = ({
(fileList: File[] | FileList) => {
const incoming = Array.from(fileList);
const accepted = incoming.filter((f) => matchesAccept(f));
if (incoming.length && accepted.length === 0) {
onError?.({
if (accepted.length < incoming.length) {
const error = {
code: "accept",
message: "No files match the accepted types.",
});
return;
message: "部分文件不符合当前输入框允许的格式。",
} as const;
onError?.(error);
if (!onError) toast.error(error.message);
}
const withinSize = (f: File) =>
maxFileSize ? f.size <= maxFileSize : true;
const sized = accepted.filter(withinSize);
if (accepted.length > 0 && sized.length === 0) {
onError?.({
code: "max_file_size",
message: "All files exceed the maximum size.",
});
return;
}
setItems((prev) => {
const capacity =
const localPolicy: UploadPolicy = {
...effectiveUploadPolicy,
max_files_per_message:
typeof maxFiles === "number"
? Math.max(0, maxFiles - prev.length)
: undefined;
const capped =
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
if (typeof capacity === "number" && sized.length > capacity) {
onError?.({
code: "max_files",
message: "Too many files. Some were not added.",
});
}
const next: (PromptInputFilePart & { id: string })[] = [];
for (const file of capped) {
next.push({
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
: effectiveUploadPolicy.max_files_per_message,
groups: Object.fromEntries(
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
key,
{
...group,
max_bytes:
typeof maxFileSize === "number"
? Math.min(group.max_bytes, maxFileSize)
: group.max_bytes,
},
]),
) as UploadPolicy["groups"],
};
const existing = items.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
accepted,
existing,
localPolicy,
);
for (const issue of Array.from(
new Map(
validation.issues.map((item) => [item.message, item]),
).values(),
)) {
onError?.({ code: issue.code, message: issue.message });
if (!onError) toast.error(issue.message);
}
if (validation.accepted.length === 0) return;
setItems((prev) =>
prev.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file",
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
});
}
return prev.concat(next);
});
})),
),
);
},
[matchesAccept, maxFiles, maxFileSize, onError],
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
);
const removeLocal = useCallback(
@@ -764,11 +828,17 @@ export const PromptInput = ({
event.preventDefault();
const form = event.currentTarget;
// Prefer the live textarea. Firefox 102 applies the chosen candidate to
// the DOM before React state catches up, and a send click in that gap
// would otherwise submit the previous pinyin-less value.
const liveText = form.querySelector<HTMLTextAreaElement>(
"textarea[name='message']",
)?.value;
const text = usingProvider
? controller.textInput.value
? (liveText ?? controller.textInput.value)
: (() => {
const formData = new FormData(form);
return (formData.get("message") as string) || "";
return (formData.get("message") as string) || liveText || "";
})();
// Reset form immediately after capturing text to avoid race condition
@@ -831,7 +901,7 @@ export const PromptInput = ({
const inner = (
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
<input
accept={accept}
accept={accept ?? policyAccept}
aria-label="Upload files"
className="hidden"
multiple={multiple}
@@ -846,10 +916,12 @@ export const PromptInput = ({
ref={formRef}
{...props}
>
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
versions before 105 ignore :has(), retain h-9, and clip the whole
multi-line composer to a single row. */}
<InputGroup className="h-auto min-h-16">{children}</InputGroup>
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions
before 105 ignore them, keep the horizontal flex layout, and
squeeze the textarea beside the footer controls. */}
<InputGroup className="h-auto min-h-16 flex-col items-stretch">
{children}
</InputGroup>
</form>
</PromptInputValidationContext.Provider>
);
@@ -876,58 +948,100 @@ export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea
>;
export const PromptInputTextarea = ({
onChange,
onInput,
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
if (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("field-sizing", "content")
) {
return;
}
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const frame = window.requestAnimationFrame(() => {
resizeForLegacyChrome(textarea);
});
return () => window.cancelAnimationFrame(frame);
}, [controller?.textInput.value, resizeForLegacyChrome]);
export const PromptInputTextarea = ({
onChange,
onInput,
onKeyDown,
onCompositionStart,
onCompositionEnd,
className,
placeholder = "What would you like to know?",
defaultValue,
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const composingRef = useRef(false);
const compositionEndedAtRef = useRef(0);
const onChangeRef = useRef(onChange);
const onInputRef = useRef(onInput);
const onKeyDownRef = useRef(onKeyDown);
const onCompositionStartRef = useRef(onCompositionStart);
const onCompositionEndRef = useRef(onCompositionEnd);
const setInputRef = useRef(controller?.textInput.setInput);
const attachmentsRef = useRef(attachments);
onChangeRef.current = onChange;
onInputRef.current = onInput;
onKeyDownRef.current = onKeyDown;
onCompositionStartRef.current = onCompositionStart;
onCompositionEndRef.current = onCompositionEnd;
setInputRef.current = controller?.textInput.setInput;
attachmentsRef.current = attachments;
const publishValue = useCallback((value: string) => {
setInputRef.current?.(value);
}, []);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
// Firefox 102 commits an open IME composition as raw pinyin when the
// textarea's height or overflow changes mid-composition.
if (composingRef.current) return;
if (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("field-sizing", "content")
) {
return;
}
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => {
if (composingRef.current) return;
const textarea = textareaRef.current;
if (!textarea) return;
const frame = window.requestAnimationFrame(() => {
resizeForLegacyChrome(textarea);
});
return () => window.cancelAnimationFrame(frame);
}, [controller?.textInput.value, resizeForLegacyChrome]);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
if (isIMEComposing(e, isComposing)) {
const action = imeEnterAction({
isComposing: composingRef.current,
nativeIsComposing: e.nativeEvent.isComposing || e.key === "Process",
keyCode: e.keyCode,
compositionEndedAt: compositionEndedAtRef.current,
now: performance.now(),
});
// Firefox 102 confirms the candidate on this key. preventDefault here
// cancels that confirmation and leaves the pinyin in the box.
if (action === "ignore") {
return;
}
if (e.shiftKey) {
onKeyDownRef.current?.(e);
if (e.defaultPrevented || e.shiftKey) {
return;
}
// The confirming Enter already finished the candidate. Swallow it so
// Firefox does not also insert a newline or send the message.
e.preventDefault();
if (action === "swallow") {
return;
}
// Check if the submit button is disabled before submitting
const form = e.currentTarget.form;
@@ -939,22 +1053,59 @@ export const PromptInputTextarea = ({
}
form?.requestSubmit();
return;
}
onKeyDownRef.current?.(e);
if (e.defaultPrevented) return;
if (composingRef.current) return;
// Remove last attachment when Backspace is pressed and textarea is empty
if (
e.key === "Backspace" &&
e.currentTarget.value === "" &&
attachments.files.length > 0
attachmentsRef.current.files.length > 0
) {
e.preventDefault();
const lastAttachment = attachments.files.at(-1);
const lastAttachment = attachmentsRef.current.files.at(-1);
if (lastAttachment) {
attachments.remove(lastAttachment.id);
attachmentsRef.current.remove(lastAttachment.id);
}
}
};
const handleCompositionStart: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
composingRef.current = true;
compositionEndedAtRef.current = 0;
onCompositionStartRef.current?.(event);
};
const handleCompositionEnd: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
onCompositionEndRef.current?.(event);
composingRef.current = false;
compositionEndedAtRef.current = performance.now();
const el = event.currentTarget;
// Firefox 102 dispatches compositionend before the committed candidate is
// visible, and before the input event that carries it. Syncing the
// compositionend value here would write the raw pinyin back over 汉字.
window.setTimeout(() => {
if (el.isConnected) publishValue(el.value);
}, 0);
};
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
// Keep the controlled value identical to the DOM, including the pinyin
// still being composed. Otherwise React restores the previous value at
// the end of the input event and Firefox 102 cannot replace it.
publishValue(event.currentTarget.value);
onChangeRef.current?.(event);
};
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
const items = event.clipboardData?.items;
@@ -984,35 +1135,28 @@ export const PromptInputTextarea = ({
}
};
const controlledProps = controller
? {
value: controller.textInput.value,
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
controller.textInput.setInput(e.currentTarget.value);
onChange?.(e);
},
}
: {
onChange,
};
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
ref={textareaRef}
rows={3}
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onInput={(event) => {
resizeForLegacyChrome(event.currentTarget);
onInput?.(event);
}}
<InputGroupTextarea
{...props}
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
ref={textareaRef}
rows={3}
onChange={handleChange}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onKeyDown={handleKeyDown}
onInput={(event) => {
if (!composingRef.current) {
resizeForLegacyChrome(event.currentTarget);
}
onInputRef.current?.(event);
}}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
{...(controller
? { value: controller.textInput.value }
: { defaultValue })}
/>
);
};
@@ -1130,20 +1274,24 @@ export const PromptInputSubmit = ({
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-4" />;
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
}
return (
<InputGroupButton
aria-label="Submit"
}: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-4" />;
let ariaLabel = "发送消息";
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
ariaLabel = "正在发送";
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
ariaLabel = "停止生成";
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
ariaLabel = "重新发送";
}
return (
<InputGroupButton
aria-label={ariaLabel}
className={cn(
"transition-all duration-300",
status === "streaming" &&
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import { SafeStreamdown } from "@/core/streamdown";
import { reasoningPlugins } from "@/core/streamdown/plugins";
import { Shimmer } from "./shimmer";
@@ -179,7 +179,7 @@ export const ReasoningContent = memo(
{...props}
>
{typeof children === "string" ? (
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
<SafeStreamdown {...reasoningPlugins}>{children}</SafeStreamdown>
) : (
children
)}
@@ -31,6 +31,11 @@ import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty";
import { NetworkActor } from "./network-actor";
import {
normalizeV2UploadSources,
type StudioUploadSource,
type V2UploadResponse,
} from "@/core/studio/document-upload";
// ── 适配层 ─────────────────────────────────────────────────
@@ -86,22 +91,7 @@ interface DocumentUploadDialogContextType {
data?: { query: string; mode: string }
) => Promise<void>;
// 上传成功回调,返回上传的来源信息
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}
// ── 任务轮询类型 ─────────────────────────────────────────────
interface TaskSource {
task_id: string;
title: string;
status: 'processing' | 'success' | 'error';
source_type: string;
}
interface UploadResponse {
status: string;
uploaded: number;
skipped: number;
sources: TaskSource[];
onUploadComplete?: (sources: StudioUploadSource[]) => void;
}
interface listDataItem {
@@ -224,7 +214,7 @@ export const DocumentUploadDialogProvider: FC<{
) => void | Promise<void>;
searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void;
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
onUploadComplete?: (sources: StudioUploadSource[]) => void;
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
const [isOpen, setIsOpen] = useState(false);
const [initialSearchText, setInitialSearchText] = useState("");
@@ -353,27 +343,23 @@ const DocumentUploadPopupContent: FC<{
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload-v2`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (res.ok) {
const data = await res.json() as UploadResponse;
const data = await res.json() as V2UploadResponse;
// 调用上传完成回调,传递来源信息
if (data.sources && data.sources.length > 0) {
onUploadComplete?.(data.sources.map(s => ({
id: undefined, // 后端返回的数据中暂时没有 id
title: s.title,
task_id: s.task_id,
status: s.status,
})));
onUploadComplete?.(normalizeV2UploadSources(data));
}
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
// 只关闭上传弹窗
onOpenChange(false);
} else {
toast.error("上传失败,请重试");
const payload = await res.json().catch(() => null) as { detail?: string } | null;
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
}
} catch {
toast.error("上传失败,请重试");
@@ -410,7 +396,6 @@ const DocumentUploadPopupContent: FC<{
selectedSearchSources.map((s) => ({
id: undefined,
title: s.title,
task_id: undefined,
status: "success" as const,
}))
);
+34 -18
View File
@@ -1,14 +1,19 @@
"use client";
import { useEffect } from "react";
import { useLayoutEffect } from "react";
import { useLocalSettings } from "@/core/settings";
import {
applyFontScaleToDocument,
normalizeFontScale,
normalizeFontScaleMode,
} from "@/core/ui/page-zoom";
/**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
* `fontScale === 1` 即默认 16px。
*/
export const BASE_FONT_PX = 16;
@@ -21,25 +26,36 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
{ value: 1.25, label: "特大", px: 20 },
];
export const FONT_SCALE_MODE_OPTIONS: {
value: "text" | "page";
label: string;
description: string;
}[] = [
{
value: "text",
label: "仅文字",
description: "只放大文字,间距和布局保持原样。",
},
{
value: "page",
label: "整页缩放",
description: "文字、间距、图标和写死的像素一起放大。",
},
];
/**
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。
*/
export function FontScaleApplier() {
const [settings] = useLocalSettings();
const { fontScale } = settings.display;
const { fontScale, fontScaleMode } = settings.display;
useEffect(() => {
const scale =
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
? fontScale
: 1;
const root = document.documentElement;
root.style.setProperty("--ui-font-scale", String(scale));
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
root.style.removeProperty("font-size");
}, [fontScale]);
useLayoutEffect(() => {
applyFontScaleToDocument(
normalizeFontScaleMode(fontScaleMode),
normalizeFontScale(fontScale),
);
}, [fontScale, fontScaleMode]);
return null;
}
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected —
* all without enabling scripts (the sandbox stays script-free for security; the
* server already sanitizes script vectors and this is defense-in-depth).
* The sandbox permits scripts so the preview can use the app-bundled HTML
* visualization runtimes, while still withholding same-origin privileges.
*
* The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
@@ -26,7 +26,9 @@ export function HtmlPreviewFrame({
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
const blob = new Blob([
prepareGeneratedHtml(html ?? ""),
], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
@@ -1,3 +1,5 @@
import { toCssPx } from "@/core/ui/page-zoom";
import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
@@ -9,8 +11,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport.
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
return (
<div className="kg-tooltip" style={{ left, top }}>
+6 -1
View File
@@ -3,6 +3,7 @@
import { ChevronRightIcon } from "lucide-react";
import Link from "next/link";
import deerMaskUrl from "@/assets/deer.svg";
import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import Galaxy from "@/components/ui/galaxy";
@@ -29,7 +30,11 @@ export function Hero({ className }: { className?: string }) {
/>
</div>
<FlickeringGrid
className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
className="absolute inset-0 z-0 translate-y-8 mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
style={{
WebkitMaskImage: `url("${deerMaskUrl}")`,
maskImage: `url("${deerMaskUrl}")`,
}}
squareSize={4}
gridGap={4}
color={"white"}
+8 -8
View File
@@ -4,19 +4,19 @@
top: 4rem;
z-index: 40;
width: 98px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
flex-shrink: 0;
}
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
.sidebar-contnet--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
.sidebar-contnet--header-hidden .item-nav-content {
height: calc(100vh - 20px);
min-height: calc(100vh - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
}
.sidebar-contents {
@@ -160,8 +160,8 @@
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
min-height: calc(100vh - 4rem - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
top: 4rem;
z-index: 40;
width: 96px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
display: flex;
flex-direction: row;
background: #ffffff;
@@ -653,7 +653,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
.light-sidebar--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
@@ -194,7 +194,7 @@ export function PageSidebar({
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh/var(--df-page-zoom,1)-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
+60 -16
View File
@@ -16,6 +16,8 @@ import {
Settings,
ShieldAlert,
ShieldCheck,
ScrollText,
Timer,
User,
Wrench,
} from "lucide-react";
@@ -56,7 +58,7 @@ type PageSidebarProps = {
headerHidden?: boolean;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "encoder" | "about";
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
menuItemContainsPath(item, pathname);
@@ -222,14 +224,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
<User className="size-4" />
个人信息
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTree className="size-4" />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTree className="size-4" />
{isAdmin ? "默认菜单管理" : "我的菜单"}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/task-buttons")}
@@ -270,6 +270,28 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
@@ -478,7 +500,9 @@ function LegacyAccordionSidebar({
/>
<aside
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
headerHidden
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
} ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
@@ -555,12 +579,10 @@ function LegacyAccordionSidebar({
<User className="size-4" />
个人信息
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
<ListTree className="size-4" />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
<ListTree className="size-4" />
{isAdmin ? "默认菜单管理" : "我的菜单"}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
<MousePointerClick className="size-4" />
@@ -585,6 +607,28 @@ function LegacyAccordionSidebar({
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
本地编码器
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
<ShieldCheck className="size-4" />
@@ -0,0 +1,54 @@
import { useLayoutEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { ReactUiErrorBoundary } from "./react-ui-error-boundary";
// Offline browser QA: real React production render/commit recursion, not a
// fabricated #185 thrown by a unit-test stub. A manual button click reloads
// this same page without submitting any conversation or model request.
const key = "deerflow.boundary-browser-check";
let mounts = 0;
function InfiniteCommitLoop() {
const [, update] = useState(0);
useState(() => { mounts += 1; return 0; });
useLayoutEffect(() => { update((value) => value + 1); });
return <div>Loop fixture</div>;
}
function result(passed: boolean, details: Record<string, unknown>) {
const output = document.createElement("pre");
output.id = "boundary-check-result";
output.textContent = JSON.stringify({ passed, ...details });
document.body.append(output);
}
const resumed = sessionStorage.getItem(key) === "manual-reload";
createRoot(document.getElementById("root")!).render(
<ReactUiErrorBoundary>
{resumed ? <div id="healthy">Existing page restored</div> : <InfiniteCommitLoop />}
</ReactUiErrorBoundary>,
);
if (resumed) {
sessionStorage.removeItem(key);
window.setTimeout(() => result(Boolean(document.getElementById("healthy")), {
phase: "manual-reload-restored", renderLoopCaught: true, noAutomaticRemount: true,
}), 100);
} else {
window.setTimeout(() => {
const alert = document.querySelector('[role="alert"]');
const before = mounts;
if (!alert || /#185|react\.dev|Maximum update depth/.test(alert.textContent ?? "")) {
result(false, { phase: "friendly-fallback", hasAlert: Boolean(alert) });
return;
}
window.setTimeout(() => {
if (mounts !== before || before !== 1) {
result(false, { phase: "unexpected-automatic-remount", mounts, before });
return;
}
sessionStorage.setItem(key, "manual-reload");
(alert.querySelector("button") as HTMLButtonElement).click();
}, 250);
}, 100);
}
@@ -0,0 +1,45 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { isReactMaximumUpdateDepthError } from "@/core/threads/stream-error-classification";
import { REACT_UI_ERROR_MESSAGE } from "@/core/threads/react-ui-error";
/** Last-resort render/commit guard. It deliberately never auto-remounts a broken
* tree, which could otherwise repeatedly resume the same run and crash again. */
export class ReactUiErrorBoundary extends Component<
{ children: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError(error: unknown) {
if (!isReactMaximumUpdateDepthError(error)) throw error;
return { failed: true };
}
componentDidCatch(_error: unknown, info: ErrorInfo) {
console.warn("[deer-flow] react_ui_render_error", {
errorType: "react_maximum_update_depth",
componentStack: info.componentStack,
recovery: "manual_reload",
});
}
render() {
if (!this.state.failed) return this.props.children;
return (
<main className="flex min-h-screen items-center justify-center p-6">
<section role="alert" className="max-w-lg rounded-lg border p-6 text-center">
<h1 className="mb-3 text-lg font-medium">页面暂时无法显示</h1>
<p className="mb-5 text-sm text-muted-foreground">{REACT_UI_ERROR_MESSAGE}</p>
<button
className="rounded-md bg-primary px-4 py-2 text-primary-foreground"
onClick={() => window.location.reload()}
type="button"
>
重新载入页面
</button>
</section>
</main>
);
}
}
@@ -0,0 +1,16 @@
/** Lightweight, dependency-free fallback used while a route chunk loads. */
export function RouteLoadingFallback() {
return (
<div
className="text-muted-foreground flex size-full min-h-32 items-center justify-center gap-2 bg-background"
role="status"
aria-live="polite"
>
<span
className="border-muted-foreground/30 border-t-foreground size-4 animate-spin rounded-full border-2"
aria-hidden="true"
/>
<span className="text-sm">正在加载页面…</span>
</div>
);
}
+36 -31
View File
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"legacy-dialog-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[200] bg-black/50",
className,
)}
{...props}
@@ -71,37 +71,42 @@ const DialogOverlay = React.forwardRef<
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<div
data-slot="dialog-viewport"
className={cn("legacy-dialog-viewport", overlayClassName)}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-[200] grid min-h-0 max-h-full w-full max-w-[calc(100%-2rem)] pointer-events-auto gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</div>
</DialogPortal>
);
});
+102 -102
View File
@@ -20,42 +20,42 @@ function DropdownMenuPortal({
);
}
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
function DropdownMenuGroup({
...props
@@ -65,29 +65,29 @@ function DropdownMenuGroup({
);
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
function DropdownMenuCheckboxItem({
className,
@@ -205,51 +205,51 @@ function DropdownMenuSub({
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
+1 -1
View File
@@ -62,7 +62,7 @@ const SelectContent = React.forwardRef<
ref={ref}
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -1,5 +1,4 @@
import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
import { cn } from "@/lib/utils";
@@ -21,12 +20,12 @@ export function Breadcrumb({
)}
>
{segments.map((segment, i) => (
<Fragment key={`${segment}-${i}`}>
<div className="contents" key={`${segment}-${i}`}>
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
{segment}
</span>
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
</Fragment>
</div>
))}
<span
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
@@ -1,8 +1,10 @@
"use client";
import { Loader2 } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
@@ -17,6 +19,11 @@ export function UserManagementPage() {
title={t.adminUsers.title}
description={t.adminUsers.description}
>
<div className="mb-4 flex justify-end">
<Button variant="outline" asChild>
<Link to="/page/strategy/admin/login-whitelist">管理密码与登录口令</Link>
</Button>
</div>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -534,11 +534,6 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
}
}
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row.
const statusBadges = (
@@ -965,9 +960,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
已选 {selectedSkills.length} 个
</span>
</div>
<SkillTransferSelector
skills={enabledSkills}
selected={selectedSkills}
<SkillTransferSelector
skills={skills}
selected={selectedSkills}
onChange={setSelectedSkills}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
@@ -51,6 +51,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api";
@@ -69,6 +73,8 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import {
bootstrapResumePath,
isBootstrapThread,
@@ -379,6 +385,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
const projectThreadNames = useProjectThreadNames(visibleThreads);
const filteredThreads = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -479,6 +486,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : (
filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname;
const pinned = isThreadPinned(thread);
@@ -492,6 +503,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
href={href}
className={cn(
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
projectInfo && "h-auto min-h-12 py-1.5",
isActive
? "border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
@@ -505,6 +517,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)}
aria-label="智能体创建会话"
/>
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
<MessageSquare
className={cn(
@@ -516,8 +530,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
{pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
<span
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
@@ -152,6 +152,8 @@ export function AgentDetailPanel({
positionName,
skillsIcon,
skillsTitle,
belowSkillsSlot,
feedbackScoreSlot,
showCommonQuestions = false,
jobResponsibilities,
panelTitle,
@@ -181,6 +183,10 @@ export function AgentDetailPanel({
positionName?: string;
skillsIcon?: ReactNode;
skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
/** Optional answer-feedback summary rendered near the agent identity. */
feedbackScoreSlot?: ReactNode;
showCommonQuestions?: boolean;
/**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -744,6 +750,8 @@ export function AgentDetailPanel({
)}
</section>
{!editing && feedbackScoreSlot}
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
{jobResponsibilities !== undefined && (
@@ -905,7 +913,7 @@ export function AgentDetailPanel({
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
title={skillsTitle ?? `已配置技能 ${skillNamesShown.length} 个`}
className="position-roundtable-skills-section"
extra={
editing ? (
@@ -940,6 +948,9 @@ export function AgentDetailPanel({
/>
</Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div>
@@ -0,0 +1,213 @@
"use client";
import { BarChart3Icon, MessageSquareTextIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
useAgentFeedbackScore,
type AnswerFeedbackReason,
type FeedbackDimensionKey,
} from "@/strategy-components/api/answer-feedback";
const DIMENSIONS: Array<{
key: FeedbackDimensionKey;
label: string;
fallbackWeight: number;
}> = [
{ key: "correctness", label: "正确性", fallbackWeight: 30 },
{ key: "completeness", label: "完整性", fallbackWeight: 20 },
{ key: "evidence_grounding", label: "证据与依据", fallbackWeight: 20 },
{ key: "relevance", label: "相关性", fallbackWeight: 15 },
{ key: "actionability", label: "可操作性", fallbackWeight: 15 },
];
const REASON_LABELS: Record<AnswerFeedbackReason, string> = {
incorrect_or_incomplete: "不正确或不完整",
unmet_expectations: "与期望不符",
slow_or_problematic: "速度慢或存在问题",
style_or_tone: "风格或语气",
safety_or_legal: "安全或法律疑虑",
other: "其他",
};
const CONFIDENCE_LABELS = {
none: "暂无置信度",
low: "低置信度",
medium: "中等置信度",
high: "高置信度",
} as const;
function scoreText(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value)
? value.toFixed(1)
: "—";
}
function percent(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const normalized = value <= 1 ? value * 100 : value;
return Math.max(0, Math.min(100, normalized));
}
function weightPercent(value: number | undefined, fallback: number) {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return value <= 1 ? Math.round(value * 100) : Math.round(value);
}
export function AgentFeedbackScoreCard({ agentId }: { agentId: string }) {
const { data, isLoading, isError } = useAgentFeedbackScore(agentId);
if (isLoading) {
return (
<section className="bg-card/60 rounded-xl border p-4" aria-label="回答评价加载中">
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
<div className="bg-muted mt-4 h-12 w-28 animate-pulse rounded-lg" />
<div className="mt-4 space-y-2">
{DIMENSIONS.map((item) => (
<div key={item.key} className="bg-muted h-3 animate-pulse rounded" />
))}
</div>
</section>
);
}
if (isError || !data) {
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-center gap-1.5 text-sm font-semibold">
<BarChart3Icon className="size-3.5" />
回答评价
</div>
<p className="text-muted-foreground mt-2 text-xs">评价数据暂不可用</p>
</section>
);
}
const positiveRate = data.sample_count
? (data.positive_count / data.sample_count) * 100
: null;
const taskSuccessRate = percent(data.task_success_rate);
const topReasons = Object.entries(data.reason_breakdown ?? {})
.filter((entry): entry is [AnswerFeedbackReason, { count: number; rate: number }] =>
Boolean(entry[1]?.count),
)
.sort((left, right) => right[1].count - left[1].count)
.slice(0, 3);
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
<BarChart3Icon className="size-3.5" />
回答评价
</h4>
<p className="text-muted-foreground mt-1 text-xs">用户评价分</p>
</div>
{data.score_band && data.score !== null && (
<span className="border-primary/20 bg-primary/5 text-primary rounded-full border px-2 py-1 text-[11px] font-medium">
{data.score_band.label}
</span>
)}
</div>
<div className="mt-3 flex items-end gap-2">
{data.score === null ? (
<span className="text-muted-foreground text-xl font-semibold">暂无评价</span>
) : (
<>
<span className="text-3xl font-bold tracking-tight tabular-nums">
{scoreText(data.score)}
</span>
<span className="text-muted-foreground mb-1 text-xs">/ 100</span>
</>
)}
</div>
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px]">
<span>{data.sample_count} 条评价</span>
<span>
{CONFIDENCE_LABELS[data.confidence_level]}
{` ${Math.round(data.confidence * 100)}%`}
</span>
<span>
好评率 {positiveRate === null ? "—" : `${positiveRate.toFixed(0)}%`}
</span>
</div>
{data.sample_count > 0 && (
<div className="mt-4 space-y-3" aria-label="回答评价维度">
{DIMENSIONS.map((item) => {
const dimension = data.dimensions[item.key];
const value = percent(dimension?.score);
return (
<div key={item.key}>
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
<span className="text-foreground">
{item.label}
<span className="text-muted-foreground ml-1">
{weightPercent(dimension?.weight, item.fallbackWeight)}%
</span>
</span>
<span className="text-muted-foreground tabular-nums">
{value === null ? "—" : value.toFixed(1)}
{dimension?.evidence_count
? ` · ${dimension.evidence_count} 条依据`
: ""}
</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className={cn(
"h-full rounded-full transition-[width]",
value !== null && value >= 80
? "bg-emerald-500"
: value !== null && value >= 60
? "bg-amber-500"
: "bg-rose-500",
)}
style={{ width: `${value ?? 0}%` }}
/>
</div>
</div>
);
})}
</div>
)}
{(data.task_score !== null || taskSuccessRate !== null) && (
<div className="bg-muted/50 mt-4 flex items-center justify-between rounded-lg px-3 py-2 text-xs">
<span className="flex items-center gap-1.5 font-medium">
<MessageSquareTextIcon className="size-3.5" />
任务成功与稳定性
</span>
<span className="text-muted-foreground tabular-nums">
{data.task_score !== null && `评分 ${scoreText(data.task_score)}`}
{data.task_score !== null && taskSuccessRate !== null && " · "}
{taskSuccessRate !== null && `成功率 ${taskSuccessRate.toFixed(0)}%`}
</span>
</div>
)}
{topReasons.length > 0 && (
<div className="mt-4 border-t pt-3">
<p className="text-muted-foreground mb-2 text-[11px] font-medium">主要改进反馈</p>
<div className="flex flex-wrap gap-1.5">
{topReasons.map(([reason, breakdown]) => (
<span
key={reason}
className="bg-muted text-muted-foreground rounded-full px-2 py-1 text-[11px]"
>
{REASON_LABELS[reason]} · {breakdown.count}
</span>
))}
</div>
</div>
)}
<p className="text-muted-foreground/80 mt-4 border-t pt-3 text-[10px] leading-relaxed">
该分数仅反映用户对回答结果的评价,不代表完整任务轨迹、工具调用或技能能力。
</p>
</section>
);
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { canViewAgentFeedbackScore } from "./agent-feedback-visibility.ts";
test("allows only the canonical admin role to view agent feedback scores", () => {
assert.equal(canViewAgentFeedbackScore({ system_role: "admin" }), true);
assert.equal(canViewAgentFeedbackScore({ system_role: "user" }), false);
assert.equal(canViewAgentFeedbackScore({ system_role: "Admin" }), false);
});
test("fails closed when authentication or its role is unavailable", () => {
assert.equal(canViewAgentFeedbackScore(null), false);
assert.equal(canViewAgentFeedbackScore(undefined), false);
assert.equal(canViewAgentFeedbackScore({ system_role: "" }), false);
});
@@ -0,0 +1,12 @@
import type { LoginByUsernameResponse } from "@/core/auth";
/**
* Agent-level answer scores are an administrative quality-control view.
* Keep the role check strict so missing, stale or unknown auth roles fail
* closed instead of exposing aggregate evaluation data.
*/
export function canViewAgentFeedbackScore(
auth: Pick<LoginByUsernameResponse, "system_role"> | null | undefined,
): boolean {
return auth?.system_role === "admin";
}
@@ -16,8 +16,16 @@ import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useRouteBase } from "@/core/route-base";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import {
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
WX_RESTRICTED_TAB_NANJING_DB,
isWxRestrictedPlaceholderTab,
wxRestrictedDefaultSquareTabValue,
wxRestrictedNanjingTabValue,
} from "@/core/system-settings/wx-type-filter";
import { AgentCard } from "./agent-card";
import { SentimentAgentCard } from "./sentiment-agent-card";
@@ -34,9 +42,15 @@ const PAGE_SIZE = 12;
// password never persists beyond the browsing session.
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
export function AgentGallery({
restrictWxTypes = false,
}: {
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
restrictWxTypes?: boolean;
} = {}) {
const { t } = useI18n();
const router = useRouter();
const routeBase = useRouteBase();
const sentimentAgentVisible = isSentimentAgentVisible();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
@@ -52,6 +66,12 @@ export function AgentGallery() {
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
// 其余自定义广场继续保持后端给出的顺序。
const [defaultSquare, ...additionalSquares] = squareTabs;
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
publishSquares?.default_square_id,
);
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
const wxPlaceholderTab =
restrictWxTypes && isWxRestrictedPlaceholderTab(activeTab);
// Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
@@ -72,6 +92,30 @@ export function AgentGallery() {
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab.
useEffect(() => {
if (restrictWxTypes) {
if (
activeTab === WX_RESTRICTED_TAB_NANJING_DB &&
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
) {
setActiveTab(nanjingTabValue);
return;
}
if (
activeTab === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
) {
setActiveTab(defaultSquareTabValue);
return;
}
if (
activeTab !== "mine" &&
activeTab !== defaultSquareTabValue &&
activeTab !== nanjingTabValue
) {
setActiveTab("mine");
}
return;
}
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
if (activeTab === "builtin" && !isAdmin) {
setActiveTab("mine");
@@ -87,7 +131,14 @@ export function AgentGallery() {
) {
setActiveTab("mine");
}
}, [activeTab, squareTabs, isAdmin]);
}, [
activeTab,
squareTabs,
isAdmin,
restrictWxTypes,
defaultSquareTabValue,
nanjingTabValue,
]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -128,10 +179,13 @@ export function AgentGallery() {
sort,
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private" && activeTab !== "sentiment",
enabled:
activeTab !== "private" &&
activeTab !== "sentiment" &&
!wxPlaceholderTab,
owner: publisher?.id,
// Each square tab fetches only that square's agents.
square: isSquareTab ? activeTab : undefined,
square: isSquareTab && !wxPlaceholderTab ? activeTab : undefined,
});
const {
@@ -169,11 +223,13 @@ export function AgentGallery() {
(activeTab !== "private" || privateUnlocked);
const activeTotal =
activeTab === "sentiment"
? 1
: activeTab === "private"
? privateTotal
: mainTotal;
wxPlaceholderTab
? 0
: activeTab === "sentiment"
? 1
: activeTab === "private"
? privateTotal
: mainTotal;
const setTabPage = (tab: string, page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
@@ -404,6 +460,14 @@ export function AgentGallery() {
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
title="打开智能体广场视图"
onClick={() => router.push(`${routeBase}/agents/plaza`)}
>
广场视图
</Button>
<Button
variant="ghost"
size="sm"
@@ -442,6 +506,14 @@ export function AgentGallery() {
className="w-auto max-w-full flex-wrap justify-start self-start"
data-agent-guide="tabs"
>
{restrictWxTypes ? (
<>
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
</>
) : (
<>
<TabsTrigger value="mine">我的</TabsTrigger>
{defaultSquare && (
<TabsTrigger
@@ -475,6 +547,8 @@ export function AgentGallery() {
内置智能体
</TabsTrigger>
)}
</>
)}
</TabsList>
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm whitespace-nowrap">
@@ -545,7 +619,41 @@ export function AgentGallery() {
)}
</div>
{squareTabs.map((sq) => (
{restrictWxTypes ? (
<>
<TabsContent
value={defaultSquareTabValue}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{wxPlaceholderTab && activeTab === defaultSquareTabValue
? renderEmpty("该广场暂无智能体")
: renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: defaultSquareTabValue,
emptyDescription: "该广场暂无智能体",
})}
</TabsContent>
<TabsContent
value={nanjingTabValue}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{wxPlaceholderTab && activeTab === nanjingTabValue
? renderEmpty("该类型暂无智能体")
: renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: nanjingTabValue,
emptyDescription: "该类型暂无智能体",
})}
</TabsContent>
</>
) : (
squareTabs.map((sq) => (
<TabsContent
key={sq.id}
value={sq.id}
@@ -560,7 +668,8 @@ export function AgentGallery() {
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
})}
</TabsContent>
))}
))
)}
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
@@ -576,7 +685,7 @@ export function AgentGallery() {
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
接口地址与鉴权在 runtime-config.js 配置。 */}
{sentimentAgentVisible && (
{!restrictWxTypes && sentimentAgentVisible && (
<TabsContent
value="sentiment"
className="flex-1 overflow-y-auto p-6 mt-0"
@@ -593,7 +702,7 @@ export function AgentGallery() {
</TabsContent>
)}
{isAdmin && (
{!restrictWxTypes && isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
@@ -606,7 +715,7 @@ export function AgentGallery() {
</TabsContent>
)}
{privateFeatureAvailable && (
{!restrictWxTypes && privateFeatureAvailable && (
<TabsContent
value="private"
className="flex-1 overflow-y-auto p-6 mt-0"
@@ -278,6 +278,7 @@ export function AgentGuide({
autoStart?: boolean;
}) {
const [current, setCurrent] = useState(-1);
const [appDialogOpen, setAppDialogOpen] = useState(false);
const stepsRef = useRef<GuideStep[]>([]);
const start = useCallback(() => {
@@ -317,7 +318,32 @@ export function AgentGuide({
setCurrent(-1);
}, [stage]);
if (!eligible) return null;
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200,
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 Dialog 打开时
// 先卸掉引导层,关掉后再从当前步恢复。
useEffect(() => {
if (!eligible) return;
const sync = () => {
setAppDialogOpen(
Boolean(document.querySelector('[data-slot="dialog-content"]')),
);
};
sync();
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [eligible]);
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (
!eligible ||
appDialogOpen ||
current < 0 ||
current >= stepsRef.current.length
) {
return null;
}
return (
<Guide
@@ -4,7 +4,13 @@ import { MessageSquareIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useThreadsPage } from "@/core/threads/hooks";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -26,6 +32,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
const threads = (data?.threads ?? []).filter(
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
);
const projectThreadNames = useProjectThreadNames(threads);
return (
<section className="bg-card/60 rounded-xl border p-4">
@@ -45,6 +52,10 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
{threads.map((thread) => {
const href = pathOfThread(thread);
const isActive = href === pathname;
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
return (
<Link
key={thread.thread_id}
@@ -52,19 +63,37 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
title={desensitize(titleOfThread(thread, "新对话"))}
className={cn(
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
projectInfo && "min-h-12",
isActive
? "bg-primary/5 text-primary font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<MessageSquareIcon
{projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
<MessageSquareIcon
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
<span
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, "新对话"))}
</span>
{projectInfo && (
<MyProjectThreadBadge
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
/>
<span className="truncate">
{desensitize(titleOfThread(thread, "新对话"))}
</span>
</Link>
);
@@ -6,6 +6,8 @@ import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Skill } from "@/core/skills";
import { useI18n } from "@/core/i18n/hooks";
import { isSkillOffline } from "@/core/skill-availability";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -53,6 +55,7 @@ export function EnabledSkillList({
onViewDetail?: (name: string) => void;
isLoading?: boolean;
}) {
const { t } = useI18n();
const byName = useMemo(
() => new Map(skills.map((s) => [s.name, s])),
[skills],
@@ -79,12 +82,14 @@ export function EnabledSkillList({
<div className="space-y-2">
{skillNames.map((name) => {
const skill = byName.get(name);
const offline = isSkillOffline(skill);
return (
<div
key={name}
className={cn(
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
offline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
)}
>
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
@@ -108,6 +113,15 @@ export function EnabledSkillList({
{skillSourceLabel(skill)}
</Badge>
)}
{offline && (
<Badge
variant="outline"
className="shrink-0 border-red-500/35 bg-red-500/5 text-[10px] text-red-700 dark:text-red-300"
title={t.settings.skills.badgeOfflineTitle}
>
{t.settings.skills.badgeOffline}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{skill
@@ -21,6 +21,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { Skill } from "@/core/skills";
import { isSkillOffline } from "@/core/skill-availability";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -28,7 +29,11 @@ const PAGE_SIZE = 5;
const ALL_CATEGORIES = "__all__";
interface SkillTransferSelectorProps {
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
/**
* Full visible skill pool. Offline skills are never offered on the left,
* but an already-selected offline skill stays visible on the right so it
* can be removed without silently changing the persisted whitelist.
*/
skills: Skill[];
/** Currently selected skill names — the persisted `skills: string[]`. */
selected: string[];
@@ -91,7 +96,9 @@ export function SkillTransferSelector({
const categories = useMemo(() => {
const set = new Set<string>();
for (const skill of skills) {
if (skill.category?.trim()) set.add(skill.category.trim());
if (!isSkillOffline(skill) && skill.category?.trim()) {
set.add(skill.category.trim());
}
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [skills]);
@@ -102,6 +109,7 @@ export function SkillTransferSelector({
const availableSkills = useMemo(() => {
return skills.filter(
(skill) =>
!isSkillOffline(skill) &&
!selectedSet.has(skill.name) &&
matchesQuery(skill, query) &&
(category === ALL_CATEGORIES || skill.category?.trim() === category),
@@ -384,6 +392,7 @@ function SkillItemCard({
onPick,
onViewDetail,
}: SkillItemCardProps) {
const isOffline = isSkillOffline(skill);
return (
<div
role="button"
@@ -398,6 +407,7 @@ function SkillItemCard({
title={isSelected ? "点击移除" : "点击添加"}
className={cn(
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
isOffline && "border-dashed bg-muted/50",
isSelected
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
@@ -425,6 +435,11 @@ function SkillItemCard({
>
{skillLabel(skill)}
</div>
{isOffline && (
<div className="text-destructive truncate text-[10px]">
已下线,仅可移除
</div>
)}
{skill.description?.trim() && (
<div className="text-muted-foreground truncate text-[11px]">
{desensitize(skill.description)}
@@ -223,22 +223,19 @@ const LAYOUT_OPTIONS = [
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
* - 全量模式 Header 右上角。
* 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面
* 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。
* 当前态为有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认)——
* useLayoutMode 总会返回具体值,不再有 undefined 兜底。
*/
export function LayoutModeSwitcher({
tone,
fallback = "compact",
navigateTo = "/page/workspace/chats/new",
}: {
tone: AppearanceControlTone;
fallback?: "full" | "compact";
navigateTo?: string | null;
}) {
const [mode, setLayoutMode] = useLayoutMode();
const navigate = useNavigate();
const current = mode ?? fallback;
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
const TriggerIcon = mode === "full" ? PanelLeftIcon : LayoutIcon;
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
@@ -262,7 +259,7 @@ export function LayoutModeSwitcher({
<DropdownMenuSeparator />
{LAYOUT_OPTIONS.map((option) => {
const Icon = option.icon;
const active = current === option.id;
const active = mode === option.id;
return (
<DropdownMenuItem
key={option.id}
@@ -8,6 +8,7 @@ import {
ExternalLinkIcon,
EyeIcon,
FileType2Icon,
GlobeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
@@ -17,11 +18,11 @@ import {
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { defaultUrlTransform } from "react-markdown";
import { Streamdown } from "streamdown";
import {
Artifact,
@@ -58,17 +59,13 @@ import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import { copyTextToClipboard } from "@/core/clipboard";
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
import {
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_STARTED_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
completeArtifactRewriteFallback,
primeArtifactRewriteFallback,
} from "@/core/document-rewrite/local-fallback";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
@@ -85,7 +82,11 @@ import {
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { streamdownPlugins } from "@/core/streamdown";
import {
SafeStreamdown,
streamdownPlugins,
useStreamdownMermaidPngDownload,
} from "@/core/streamdown";
import {
checkCodeFile,
getFileName,
@@ -93,6 +94,7 @@ import {
} from "@/core/utils/files";
import {
downloadMermaidSvgFile,
downloadSvgElementAsPng,
isMermaidFile,
openMermaidSvgInNewTab,
renderMermaidToSvg,
@@ -208,6 +210,22 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
);
}
/**
* 识别 Web App 站点清单:`outputs/<site>/site.json`(兼容 `write-file:` 虚拟 URL
* 与查询串)→ 返回站点目录名;其它文件返回 null(按钮不渲染,零行为变化)。
*/
function extractSiteStudioTarget(filepath: string): string | null {
const normalized = filepath.replace(/^write-file:/, "").split("?")[0];
if (!/(^|\/)site\.json$/.test(normalized)) return null;
const parts = normalized.split("/").filter(Boolean);
const idx = parts.lastIndexOf("site.json");
if (idx <= 0) return null;
const siteName = parts[idx - 1];
if (!siteName || siteName === "outputs") return null;
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(siteName)) return null;
return siteName;
}
export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
@@ -265,8 +283,16 @@ export function ArtifactFileDetail({
headerLeadingActionBare?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
const navigate = useNavigate();
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
const siteStudioTarget = useMemo(
() => extractSiteStudioTarget(filepathFromProps),
[filepathFromProps],
);
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
@@ -412,16 +438,23 @@ export function ArtifactFileDetail({
presentationId: null,
thinking: "",
});
const documentRewriteActive = documentRewrite.isActive;
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
? draftContent
: (content ?? ""));
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
const documentRewriteActive = documentRewrite.isActive;
const generationLockId = useId();
useLayoutEffect(() => {
const owner = `preview:${generationLockId}`;
const generating = (writeFileInFlight && thread.isLoading) || documentRewriteActive;
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
return () => setGeneratingArtifact(owner, null);
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
? draftContent
: (content ?? ""));
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
const sandboxDisplayText = useMemo(() => {
const raw = displayContent ?? "";
if (disableDesensitize) return raw;
@@ -433,6 +466,10 @@ export function ArtifactFileDetail({
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
const streamingWriteContentPending =
useWriteFileStreaming && writeFileInFlight && !content;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
@@ -440,8 +477,8 @@ export function ArtifactFileDetail({
!isArtifactContentLoading;
const isAutoPreviewLanguage =
language === "html" || language === "markdown" || language === "mermaid";
const isPreviewStreaming =
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const isPreviewStreaming =
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
@@ -466,16 +503,16 @@ export function ArtifactFileDetail({
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
if (!documentRewriteActive) return;
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
}, [documentRewriteActive]);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
if (!documentRewriteActive) return;
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
}, [documentRewriteActive]);
useEffect(() => {
if (viewMode !== "edit" || canUseAiEditor) return;
@@ -520,13 +557,20 @@ export function ArtifactFileDetail({
presentationId: string,
event: DocumentRewriteStreamEvent,
) => {
if (!rewritePresentation) return;
switch (event.type) {
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
originalCharCount: event.originalCharCount,
if (!rewritePresentation) return;
switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount,
requirementsPlan: "",
});
break;
@@ -587,10 +631,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
versionId: event.versionId ?? null,
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
@@ -606,9 +651,13 @@ export function ArtifactFileDetail({
case "cancelled":
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
break;
case "error":
console.error("AI 全文改写历史恢复失败:", event.message);
break;
case "error":
rewritePresentation.update(presentationId, {
stage: "error",
error: event.message,
diagnostic: event.diagnostic ?? null,
});
break;
default:
break;
}
@@ -630,10 +679,18 @@ export function ArtifactFileDetail({
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
? historicalResult.summary as DocumentRewriteSummary
: null;
const historicalPresentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
fileName: getFileName(filepath),
const historicalPresentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
jobId: historical.id,
outputPath: typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath,
fileName: getFileName(
typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath ?? filepath,
),
instruction: historical.request.instruction,
style: historical.request.style,
modelName: historical.request.modelName,
@@ -654,11 +711,14 @@ export function ArtifactFileDetail({
summary: historicalSummary,
warnings: historicalSummary?.validationWarnings ?? [],
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
error: historicalResult.status === "conflict"
? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
error: historicalResult.status === "conflict"
? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: historical.status === "failed"
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
: null,
diagnostic: historical.diagnostic ?? null,
});
}
const result = latest.result;
@@ -667,36 +727,25 @@ export function ArtifactFileDetail({
: null;
const originalContent = latest.request.originalContent || draftContent;
const rewrittenContent = typeof result.content === "string" ? result.content : null;
const versionSnapshotFailed = result.versionSnapshotFailed === true;
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
const localFallbackAvailable =
versionSnapshotFailed &&
latest.status === "completed" &&
result.status !== "conflict" &&
committedHash !== null &&
completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
fileName: getFileName(filepath),
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
const outputPath = typeof result.outputPath === "string"
? result.outputPath
: latest.outputPath;
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
jobId: latest.id,
outputPath,
fileName: getFileName(outputPath ?? filepath),
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent,
rewrittenContent,
committedHash,
localFallbackAvailable,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
rewrittenContent,
committedHash,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
});
if (!presentationId || !rewritePresentation) return;
setDocumentRewritePresentationId(presentationId);
@@ -705,32 +754,22 @@ export function ArtifactFileDetail({
rewritePresentation.update(presentationId, {
stage: result.status === "conflict" ? "conflict" : "completed",
summary,
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
localFallbackAvailable,
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
});
if (rewrittenContent && result.status !== "conflict") {
setDraftContent(rewrittenContent);
setHasLocalEdits(versionSnapshotFailed);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewrittenContent, url: undefined },
);
}
return;
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
error: result.status === "conflict" ? "改写版文件未生成。" : null,
});
return;
}
if (latest.status === "failed" || latest.status === "cancelled") {
rewritePresentation.update(presentationId, {
stage: latest.status === "cancelled" ? "cancelled" : "error",
summary,
});
if (latest.status === "failed" || latest.status === "cancelled") {
rewritePresentation.update(presentationId, {
stage: latest.status === "cancelled" ? "cancelled" : "error",
summary,
error: latest.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
});
return;
}
@@ -752,35 +791,22 @@ export function ArtifactFileDetail({
signal,
),
onEvent: (event) => updatePresentation(presentationId, event),
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewritten, url: undefined },
);
},
},
);
onCommitted: (rewritten, { committedHash, outputPath }) => {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
...(outputPath ? { outputPath } : {}),
});
},
onFailure: ({ error, diagnostic }) => {
rewritePresentation.update(presentationId, {
stage: "error",
error,
diagnostic: diagnostic ?? null,
});
},
},
);
} catch {
// Existing artifacts remain available even if history hydration fails.
}
@@ -903,8 +929,8 @@ export function ArtifactFileDetail({
return;
}
if (language === "html") {
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
if (language === "html") {
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
return;
}
@@ -914,9 +940,9 @@ export function ArtifactFileDetail({
}
setViewMode("code");
}, [
documentRewriteActive,
isPreviewStreaming,
}, [
documentRewriteActive,
isPreviewStreaming,
isSupportPreview,
language,
writeFileInFlight,
@@ -927,7 +953,7 @@ export function ArtifactFileDetail({
return;
}
if (writeFileInFlight || documentRewriteActive) {
if (writeFileInFlight || documentRewriteActive) {
setViewMode("code");
return;
}
@@ -939,10 +965,10 @@ export function ArtifactFileDetail({
}, 1400);
return () => window.clearTimeout(timeoutId);
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
isSupportPreview,
writeFileInFlight,
]);
@@ -1103,16 +1129,17 @@ export function ArtifactFileDetail({
generateImages: boolean;
maxGeneratedImages: number;
}) => {
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
// current draft first so the rewrite starts from exactly what they see,
// and the optimistic hash remains valid on the server.
if (hasLocalEdits) {
@@ -1131,12 +1158,7 @@ export function ArtifactFileDetail({
}
}
primeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
});
const expectedHash = await sha256Content(draftContent);
const expectedHash = await sha256Content(draftContent);
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
@@ -1168,14 +1190,21 @@ export function ArtifactFileDetail({
expectedHash,
},
{
onEvent: (event) => {
if (!presentationId || !rewritePresentation) return;
switch (event.type) {
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
originalCharCount: event.originalCharCount,
onEvent: (event) => {
if (!presentationId || !rewritePresentation) return;
switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount,
requirementsPlan: "",
});
break;
@@ -1239,9 +1268,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
versionId: event.versionId ?? null,
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
@@ -1251,77 +1282,48 @@ export function ArtifactFileDetail({
versionId: event.versionId ?? null,
});
break;
case "conflict":
rewritePresentation.update(presentationId, {
stage: "conflict",
error: event.message,
});
break;
case "error":
// The sandbox retains the visible draft and exposes manual
// save instead of showing an error state to the user.
console.error("AI 全文改写接口失败:", event.message);
break;
case "conflict":
rewritePresentation.update(presentationId, {
stage: "conflict",
error: event.message,
});
break;
case "cancelled":
rewritePresentation.update(presentationId, {
stage: "cancelled",
error: event.message,
});
break;
case "error":
rewritePresentation.update(presentationId, {
stage: "error",
error: event.message,
diagnostic: event.diagnostic ?? null,
});
break;
default:
break;
}
},
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
}
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: rewritten, url: undefined },
);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
} else {
toast.success("Markdown 全文改写完成,已安全替换原文件。");
}
},
onFailure: ({ content, error }) => {
// The rewrite transport can fail after the server has already
// streamed text (or before the first token on a 404). Keep that
// visible content in the sandbox, make Save actionable, and leave
// diagnostics to the developer console only.
const retainedContent = content || draftContent;
setDraftContent(retainedContent);
setHasLocalEdits(true);
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: retainedContent, url: undefined },
);
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "completed",
rewrittenContent: retainedContent,
error: null,
});
}
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
},
onCommitted: (rewritten, { committedHash, outputPath }) => {
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
...(outputPath ? { outputPath } : {}),
});
}
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。");
},
onFailure: ({ error, diagnostic }) => {
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "error",
error,
diagnostic: diagnostic ?? null,
});
}
},
},
);
},
@@ -1339,15 +1341,37 @@ export function ArtifactFileDetail({
],
);
const handleCancelDocumentRewrite = useCallback(() => {
documentRewrite.cancel();
const handleCancelDocumentRewrite = useCallback(() => {
documentRewrite.cancel();
if (documentRewritePresentationId && rewritePresentation) {
rewritePresentation.update(documentRewritePresentationId, {
stage: "cancelled",
error: "已停止全文改写,原文件未被修改。",
});
}
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
// The docked conversation composer is outside this artifact pane. Register
// the same cancellation path so its standard square stop button can abort
// both the local SSE reader and the durable rewrite job.
useEffect(() => {
if (
!documentRewriteActive ||
!documentRewritePresentationId ||
!rewritePresentation
) {
return;
}
return rewritePresentation.registerCancellation(
documentRewritePresentationId,
handleCancelDocumentRewrite,
);
}, [
documentRewriteActive,
documentRewritePresentationId,
handleCancelDocumentRewrite,
rewritePresentation,
]);
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
@@ -1422,7 +1446,9 @@ export function ArtifactFileDetail({
toast.error("文件内容为空,暂时无法在新窗口打开");
return;
}
const blob = new Blob([prepareGeneratedHtml(source)], {
const blob = new Blob([
prepareGeneratedHtml(source),
], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
@@ -1449,7 +1475,11 @@ export function ArtifactFileDetail({
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const downloadSource =
language === "html"
? prepareGeneratedHtml(source)
: source;
const blob = new Blob([downloadSource], { type: blobType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -1580,19 +1610,25 @@ export function ArtifactFileDetail({
{canEditArtifact && (
<ArtifactAction
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => setDocumentRewriteDialogOpen(true)}
/>
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成独立改写版文件,原文件保持不变;点击停止"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => {
if (documentRewriteActive) {
handleCancelDocumentRewrite();
return;
}
setDocumentRewriteDialogOpen(true);
}}
/>
)}
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
@@ -1642,6 +1678,16 @@ export function ArtifactFileDetail({
onClick={handleOpenInNewWindow}
/>
)}
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
<ArtifactAction
icon={GlobeIcon}
label="站点预览"
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
onClick={() =>
navigate(siteStudioRoute(threadId, siteStudioTarget))
}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
@@ -1708,27 +1754,6 @@ export function ArtifactFileDetail({
正在改写 Markdown
</div>
)}
{documentRewriteActive && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<LoaderIcon className="size-3.5 animate-spin" />
{documentRewrite.run.stage === "requirements"
? "正在理解改写需求,原文件未被修改"
: documentRewrite.run.stage === "validation"
? "正在校验 Markdown"
: documentRewrite.run.stage === "committing"
? "正在安全替换原文件"
: "正在写入临时草稿 · " +
documentRewrite.run.writtenCharCount.toLocaleString() +
" 字"}
<button
type="button"
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
onClick={handleCancelDocumentRewrite}
>
停止
</button>
</div>
)}
{showResumeAutoScroll && (
<button
type="button"
@@ -1739,7 +1764,7 @@ export function ArtifactFileDetail({
滑动到底部并继续自动下滑
</button>
)}
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
<MarkdownAiEditor
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
@@ -1766,10 +1791,10 @@ export function ArtifactFileDetail({
activeCursor={isApplyingEdit}
/>
)}
{artifactContentPending && viewMode === "code" && (
{(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 +1812,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"
@@ -1796,16 +1821,16 @@ export function ArtifactFileDetail({
? (displayContent ?? "")
: sandboxDisplayText
}
readonly={!canEditArtifact || documentRewriteActive}
onChange={
canEditArtifact && !documentRewriteActive
readonly={!canEditArtifact || documentRewriteActive}
onChange={
canEditArtifact && !documentRewriteActive
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
isStreaming={writeFileInFlight || documentRewriteActive}
isStreaming={writeFileInFlight || documentRewriteActive}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
@@ -2007,6 +2032,9 @@ export function ArtifactFilePreview({
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(null);
const markdownPreviewRef = useRef<HTMLDivElement>(null);
const mermaidChartRef = useRef<HTMLDivElement>(null);
useStreamdownMermaidPngDownload(markdownPreviewRef);
const researchSourcesQuery = useResearchSources(
language === "markdown" ? (researchSessionId ?? null) : null,
);
@@ -2086,7 +2114,9 @@ export function ArtifactFilePreview({
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
// Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame).
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
const blob = new Blob([
prepareGeneratedHtml(content ?? ""),
], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
@@ -2131,6 +2161,7 @@ export function ArtifactFilePreview({
if (language === "markdown") {
return (
<div
ref={markdownPreviewRef}
data-artifact-preview-scroll
data-markdown-anchor-root
className={cn(
@@ -2138,7 +2169,7 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
<SafeStreamdown
className="size-full"
{...streamdownPlugins}
// react-markdown's safe default strips unknown protocols before the
@@ -2163,7 +2194,7 @@ export function ArtifactFilePreview({
}}
>
{previewContent}
</Streamdown>
</SafeStreamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
@@ -2195,10 +2226,42 @@ export function ArtifactFilePreview({
)}
>
{mermaidSvg ? (
<div
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
/>
<div className="relative mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 pt-12 shadow-sm">
<div className="absolute right-3 top-3 flex items-center gap-1">
<button
type="button"
className="text-muted-foreground hover:text-foreground rounded-md p-1"
title="下载图片"
onClick={() => {
const svg = mermaidChartRef.current?.querySelector("svg");
if (!(svg instanceof SVGSVGElement)) {
toast.error("流程图尚未渲染完成,请稍后再下载");
return;
}
void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => {
console.error("Failed to download mermaid image:", error);
toast.error(error instanceof Error ? error.message : "下载流程图失败");
});
}}
>
<DownloadIcon className="size-3.5" />
</button>
<button
type="button"
className="text-muted-foreground hover:text-foreground rounded-md p-1"
title="新窗口打开"
onClick={() => {
void openMermaidSvgInNewTab(content ?? "").catch((error) => {
console.error("Failed to open mermaid preview:", error);
toast.error(error instanceof Error ? error.message : "打开流程图失败");
});
}}
>
<ExternalLinkIcon className="size-3.5" />
</button>
</div>
<div ref={mermaidChartRef} dangerouslySetInnerHTML={{ __html: mermaidSvg }} />
</div>
) : mermaidError ? (
<div className="space-y-3">
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
@@ -16,6 +16,7 @@ import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
isWordDocumentFile,
} from "@/core/utils/files";
import { cn } from "@/lib/utils";
@@ -93,12 +94,12 @@ export function ArtifactFileList({
return (
<ul className={cn("flex w-full flex-col gap-4", className)}>
{files.map((file) => (
<Card
key={file}
className="relative cursor-pointer p-3"
onClick={() => handleClick(file)}
>
{files.map((file) => (
<Card
key={file}
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)}
>
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
<div className="min-w-0">{getFileName(file)}</div>
@@ -133,7 +134,7 @@ export function ArtifactFileList({
</Button>
</CardAction>
</CardHeader>
</Card>
</Card>
))}
<SkillInstallDialog
open={!!installFile}
@@ -7,9 +7,11 @@ import {
useState,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
export function artifactPreviewKey(artifact: string): string {
if (!artifact.startsWith("write-file:")) {
@@ -38,6 +40,8 @@ export interface ArtifactsContextType {
select: (artifact: string, autoSelect?: boolean) => void;
/** Open/update a streamed write without overriding a manual close of that call. */
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
/** Register the file being generated; remove this owner's lock on completion. */
setGeneratingArtifact: (owner: string, artifact: string | null) => void;
deselect: () => void;
open: boolean;
@@ -86,6 +90,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [preOpen, setPreOpen] = useState(false);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
const generatingArtifactsRef = useRef(new Map<string, string>());
const setGeneratingArtifact = useCallback((owner: string, artifact: string | null) => {
if (artifact) generatingArtifactsRef.current.set(owner, artifact);
else generatingArtifactsRef.current.delete(owner);
}, []);
const canSwitchArtifact = useCallback((artifact: string, notify: boolean) => {
for (const generating of generatingArtifactsRef.current.values()) {
if (
artifactPreviewKey(generating) === artifactPreviewKey(artifact) ||
resolveWriteFilePath(generating) === resolveWriteFilePath(artifact)
) continue;
if (notify) {
toast.info("当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。", {
id: "artifact-generation-switch-locked",
});
}
return false;
}
return true;
}, []);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
@@ -129,6 +153,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const select = useCallback(
(artifact: string, autoSelect = false) => {
if (!canSwitchArtifact(artifact, !autoSelect)) return;
const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
@@ -140,11 +165,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setAutoSelect((previous) => (previous ? false : previous));
}
},
[sidebar],
[canSwitchArtifact, sidebar],
);
const openStreamedArtifact = useCallback(
(artifact: string, previewKey: string) => {
if (!canSwitchArtifact(artifact, false)) return false;
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
@@ -180,10 +206,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
}
return true;
},
[applyOpen, cancelPendingStreamedOpen, sidebar],
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
);
const deselect = useCallback(() => {
generatingArtifactsRef.current.clear();
selectedArtifactKeyRef.current = null;
setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
@@ -224,6 +251,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
selectedArtifactKey,
select,
openStreamedArtifact,
setGeneratingArtifact,
deselect,
};
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { useUploadPolicy } from "@/core/uploads";
/**
* Shared providers required by every full chat surface.
*
* Keep this in a small standalone module: importing it from a route must not
* eagerly evaluate the complete WorkspaceRoutes tree and all of its pages.
*/
export function ChatRuntime({ children }: { children: ReactNode }) {
const { data: uploadPolicy } = useUploadPolicy();
return (
<SubtasksProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider uploadPolicy={uploadPolicy}>
{children}
</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -1,8 +1,8 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
@@ -15,14 +15,27 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiFetch } from "@/core/api";
import {
artifactPathname,
listThreadArtifacts,
mergeArtifactPaths,
} from "@/core/artifacts/listing";
import {
latestCompletedArtifactMutationKey,
latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
isWriteFileInFlight,
} from "@/core/artifacts/write-file-in-flight";
import {
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
} from "@/core/artifacts/streaming-write-preview";
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";
import { useIsMobile } from "@/hooks/use-mobile";
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
@@ -33,6 +46,7 @@ import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import { isWordDocumentFile } from "@/core/utils/files";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -43,6 +57,7 @@ import {
useArtifacts,
} from "../artifacts";
import { ReferenceBatchesContext, useThread } from "../messages/context";
import { KnowledgeWikiPanel } from "../knowledge-wiki-panel";
import { SkillDisplaySources } from "../skill-display-sources";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
@@ -81,18 +96,41 @@ const ChatBox: React.FC<{
children: React.ReactNode;
threadId: string;
disableArtifacts?: boolean;
writingMode?: boolean;
/** Prefer and automatically reveal a specific artifact once it is available. */
preferredArtifact?: string | null;
/** Keep scanning until the preferred artifact appears (for delayed file persistence). */
pollForPreferredArtifact?: boolean;
/** Report the real artifact discovered on disk back to the page state. */
onPreferredArtifactAvailabilityChange?: (artifact: string | null) => void;
/** Stack the artifact below chat on small screens. */
responsiveArtifactLayout?: boolean;
}> = ({
children,
threadId,
disableArtifacts = false,
writingMode,
preferredArtifact = null,
pollForPreferredArtifact = false,
onPreferredArtifactAvailabilityChange,
responsiveArtifactLayout = false,
}) => {
const {
isMock,
thread,
knowledgeReferencesEnabled = false,
knowledgeReferences,
} = 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 openedPreferredArtifactRef = useRef<string | null>(null);
const preferredRunWasLoadingRef = useRef(false);
const artifactsCountRef = useRef(0);
const layoutRef = useRef<GroupImperativeHandle>(null);
const latestReferenceBatchIdRef = useRef<string | null>(null);
@@ -104,6 +142,12 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const stackArtifactPanel = responsiveArtifactLayout && isMobile;
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
@@ -112,15 +156,15 @@ const ChatBox: React.FC<{
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
knowledgeReferencesEnabled ||
!!knowledgeReferences;
// Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs.
const hasFrontendRag = useMemo(
() =>
(thread.values.messages ?? []).some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
(thread.values.messages ?? []).some((message) =>
isSingleRoundRagMessage(message),
),
[thread.values.messages],
);
@@ -130,7 +174,7 @@ const ChatBox: React.FC<{
const { skills } = useSkills();
const canScanArtifactDirectory =
artifactDirectoryModeEnabled &&
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) &&
!disableArtifacts &&
!isMock &&
!!threadId &&
@@ -139,7 +183,8 @@ const ChatBox: React.FC<{
queryKey: ["artifact-directory", threadId],
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
enabled: canScanArtifactDirectory,
refetchInterval: thread.isLoading ? 1_500 : false,
refetchInterval:
thread.isLoading || pollForPreferredArtifact ? 1_500 : false,
retry: false,
});
const checkpointArtifacts = useMemo(
@@ -148,19 +193,42 @@ 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 && !preferredArtifact) {
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,
preferredArtifact,
requestedArtifact,
],
);
const availablePreferredArtifact = useMemo(() => {
if (!preferredArtifact) return null;
const preferredPath = artifactPathname(preferredArtifact);
return (
[...availableArtifacts]
.reverse()
.find((artifact) => artifactPathname(artifact) === preferredPath) ?? null
);
}, [availableArtifacts, preferredArtifact]);
useEffect(() => {
if (!preferredArtifact) return;
onPreferredArtifactAvailabilityChange?.(availablePreferredArtifact);
}, [
availablePreferredArtifact,
onPreferredArtifactAvailabilityChange,
preferredArtifact,
]);
useEffect(() => {
if (!canScanArtifactDirectory || thread.isLoading) return;
@@ -184,8 +252,21 @@ const ChatBox: React.FC<{
deselect,
selectedArtifact,
selectedArtifactKey,
openStreamedArtifact,
setGeneratingArtifact,
} = useArtifacts();
// Keep the lock even when the user closes the preview while writing.
const selectedFileGenerating = Boolean(
selectedArtifact && thread.isLoading &&
isWriteFileInFlight({ messages: thread.messages ?? [] }, selectedArtifact),
);
useLayoutEffect(() => {
const owner = `chat:${threadId}`;
setGeneratingArtifact(owner, selectedFileGenerating ? selectedArtifact : null);
return () => setGeneratingArtifact(owner, null);
}, [selectedArtifact, selectedFileGenerating, setGeneratingArtifact, threadId]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -201,6 +282,64 @@ 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(() => {
const completedNow =
preferredRunWasLoadingRef.current && !thread.isLoading;
preferredRunWasLoadingRef.current = thread.isLoading;
if (disableArtifacts || !preferredArtifact) {
openedPreferredArtifactRef.current = null;
return;
}
if (!availablePreferredArtifact) return;
const preferredKey = `${threadId}:${artifactPathname(availablePreferredArtifact)}`;
if (openedPreferredArtifactRef.current === preferredKey && !completedNow) {
return;
}
openedPreferredArtifactRef.current = preferredKey;
selectArtifact(availablePreferredArtifact, true);
setArtifactsOpen(true);
setSideTab("document");
// The same canonical report path may be overwritten by a later run while
// its content query is still inside the five-minute cache window. Invalidate
// the real selected path before the detail panel mounts/reopens so it never
// shows the previous report after the assistant announces completion.
void queryClient.invalidateQueries({
queryKey: ["artifact", availablePreferredArtifact, threadId],
});
}, [
availablePreferredArtifact,
disableArtifacts,
preferredArtifact,
queryClient,
selectArtifact,
setArtifactsOpen,
thread.isLoading,
threadId,
]);
useEffect(() => {
if (!shouldFetchBackendReferenceBatches) {
setBackendReferenceBatches([]);
@@ -252,7 +391,7 @@ const ChatBox: React.FC<{
),
[backendReferenceBatches, skillDisplayMap],
);
const referenceBatches = useMemo(
const resolvedReferenceBatches = useMemo(
() => {
if (
shouldFetchBackendReferenceBatches &&
@@ -268,6 +407,18 @@ const ChatBox: React.FC<{
shouldFetchBackendReferenceBatches,
],
);
const referenceBatches = useMemo(() => {
if (!knowledgeReferences) return resolvedReferenceBatches;
return resolvedReferenceBatches
.map((batch) => ({
...batch,
sources: filterKnowledgeWikiSources(
batch.sources,
knowledgeReferences,
),
}))
.filter((batch) => batch.sources.length > 0);
}, [knowledgeReferences, resolvedReferenceBatches]);
useEffect(() => {
if (!shouldFetchBackendReferenceBatches) {
return;
@@ -396,11 +547,51 @@ const ChatBox: React.FC<{
!disableArtifacts &&
nextArtifacts.length > 0 &&
((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
(hasNewArtifact || !hadArtifacts))
) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
setSideTab("document");
// The preferred deliverable wins over the generic "newest artifact"
// heuristic. Both effects can run in the same commit with a stale null
// selectedArtifact; choosing the preferred path here prevents a sibling
// file such as research-plan.json from overwriting report.md.
const automaticTarget =
availablePreferredArtifact ?? nextArtifacts[nextArtifacts.length - 1]!;
const isGeneratedHtml =
languageOfStreamingArtifact(automaticTarget) === "html" &&
isAutoPreviewableArtifactPath(automaticTarget);
if (
shouldAutoOpenWritingArtifact(
automaticTarget,
thread.messages,
writingModeEnabled,
) &&
(isGeneratedHtml || !selectedArtifact)
) {
if (isGeneratedHtml) {
// HTML is the requested end product, so a later webpage should
// replace a previously selected document automatically. Preserve a
// matching in-flight write key so a user-closed live preview stays
// closed after the tool call completes; otherwise a completed
// tool-call id makes a later write to the same path a new preview.
const revision = latestCompletedArtifactMutationKey(
{ messages: thread.messages ?? [] },
automaticTarget,
);
const previewKey =
selectedArtifact?.startsWith("write-file:") &&
artifactPathname(selectedArtifact) === artifactPathname(automaticTarget) &&
selectedArtifactKey
? selectedArtifactKey
: revision ?? `artifact:${threadId}:${artifactPathname(automaticTarget)}`;
openStreamedArtifact(
automaticTarget,
previewKey,
);
} else {
selectArtifact(automaticTarget, true);
setArtifactsOpen(true);
setSideTab("document");
}
}
}
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
@@ -418,7 +609,12 @@ const ChatBox: React.FC<{
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
const firstPreviewableArtifact = thread.values.artifacts.find(
(artifact) => !isWordDocumentFile(artifact),
);
if (firstPreviewableArtifact) {
selectArtifact(firstPreviewableArtifact, true);
}
}
}
}, [
@@ -427,13 +623,58 @@ const ChatBox: React.FC<{
autoSelectFirstArtifact,
deselect,
selectArtifact,
openStreamedArtifact,
selectedArtifact,
selectedArtifactKey,
artifacts,
setArtifacts,
setArtifactsOpen,
availableArtifacts,
availablePreferredArtifact,
disableArtifacts,
thread.messages,
writingModeEnabled,
]);
// Live sandbox: open when an in-flight write_file has a usable output path.
// Pathless content stays inline in the CoT, including content-first streams.
// 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]);
// page_report_template(prepare) creates the output HTML before content
// editing starts. Open that real artifact immediately; this also covers
// models that use str_replace or whose write_file arguments arrive at once.
const preparedHtmlPreview = useMemo(
() =>
disableArtifacts || !thread.isLoading
? null
: latestPreparedHtmlArtifact(thread.messages ?? []),
[disableArtifacts, thread.isLoading, thread.messages],
);
useEffect(() => {
if (!preparedHtmlPreview) return;
openStreamedArtifact(
preparedHtmlPreview.artifactPath,
preparedHtmlPreview.previewKey,
);
setSideTab("document");
}, [openStreamedArtifact, preparedHtmlPreview]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current;
@@ -441,12 +682,13 @@ const ChatBox: React.FC<{
if (
wasEnabled ||
!artifactDirectoryModeEnabled ||
availableArtifacts.length === 0
availableArtifacts.length === 0 ||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
) {
return;
}
if (!selectedArtifact) {
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
}
setArtifactsOpen(true);
setSideTab("document");
@@ -601,7 +843,7 @@ const ChatBox: React.FC<{
<ReferenceBatchesContext.Provider value={referenceBatches}>
<ResizablePanelGroup
id={`${resizableIdBase}-panels`}
orientation="horizontal"
orientation={stackArtifactPanel ? "vertical" : "horizontal"}
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
>
@@ -612,6 +854,8 @@ const ChatBox: React.FC<{
id={`${resizableIdBase}-separator`}
className={cn(
"opacity-33 hover:opacity-100",
stackArtifactPanel &&
"h-px w-full after:inset-x-0 after:inset-y-auto after:top-1/2 after:h-4 after:w-full after:-translate-x-0 after:-translate-y-1/2 after:cursor-row-resize",
!sidePanelOpen && "pointer-events-none opacity-0",
)}
/>
@@ -633,7 +877,9 @@ const ChatBox: React.FC<{
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
<TabsList variant="line">
<TabsTrigger value="document">文档</TabsTrigger>
<TabsTrigger value="references">参考文献</TabsTrigger>
<TabsTrigger value="references">
{knowledgeReferences ? "Wiki" : "参考文献"}
</TabsTrigger>
</TabsList>
<Button
size="icon-sm"
@@ -649,11 +895,19 @@ const ChatBox: React.FC<{
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
{artifactDocumentPanel}
</TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
<TabsContent value="references" className="min-h-0 flex-1 overflow-hidden p-0">
{knowledgeReferences ? (
<KnowledgeWikiPanel
highlightedReferenceId={pinnedReferenceId}
selection={knowledgeReferences}
sources={referenceSources}
/>
) : (
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
)}
</TabsContent>
</Tabs>
) : artifactPanelOpen ? (
@@ -672,10 +926,18 @@ const ChatBox: React.FC<{
<XIcon />
</Button>
</div>
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
{knowledgeReferences ? (
<KnowledgeWikiPanel
highlightedReferenceId={pinnedReferenceId}
selection={knowledgeReferences}
sources={referenceSources}
/>
) : (
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
)}
</div>
) : null}
</div>
@@ -942,8 +1204,7 @@ function buildTurnReferenceBatches(
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
// those cards available immediately, while keeping normal streaming turns
// on their existing "wait for an answer" behavior.
const isFrontendRagTurn =
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
const isFrontendRagTurn = isSingleRoundRagMessage(assistant);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
continue;
}
@@ -994,7 +1255,7 @@ function findFrontendRagAssistant(
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
isSingleRoundRagMessage(message)
) {
return message;
}
@@ -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();
@@ -304,7 +304,9 @@ export function CompactModeGuide({
setCurrent(-1);
}, [clearPollTimer, releasePromptDialog]);
if (!eligible) return null;
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (!eligible || current < 0 || current >= stepsRef.current.length) return null;
return (
<Guide
@@ -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. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
@@ -21,6 +22,7 @@ import {
Download,
FileJson,
FileText,
Loader2,
MessageSquare,
MoreHorizontal,
Pencil,
@@ -31,14 +33,17 @@ import {
Share2,
Star,
Trash2,
X,
} from "lucide-react";
import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Select } from "tdesign-react";
import { useTheme } from "@/contexts/ThemeContext";
import { findNanjingDbSquareId } from "@/core/system-settings/wx-type-filter";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -59,6 +64,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
@@ -74,6 +83,8 @@ import {
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -85,19 +96,22 @@ import {
titleOfThread,
} from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { useRouteBase } from "@/core/route-base";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
import {
mergeRecentWorkspaceItems,
useRecentStrategyProjects,
} from "@/strategy-selection/recent-projects";
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;
@@ -117,31 +131,76 @@ function SectionLabel({
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5">
<span>{label}</span>
{count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count}
</span>
)}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
collapsed && "-rotate-90",
)}
/>
</button>
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
<button
type="button"
onClick={onToggle}
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{label}</span>
{count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count}
</span>
)}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
collapsed && "-rotate-90",
)}
/>
</button>
</div>
);
}
export function GeneralQaSidebar() {
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */
const WX_AGENT_TYPE_MINE = "mine";
const WX_AGENT_TYPE_DEFAULT_SQUARE = "default_square";
const WX_AGENT_TYPE_NANJING_DB = "nanjing-db";
const WX_AGENT_TYPE_STORAGE_KEY = "gqa-sidebar:wx-agent-type";
const WX_AGENT_LIST_PAGE_SIZE = 50;
const WX_AGENT_TYPE_OPTIONS = [
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
{ value: WX_AGENT_TYPE_DEFAULT_SQUARE, label: "默认广场" },
{ value: WX_AGENT_TYPE_NANJING_DB, label: "南京-db" },
] as const;
type WxAgentType = (typeof WX_AGENT_TYPE_OPTIONS)[number]["value"];
function isWxAgentType(value: string | null | undefined): value is WxAgentType {
return WX_AGENT_TYPE_OPTIONS.some((option) => option.value === value);
}
function readStoredWxAgentType(): WxAgentType {
if (typeof window === "undefined") return WX_AGENT_TYPE_MINE;
const stored = window.localStorage.getItem(WX_AGENT_TYPE_STORAGE_KEY);
return isWxAgentType(stored) ? stored : WX_AGENT_TYPE_MINE;
}
function resolveWxSquareId(
agentType: WxAgentType,
squares: Array<{ id: string; name: string }> | undefined,
defaultSquareId: string | undefined,
): string | undefined {
if (agentType === WX_AGENT_TYPE_MINE) return undefined;
if (agentType === WX_AGENT_TYPE_DEFAULT_SQUARE) return defaultSquareId;
return findNanjingDbSquareId(squares);
}
export function GeneralQaSidebar({
wxCommonAgents,
}: {
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
} = {}) {
const { t } = useI18n();
const routeBase = useRouteBase();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
@@ -160,17 +219,70 @@ export function GeneralQaSidebar() {
setAgentsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
return !v;
});
});
const { data: publishSquares, isLoading: wxSquaresLoading } = usePublishSquares({
enabled: !!wxCommonAgents,
});
const [wxAgentType, setWxAgentTypeState] = useState<WxAgentType>(
readStoredWxAgentType,
);
const setWxAgentType = useCallback((next: WxAgentType) => {
setWxAgentTypeState(next);
window.localStorage.setItem(WX_AGENT_TYPE_STORAGE_KEY, next);
}, []);
const [wxSearchInput, setWxSearchInput] = useState("");
const [wxSearch, setWxSearch] = useState("");
useEffect(() => {
const timer = window.setTimeout(() => setWxSearch(wxSearchInput.trim()), 300);
return () => window.clearTimeout(timer);
}, [wxSearchInput]);
const wxSquareId = wxCommonAgents
? resolveWxSquareId(
wxAgentType,
publishSquares?.squares,
publishSquares?.default_square_id,
)
: undefined;
const wxFetchEnabled =
!!wxCommonAgents &&
(wxAgentType === WX_AGENT_TYPE_MINE || Boolean(wxSquareId));
const toggleChats = () =>
setChatsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
return !v;
});
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const frequent = useMemo<Agent[]>(() => {
// ── 常用智能体取数:wx 走类型筛选;其它页面仍是置顶 → 我的 → 广场 ──
const { agents: mine } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 20,
enabled: !wxCommonAgents,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
enabled: !wxCommonAgents,
});
const {
agents: wxAgents,
total: wxTotal,
isLoading: wxAgentsLoading,
isFetching: wxAgentsFetching,
} = useAgentsPaginated({
scope: wxAgentType === WX_AGENT_TYPE_MINE ? "mine" : "square",
page: 1,
pageSize: WX_AGENT_LIST_PAGE_SIZE,
square: wxSquareId,
search: wxSearch || undefined,
enabled: wxFetchEnabled,
});
const frequent = useMemo<CommonAgent[]>(() => {
if (wxCommonAgents) {
return wxFetchEnabled ? wxAgents : [];
}
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
@@ -182,37 +294,65 @@ export function GeneralQaSidebar() {
mine.forEach(add);
square.forEach(add);
return out;
}, [mine, square]);
}, [mine, square, wxAgents, wxCommonAgents, wxFetchEnabled]);
const wxEmptyHint =
wxAgentType === WX_AGENT_TYPE_NANJING_DB && !wxSquareId
? "该类型暂无智能体"
: wxSearch
? "未找到匹配的智能体"
: wxAgentType === WX_AGENT_TYPE_MINE
? "暂无智能体"
: "该广场暂无智能体";
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
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 moreSkillsHref = buildFullChatNavigationPath(
`${routeBase}/skills`,
currentSearch,
);
const wxAgentsPageActive = pathname === `${routeBase}/agents`;
const wxSkillsPageActive = pathname === `${routeBase}/skills`;
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback(
(agentId: string) => {
if (agentId === SENTIMENT_AGENT_ID) {
navigate(
buildFullChatNavigationPath(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
currentSearch,
),
);
return;
}
navigate(
buildFullChatNavigationPath(
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
routeBase === "/page/workspace" || wxCommonAgents
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
currentSearch,
),
);
},
[currentSearch, navigate],
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
);
// ── 最近的对话取数 + 搜索 ──
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 { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
@@ -224,6 +364,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)会话也展示(带机器人图标、点击回创建页);
@@ -231,17 +379,28 @@ export function GeneralQaSidebar() {
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
const projectThreadNames = useProjectThreadNames(visibleThreads);
const recentItems = useMemo(
() =>
mergeRecentWorkspaceItems(visibleThreads, recentStrategyProjects),
[recentStrategyProjects, visibleThreads],
);
const filteredThreads = useMemo(() => {
const filteredRecentItems = useMemo(() => {
const q = query.trim().toLowerCase();
const list = q
? visibleThreads.filter((thread) =>
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
? recentItems.filter((item) =>
(item.kind === "thread"
? titleOfThread(item.thread, t.pages.newChat)
: item.project.title
)
.toLowerCase()
.includes(q),
)
: visibleThreads;
: recentItems;
// 未搜索时与旧版一致:最多展示 10 条
return q ? list : list.slice(0, 10);
}, [visibleThreads, query, t.pages.newChat]);
}, [query, recentItems, t.pages.newChat]);
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
@@ -254,21 +413,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,
@@ -343,17 +503,88 @@ export function GeneralQaSidebar() {
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{/* ── 常用智能体 ── */}
{frequent.length > 0 && (
{(wxCommonAgents || frequent.length > 0) && (
<div className="pt-2">
<SectionLabel
label="常用智能体"
count={wxCommonAgents ? frequent.length : undefined}
collapsed={agentsCollapsed}
onToggle={toggleAgents}
/>
{!agentsCollapsed && (
<div className="mt-1 flex flex-col gap-1">
{wxCommonAgents && (
<div className="space-y-2 px-1 pb-1">
<Select
size="medium"
value={wxAgentType}
placeholder="智能体类型"
onChange={(value) => {
if (isWxAgentType(String(value))) {
setWxAgentType(String(value) as WxAgentType);
}
}}
className="w-full [&_.t-input]:!h-9 [&_.t-input]:!rounded-lg [&_.t-input]:!border-border [&_.t-input]:!bg-card [&_.t-input]:!px-2.5 [&_.t-input]:!text-[13px]"
popupProps={{
overlayClassName: "z-[80]",
attach: () => document.body,
}}
>
{WX_AGENT_TYPE_OPTIONS.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={wxSearchInput}
onChange={(e) => setWxSearchInput(e.target.value)}
placeholder="搜索智能体"
className="h-8 rounded-lg border-border bg-card pl-8 pr-8 text-[12px]"
aria-label="搜索智能体"
/>
{wxSearchInput && (
<button
type="button"
onClick={() => setWxSearchInput("")}
className="absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
title="清除"
aria-label="清除搜索"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
)}
{wxCommonAgents &&
(wxAgentsLoading ||
(wxAgentType !== WX_AGENT_TYPE_MINE && wxSquaresLoading)) &&
frequent.length === 0 ? (
<div className="flex h-20 items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
加载中…
</div>
) : frequent.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground/70">
{wxCommonAgents ? wxEmptyHint : null}
</p>
) : (
<div
className={cn(
"flex max-h-[240px] flex-col gap-1 overflow-y-auto overscroll-contain pr-0.5",
wxCommonAgents && wxAgentsFetching && "opacity-60",
)}
>
{frequent.map((agent) => {
const isActive = activeAgentId === agent.id;
const isActive =
activeAgentId === agent.id ||
(agent.id === SENTIMENT_AGENT_ID &&
pathname.startsWith(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
));
const { from, to, foreground } = agentColor(agent.id, theme);
return (
<button
@@ -426,13 +657,50 @@ export function GeneralQaSidebar() {
</button>
);
})}
<Link
href={moreAgentsHref}
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</div>
)}
{wxCommonAgents ? (
<div className="grid grid-cols-2 gap-1.5 px-1 pt-0.5">
<Link
href={moreAgentsHref}
className={cn(
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
wxAgentsPageActive
? isDark
? "bg-[#1a5dad] text-white"
: "bg-primary text-primary-foreground"
: isDark
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
: "bg-accent text-foreground hover:bg-accent/80",
)}
>
智能体
</Link>
<Link
href={moreSkillsHref}
className={cn(
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
wxSkillsPageActive
? isDark
? "bg-[#1a5dad] text-white"
: "bg-primary text-primary-foreground"
: isDark
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
: "bg-accent text-foreground hover:bg-accent/80",
)}
>
技能
</Link>
</div>
) : (
<Link
href={moreAgentsHref}
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
)}
</div>
)}
</div>
@@ -442,7 +710,7 @@ export function GeneralQaSidebar() {
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
<SectionLabel
label={t.sidebar.recentChats}
count={visibleThreads.length}
count={recentItems.length}
collapsed={chatsCollapsed}
onToggle={toggleChats}
/>
@@ -460,29 +728,75 @@ export function GeneralQaSidebar() {
</div>
<div className="flex flex-col gap-0.5">
{filteredThreads.length === 0 ? (
{filteredRecentItems.length === 0 ? (
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
{query ? "没有匹配的会话" : t.chats.noChats}
</div>
) : (
filteredThreads.map((thread) => {
filteredRecentItems.map((item) => {
if (item.kind === "strategy-project") {
const { project } = item;
const href = strategyProjectWorkbenchPath(
"/page/strategy/qa/projects",
project.id,
);
const isActive = href === pathname;
return (
<div
key={`strategy-project:${project.id}`}
className="group/item relative flex items-center"
>
<Link
href={href}
className={cn(
"gq-history-item relative flex min-h-12 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] transition-colors",
isActive
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<MyProjectThreadIcon type="strategy" />
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
<span className="w-full truncate">
{desensitize(project.title)}
</span>
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={project.title}
type="strategy"
/>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
</div>
);
}
const { thread } = item;
const href = buildFullChatNavigationPath(
listPathOfThread(thread),
pathForThread(thread),
currentSearch,
);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<div
key={thread.thread_id}
key={`thread:${thread.thread_id}`}
className="group/item relative flex items-center"
>
<Link
href={href}
className={cn(
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
projectInfo && "h-auto min-h-12 py-1.5",
isActive
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
@@ -496,6 +810,8 @@ export function GeneralQaSidebar() {
)}
aria-label="智能体创建会话"
/>
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
<MessageSquare
className={cn(
@@ -507,8 +823,22 @@ export function GeneralQaSidebar() {
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
<span
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { hasPromptInputPayload } from "./input-box-submit.ts";
test("allows attachment-only prompt submissions", () => {
assert.equal(hasPromptInputPayload({ text: "", files: [{}] }), true);
});
test("rejects submissions with neither text nor attachments", () => {
assert.equal(hasPromptInputPayload({ text: "", files: [] }), false);
assert.equal(hasPromptInputPayload({ text: " \n", files: [] }), false);
});
test("allows text-only prompt submissions", () => {
assert.equal(hasPromptInputPayload({ text: "总结一下", files: [] }), true);
});
@@ -0,0 +1,9 @@
export type PromptInputPayload = {
text: string;
files: readonly unknown[];
};
/** A prompt may contain text, attachments, or both. */
export function hasPromptInputPayload(message: PromptInputPayload): boolean {
return message.text.trim().length > 0 || message.files.length > 0;
}
@@ -1,9 +1,10 @@
"use client";
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
import type { ChatStatus } from "ai";
import { toast } from "sonner";
import {
BookMarkedIcon,
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
@@ -62,12 +63,23 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { getBackendBaseURL } from "@/core/config";
import {
isDocumentRewriteActiveStage,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
REASONING_EFFORTS,
resolveModelReasoningEffort,
type ReasoningEffort,
type StoredReasoningEffort,
} from "@/core/models/reasoning-effort";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads";
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -101,25 +113,11 @@ import {
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (!supportsThinking && mode !== "flash") {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
import { resolveInputMode, type InputMode } from "./input-mode";
import { hasPromptInputPayload } from "./input-box-submit";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
export function InputBox({
className,
@@ -130,14 +128,17 @@ export function InputBox({
extraHeader,
isNewThread,
threadId,
initialValue,
onContextChange,
onFollowupsVisibilityChange,
onSubmit,
initialValue,
onContextChange,
onFollowupsPresenceChange,
followupsDisplayAllowed = true,
onSubmit,
onStop,
promptPrefix,
agentSelector,
knowledgeSelector,
knowledgeSelector,
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
extraModels = [],
assistantId,
hideAdminRecommendedQuestions,
@@ -150,7 +151,7 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
disabled?: boolean;
@@ -159,9 +160,8 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
memory_injection_enabled?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -176,12 +176,18 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
memory_injection_enabled?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
) => void;
/**
* Whether generated follow-up questions may be rendered. Hosts with a
* scrollable transcript disable this while the user is reviewing history;
* the generated questions are retained and reappear on returning to bottom.
*/
followupsDisplayAllowed?: boolean;
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
onFollowupsPresenceChange?: (present: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
/**
@@ -203,8 +209,19 @@ export function InputBox({
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** Per-thread ordinary-knowledge retrieval mode. Omit when unavailable. */
knowledgeRetrieval?: {
mode: KnowledgeRetrievalMode;
smartAvailable: boolean;
smartAvailabilityReason: string;
/** The administrator policy used only when a new conversation is created. */
smartIsDefault: boolean;
onModeChange: (mode: KnowledgeRetrievalMode) => void;
};
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[];
}) {
@@ -222,11 +239,25 @@ export function InputBox({
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const rewritePresentation = useDocumentRewritePresentation();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const documentRewriteActive = useMemo(
() =>
rewritePresentation?.forThread(threadId).some((item) =>
isDocumentRewriteActiveStage(item.stage),
) ?? false,
[rewritePresentation, threadId],
);
// Keep normal chat generation authoritative when both states overlap. In
// the usual rewrite-only case this gives the composer the same square stop
// affordance as a normal streamed reply.
const composerStatus: ChatStatus = documentRewriteActive
? "streaming"
: status;
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -241,8 +272,10 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
const followupsEnabled = userPreferences.followup_suggestions_enabled;
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
@@ -258,12 +291,20 @@ export function InputBox({
const currentModel = modelOptions.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? modelOptions[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false;
const supportsReasoningEffort =
fallbackModel.supports_reasoning_effort ?? false;
const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, supportsThinking);
const nextMode = resolveInputMode(context.mode, supportsThinking);
const nextReasoningEffort = resolveModelReasoningEffort(
supportsReasoningEffort,
context.reasoning_effort,
context.model_name !== nextModelName,
);
if (
context.model_name === nextModelName &&
context.mode === nextMode &&
context.reasoning_effort === nextReasoningEffort &&
context.execution_profile === fallbackModel.execution_profile
) {
return;
@@ -273,6 +314,7 @@ export function InputBox({
...context,
model_name: nextModelName,
mode: nextMode,
reasoning_effort: nextReasoningEffort,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
@@ -297,6 +339,31 @@ export function InputBox({
[selectedModel],
);
const selectedReasoningEffort = useMemo(
() =>
resolveModelReasoningEffort(
supportReasoningEffort,
context.reasoning_effort,
),
[context.reasoning_effort, supportReasoningEffort],
);
const reasoningEffortIndex = Math.max(
0,
REASONING_EFFORTS.indexOf(selectedReasoningEffort ?? "xhigh"),
);
const reasoningEffortThumbLeft =
reasoningEffortIndex === 0
? "0.5rem"
: reasoningEffortIndex === 1
? "50%"
: "calc(100% - 0.5rem)";
const reasoningEffortLabel =
selectedReasoningEffort === "low"
? t.inputBox.reasoningEffortLow
: selectedReasoningEffort === "medium"
? t.inputBox.reasoningEffortMedium
: t.inputBox.reasoningEffortXHigh;
const handleModelSelect = useCallback(
(model_name: string) => {
const model = modelOptions.find((m) => m.name === model_name);
@@ -306,8 +373,12 @@ export function InputBox({
onContextChange?.({
...context,
model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: resolveModelReasoningEffort(
model.supports_reasoning_effort ?? false,
context.reasoning_effort,
model_name !== context.model_name,
),
execution_profile: model.execution_profile,
});
setModelDialogOpen(false);
@@ -322,22 +393,24 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
mode: getResolvedMode(
mode: resolveInputMode(
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
reasoning_effort:
mode === "ultra"
? "high"
: mode === "pro"
? "medium"
: mode === "thinking"
? "low"
: "minimal",
reasoning_effort: resolveModelReasoningEffort(
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
context.reasoning_effort,
),
execution_profile: undefined,
});
},
[onContextChange, context, models, supportThinking],
[
onContextChange,
context,
models,
supportThinking,
supportReasoningEffort,
],
);
const handleSingleRoundRagSelect = useCallback(() => {
@@ -351,7 +424,7 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ragModel.name,
mode: getResolvedMode(
mode: resolveInputMode(
context.mode,
ragModel.supports_thinking ?? false,
),
@@ -367,7 +440,7 @@ export function InputBox({
}, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
(effort: ReasoningEffort) => {
onContextChange?.({
...context,
reasoning_effort: effort,
@@ -391,16 +464,13 @@ export function InputBox({
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const smartKnowledgeRetrievalActive =
knowledgeRetrieval?.mode === "smart_reasoning";
const ragModeLocked =
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
const ragModeEnabled =
!smartKnowledgeRetrievalActive &&
(localSettings.display.ragModeEnabled || ragModeLocked);
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
@@ -416,15 +486,19 @@ export function InputBox({
});
}, [contextCompressionEnabled, setLocalSettings]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
onStop?.();
return;
}
if (!message.text) {
return;
}
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (composerStatus === "streaming") {
toast.info("正在生成中,请等待当前回复完成后再发送。", {
description: "已保留您输入的内容。",
});
// Reject rather than resolve: PromptInput treats a resolved submit as
// accepted and clears its draft. The current message has not been sent.
throw new Error("Cannot submit while a response is streaming.");
}
if (!hasPromptInputPayload(message)) {
return;
}
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
@@ -434,14 +508,19 @@ export function InputBox({
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) {
if (
resolvedModelName &&
(context.model_name !== resolvedModelName ||
context.reasoning_effort !== selectedReasoningEffort)
) {
onContextChange?.({
...context,
model_name: resolvedModelName,
mode: getResolvedMode(
mode: resolveInputMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
reasoning_effort: selectedReasoningEffort,
execution_profile: selectedModel?.execution_profile,
});
setTimeout(() => onSubmit?.(message), 0);
@@ -452,26 +531,40 @@ export function InputBox({
},
[
context,
onContextChange,
onSubmit,
onStop,
resolvedModelName,
onContextChange,
onSubmit,
composerStatus,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
selectedReasoningEffort,
status,
threadId,
],
);
const handleStopCurrentGeneration = useCallback(() => {
if (status === "streaming") {
onStop?.();
return;
}
if (!documentRewriteActive || !rewritePresentation) return;
void rewritePresentation.cancelActiveForThread(threadId).then((result) => {
if (!result.cancelled) {
toast.error(result.error ?? "停止全文改写失败,请稍后重试。");
}
});
}, [documentRewriteActive, onStop, rewritePresentation, status, threadId]);
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
form?.requestSubmit();
}, []);
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (status === "streaming") {
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (composerStatus === "streaming") {
return;
}
const current = (textInput.value ?? "").trim();
@@ -484,7 +577,7 @@ export function InputBox({
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[requestFormSubmit, status, textInput],
[composerStatus, requestFormSubmit, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
@@ -515,32 +608,43 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const showFollowups =
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
const followupsPresent =
followupsEnabled &&
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
const showFollowups = followupsDisplayAllowed && followupsPresent;
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
useEffect(() => {
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
}, [onFollowupsPresenceChange]);
useEffect(() => {
followupsPresenceChangeRef.current?.(followupsPresent);
}, [followupsPresent]);
useEffect(() => {
return () => followupsPresenceChangeRef.current?.(false);
}, []);
useEffect(() => {
followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
}, [onFollowupsVisibilityChange]);
useEffect(() => {
followupsVisibilityChangeRef.current?.(showFollowups);
}, [showFollowups]);
useEffect(() => {
return () => followupsVisibilityChangeRef.current?.(false);
}, []);
if (followupsEnabled) return;
setFollowups([]);
setFollowupsLoading(false);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
}, [followupsEnabled]);
useEffect(() => {
const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = streaming;
if (!wasStreaming || streaming) {
if (!followupsEnabled || !wasStreaming || streaming) {
return;
}
@@ -592,6 +696,7 @@ export function InputBox({
return (await res.json()) as { suggestions?: string[] };
})
.then((data) => {
if (controller.signal.aborted) return;
const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0)
@@ -599,24 +704,51 @@ export function InputBox({
setFollowups(suggestions);
})
.catch(() => {
if (controller.signal.aborted) return;
setFollowups([]);
})
.finally(() => {
if (controller.signal.aborted) return;
setFollowupsLoading(false);
});
return () => controller.abort();
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return () => {
controller.abort();
setFollowupsLoading(false);
};
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
{extraHeader && (
{extraHeader && (
<div className="flex items-center justify-center">
{extraHeader}
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
)}
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
<div className="px-4 mb-[10px]">
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
<div className="flex flex-wrap justify-center gap-2.5">
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
<button
key={question}
type="button"
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
onClick={() => {
textInput.setInput(question);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
}}
>
{question}
</button>
))}
</div>
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
@@ -727,12 +859,12 @@ export function InputBox({
</PromptInputBody>
<PromptInputFooter
className={cn(
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3",
)}
>
<PromptInputTools
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
>
{/* TODO: Add more connectors here
<PromptInputActionMenu>
@@ -752,7 +884,7 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && !hideContextTools && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -807,62 +939,42 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && !hideContextTools && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
: "记忆注入已关闭:本次对话不注入记忆"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled &&
!frontendRagActive &&
!smartKnowledgeRetrievalActive &&
!hideContextTools && (
<Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact &&
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
)}
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
@@ -1155,124 +1267,63 @@ export function InputBox({
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" &&
" " + t.inputBox.reasoningEffortMinimal}
{context.reasoning_effort === "low" &&
" " + t.inputBox.reasoningEffortLow}
{context.reasoning_effort === "medium" &&
" " + t.inputBox.reasoningEffortMedium}
{context.reasoning_effort === "high" &&
" " + t.inputBox.reasoningEffortHigh}
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-70">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.reasoningEffort}
</DropdownMenuLabel>
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "minimal"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("minimal")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMinimal}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMinimalDescription}
</div>
</div>
{context.reasoning_effort === "minimal" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "low"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("low")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortLow}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortLowDescription}
</div>
</div>
{context.reasoning_effort === "low" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "medium" ||
!context.reasoning_effort
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("medium")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMedium}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMediumDescription}
</div>
</div>
{context.reasoning_effort === "medium" ||
!context.reasoning_effort ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "high"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("high")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortHigh}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortHighDescription}
</div>
</div>
{context.reasoning_effort === "high" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
{!frontendRagActive &&
supportReasoningEffort &&
context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger
aria-label={`${t.inputBox.reasoningEffortSelect}: ${reasoningEffortLabel}`}
className={cn(
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"rounded-lg border border-border px-2.5! py-1! text-[13px]",
)}
>
<div className="flex items-center gap-1.5 text-xs font-medium">
<SparklesIcon className="size-3" />
<span className="text-foreground">
{reasoningEffortLabel}
</span>
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-56 p-3">
<div className="flex items-center justify-between gap-3 px-1">
<span className="text-xs text-muted-foreground">
{t.inputBox.reasoningEffortSelect}
</span>
<span className="text-sm font-semibold text-violet-600 dark:text-violet-300">
{reasoningEffortLabel}
</span>
</div>
<div className="mt-2 px-1">
<div className="relative h-9 w-full">
<input
type="range"
aria-label={`${t.inputBox.reasoningEffortSelect}: ${selectedModel?.display_name || selectedModel?.name || ""}`}
aria-valuetext={reasoningEffortLabel}
className="peer absolute inset-x-0 -inset-y-1 z-10 h-auto w-full cursor-grab touch-none opacity-0 active:cursor-grabbing"
value={reasoningEffortIndex}
min={0}
max={2}
step={1}
onChange={(event) => {
const effort =
REASONING_EFFORTS[Number(event.currentTarget.value)];
if (effort) handleReasoningEffortSelect(effort);
}}
/>
<div className="pointer-events-none absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-fuchsia-500 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/70 peer-focus-visible:ring-offset-2" />
<div
className="pointer-events-none absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-violet-600 shadow-md peer-active:scale-110 dark:border-slate-800"
style={{ left: reasoningEffortThumbLeft }}
/>
</div>
</div>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
@@ -1282,6 +1333,100 @@ export function InputBox({
/>
)}
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && knowledgeRetrieval && (
<DropdownMenu>
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索已开启:直接提问即可,系统会检索原文分片并沿 Wiki 线索扩展背景资料"
: knowledgeRetrieval.smartAvailabilityReason
}
>
<DropdownMenuTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
aria-label={`普通知识库检索模式:${
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索"
: "经典检索"
}`}
aria-haspopup="menu"
className={cn(
"rounded-full px-3 shadow-none transition-colors",
knowledgeRetrieval.mode === "smart_reasoning"
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
>
<BrainCircuitIcon className="size-3.5" />
<span className="max-w-[160px] truncate text-xs font-normal">
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
</span>
</PromptInputButton>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-80 rounded-xl p-1.5"
>
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
普通知识库检索模式
</DropdownMenuLabel>
<DropdownMenuItem
className="min-h-16 items-start rounded-lg py-2"
disabled={!knowledgeRetrieval.smartAvailable}
onSelect={() =>
knowledgeRetrieval.onModeChange("smart_reasoning")
}
>
<BrainCircuitIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
智能检索
{knowledgeRetrieval.smartIsDefault ? "(新对话默认)" : ""}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
先检索原文分片,再按命中线索查阅并扩展 Wiki;资料仅作为回答背景
</div>
</div>
{knowledgeRetrieval.mode === "smart_reasoning" && (
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-14 items-start rounded-lg py-2"
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
>
<BookMarkedIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
经典检索
{knowledgeRetrieval.smartIsDefault ? "(候选)" : "(新对话默认)"}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
沿用原有知识库检索与回答方式
</div>
</div>
{knowledgeRetrieval.mode === "classic" && (
<CheckIcon className="mt-0.5 size-4 text-primary" />
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<p
className={cn(
"px-2 py-1 text-xs leading-5",
knowledgeRetrieval.smartAvailable
? "text-muted-foreground"
: "text-amber-600 dark:text-amber-300",
)}
>
{knowledgeRetrieval.smartAvailabilityReason}
</p>
</DropdownMenuContent>
</DropdownMenu>
)}
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
@@ -1366,11 +1511,21 @@ export function InputBox({
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
status={status}
/>
)}
disabled={disabled}
onClick={(event) => {
if (composerStatus !== "streaming") return;
// A deliberate press of the square button stops the stream.
// Prevent the enclosing form from treating it as a new submit.
event.preventDefault();
handleStopCurrentGeneration();
}}
variant="outline"
status={composerStatus}
{...(documentRewriteActive
? { "aria-label": "停止 AI 全文改写" }
: {})}
/>
</PromptInputTools>
</PromptInputFooter>
{!isNewThread && (
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { resolveInputMode } from "./input-mode.ts";
test("keeps plan and subagent modes for models without native thinking", () => {
assert.equal(resolveInputMode("pro", false), "pro");
assert.equal(resolveInputMode("ultra", false), "ultra");
});
test("falls back only when native thinking mode is unsupported", () => {
assert.equal(resolveInputMode("thinking", false), "flash");
assert.equal(resolveInputMode("thinking", true), "thinking");
});
test("keeps the existing defaults when no mode has been chosen", () => {
assert.equal(resolveInputMode(undefined, false), "flash");
assert.equal(resolveInputMode(undefined, true), "pro");
});
@@ -0,0 +1,20 @@
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
/**
* Keep plan/subagent modes available even when the selected model has no
* native thinking switch. The backend already falls back to non-thinking for
* that model, while `pro` and `ultra` still control planning and subagents.
* Only the model-specific `thinking` mode requires `supports_thinking`.
*/
export function resolveInputMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (mode === "thinking" && !supportsThinking) {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
@@ -0,0 +1,102 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen /* , Database, SearchCheck */ } 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 { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
// import {
// useAssistantKnowledgeVisibility,
// useUpdateAssistantKnowledgeVisibility,
// } from "@/core/system-settings/hooks";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding,
} from "@/core/system-settings/api";
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
export function useOrdinaryKnowledgeLanding(enabled = true) {
return useQuery({
queryKey: LANDING_KEY,
queryFn: ({ signal }) => loadOrdinaryKnowledgeLanding(signal),
enabled,
staleTime: 30_000,
retry: false,
});
}
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
export function KnowledgeProductNav() {
const location = useLocation();
const navigate = useNavigate();
const client = useQueryClient();
// const assistantVisibility = useAssistantKnowledgeVisibility();
const isAdmin = getStoredAuth()?.system_role === "admin";
const landing = useOrdinaryKnowledgeLanding(isAdmin);
const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
onError: (error) => toast.error(error.message),
});
const runtime = useLlmWikiRuntime();
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
// const assistantAllowed = canAccessAssistantKnowledge(
// isAdmin,
// assistantVisibility.isError ? undefined : assistantVisibility.data,
// );
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
const assistant = location.pathname.includes("/knowledge/assistant");
const ordinary = !assistant && !retrievalDebug;
return (
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
{assistantAllowed && (
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
)}
{isAdmin && (
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
<SearchCheck />检索调试
</Button>
)}
*/}
{isAdmin && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
</Select>
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
助手知识库日常展示
<Select
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
value={assistantVisibility.data === true ? "visible" : "hidden"}
onValueChange={(value) =>
saveAssistantVisibility.mutate(value === "visible", {
onSuccess: (visible) => toast.success(visible ? "已向普通用户开放助手知识库" : "已隐藏普通用户的助手知识库"),
onError: (error) => toast.error(error.message),
})
}
>
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
</Select>
*/}
</div>
)}
</div>
);
}
@@ -0,0 +1,418 @@
import { BookOpenText, ChevronRight, FileText } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
import {
classifyKnowledgeWikiSource,
filterKnowledgeWikiSources,
} from "@/core/llmwiki/knowledge-reference";
import { resolveDisplaySourceChunk } from "@/core/llmwiki/source-excerpt";
import {
formatRetrievalScore,
retrievalModeLabel,
retrievalRankScoreLabel,
} from "@/core/llmwiki/retrieval-debug";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { MarkdownContent } from "./messages/markdown-content";
type KnowledgeWikiPanelProps = {
highlightedReferenceId?: string | null;
selection: KnowledgeWikiReferenceSelection;
sources: RuntimeDisplaySource[];
};
function sourceKey(source: RuntimeDisplaySource) {
return `${source.skillName}:${source.index}:${source.id ?? source.url ?? source.title}`;
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function sourceContent(source: RuntimeDisplaySource) {
return (source.content || source.snippet || "").trim();
}
function sourceMode(source: RuntimeDisplaySource) {
return stringValue(
source.raw?.retrieval_mode ??
(source as RuntimeDisplaySource & { retrieval_mode?: unknown })
.retrieval_mode,
).toLowerCase();
}
function matchedHeading(source: RuntimeDisplaySource) {
const sections = Array.isArray(source.raw?.matched_sections)
? source.raw.matched_sections
: [];
const firstSectionWithHeading = sections.find(
(section): section is Record<string, unknown> =>
!!section &&
typeof section === "object" &&
!Array.isArray(section) &&
Boolean(stringValue((section as Record<string, unknown>).heading)),
);
const matchedSection =
source.raw?.matched_section &&
typeof source.raw.matched_section === "object" &&
!Array.isArray(source.raw.matched_section)
? (source.raw.matched_section as Record<string, unknown>)
: null;
return stringValue(
source.raw?.matched_heading ??
source.raw?.heading ??
firstSectionWithHeading?.heading ??
matchedSection?.heading,
);
}
function knowledgeSpaceName(source: RuntimeDisplaySource) {
return (
stringValue(source.raw?.knowledge_base_name) ||
stringValue(source.raw?.base_name) ||
stringValue(source.raw?.source) ||
source.source ||
"知识空间"
);
}
function wikiPageTypeLabel(value: unknown) {
const normalized = stringValue(value).toLowerCase();
if (normalized === "directory") return "目录页";
if (normalized === "overview") return "概览页";
if (normalized === "article") return "文章页";
return normalized ? stringValue(value) : "";
}
function plainTextPreview(value: string) {
return value
.replace(/^---[\s\S]*?---\s*/m, "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+[.)]\s+/gm, "")
.replace(/[>*_`~|]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function cardPreview(source: RuntimeDisplaySource) {
const summary = stringValue(source.raw?.summary);
return plainTextPreview(sourceContent(source) || summary).slice(0, 420);
}
function updatedAtLabel(source: RuntimeDisplaySource) {
const value =
source.time ||
stringValue(source.raw?.updated_at) ||
stringValue(source.raw?.remote_updated_at);
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleString("zh-CN", { hour12: false });
}
function scoreLabel(source: RuntimeDisplaySource) {
const score = formatRetrievalScore(source.score);
if (!score) return "";
const label = retrievalRankScoreLabel(
source.raw?.rank_score_semantics,
Boolean(source.raw?.channel_score || source.raw?.vector_score),
);
return label ? `${label} ${score}` : "";
}
/**
* Knowledge-space references keep both evidence layers visible: formatted Wiki
* pages and their original document chunks. They deliberately use dedicated
* Wiki cards instead of the generic numbered reference-card presentation.
*/
export function KnowledgeWikiPanel({
highlightedReferenceId,
selection,
sources,
}: KnowledgeWikiPanelProps) {
const wikiSources = useMemo(
() => filterKnowledgeWikiSources(sources, selection),
[selection, sources],
);
const orderedSources = useMemo(() => {
const formattedWiki = wikiSources.filter(
(source) => classifyKnowledgeWikiSource(source) === "wiki",
);
const rawDocuments = wikiSources.filter(
(source) => classifyKnowledgeWikiSource(source) === "raw",
);
return [...formattedWiki, ...rawDocuments];
}, [wikiSources]);
const [openSourceKey, setOpenSourceKey] = useState<string | null>(null);
const openSource =
wikiSources.find((source) => sourceKey(source) === openSourceKey) ?? null;
const previewTarget = openSource
? resolveWeKnoraSourceTarget({
...(openSource.raw ?? {}),
url: openSource.url,
})
: null;
const openContent = openSource ? sourceContent(openSource) : "";
const openChunk = openSource ? resolveDisplaySourceChunk(openSource) : "";
const selectedSpaceCount =
selection.knowledgeBaseIds.length +
selection.assistantKnowledgeBaseIds.length;
const formattedWikiCount = wikiSources.filter(
(source) => classifyKnowledgeWikiSource(source) === "wiki",
).length;
const rawDocumentCount = wikiSources.length - formattedWikiCount;
return (
<div className="flex size-full min-h-0 flex-col overflow-hidden">
<header className="shrink-0 border-b border-emerald-200/60 px-1 pb-3 dark:border-emerald-900/50">
<div className="flex flex-wrap items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
<BookOpenText className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<h2 className="text-foreground text-base font-semibold tracking-tight">
Wiki 资料
</h2>
<span className="text-muted-foreground text-xs tabular-nums">
{wikiSources.length} 条
</span>
</div>
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">
来自已选 {selectedSpaceCount} 个知识空间,点击卡片查看完整内容。
</p>
</div>
</div>
{wikiSources.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
Wiki 页面 {formattedWikiCount}
</Badge>
<Badge variant="outline" className="bg-background/80">
原始资料 {rawDocumentCount}
</Badge>
</div>
)}
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-3 pb-6">
{orderedSources.length > 0 ? (
<div className="grid gap-3">
{orderedSources.map((source) => (
<KnowledgeWikiCard
key={sourceKey(source)}
highlighted={highlightedReferenceId === String(source.index)}
source={source}
onOpen={() => setOpenSourceKey(sourceKey(source))}
/>
))}
</div>
) : (
<div className="text-muted-foreground flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/20 px-6 text-center text-sm">
<BookOpenText className="mb-3 size-8 opacity-50" />
<span>本轮未检索到所选知识空间内的 Wiki 资料。</span>
</div>
)}
</main>
{openSource && previewTarget && (
<WeKnoraSourceDrawer
open
target={previewTarget}
title={openSource.title}
quote={openChunk}
collapseRetrievedExcerptByDefault={
classifyKnowledgeWikiSource(openSource) === "wiki"
}
onOpenChange={(open) => {
if (!open) setOpenSourceKey(null);
}}
/>
)}
<Sheet
open={Boolean(openSource && !previewTarget)}
onOpenChange={(open) => {
if (!open) setOpenSourceKey(null);
}}
>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b border-emerald-200/60 bg-emerald-50/60 px-5 py-4 pr-12 text-left dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="flex items-start gap-3 pr-8">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-600 text-white">
<BookOpenText className="size-4.5" />
</div>
<div className="min-w-0">
<SheetTitle className="text-base leading-relaxed">
{openSource?.title || "Wiki 内容"}
</SheetTitle>
<SheetDescription className="mt-1">
{openSource ? knowledgeSpaceName(openSource) : "知识空间"}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{openContent ? (
<article className="mx-auto max-w-3xl rounded-xl bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<MarkdownContent
className="max-w-none"
content={openContent}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</article>
) : (
<div className="text-muted-foreground flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm">
当前 Wiki 暂无可展示内容。
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
);
}
function KnowledgeWikiCard({
highlighted,
source,
onOpen,
}: {
highlighted: boolean;
source: RuntimeDisplaySource;
onOpen: () => void;
}) {
const formattedWiki = classifyKnowledgeWikiSource(source) === "wiki";
const heading = matchedHeading(source);
const pageType = wikiPageTypeLabel(source.raw?.page_type);
const mode = sourceMode(source);
const modeLabel = mode ? retrievalModeLabel(mode) : "";
const preview = cardPreview(source);
const score = scoreLabel(source);
const updatedAt = updatedAtLabel(source);
const target = resolveWeKnoraSourceTarget({
...(source.raw ?? {}),
url: source.url,
});
const canOpen = Boolean(target || sourceContent(source));
return (
<button
id={`reference-${source.index}`}
type="button"
disabled={!canOpen}
className={cn(
"group w-full scroll-mt-12 rounded-xl border bg-background/90 p-4 text-left shadow-sm transition",
"focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:opacity-70",
formattedWiki
? "border-emerald-200/80 hover:-translate-y-0.5 hover:border-emerald-400 hover:shadow-md focus-visible:ring-emerald-500/40 dark:border-emerald-900/70"
: "border-sky-200/80 hover:-translate-y-0.5 hover:border-sky-400 hover:shadow-md focus-visible:ring-sky-500/40 dark:border-sky-900/70",
highlighted &&
(formattedWiki
? "border-emerald-500 bg-emerald-50/80 ring-2 ring-emerald-500/20 dark:bg-emerald-950/25"
: "border-sky-500 bg-sky-50/80 ring-2 ring-sky-500/20 dark:bg-sky-950/25"),
)}
onClick={onOpen}
>
<div className="flex items-start gap-3">
<div
className={cn(
"mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg",
formattedWiki
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
)}
>
{formattedWiki ? (
<BookOpenText className="size-4.5" />
) : (
<FileText className="size-4.5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant="secondary"
className={cn(
formattedWiki
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
)}
>
{formattedWiki ? "Wiki 页面" : "原始资料"}
</Badge>
{formattedWiki && pageType && (
<Badge variant="outline">{pageType}</Badge>
)}
{modeLabel && <Badge variant="outline">{modeLabel}</Badge>}
<span className="text-muted-foreground min-w-0 truncate text-xs">
{knowledgeSpaceName(source)}
</span>
</div>
<h3 className="text-foreground mt-2 line-clamp-2 text-[15px] leading-6 font-semibold">
{source.title ||
(formattedWiki ? "未命名 Wiki 页面" : "未命名原始资料")}
</h3>
{heading && (
<div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
命中章节:{heading}
</div>
)}
{preview && (
<p className="text-muted-foreground mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6">
{preview}
</p>
)}
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{score && <span>{score}</span>}
{updatedAt && <span>更新于 {updatedAt}</span>}
</div>
{canOpen && (
<span
className={cn(
"inline-flex items-center font-medium transition group-hover:translate-x-0.5",
formattedWiki
? "text-emerald-700 dark:text-emerald-300"
: "text-sky-700 dark:text-sky-300",
)}
>
{target
? formattedWiki
? "查看完整 Wiki"
: "查看原始资料"
: "查看命中内容"}
<ChevronRight className="ml-1 size-3.5" />
</span>
)}
</div>
</div>
</div>
</button>
);
}
@@ -21,7 +21,14 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { useAssistantKnowledgeAccess } from "@/core/llmwiki/assistant-knowledge-access";
import { ASSISTANT_KNOWLEDGE_UI_VISIBLE } from "@/core/llmwiki/assistant-knowledge-ui";
import { userFacingKnowledgeSuggestedQuestions } from "@/core/llmwiki/knowledge-suggested-questions";
import { cn } from "@/lib/utils";
import {
type AssistantKnowledgeBase,
useAssistantKnowledgeBases,
} from "@/strategy-components/api/assistant-knowledge";
import {
type LlmWikiKnowledgeBase,
useAgentLlmWikiKnowledgeBases,
@@ -31,6 +38,28 @@ import {
type SelectorSource = "all" | "agent";
type SelectorVariant = "popover" | "panel";
const EMPTY_IDS: string[] = [];
const IGNORE_ASSISTANT_CHANGE = () => undefined;
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
if (cleanNames.length === 0) return [];
if (cleanNames.length === 1) {
const name = cleanNames[0]!;
return [
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
`「${name}」包含哪些核心主题?请按重要性说明。`,
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
];
}
return [
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
"所选知识空间中有哪些相互冲突或证据不足的结论?",
];
}
function groupRows(rows: LlmWikiKnowledgeBase[]) {
return [
@@ -66,10 +95,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 +124,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 +138,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 +146,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 +193,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 +202,186 @@ 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} 个`}
</span>
<div>
<div className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</div>
{selectedCount > 0 && (
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
)}
</div>
{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 +393,10 @@ function SelectionContent({
export function LlmWikiKnowledgeSelector({
value,
assistantValue = EMPTY_IDS,
onChange,
onAssistantChange,
onSuggestedQuestionsChange,
source = "all",
agentId,
autoSelectAll = false,
@@ -240,10 +404,15 @@ export function LlmWikiKnowledgeSelector({
readOnly = false,
className,
label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover",
}: {
value: string[];
assistantValue?: string[];
onChange: (value: string[]) => void;
onAssistantChange?: (value: string[]) => void;
onSuggestedQuestionsChange?: (questions: string[]) => void;
source?: SelectorSource;
agentId?: string | null;
autoSelectAll?: boolean;
@@ -252,33 +421,107 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean;
className?: string;
label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const assistantAccess = useAssistantKnowledgeAccess({
enabled:
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
});
const assistantAllowed =
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
onAssistantChange !== undefined &&
assistantAccess.canAccess;
const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora";
const shouldLoadOptions =
variant === "panel" ||
open ||
autoSelectAll ||
value.length > 0 ||
assistantValue.length > 0;
const all = useLlmWikiKnowledgeBases(
"all",
runtime.data?.provider === "weknora",
weknoraEnabled && shouldLoadOptions,
);
const agent = useAgentLlmWikiKnowledgeBases(
agentId,
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
);
const rows = all.data ?? [];
const assistantBases = useAssistantKnowledgeBases(
assistantAllowed &&
(shouldLoadOptions ||
runtime.isError ||
(!runtime.isLoading && !weknoraEnabled)),
);
// Stable empty results are essential: suggested questions are reported to
// the parent, so a fresh [] on every render creates an effect/update loop
// before lazy knowledge queries have loaded (or when they are disabled).
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
const assistantRows = useMemo(
() => assistantAllowed ? (assistantBases.data ?? []) : [],
[assistantAllowed, assistantBases.data],
);
const effectiveAssistantValue = assistantAllowed ? assistantValue : EMPTY_IDS;
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) || (assistantAllowed && assistantBases.isLoading) || (source === "agent" && !!agentId && agent.isLoading);
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? (assistantAllowed ? assistantBases.error : null);
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);
const selectedNames = useMemo(
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[rows, value],
);
const selectedAssistantNames = useMemo(
() => effectiveAssistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[assistantRows, effectiveAssistantValue],
);
const allSelectedNames = useMemo(
() => [...selectedNames, ...selectedAssistantNames],
[selectedAssistantNames, selectedNames],
);
const selectedConfiguredQuestions = useMemo(
() =>
userFacingKnowledgeSuggestedQuestions(
value.flatMap(
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
),
),
[rows, value],
);
const suggestedQuestions = useMemo(
() => {
if (value.length === 1 && effectiveAssistantValue.length === 0 && selectedConfiguredQuestions.length > 0) {
return selectedConfiguredQuestions.slice(0, 12);
}
const general = buildKnowledgeSpaceQuestions(allSelectedNames);
return Array.from(new Set([...selectedConfiguredQuestions, ...general])).slice(0, 12);
},
[allSelectedNames, effectiveAssistantValue.length, selectedConfiguredQuestions, value.length],
);
useEffect(() => {
onSuggestedQuestionsChange?.(suggestedQuestions);
}, [onSuggestedQuestionsChange, suggestedQuestions]);
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 +531,39 @@ 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 (!assistantAllowed || !assistantBases.data || !onAssistantChange) return;
const valid = effectiveAssistantValue.filter((id) => assistantAvailableIds.has(id));
if (valid.length !== effectiveAssistantValue.length || valid.some((id, index) => id !== effectiveAssistantValue[index])) {
onAssistantChange(valid);
}
}, [assistantAllowed, assistantAvailableIds, assistantBases.data, effectiveAssistantValue, onAssistantChange]);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
if (
!runtime.isLoading &&
(runtime.isError || !weknoraEnabled) &&
(!assistantAllowed || !assistantBases.isLoading) &&
assistantRows.length === 0
) return null;
const content = (
<SelectionContent
rows={rows}
assistantRows={assistantRows}
value={value}
assistantValue={effectiveAssistantValue}
onChange={onChange}
onAssistantChange={
assistantAllowed
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
: IGNORE_ASSISTANT_CHANGE
}
search={search}
onSearchChange={setSearch}
loading={loading}
@@ -317,26 +580,24 @@ export function LlmWikiKnowledgeSelector({
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />绑定知识空间
<Database className="size-4 text-primary" />{panelTitle}
</div>
<p className="mt-1 text-xs text-muted-foreground">
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
{panelDescription}
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
<Badge variant="secondary" className="shrink-0">{value.length + effectiveAssistantValue.length} 个</Badge>
</div>
{content}
</div>
);
}
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 selectedCount = value.length + effectiveAssistantValue.length;
const selectedLabel = allSelectedNames.length > 0
? allSelectedNames.join("、")
: selectedCount > 0
? `已选 ${selectedCount} 个知识空间`
: label;
return (
@@ -348,20 +609,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">
@@ -0,0 +1,248 @@
"use client";
import {
CheckIcon,
Loader2Icon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
useUpsertAnswerFeedback,
type AnswerFeedbackContext,
type AnswerFeedbackReason,
} from "@/strategy-components/api/answer-feedback";
import { Tooltip } from "../tooltip";
const REASON_OPTIONS: Array<{
value: AnswerFeedbackReason;
label: string;
}> = [
{ value: "incorrect_or_incomplete", label: "不正确或不完整" },
{ value: "unmet_expectations", label: "与期望不符" },
{ value: "slow_or_problematic", label: "速度慢或存在问题" },
{ value: "style_or_tone", label: "风格或语气" },
{ value: "safety_or_legal", label: "安全或法律疑虑" },
{ value: "other", label: "其他" },
];
export function AnswerFeedbackControl({
threadId,
messageId,
context,
}: {
threadId: string;
messageId: string;
context: AnswerFeedbackContext;
}) {
const saveFeedback = useUpsertAnswerFeedback();
const feedback = context?.feedback ?? null;
const [poorDialogOpen, setPoorDialogOpen] = useState(false);
const [reasons, setReasons] = useState<AnswerFeedbackReason[]>([]);
const [detail, setDetail] = useState("");
useEffect(() => {
if (!poorDialogOpen) return;
setReasons(feedback?.rating === -1 ? feedback.reasons : []);
setDetail(
feedback?.rating === -1
? (feedback.detail ?? feedback.comment ?? "")
: "",
);
}, [feedback, poorDialogOpen]);
const submitPositive = async () => {
try {
await saveFeedback.mutateAsync({
threadId,
messageId,
input: { rating: 1, reasons: [], detail: null },
});
toast.success("感谢你的评价");
} catch (error) {
toast.error(error instanceof Error ? error.message : "评价提交失败");
}
};
const submitNegative = async () => {
if (reasons.length === 0) return;
try {
await saveFeedback.mutateAsync({
threadId,
messageId,
input: {
rating: -1,
reasons,
detail: detail.trim() || null,
},
});
setPoorDialogOpen(false);
toast.success("反馈已提交,感谢你的帮助");
} catch (error) {
toast.error(error instanceof Error ? error.message : "反馈提交失败");
}
};
const toggleReason = (reason: AnswerFeedbackReason) => {
setReasons((current) =>
current.includes(reason)
? current.filter((item) => item !== reason)
: [...current, reason],
);
};
const triggerIcon =
feedback?.rating === 1 ? (
<ThumbsUpIcon
size={12}
className="fill-emerald-500/15 text-emerald-600 dark:text-emerald-400"
/>
) : feedback?.rating === -1 ? (
<ThumbsDownIcon
size={12}
className="fill-amber-500/15 text-amber-600 dark:text-amber-400"
/>
) : (
<span className="flex items-center gap-px" aria-hidden="true">
<ThumbsUpIcon className="size-3" />
<ThumbsDownIcon className="size-3" />
</span>
);
return (
<>
<DropdownMenu>
<Tooltip content="评价回复">
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon-sm"
variant="ghost"
className={cn(
"size-6",
feedback?.rating === 1 &&
"bg-emerald-500/10 hover:bg-emerald-500/15",
feedback?.rating === -1 &&
"bg-amber-500/10 hover:bg-amber-500/15",
)}
disabled={saveFeedback.isPending}
aria-label={
feedback?.rating === 1
? "已评价为回答优秀"
: feedback?.rating === -1
? "已评价为回答不佳"
: "评价回复"
}
>
{saveFeedback.isPending ? (
<Loader2Icon className="size-3 animate-spin" />
) : (
triggerIcon
)}
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="start" sideOffset={6} className="min-w-44 p-1.5">
<DropdownMenuItem
className="gap-2.5 py-2"
onSelect={() => void submitPositive()}
>
<ThumbsUpIcon className="size-4" />
<span>回答优秀</span>
{feedback?.rating === 1 && (
<CheckIcon className="text-primary ml-auto size-4" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 py-2"
onSelect={() => setPoorDialogOpen(true)}
>
<ThumbsDownIcon className="size-4" />
<span>回答不佳</span>
{feedback?.rating === -1 && (
<CheckIcon className="text-primary ml-auto size-4" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={poorDialogOpen} onOpenChange={setPoorDialogOpen}>
<DialogContent className="gap-5 sm:max-w-[min(48rem,calc(100vw-2rem))]">
<DialogHeader className="pr-8">
<DialogTitle className="text-xl font-medium">分享反馈</DialogTitle>
<DialogDescription>
可多选这条回答需要改进的方面。
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-2.5">
{REASON_OPTIONS.map((option) => {
const selected = reasons.includes(option.value);
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => toggleReason(option.value)}
className={cn(
"rounded-full border px-4 py-2 text-sm transition-colors",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background hover:bg-accent",
)}
>
{option.label}
</button>
);
})}
</div>
<Textarea
value={detail}
onChange={(event) => setDetail(event.currentTarget.value)}
maxLength={2000}
placeholder="分享详细信息(可选)"
className="min-h-28 resize-y"
/>
<div className="bg-muted/60 text-muted-foreground rounded-lg px-4 py-3 text-sm">
你的反馈将用于改进智能体的回答质量和稳定性。
</div>
<DialogFooter>
<Button
type="button"
onClick={() => void submitNegative()}
disabled={reasons.length === 0 || saveFeedback.isPending}
>
{saveFeedback.isPending && (
<Loader2Icon className="size-4 animate-spin" />
)}
提交
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility.ts";
function message(overrides: Partial<Message> = {}): Message {
return {
id: "answer-1",
type: "ai",
content: "A persisted answer",
additional_kwargs: {},
...overrides,
} as Message;
}
test("accepts a completed persisted assistant answer", () => {
assert.equal(isEligibleAnswerFeedbackMessage(message()), true);
});
test("rejects loading, user, optimistic and empty messages", () => {
assert.equal(isEligibleAnswerFeedbackMessage(message(), true), false);
assert.equal(
isEligibleAnswerFeedbackMessage(message({ type: "human" })),
false,
);
assert.equal(
isEligibleAnswerFeedbackMessage(message({ id: "opt-ai-1" })),
false,
);
assert.equal(
isEligibleAnswerFeedbackMessage(message({ content: " " })),
false,
);
});
test("rejects tool-call shells and virtual business cards", () => {
assert.equal(
isEligibleAnswerFeedbackMessage(
message({
tool_calls: [{ id: "tool-1", name: "search", args: {} }],
}),
),
false,
);
assert.equal(
isEligibleAnswerFeedbackMessage(
message({
additional_kwargs: { setup_research_approval: { stage: "plan" } },
}),
),
false,
);
});
test("rejects ids outside the message-scoped API contract", () => {
assert.equal(
isEligibleAnswerFeedbackMessage(message({ id: "x".repeat(65) })),
false,
);
});
@@ -0,0 +1,52 @@
import type { Message } from "@langchain/langgraph-sdk";
function hasTextContent(content: Message["content"]): boolean {
if (typeof content === "string") return content.trim().length > 0;
if (!Array.isArray(content)) return false;
return content.some((part) => {
if (!part || typeof part !== "object") return false;
const text = "text" in part ? part.text : undefined;
return typeof text === "string" && text.trim().length > 0;
});
}
/**
* Only completed, persisted, user-visible assistant answers can be rated.
* Tool-call shells, optimistic/transient rows and special virtual cards are
* deliberately excluded because they are not standalone answers.
*/
export function isEligibleAnswerFeedbackMessage(
message: Message,
isLoading = false,
): boolean {
if (
isLoading ||
message.type !== "ai" ||
!message.id ||
message.id.length > 64
) {
return false;
}
if (message.id.startsWith("opt-")) return false;
if (!hasTextContent(message.content)) return false;
const directToolCalls = "tool_calls" in message ? message.tool_calls : null;
const additional = message.additional_kwargs ?? {};
const additionalToolCalls = additional.tool_calls;
if (
(Array.isArray(directToolCalls) && directToolCalls.length > 0) ||
(Array.isArray(additionalToolCalls) && additionalToolCalls.length > 0)
) {
return false;
}
const virtualCardKeys = [
"setup_agent_approval",
"setup_writing_approval",
"setup_report_structure_approval",
"setup_research_approval",
"task_report_import_approval",
"report_collaboration_card",
];
return !virtualCardKeys.some((key) => additional[key] !== undefined);
}
@@ -1,7 +1,8 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { createContext, useContext } from "react";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { createContext, useContext } from "react";
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType {
@@ -13,6 +14,11 @@ export interface ThreadContextType {
* from the admin-controlled manual reference-mode button.
*/
knowledgeReferencesEnabled?: boolean;
/**
* Server-scoped knowledge selection for the dedicated Wiki reference view.
* Sources outside these ids are never displayed as knowledge evidence.
*/
knowledgeReferences?: KnowledgeWikiReferenceSelection;
/**
* When set, the conversation is rendered from a public read-only share.
* Artifact/image URLs then resolve to the unauthenticated
@@ -4,6 +4,7 @@ import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
CopyIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
@@ -14,6 +15,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
import {
Collapsible,
CollapsibleContent,
@@ -22,6 +24,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { copyTextToClipboard } from "@/core/clipboard";
import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
@@ -42,7 +45,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "writing", label: "生成改写版 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
@@ -221,6 +224,9 @@ export function DocumentRewriteTimeline({
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
const diagnosticText = item.diagnostic
? JSON.stringify(item.diagnostic, null, 2)
: "";
const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
@@ -370,7 +376,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
@@ -440,13 +446,13 @@ export function DocumentRewriteTimeline({
{step.key === "writing" && (
item.writtenCharCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"}
</p>
) : null
)}
@@ -583,9 +589,42 @@ export function DocumentRewriteTimeline({
)}
{item.error && (
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
<XCircleIcon className="size-4 shrink-0" />
{item.error}
<div className="border-t px-5 py-3 text-sm text-destructive">
<div className="flex items-start gap-2">
<XCircleIcon className="mt-0.5 size-4 shrink-0" />
<span>{item.error}</span>
</div>
{item.diagnostic && (
<Collapsible className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<CollapsibleTrigger asChild>
<Button className="h-8 gap-1.5 px-2.5 text-xs" size="sm" type="button" variant="outline">
<ChevronDownIcon className="size-3.5" />
查看后台诊断
</Button>
</CollapsibleTrigger>
<Button
className="h-8 gap-1.5 px-2.5 text-xs"
onClick={() => {
void copyTextToClipboard(diagnosticText)
.then(() => toast.success("诊断信息已复制"))
.catch(() => toast.error("复制诊断信息失败"));
}}
size="sm"
type="button"
variant="outline"
>
<CopyIcon className="size-3.5" />
复制诊断
</Button>
</div>
<CollapsibleContent>
<pre className="bg-muted/55 text-foreground mt-2 max-h-72 overflow-auto rounded-lg border p-3 font-mono text-xs leading-5 whitespace-pre-wrap break-words">
{diagnosticText}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</div>
)}
<DocumentRewriteDiffDialog
@@ -598,3 +637,36 @@ export function DocumentRewriteTimeline({
</article>
);
}
/** A standalone chat row for a sandbox document-rewrite task. */
export function DocumentRewriteMessage({
item,
threadId,
onOpenArtifact,
onDownloadArtifact,
}: {
item: DocumentRewritePresentation;
threadId: string;
onOpenArtifact?: (filepath: string) => void;
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
}) {
const artifactPath = item.source.kind === "artifact" ? item.outputPath : undefined;
const completed = item.stage === "completed";
return (
<section aria-label={`AI 全文改写:${item.fileName}`} className="w-full space-y-3">
<DocumentRewriteTimeline item={item} />
{completed && artifactPath && (
<ArtifactFileList
fileDescriptions={{
[artifactPath]: "AI 改写版 Markdown 文件",
}}
files={[artifactPath]}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
)}
</section>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, useRef } from "react";
import type {
AnchorHTMLAttributes,
HTMLAttributes,
@@ -12,7 +12,13 @@ import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import {
looksLikeHtmlDocument,
MarkdownFallback,
MarkdownRenderBoundary,
streamdownPlugins,
useStreamdownMermaidPngDownload,
} from "@/core/streamdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
@@ -20,6 +26,9 @@ import {
} from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { VisualizationBlock } from "../visualizations/visualization-block";
import { splitVisualizationContent } from "../visualizations/visualization-spec";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
@@ -50,6 +59,7 @@ export type MarkdownContentProps = {
/** Renders markdown content. */
export function MarkdownContent({
content,
isLoading,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
@@ -57,7 +67,10 @@ export function MarkdownContent({
linkClassName,
onLinkClick,
}: MarkdownContentProps) {
const rootRef = useRef<HTMLDivElement>(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
useStreamdownMermaidPngDownload(rootRef);
const components = useMemo(() => {
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
@@ -137,17 +150,72 @@ export function MarkdownContent({
if (!content) return null;
// Full HTML pages belong in the artifact iframe. Feeding them to Streamdown
// mid-stream can recurse forever in marked and white-screen the chat.
if (looksLikeHtmlDocument(content)) {
return (
<MarkdownFallback message="正在生成 HTML 页面,请查看右侧预览。" />
);
}
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
// visualization renderers are lazy-loaded only when a dedicated fence exists.
if (
visualizationSegments.length === 1 &&
visualizationSegments[0]?.type === "markdown"
) {
return (
<div ref={rootRef} data-markdown-anchor-root className="contents">
<MarkdownRenderBoundary resetKey={`${content.length}:${content.slice(-32)}`}>
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{content}
</MessageResponse>
</MarkdownRenderBoundary>
</div>
);
}
return (
<div data-markdown-anchor-root className="contents">
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{content}
</MessageResponse>
<div ref={rootRef} data-markdown-anchor-root className="contents">
{visualizationSegments.map((segment, index) =>
segment.type === "visualization" ? (
<VisualizationBlock
key={`visualization-${index}`}
language={segment.language}
source={segment.source}
complete={segment.complete}
isLoading={isLoading}
/>
) : segment.content ? (
looksLikeHtmlDocument(segment.content) ? (
<MarkdownFallback
key={`markdown-${index}`}
message="正在生成 HTML 页面,请查看右侧预览。"
/>
) : (
<MarkdownRenderBoundary
key={`markdown-${index}`}
resetKey={`${segment.content.length}:${segment.content.slice(-32)}`}
>
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{segment.content}
</MessageResponse>
</MarkdownRenderBoundary>
)
) : null,
)}
</div>
);
}
@@ -32,31 +32,37 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
buildWriteFileVirtualUrl,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
resolveSearchQuery,
type SearchResultItem,
} from "@/core/messages/search-results";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility";
import {
applyInlineCitations,
buildSkillDisplayMap,
@@ -67,6 +73,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";
@@ -78,7 +85,8 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
export function MessageGroup({
className,
@@ -86,26 +94,42 @@ export function MessageGroup({
isLoading = false,
displaySources = [],
ragModeEnabled = false,
threadId,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
threadId?: string;
}) {
const { t } = useI18n();
const { data: citationDisplay } = useCitationDisplaySettings();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
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 retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
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 +221,12 @@ export function MessageGroup({
<ToolCall
key={step.id}
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
),
/>
),
)}
{lastToolCallStep && (
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
@@ -209,10 +234,11 @@ export function MessageGroup({
key={lastToolCallStep.id}
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
/>
</FlipDisplay>
)}
</ChainOfThoughtContent>
@@ -260,7 +286,8 @@ export function MessageGroup({
</>
)}
</ChainOfThought>
<SingleRoundRagCitationDialog
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
@@ -325,6 +352,7 @@ function ToolCall({
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false,
}: {
id?: string;
@@ -335,6 +363,7 @@ function ToolCall({
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
@@ -345,19 +374,42 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
? syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDisplaySources(result, displayMatch.display).map(
(item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}),
),
),
displaySources,
)
: [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const isLlmWikiTool =
name === "llmwiki_search" ||
name === "llmwiki_read_wiki_page" ||
name === "llmwiki_browse_wiki_index";
const wikiQuery = isLlmWikiTool
? resolveSearchQuery(args, result) ||
(name === "llmwiki_read_wiki_page"
? normalizeSearchResults(result)[0]?.title ||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
: "")
: "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
@@ -383,12 +435,17 @@ function ToolCall({
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
}));
const defaultItems = syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
})),
),
displaySources,
);
return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
@@ -754,7 +811,6 @@ function ArtifactWriteToolCall({
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
@@ -771,17 +827,10 @@ function ArtifactWriteToolCall({
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const sourcePath = namedStreamingWriteFilePath(rawPath);
const showInlineContent = name === "write_file" && !sourcePath;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
// Until a real path arrives, both auto-open and manual clicks stay inline.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
@@ -793,19 +842,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 (
@@ -820,7 +871,9 @@ function ArtifactWriteToolCall({
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult>
@@ -829,7 +882,29 @@ function ArtifactWriteToolCall({
);
}
interface GenericCoTStep<T extends string = string> {
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
useLayoutEffect(() => {
if (follow.current && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [content]);
return (
<pre
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
}}
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
>
<code>{content}</code>
</pre>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
@@ -847,8 +922,12 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[]): CoTStep[] {
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
// per-name cursor so parallel searches consume distinct unbound results in
// order instead of every step reusing the first page.
const toolCallNameFallbackIndexes = new Map<string, number>();
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
@@ -861,8 +940,47 @@ function convertToSteps(messages: Message[]): CoTStep[] {
};
steps.push(step);
}
for (const tool_call of message.tool_calls ?? []) {
if (tool_call.name === "task") {
const writes = new Map(
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
);
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
.tool_call_chunks;
const toolCalls = [...(message.tool_calls ?? [])];
for (const write of writes.values()) {
if (
!toolCalls.some((call) => call.id === write.id) &&
chunks?.some(
(chunk) => chunk.id === write.id && chunk.name === "write_file",
)
) {
toolCalls.push({
id: write.id,
name: "write_file",
args: { path: write.path, content: write.content },
});
}
}
for (const tool_call of toolCalls) {
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
continue;
}
const write = tool_call.name === "write_file" && tool_call.id
? writes.get(tool_call.id)
: undefined;
const args = write
? { ...tool_call.args, path: write.path, content: write.content }
: tool_call.args;
const writePath = typeof args?.path === "string" ? args.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 = {
@@ -870,15 +988,27 @@ function convertToSteps(messages: Message[]): CoTStep[] {
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args: tool_call.args,
args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(
let toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: false },
);
if (toolCallResult === undefined) {
const nameKey = tool_call.name.toLowerCase();
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
);
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
}
if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
@@ -945,6 +1075,48 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
);
}
function rankDisplaySources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }, index) => ({ ...source, index: index + 1 }));
}
function syncDisplaySourceIndexes(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const indexByCitationId = new Map(
globalSources.flatMap((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
return citationId ? [[citationId, source.index] as const] : [];
}),
);
return sources.map((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
const index = citationId ? indexByCitationId.get(citationId) : undefined;
return index ? { ...source, index } : source;
});
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
@@ -982,32 +1154,46 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
</div>
)}
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="pr-6 text-base leading-relaxed">
{item.title ?? "检索结果"}
</DialogTitle>
{(sourceLabel || date) && (
<DialogDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</DialogDescription>
)}
</DialogHeader>
{content && (
<div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{content}
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</DialogContent>
</Dialog>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"}
</SheetTitle>
{(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</SheetDescription>
)}
{!sourceLabel && !date && (
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import {
@@ -28,32 +28,48 @@ import {
hasToolCalls,
isHiddenFromUIMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import {
buildSkillDisplayMap,
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import {
shouldRenderUnattachedKnowledgeRetrievalProgress,
useKnowledgeRetrievalProgress,
} from "@/core/threads/knowledge-retrieval-progress";
import {
buildSmartKnowledgeRetrievalDisplaySegments,
collectSmartKnowledgeRetrievalTurnAssistantMessageIds,
hasSmartKnowledgeRetrievalCalls,
latestSmartKnowledgeRetrievalMessageId,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { DocumentRewriteMessage, DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
import {
MessageMetricsProvider,
MessageTokenUsageList,
@@ -65,7 +81,7 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
function ScrollToBottomOnUserMessage({
function ScrollToBottomOnUserMessage({
threadId,
messages,
}: {
@@ -109,10 +125,54 @@ function ScrollToBottomOnUserMessage({
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]);
return null;
}
export function MessageList({
return null;
}
/**
* Exposes the conversation's scroll position to hosts that render controls
* outside the scroll container (for example the docked follow-up questions).
*/
function ConversationBottomObserver({
onAtBottomChange,
}: {
onAtBottomChange?: (isAtBottom: boolean) => void;
}) {
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const update = () => {
const distance =
scrollElement.scrollHeight -
scrollElement.scrollTop -
scrollElement.clientHeight;
setIsExactlyAtBottom(distance <= 2);
};
update();
scrollElement.addEventListener("scroll", update, { passive: true });
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
resizeObserver?.observe(scrollElement);
if (contentRef.current) resizeObserver?.observe(contentRef.current);
return () => {
scrollElement.removeEventListener("scroll", update);
resizeObserver?.disconnect();
};
}, [contentRef, scrollRef]);
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
useEffect(() => {
onAtBottomChange?.(displayControlsAtBottom);
}, [displayControlsAtBottom, onAtBottomChange]);
return null;
}
export function MessageList({
className,
contentClassName,
fitMessageRowsToContent = false,
@@ -120,9 +180,10 @@ export function MessageList({
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
scrollButtonClassName,
onClarificationSubmit,
extraRehypePlugins,
scrollButtonClassName,
onAtBottomChange,
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
@@ -136,10 +197,13 @@ export function MessageList({
onSetupReportStructureReject,
researchApprovalSlot,
taskReportImportSlot,
businessCardSlot,
speakerShell,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
}: {
onDownloadArtifact,
afterDocumentRewriteItems,
answerFeedbackEnabled = false,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -147,8 +211,10 @@ export function MessageList({
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
/** Reports whether the message viewport is currently at its bottom edge. */
onAtBottomChange?: (isAtBottom: boolean) => void;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
@@ -176,12 +242,31 @@ export function MessageList({
* 「是否入库」卡片。不传该 slot 时(普通对话等)该分组不渲染,零行为变化。
*/
taskReportImportSlot?: (message: Message) => React.ReactNode;
/**
* 通用业务卡插槽(报告协作工作台等):宿主向 displayThread 注入带
* `additional_kwargs.report_collaboration_card` 的虚拟 ai 消息(方案组 /
* 命令确认 / 报告入口),由本插槽渲染。不传该 slot 时(普通对话等)该
* 分组不渲染,零行为变化;卡 payload 结构由各页面在 message-adapter
* 层定义,MessageList 不感知具体业务字段。
*/
businessCardSlot?: (message: Message) => React.ReactNode;
/**
* 可选说话人壳(报告协作对齐会商第二步):包住 assistant / processing 分组。
* 不传则问答页零变化。
*/
speakerShell?: (messages: Message[], children: ReactNode) => ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
}) {
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
/**
* Enables per-answer user feedback for persisted assistant replies. Kept
* opt-in because MessageList is also reused by read-only previews, setup
* flows and virtual business-card feeds.
*/
answerFeedbackEnabled?: boolean;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -202,33 +287,65 @@ export function MessageList({
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
() => new Set(),
);
const [selectedRetrievalResult, setSelectedRetrievalResult] = useState<{
item: SmartKnowledgeRetrievalResultItem;
target: WeKnoraSourceTarget;
} | null>(null);
useEffect(() => {
setHiddenMessageIds(new Set());
setSelectedRetrievalResult(null);
}, [threadId]);
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
if (deletedIds.length === 0) return;
setHiddenMessageIds((prev) => {
const next = new Set(prev);
for (const id of deletedIds) next.add(id);
return next;
});
}, []);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
return thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
});
}, []);
const documentRewriteItems = useMemo(
() => documentRewritePresentation?.forThread(threadId) ?? [],
[documentRewritePresentation, threadId],
);
const artifactRewriteItems = useMemo(
() => documentRewriteItems.filter(
(item) => item.source.kind === "artifact" && item.operation === "rewrite",
),
[documentRewriteItems],
);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
const persistedMessages = thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
const rewriteMessages = artifactRewriteItems.flatMap((item) => [
{
id: `document-rewrite-input-${item.id}`,
type: "human",
content: "AI改写",
} as unknown as Message,
{
id: `document-rewrite-${item.id}`,
type: "ai",
content: "",
additional_kwargs: {
document_rewrite_presentation_id: item.id,
},
} as unknown as Message,
]);
return [...persistedMessages, ...rewriteMessages];
}, [artifactRewriteItems, messageContentOverrides, thread.messages]);
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
@@ -240,10 +357,58 @@ export function MessageList({
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
);
const knowledgeRetrievalProgress = useKnowledgeRetrievalProgress(threadId);
const hasHumanMessage = messages.some((message) => message.type === "human");
const currentTurnMessages = useMemo(() => {
let lastHumanIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.type === "human") {
lastHumanIndex = index;
break;
}
}
return messages.slice(lastHumanIndex + 1);
}, [messages]);
const latestCurrentSmartMessageId = useMemo(
() => latestSmartKnowledgeRetrievalMessageId(currentTurnMessages),
[currentTurnMessages],
);
const lastCurrentTurnMessageId = currentTurnMessages.at(-1)?.id;
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
const answerFeedbackMessageIds = useMemo(() => {
if (!feedbackAvailable) return [];
const streamingAssistantId = thread.isLoading
? [...messages].reverse().find((message) => message.type === "ai")?.id
: undefined;
return messages.flatMap((message) => {
const kwargs = message.additional_kwargs ?? {};
const explicitlyStreaming =
kwargs.deep_research_summary_streaming === true ||
kwargs.deep_research_follow_up_streaming === true;
const loading =
explicitlyStreaming ||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
? [message.id]
: [];
});
}, [feedbackAvailable, messages, thread.isLoading]);
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
threadId,
answerFeedbackMessageIds,
feedbackAvailable,
);
const ratableAnswerMessageIds = useMemo(() => {
const lastMessageByRun = new Map<string, string>();
for (const message of messages) {
if (!message.id) continue;
const context = answerFeedbackContexts?.[message.id];
if (context) lastMessageByRun.set(context.run_id, message.id);
}
return new Set(lastMessageByRun.values());
}, [answerFeedbackContexts, messages]);
const askTimes = useChatAskTimes();
// Stamp a wall-clock time onto every *question* the first time it is seen, in
// a single batched commit. Questions sent this session already carry an
@@ -300,23 +465,20 @@ export function MessageList({
}
return map;
}, [messages]);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
threadContext?.knowledgeReferencesEnabled === true ||
!!threadContext?.knowledgeReferences;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
const hasSingleRoundRag = useMemo(
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
() => messages.some((message) => isSingleRoundRagMessage(message)),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
@@ -331,7 +493,7 @@ export function MessageList({
// frontend tool-message scan below comes up empty (e.g. after a reload).
const referenceBatches = useContext(ReferenceBatchesContext);
// 单轮/多轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
// 单轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
@@ -342,7 +504,7 @@ export function MessageList({
const msg = raw[i];
if (!msg || msg.type !== "ai" || !msg.id) continue;
const kwargs = msg.additional_kwargs;
if (!kwargs || (kwargs.single_round_rag !== true && kwargs.multi_round_rag !== true)) {
if (!kwargs || kwargs.single_round_rag !== true) {
continue;
}
let sources: RuntimeDisplaySource[] = [];
@@ -353,19 +515,13 @@ export function MessageList({
if (prev.type === "human" || prev.type === "ai") break;
if (prev.type !== "tool") continue;
const prevKwargs = prev.additional_kwargs;
if (
!prevKwargs ||
(prevKwargs.single_round_rag !== true && prevKwargs.multi_round_rag !== true)
) {
if (!prevKwargs || prevKwargs.single_round_rag !== true) {
continue;
}
const results = extractFrontendRagResults(prev.content);
if (results && results.length > 0) {
const toolName = String(
(prev as { name?: unknown }).name ??
(prevKwargs.multi_round_rag === true
? "multi_round_rag_references"
: "knowledge_base_search"),
(prev as { name?: unknown }).name ?? "knowledge_base_search",
);
sources = results.map((item, index) =>
toRuntimeDisplaySource(item, index + 1, toolName),
@@ -378,9 +534,29 @@ export function MessageList({
return map;
}, [thread.messages]);
const smartRetrievalAssistantMessageIds = useMemo(() => {
return collectSmartKnowledgeRetrievalTurnAssistantMessageIds(messages);
}, [messages]);
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
if (isMultiRoundRagMessage(msg)) return [];
const previousHumanId = findPreviousHumanMessageId(messages, msg.id);
// Smart retrieval has its own clickable evidence trace and explicitly
// does not require numbered references. Do not feed the same transport
// results into the legacy citation layer, which would append [1][2] to
// an otherwise complete answer.
const isSmartRetrievalTurn = msg.id
? smartRetrievalAssistantMessageIds.has(msg.id)
: hasSmartKnowledgeRetrievalCalls(
messages.slice(
previousHumanId
? messages.findIndex((message) => message.id === previousHumanId) + 1
: 0,
),
);
if (isSmartRetrievalTurn) return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
@@ -390,7 +566,7 @@ export function MessageList({
if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
afterMessageId: previousHumanId,
beforeMessageId: msg.id,
includeBeforeMessage: true,
});
@@ -401,6 +577,7 @@ export function MessageList({
referenceBatches,
referencesEnabled,
skillDisplayMap,
smartRetrievalAssistantMessageIds,
],
);
@@ -516,27 +693,22 @@ export function MessageList({
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{(() => {
const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{(() => {
let didRenderLiveKnowledgeRetrieval = false;
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
const items = group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
const messageIsLoading =
@@ -563,7 +735,12 @@ export function MessageList({
questionMarkdown={
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
}
isLoading={messageIsLoading}
isLoading={
isMultiRoundRagMessage(msg)
? thread.isLoading &&
msg.id === messages[messages.length - 1]?.id
: messageIsLoading
}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
@@ -573,10 +750,24 @@ export function MessageList({
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
/>
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
answerFeedbackEnabled={answerFeedbackEnabled}
answerFeedbackContext={
msg.id && ratableAnswerMessageIds.has(msg.id)
? answerFeedbackContexts?.[msg.id]
: undefined
}
/>
);
});
if (speakerShell && group.type === "assistant") {
return (
<div key={group.id} className="w-full">
{speakerShell(group.messages, items)}
</div>
);
}
return items;
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
@@ -692,7 +883,30 @@ export function MessageList({
{taskReportImportSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:present-files") {
} else if (group.type === "assistant:business-card") {
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return businessCardSlot && message ? (
<div key={group.id} className="w-full pb-2">
{businessCardSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:document-rewrite") {
const presentationId = group.messages[0]?.additional_kwargs?.document_rewrite_presentation_id;
const item = typeof presentationId === "string"
? artifactRewriteItems.find((candidate) => candidate.id === presentationId)
: undefined;
return item ? (
<div className="w-full" key={group.id}>
<DocumentRewriteMessage
item={item}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
if (hasPresentFiles(message)) {
@@ -700,16 +914,9 @@ export function MessageList({
files.push(...presentFiles);
}
}
const rewriteAbove = !didRenderRewriteAboveFile
? rewriteItemsAboveFile
: [];
didRenderRewriteAboveFile = true;
return (
<div className="w-full" key={group.id}>
{rewriteAbove.map((item) => (
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
))}
{group.messages[0] && hasContent(group.messages[0]) && (
return (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
@@ -786,13 +993,14 @@ export function MessageList({
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
ragModeEnabled={ragModeEnabled}
/>,
<MessageGroup
key={"thinking-group-" + message.id}
threadId={threadId}
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
ragModeEnabled={ragModeEnabled}
/>,
);
}
results.push(
@@ -838,14 +1046,95 @@ export function MessageList({
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
return (
<div key={"group-" + group.id} className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
ragModeEnabled={ragModeEnabled}
/>
const hasSmartRetrievalCalls = hasSmartKnowledgeRetrievalCalls(
group.messages,
);
const isCurrentTurnRetrievalGroup = group.messages.some(
(message) =>
message.id ===
(latestCurrentSmartMessageId ?? lastCurrentTurnMessageId),
);
const receivesLiveRetrievalProgress =
knowledgeRetrievalProgress.length > 0 &&
isCurrentTurnRetrievalGroup;
const retrievalDisplaySegments =
hasSmartRetrievalCalls || receivesLiveRetrievalProgress
? buildSmartKnowledgeRetrievalDisplaySegments(
group.messages,
receivesLiveRetrievalProgress
? knowledgeRetrievalProgress
: [],
)
: [
{
kind: "processing" as const,
id: `processing-${group.id ?? "group"}`,
messages: group.messages,
},
];
if (
retrievalDisplaySegments.some(
(segment) =>
segment.kind === "retrieval" &&
segment.progressFrames.length > 0,
)
) {
didRenderLiveKnowledgeRetrieval = true;
}
const processingBody = (
<div className="w-full space-y-2">
{retrievalDisplaySegments.map((segment) => {
if (segment.kind === "retrieval") {
return (
<SmartKnowledgeRetrievalTrace
key={segment.id}
messages={segment.messages}
progressFrames={segment.progressFrames}
dedupeScopeMessages={group.messages}
dedupeScopeProgressFrames={
receivesLiveRetrievalProgress
? knowledgeRetrievalProgress
: []
}
roundOffset={segment.roundOffset}
// The AI tool-call message can arrive before its custom
// `started` frame. Keep the current turn active through
// that short gap instead of misclassifying the pending
// call as an interrupted historical action.
isLoading={
isCurrentTurnRetrievalGroup && thread.isLoading
}
onOpenResult={(item, target) =>
setSelectedRetrievalResult({ item, target })
}
/>
);
}
const segmentHasVisibleProcessing = segment.messages.some(
(message) =>
message.type === "tool" ||
(message.type === "ai" &&
(hasReasoning(message) ||
hasContent(message) ||
hasToolCalls(message))),
);
if (!segmentHasVisibleProcessing) return null;
const segmentAi = [...segment.messages]
.reverse()
.find((message) => message.type === "ai");
return (
<MessageGroup
key={segment.id}
threadId={threadId}
messages={segment.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(
segmentAi ?? processingAi,
)}
ragModeEnabled={ragModeEnabled}
/>
);
})}
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -853,13 +1142,33 @@ export function MessageList({
/>
</div>
);
return (
<div key={"group-" + group.id} className="w-full">
{speakerShell ? speakerShell(group.messages, processingBody) : processingBody}
</div>
);
});
const leftoverRewriteItems = didRenderRewriteAboveFile
? rewriteItemsBelowFile
: documentRewriteItems;
const leftoverRewriteItems = documentRewriteItems.filter(
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
);
return (
<>
{grouped}
{shouldRenderUnattachedKnowledgeRetrievalProgress({
frameCount: knowledgeRetrievalProgress.length,
isLoading: thread.isLoading,
hasHumanMessage,
}) &&
!didRenderLiveKnowledgeRetrieval && (
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={knowledgeRetrievalProgress}
isLoading={thread.isLoading}
onOpenResult={(item, target) =>
setSelectedRetrievalResult({ item, target })
}
/>
)}
{leftoverRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
@@ -881,6 +1190,16 @@ export function MessageList({
aria-label="回到最新"
title="回到最新"
/>
<WeKnoraSourceDrawer
open={selectedRetrievalResult !== null}
target={selectedRetrievalResult?.target ?? null}
title={selectedRetrievalResult?.item.title}
quote={selectedRetrievalResult?.item.preview ?? ""}
collapseRetrievedExcerptByDefault
onOpenChange={(open) => {
if (!open) setSelectedRetrievalResult(null);
}}
/>
</Conversation>
);
}
@@ -924,8 +1243,9 @@ export type SetupWritingApprovalPayload = {
outline?: string;
};
export type SetupReportStructureApprovalPayload = {
title?: string;
export type SetupReportStructureApprovalPayload = {
kind?: "report_structure" | "gongwen_template";
title?: string;
name?: string;
content?: string;
structure_mode?: string;
@@ -1028,8 +1348,8 @@ function SetupAgentApprovalCard({
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const skills = approvalPayload.skills ?? [];
async function run(action: "approve" | "reject") {
@@ -1325,7 +1645,7 @@ const STRUCTURE_MODE_LABELS: Record<string, string> = {
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
@@ -1343,7 +1663,8 @@ function SetupReportStructureApprovalCard({
return null;
}
const approvalPayload = payload;
const approvalPayload = payload;
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
@@ -1368,8 +1689,8 @@ function SetupReportStructureApprovalCard({
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
</div>
{approvalPayload.title ? (
@@ -1411,7 +1732,7 @@ function SetupReportStructureApprovalCard({
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">报告大纲</div>
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
@@ -74,6 +74,18 @@ export function SpeedLegend({
}
export function SpeedPill({ metric }: { metric: MessageMetric }) {
if (metric.isPaused) {
return (
<span
className="inline-flex items-center gap-1.5"
title="本段生成停顿,窗口内无新 token"
>
<GaugeIcon className="text-muted-foreground size-3" />
<span className="text-muted-foreground">暂停</span>
</span>
);
}
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
@@ -85,7 +97,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
return (
<span
className="inline-flex items-center gap-1.5"
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
title={`${label} · ${metric.fromUsage ? "按本段 output_tokens / 本段生成时长" : "字符估算 ≈ 2 字符 / token"}`}
>
<GaugeIcon className="size-3" style={{ color }} />
<span className="font-mono tabular-nums" style={{ color }}>
@@ -0,0 +1,472 @@
import {
BookOpenIcon,
ChevronDownIcon,
FileTextIcon,
Loader2Icon,
SearchIcon,
SparklesIcon,
} from "lucide-react";
import { useState } from "react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message as AIElementMessage,
MessageContent as AIElementMessageContent,
} from "@/components/ai-elements/message";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { MessageResponseProps } from "@/components/ai-elements/message";
import type {
MultiRoundRagChatMessage,
MultiRoundRagRetrieval,
MultiRoundRagRound,
MultiRoundRagTrace,
} from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { MarkdownContent } from "./markdown-content";
export function MultiRoundRagMessageList({
className,
messages,
isLoading,
paddingBottom = 160,
}: {
className?: string;
messages: MultiRoundRagChatMessage[];
isLoading: boolean;
paddingBottom?: number;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return (
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
<ConversationContent
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
>
{messages.map((message, index) => {
const isLastAssistant =
message.role === "assistant" && index === messages.length - 1;
return (
<AIElementMessage
key={message.id}
className="group/conversation-message relative w-full"
from={message.role === "user" ? "user" : "assistant"}
>
{message.role === "user" ? (
<AIElementMessageContent className="w-fit">
<p className="whitespace-pre-wrap text-[15px] leading-7">
{message.content}
</p>
</AIElementMessageContent>
) : (
<AIElementMessageContent className="w-full">
<MultiRoundRagAssistantBody
content={
typeof message.content === "string" ? message.content : ""
}
thinkingTrace={message.thinkingTrace}
isLoading={isLastAssistant && isLoading}
rehypePlugins={rehypePlugins}
/>
</AIElementMessageContent>
)}
</AIElementMessage>
);
})}
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
<ConversationScrollButton
className="pointer-events-auto z-20 size-9 shadow-md bottom-40"
aria-label="回到最新"
title="回到最新"
/>
</Conversation>
);
}
export function MultiRoundRagAssistantBody({
content,
thinkingTrace,
isLoading,
rehypePlugins,
}: {
content: string;
thinkingTrace?: MultiRoundRagTrace | null;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
}) {
const [selected, setSelected] = useState<MultiRoundRagRetrieval | null>(
null,
);
const trace = thinkingTrace;
const answer = trace?.finalAnswerMarkdown || content;
return (
<>
{trace && (
<MultiRoundRagTraceView
trace={trace}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onSelectRetrieval={setSelected}
/>
)}
<section className="mt-4">
<div className="text-foreground mb-2 text-sm font-medium">最终回答</div>
{answer ? (
<MarkdownContent
content={answer}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="my-0 text-[15px] leading-7"
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在生成思考过程与检索内容...
</div>
)}
</section>
<RetrievalDetailDialog
item={selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
/>
</>
);
}
function MultiRoundRagTraceView({
trace,
isLoading,
rehypePlugins,
onSelectRetrieval,
}: {
trace: MultiRoundRagTrace;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
}) {
const [open, setOpen] = useState(!trace.thinkingComplete);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose w-full min-w-0 max-w-5xl"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors"
>
{trace.thinkingComplete ? (
<SparklesIcon className="text-primary size-4 shrink-0" />
) : (
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<span>{trace.title || "思考过程"}</span>
<span className="text-muted-foreground/70 text-xs">
共 {trace.rounds.length} 轮推理与检索
</span>
<ChevronDownIcon
className={cn("ml-auto size-4 transition-transform", !open && "-rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
className={cn("relative pl-5", index < trace.rounds.length - 1 && "pb-1")}
>
{index < trace.rounds.length - 1 && (
<span className="bg-primary/25 absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
isLoading={isLoading}
isActiveRound={isLoading && index === trace.rounds.length - 1}
rehypePlugins={rehypePlugins}
onSelectRetrieval={onSelectRetrieval}
/>
</div>
))}
</CollapsibleContent>
</Collapsible>
);
}
function MultiRoundRagRoundCard({
index,
round,
isLoading,
isActiveRound,
rehypePlugins,
onSelectRetrieval,
}: {
index: number;
round: MultiRoundRagRound;
isLoading: boolean;
isActiveRound: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
}) {
const query = round.query.trim();
const searching = Boolean(query && round.queryLoading);
const showQuery = Boolean(query);
const showRetrievals = showQuery || round.retrievals.length > 0;
const [open, setOpen] = useState(true);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<article className="overflow-hidden rounded-lg border border-border/70 bg-background">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
{index}
</span>
<span className="min-w-0 flex-1 truncate font-medium">{round.title}</span>
{searching ? (
<span className="text-primary inline-flex items-center gap-1 text-[11px]">
<Loader2Icon className="size-3 animate-spin" />
检索中
</span>
) : isActiveRound ? null : (
<span className="text-muted-foreground text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/60 space-y-3 border-t px-3 pb-3 pt-3">
{round.thoughtMarkdown && (
<section>
<div className="text-muted-foreground mb-1.5 flex items-center gap-2 text-sm font-medium">
<SparklesIcon className="size-4" />
思考内容
</div>
<MarkdownContent
content={round.thoughtMarkdown}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="text-muted-foreground my-0 text-sm leading-6"
/>
</section>
)}
{showQuery && (
<div
className="border-primary/20 bg-primary/5 text-primary inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5"
aria-live={searching ? "polite" : undefined}
>
{searching ? (
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="text-primary/80 shrink-0 text-xs">
{round.queryTitle || "检索词"}
</span>
<span className="min-w-0 truncate text-xs font-medium">
{query}
</span>
</div>
)}
{showRetrievals && (
<section>
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<FileTextIcon className="text-muted-foreground size-4" />
检索内容
<span className="text-muted-foreground text-xs font-normal">
{round.retrievals.length} 条
</span>
</div>
{searching ? (
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-border/70 px-3 py-3 text-sm">
<Loader2Icon className="text-primary size-4 animate-spin" />
正在召回相关内容...
</div>
) : (
<RetrievalCardList
items={round.retrievals}
onSelect={onSelectRetrieval}
/>
)}
</section>
)}
{(round.extractInfo || round.extractContent || round.extractLoading) && (
<section className="rounded-lg border border-border/70 px-3 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<BookOpenIcon className="text-muted-foreground size-4" />
阅读内容
{round.extractLoading && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.extractInfo && (
<p className="text-muted-foreground text-sm leading-6">{round.extractInfo}</p>
)}
{round.extractContent && (
<p className="mt-2 text-sm leading-6 whitespace-pre-wrap">{round.extractContent}</p>
)}
</section>
)}
{round.textidContent && (
<section className="rounded-lg border border-border/70 px-3 py-3 text-sm leading-6 whitespace-pre-wrap">
{round.textidContent}
</section>
)}
</CollapsibleContent>
</article>
</Collapsible>
);
}
function RetrievalCardList({
items,
onSelect,
}: {
items: MultiRoundRagRetrieval[];
onSelect: (item: MultiRoundRagRetrieval) => void;
}) {
if (items.length === 0) {
return <p className="text-muted-foreground py-1 text-sm">本轮未召回相关资料。</p>;
}
return (
<div className="space-y-2">
{items.map((item, index) => (
<RetrievalCard
key={`${item.id ?? item.title}-${index}`}
item={item}
index={index + 1}
defaultOpen={index === 0}
onSelect={onSelect}
/>
))}
</div>
);
}
function RetrievalCard({
item,
index,
defaultOpen,
onSelect,
}: {
item: MultiRoundRagRetrieval;
index: number;
defaultOpen: boolean;
onSelect: (item: MultiRoundRagRetrieval) => void;
}) {
const [open, setOpen] = useState(defaultOpen);
const meta = retrievalMeta(item);
if (item.type === "user_behavior") {
return (
<div className="rounded-xl border border-border/70 px-3 py-2 text-sm">
<span className="text-muted-foreground mr-2 font-medium">{index}.</span>
{item.title}
</div>
);
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left transition-colors"
>
<span className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-medium">
{index}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium" title={item.title}>
{item.title}
</div>
{meta && (
<div className="text-muted-foreground truncate text-xs">{meta}</div>
)}
</div>
<ChevronDownIcon
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/60 border-t px-3 pb-3 pt-2.5">
<button
type="button"
className="text-muted-foreground hover:text-foreground line-clamp-3 w-full text-left text-sm leading-6 whitespace-pre-wrap"
onClick={() => onSelect(item)}
>
{item.content || "暂无资料摘要"}
</button>
</CollapsibleContent>
</div>
</Collapsible>
);
}
function retrievalMeta(item: MultiRoundRagRetrieval): string {
if (item.type === "more") return `时间:${item.time || "-"}`;
if (item.type === "task") return `关键词:${item.keywords || "-"}`;
if (item.type === "hx") return `平台:${item.platform || "-"} 粉丝数:${item.fans ?? "-"}`;
if (
item.type === "taiwan_news" ||
item.type === "world_news" ||
item.type === "messaage_comment" ||
item.type === "america_news"
) {
return `平台:${item.platform || "-"}${item.comment_count ? ` 评论数:${item.comment_count}` : ""}`;
}
if (item.type === "get_time_data") return `时间:${item.time || "-"} 热度:${item.hot_num ?? "-"}`;
return [item.source, item.time].filter(Boolean).join(" · ");
}
function RetrievalDetailDialog({
item,
onOpenChange,
}: {
item: MultiRoundRagRetrieval | null;
onOpenChange: (open: boolean) => void;
}) {
const content = item?.content;
return (
<Dialog open={!!item} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
<DialogTitle className="leading-relaxed">{item?.title || "引用资料"}</DialogTitle>
<DialogDescription className="space-y-1 text-xs">
{item?.time && <span className="block">时间:{item.time}</span>}
{item?.source && <span className="block">来源:{item.source}</span>}
</DialogDescription>
</DialogHeader>
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-xl p-3 text-sm leading-7 whitespace-pre-wrap">
{content || "暂无引用内容"}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,207 @@
import { ChevronDownIcon, DownloadIcon, EyeIcon, Loader2Icon } from "lucide-react";
import { useContext, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { urlOfArtifact } from "@/core/artifacts/utils";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import type { RetrievedFile } from "@/core/messages/retrieved-files";
import { getFileIcon } from "@/core/utils/files";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
getLlmWikiSourceContext,
resolveLlmWikiSourceKnowledgeBase,
} from "@/strategy-components/api/llmwiki";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ThreadContext } from "./context";
import { UploadedFilePreviewDialog } from "./uploaded-file-preview-dialog";
// File links belong to the upstream service. Never attach DeerFlow auth headers.
const fetchExternalFile: typeof fetch = (input, init) =>
fetch(input, { ...init, credentials: "omit" });
export function RetrievedFileList({ files, threadId }: { files: RetrievedFile[]; threadId: string }) {
const { isMock, publicShareCode } = useContext(ThreadContext) ?? {};
const [expanded, setExpanded] = useState(false);
const visible = files.filter((file) => !(publicShareCode && file.kind === "knowledge"));
// Results arrive in retrieval order; keep the newest file outside the fold.
const latest = visible.at(-1);
if (!latest) return null;
const older = visible.slice(0, -1).reverse();
return (
<Collapsible asChild open={expanded} onOpenChange={setExpanded}>
<section aria-label="检索到的文件" className="not-prose mt-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-muted-foreground text-xs">检索到的文件 · {visible.length} 个</div>
{older.length > 0 && (
<CollapsibleTrigger asChild>
<Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
{expanded ? "收起" : "展开"}
<ChevronDownIcon className={`size-3.5 transition-transform ${expanded ? "rotate-180" : ""}`} />
</Button>
</CollapsibleTrigger>
)}
</div>
<ul className="grid gap-2">
<RetrievedFileCard
key={latest.key}
file={latest}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
</ul>
<CollapsibleContent>
<ul className="grid gap-2">
{older.map((file) => (
<RetrievedFileCard
key={file.key}
file={file}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
))}
</ul>
</CollapsibleContent>
</section>
</Collapsible>
);
}
function RetrievedFileCard({ file, threadId, isMock, publicShareCode }: {
file: RetrievedFile;
threadId: string;
isMock?: boolean;
publicShareCode?: string;
}) {
const [previewOpen, setPreviewOpen] = useState(false);
const [preparingPreview, setPreparingPreview] = useState(false);
const [downloading, setDownloading] = useState(false);
const [knowledgeTarget, setKnowledgeTarget] = useState<WeKnoraSourceTarget | null>(null);
const fileUrl = file.kind === "artifact"
? urlOfArtifact({ filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode })
: file.kind === "external" ? file.url : "";
const resolveKnowledgeTarget = async () => {
if (file.kind !== "knowledge") throw new Error("当前文件不是知识库文档");
if (knowledgeTarget) return knowledgeTarget;
const knowledgeBaseId = await resolveLlmWikiSourceKnowledgeBase(file.target.knowledgeBaseId);
const target = { ...file.target, knowledgeBaseId };
setKnowledgeTarget(target);
return target;
};
const preview = async () => {
if (preparingPreview) return;
setPreparingPreview(true);
try {
if (file.kind === "knowledge") await resolveKnowledgeTarget();
setPreviewOpen(true);
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件预览失败");
} finally {
setPreparingPreview(false);
}
};
const download = async () => {
if (downloading || file.kind === "external") return;
setDownloading(true);
try {
if (file.kind === "artifact") {
await downloadArtifactFile({
filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode,
});
} else {
const target = await resolveKnowledgeTarget();
let documentId = target.documentId;
if (!documentId && target.chunkId) {
const context = await getLlmWikiSourceContext(target.knowledgeBaseId, target.chunkId);
documentId = context.document_id || undefined;
}
if (!documentId) throw new Error("当前检索结果没有可下载的文件原件,请先查看预览");
const [blob, document] = await Promise.all([
getLlmWikiDocumentPreview(target.knowledgeBaseId, documentId),
getLlmWikiDocument(target.knowledgeBaseId, documentId).catch(() => null),
]);
const objectUrl = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = objectUrl;
anchor.download = document?.file_name || file.name;
anchor.rel = "noopener";
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件下载失败");
} finally {
setDownloading(false);
}
};
return (
<li className="bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
{getFileIcon(file.name, "text-muted-foreground size-5 shrink-0")}
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium break-words">{file.name}</div>
{file.source && <div className="text-muted-foreground mt-0.5 truncate text-xs">{file.source}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{file.kind === "external" && file.previewUrl ? (
<Button asChild size="sm" variant="ghost">
<a href={file.previewUrl} target="_blank" rel="noopener noreferrer">
<EyeIcon className="size-4" />预览
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={preparingPreview} onClick={() => void preview()}>
{preparingPreview ? <Loader2Icon className="size-4 animate-spin" /> : <EyeIcon className="size-4" />}预览
</Button>
)}
{file.kind === "external" ? (
<Button asChild size="sm" variant="ghost">
<a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer">
<DownloadIcon className="size-4" />下载
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={downloading} onClick={() => void download()}>
{downloading ? <Loader2Icon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}下载
</Button>
)}
</div>
{previewOpen && (file.kind === "knowledge" ? (
<WeKnoraSourceDrawer
open
target={knowledgeTarget || file.target}
title={file.name}
quote={file.quote || ""}
onOpenChange={setPreviewOpen}
/>
) : (
<UploadedFilePreviewDialog
filename={file.name}
fileUrl={fileUrl}
open
onOpenChange={setPreviewOpen}
threadId={file.kind === "artifact" && !publicShareCode ? threadId : undefined}
virtualPath={file.kind === "artifact" ? file.path : undefined}
requestFile={file.kind === "external" ? fetchExternalFile : undefined}
onDownload={file.kind === "artifact" ? download : undefined}
downloading={downloading}
/>
))}
</li>
);
}
@@ -0,0 +1,406 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { Message } from "@langchain/langgraph-sdk";
import type { KnowledgeRetrievalProgressFrame } from "@/core/threads/smart-knowledge-retrieval";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
const completedFrames: KnowledgeRetrievalProgressFrame[] = [
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "started",
queries: ["成都世运会"],
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 3,
elapsed_ms: 420,
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "started",
pattern: "火炬|开幕|闭幕",
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 4,
elapsed_ms: 1380,
},
];
test("completed and replayed retrieval traces start collapsed with their hit summary visible", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading={false}
/>,
);
assert.match(html, /aria-label="展开智能检索过程"/);
assert.match(html, /aria-expanded="false"/);
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
assert.match(html, /累计耗时 1\.8 秒/);
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
});
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
const messages = [
{
type: "ai",
id: "ai-pending-wiki",
content: "",
tool_calls: [
{
id: "wiki-pending",
name: "ordinary_wiki_search",
args: { pattern: "数字中国|数据要素" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /检索中/);
assert.match(html, /正在查 Wiki/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤未完成|已中止/);
});
test("labels and normalizes a visible model-authored regex", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[
{
type: "ai",
id: "ai-regex",
content: "",
tool_calls: [
{
id: "wiki-regex",
name: "ordinary_wiki_search",
args: { pattern: "^# [一-龥]{2,10}篇�" },
},
],
},
] as unknown as Message[]}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /正则:\^# \\p\{Han\}\{2,10\}篇/);
assert.doesNotMatch(html, /一-龥|�/);
});
test("presents an interrupted historical action as neutral instead of failed", () => {
const messages = [
{
type: "ai",
id: "ai-interrupted-wiki",
content: "",
tool_calls: [
{
id: "wiki-interrupted",
name: "ordinary_wiki_search",
args: { pattern: "数字中国" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading={false}
/>,
);
assert.match(html, /检索已结束(部分步骤未完成)/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤异常/);
});
test("an in-flight retrieval stays expanded and does not expose a collapse control", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索中/);
assert.match(html, /成都世运会/);
assert.doesNotMatch(html, /收起智能检索过程|展开智能检索过程/);
});
test("completed retrieval can be collapsed while the final answer is still streaming", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索完成/);
assert.match(html, /aria-label="收起智能检索过程"/);
assert.match(html, /检索 Wiki/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
assert.doesNotMatch(html, /检索中/);
});
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
roundOffset={2}
/>,
);
assert.match(html, /检索原始文档/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
});
test("renders stable retrieval results as right-drawer controls", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-news",
slug: "news/opening",
title: "开幕新闻 Wiki",
preview: "页面摘要",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /data-retrieval-result-detail="true"/);
assert.match(html, /在右侧查看:开幕新闻 Wiki/);
assert.doesNotMatch(html, /data-retrieval-result-summary="true"/);
});
test("renders a Wiki read as a preview card with elapsed time and right-drawer entry", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_read_page",
status: "completed",
result_count: 1,
knowledge_base_count: 1,
coverage: [
{ knowledge_base: "数字中国政策目录智能检索远端验收" },
],
elapsed_ms: 1260,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-digital-china",
knowledge_base: "数字中国政策目录智能检索远端验收",
slug: "digital-china/timeline",
page_ref: "wiki-page:v1:digital-china-timeline",
title: "数字中国建设时间线",
content_markdown:
"页面正文概览,包含建设目标、关键年份、量化指标和制度保障。",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /读取结果/);
assert.match(html, /数字中国建设时间线/);
assert.match(html, /页面正文概览,包含建设目标/);
assert.match(html, /右侧查看/);
assert.match(html, /耗时 1\.3 秒/);
assert.match(html, /覆盖 1 个知识库:数字中国政策目录智能检索远端验收/);
assert.doesNotMatch(html, /知识库知识库/);
assert.match(html, /data-retrieval-result-detail="true"/);
});
test("shows all usable document-read results without a generic partial warning", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "document_chunk_read",
status: "completed",
result_count: 20,
partial: true,
warning_codes: ["PROVIDER_CONTEXT_PARTIAL"],
message: "原文上下文读取完成,找到 20 条结果",
result_items: Array.from({ length: 20 }, (_, index) => ({
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_base: "国内新闻",
knowledge_id: "document-long-news",
chunk_id: `chunk-${index + 1}`,
title: `长篇新闻第 ${index + 1} 段`,
content: `原文上下文第 ${index + 1} 段`,
})),
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(html.match(/data-retrieval-result-detail="true"/g) ?? []).length,
20,
);
assert.match(html, /长篇新闻第 1 段/);
assert.match(html, /长篇新闻第 20 段/);
assert.doesNotMatch(html, /部分数据源返回警告/);
assert.doesNotMatch(html, /部分完成/);
});
test("keeps historical results without stable ids as non-clickable summaries", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base: "旧知识库",
title: "旧检索结果",
},
],
},
]}
isLoading
/>,
);
assert.match(html, /data-retrieval-result-summary="true"/);
assert.match(html, /该历史检索结果缺少稳定详情标识/);
assert.doesNotMatch(html, /data-retrieval-result-detail="true"/);
});
test("uses the whole turn as the dedupe scope for interleaved retrieval cards", () => {
const firstFrame: KnowledgeRetrievalProgressFrame = {
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
retrieval_id: "raw-first",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-1",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const repeatedFrame: KnowledgeRetrievalProgressFrame = {
...firstFrame,
retrieval_id: "raw-second",
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-provider-remapped",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const firstHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[firstFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
const repeatedHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[repeatedFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(firstHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
1,
);
assert.equal(
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
0,
);
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
});
@@ -0,0 +1,525 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
AlertCircleIcon,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
CirclePauseIcon,
DatabaseIcon,
Loader2Icon,
PanelRightOpenIcon,
SearchCheckIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
buildSmartKnowledgeRetrievalRounds,
type KnowledgeRetrievalProgressFrame,
type SmartKnowledgeRetrievalAction,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { cn } from "@/lib/utils";
function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
if (action.status === "failed") return "检索失败";
if (action.status === "interrupted") return "已中止";
if (action.status === "running") {
return action.surface === "wiki" ? "正在查 Wiki" : "正在查知识库";
}
if (action.allResultsAlreadyShown) return "均为已展示结果";
const hasUsableResults =
action.resultItems.length > 0 || (action.resultCount ?? 0) > 0;
const partialPrefix = action.partial && !hasUsableResults ? "部分完成 · " : "";
if (action.kind === "search") {
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
}
return typeof action.resultCount === "number"
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
: `${partialPrefix}读取完成`;
}
function formatElapsedTime(elapsedMs: number): string {
const normalized = Math.max(0, elapsedMs);
if (normalized < 1_000) return `${Math.round(normalized)} 毫秒`;
if (normalized < 60_000) {
const seconds = (normalized / 1_000).toFixed(1).replace(/\.0$/, "");
return `${seconds} 秒`;
}
const roundedSeconds = Math.round(normalized / 1_000);
const minutes = Math.floor(roundedSeconds / 60);
const seconds = roundedSeconds % 60;
return seconds > 0 ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`;
}
function channelText(channel: string | undefined): string | null {
if (!channel) return null;
if (channel === "local_wiki_mirror") return "本地 Wiki 镜像";
if (channel === "provider") return "知识库 Wiki";
if (channel === "mixed") return "本地镜像 + 知识库 Wiki";
return channel;
}
function ActionStatusIcon({ action }: { action: SmartKnowledgeRetrievalAction }) {
if (action.status === "running") {
return <Loader2Icon className="size-4 animate-spin text-primary" />;
}
if (action.status === "completed") {
return <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />;
}
if (action.status === "failed") {
return <AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400" />;
}
return <CirclePauseIcon className="size-4 text-muted-foreground" />;
}
function resultDetailTarget(
item: SmartKnowledgeRetrievalResultItem,
): WeKnoraSourceTarget | null {
if (!item.knowledgeBaseId) return null;
if (item.kind === "wiki" && item.wikiSlug) {
return {
knowledgeBaseId: item.knowledgeBaseId,
wikiSlug: item.wikiSlug,
tab: "wiki",
};
}
if (item.kind === "raw" && (item.chunkId || item.documentId)) {
return {
knowledgeBaseId: item.knowledgeBaseId,
chunkId: item.chunkId,
documentId: item.documentId,
tab: "documents",
};
}
return null;
}
function RetrievalAction({
action,
onOpenResult,
}: {
action: SmartKnowledgeRetrievalAction;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const SurfaceIcon = action.surface === "wiki" ? BookOpenTextIcon : DatabaseIcon;
const channel = channelText(action.channel);
return (
<div
className="rounded-lg border border-border/70 bg-background/75 px-3 py-2.5"
data-retrieval-operation={action.operation}
>
<div className="flex min-w-0 items-start gap-2.5">
<SurfaceIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span className="text-sm font-medium text-foreground">{action.label}</span>
<span
className={cn(
"inline-flex items-center gap-1 text-xs",
action.status === "running"
? "text-primary"
: action.status === "completed"
? "text-emerald-700 dark:text-emerald-300"
: action.status === "failed"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground",
)}
>
<ActionStatusIcon action={action} />
{actionStatusText(action)}
</span>
</div>
{action.queries.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-muted-foreground">查询</span>
{action.queries.map((query) => (
<span
key={query}
className="max-w-full rounded-md bg-muted px-2 py-0.5 text-foreground/85 break-all"
>
{query}
</span>
))}
</div>
)}
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{typeof action.knowledgeBaseCount === "number" ? (
<span>
覆盖 {action.knowledgeBaseCount} 个知识库
{action.knowledgeBases.length > 0
? `:${action.knowledgeBases.join("、")}`
: ""}
</span>
) : action.knowledgeBases.length > 0 ? (
<span>知识库:{action.knowledgeBases.join("、")}</span>
) : null}
{typeof action.scannedPages === "number" && (
<span>扫描 {action.scannedPages} 个 Wiki 页面</span>
)}
{typeof action.scannedChunks === "number" && (
<span>扫描 {action.scannedChunks} 个原文片段</span>
)}
{action.kind === "read" && typeof action.total === "number" && (
<span>共 {action.total} 个片段</span>
)}
{channel && <span>通道:{channel}</span>}
{typeof action.elapsedMs === "number" && (
<span>耗时 {formatElapsedTime(action.elapsedMs)}</span>
)}
</div>
{action.allResultsAlreadyShown ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
本次查询结果均已在前面展示
</p>
) : action.resultItems.length > 0 ? (
<div className="mt-2">
<div className="mb-1.5 text-xs text-muted-foreground">
{action.kind === "read" ? "读取结果" : "查询结果"}
</div>
{action.kind === "read" ? (
<div className="space-y-2">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
const preview = item.preview?.trim().slice(0, 600);
const content = (
<>
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground">
{item.title}
</span>
{target && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
右侧查看
<PanelRightOpenIcon className="size-3.5" />
</span>
)}
</div>
{preview && (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
{preview}
</p>
)}
</>
);
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="block w-full rounded-lg border border-primary/20 bg-primary/[0.045] px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.075] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
{content}
</button>
) : (
<div
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="rounded-lg border border-border/70 bg-muted/45 px-3 py-2.5"
title="该历史检索结果缺少稳定详情标识"
>
{content}
</div>
);
})}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.055] px-2 py-1 text-left text-xs leading-5 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
<span className="truncate">{item.title}</span>
<PanelRightOpenIcon className="size-3.5 shrink-0 text-primary" />
</button>
) : (
<span
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="inline-flex max-w-full rounded-md bg-muted px-2 py-1 text-xs leading-5 text-muted-foreground"
title="该历史检索结果缺少稳定详情标识"
>
<span className="truncate">{item.title}</span>
</span>
);
})}
{(action.resultCount ?? 0) > action.resultItems.length && (
<span className="inline-flex items-center px-1 text-xs text-muted-foreground">
另有 {(action.resultCount ?? 0) - action.resultItems.length} 条结果
</span>
)}
</div>
)}
</div>
) : action.resultTitles.length > 0 ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
结果摘要:{action.resultTitles.join(";")}
{(action.resultCount ?? 0) > action.resultTitles.length ? ";…" : ""}
</p>
) : null}
{action.message && (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
{action.message}
</p>
)}
{action.error && (
<p className="mt-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300">
{action.error}
</p>
)}
{/* Provider warning codes stay in the structured trace for
diagnostics. The old generic warning was misleading both when
useful evidence existed and when the result set was empty. */}
</div>
</div>
</div>
);
}
export function SmartKnowledgeRetrievalTrace({
messages,
progressFrames = [],
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
onOpenResult,
}: {
messages: Message[];
progressFrames?: KnowledgeRetrievalProgressFrame[];
/**
* The whole current user-turn group. Interleaved rendering splits one
* retrieval invocation into one component, but result dedupe must still see
* every invocation in the turn.
*/
dedupeScopeMessages?: Message[];
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
isLoading: boolean;
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
roundOffset?: number;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const rounds = useMemo(
() => {
const localRounds = buildSmartKnowledgeRetrievalRounds(
messages,
progressFrames,
isLoading,
);
if (!dedupeScopeMessages) return localRounds;
const globallyDedupedActions = new Map(
buildSmartKnowledgeRetrievalRounds(
dedupeScopeMessages,
dedupeScopeProgressFrames ?? [],
isLoading,
)
.flatMap((round) => round.actions)
.map((action) => [action.id, action] as const),
);
return localRounds.map((round) => ({
...round,
actions: round.actions.map((action) => {
const globalAction = globallyDedupedActions.get(action.id);
return globalAction ? { ...globalAction, round: action.round } : action;
}),
}));
},
[
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
messages,
progressFrames,
],
);
const actions = rounds.flatMap((round) => round.actions);
// The chat can keep streaming its final answer after retrieval has already
// finished. Only an unfinished retrieval action owns the "检索中" state;
// the broader thread loading flag must not keep the spinner alive or block
// the collapse control during answer generation.
const isActive = actions.some((action) => action.status === "running");
const hasFailure = actions.some((action) => action.status === "failed");
const hasInterrupted = actions.some(
(action) => action.status === "interrupted",
);
const rawHits = actions
.filter((action) => action.surface === "raw" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const wikiHits = actions
.filter((action) => action.surface === "wiki" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const elapsedActions = actions.filter(
(action) => typeof action.elapsedMs === "number",
);
const cumulativeElapsedMs =
elapsedActions.length > 0
? elapsedActions.reduce(
(total, action) => total + (action.elapsedMs ?? 0),
0,
)
: undefined;
// A live trace opens so newly completed result cards remain visible while
// the final answer starts streaming. Replayed history starts collapsed.
const [isExpanded, setIsExpanded] = useState(
isLoading && actions.length > 0,
);
const wasActive = useRef(isActive);
const wasLoading = useRef(isLoading);
useEffect(() => {
if (!wasActive.current && isActive) {
setIsExpanded(true);
}
if (wasLoading.current && !isLoading) {
setIsExpanded(false);
}
wasActive.current = isActive;
wasLoading.current = isLoading;
}, [isActive, isLoading]);
// An in-flight retrieval always stays visible. A just-completed live trace
// remains open and user-collapsible while the answer streams; replayed
// history starts collapsed and can be reopened for inspection.
const isOpen = isActive || isExpanded;
if (actions.length === 0) return null;
return (
<Collapsible
open={isOpen}
onOpenChange={(open) => {
if (!isActive) setIsExpanded(open);
}}
>
<section
aria-label="智能检索过程"
aria-live={isActive ? "polite" : undefined}
className="w-full rounded-xl border border-primary/20 bg-primary/[0.035] p-3.5 shadow-sm"
data-smart-knowledge-retrieval-trace
>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{isActive ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SearchCheckIcon className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">智能检索过程</h3>
<div className="flex items-center gap-1.5">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
isActive
? "bg-primary/10 text-primary"
: hasFailure
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: hasInterrupted
? "bg-muted text-muted-foreground"
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
{isActive
? "检索中"
: hasFailure
? "已完成(部分步骤失败)"
: hasInterrupted
? "检索已结束(部分步骤未完成)"
: "检索完成"}
</span>
{!isActive && (
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={isOpen ? "收起智能检索过程" : "展开智能检索过程"}
>
<span>{isOpen ? "收起" : "展开"}</span>
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
)}
</div>
</div>
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
{typeof cumulativeElapsedMs === "number"
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
: ""}
</p>
)}
{isActive && (
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
</div>
</div>
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
<div className="mt-3 space-y-3">
{actions.map((action) => (
<RetrievalAction
key={action.id}
action={action}
onOpenResult={onOpenResult}
/>
))}
</div>
</CollapsibleContent>
</section>
</Collapsible>
);
}
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import {
GENERATION_IDLE_MS,
ROLLING_WINDOW_MS,
charsToTokensPerSec,
messageHasToolCalls,
rollingTokensPerSec,
shouldFreezeGeneration,
usageTokensPerSec,
} from "./speed-metrics.ts";
test("rolling window reports the recent rate while characters grow", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 500, chars: 100 },
{ at: 1000, chars: 200 },
];
const rate = rollingTokensPerSec(samples, 200, 1000, 1000);
// 200 chars / 1000ms / 2 chars-per-token = 100 tok/s
assert.equal(rate, 100);
});
test("rolling window drops to 0 when every sample is older than the window", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 200, chars: 80 },
{ at: 400, chars: 160 },
];
const now = 400 + ROLLING_WINDOW_MS + 50;
assert.equal(rollingTokensPerSec(samples, 160, now), 0);
});
test("rolling window decays toward 0 after a mid-stream pause", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 400, chars: 80 },
];
const live = rollingTokensPerSec(samples, 80, 500, 1000);
const paused = rollingTokensPerSec(samples, 80, 900, 1000);
assert.ok(live > paused);
assert.ok(paused > 0);
assert.equal(rollingTokensPerSec(samples, 80, 400 + 1000 + 1, 1000), 0);
});
test("usageTokensPerSec is output tokens over the segment duration", () => {
assert.equal(usageTokensPerSec(278, 6200), (278 / 6200) * 1000);
assert.equal(usageTokensPerSec(0, 1000), 0);
assert.equal(usageTokensPerSec(10, 0), 0);
});
test("charsToTokensPerSec uses the 2-chars-per-token heuristic", () => {
assert.equal(charsToTokensPerSec(100, 1000), 50);
});
test("shouldFreezeGeneration ends the segment on the next call, tools, idle usage, or run stop", () => {
const base = {
endedAt: null,
isLoading: true,
isLatestGenerating: true,
hasToolCalls: false,
grewThisTick: false,
lastGrewAt: 1000,
now: 1100,
outputTokens: 0,
};
assert.equal(shouldFreezeGeneration(base), false);
assert.equal(
shouldFreezeGeneration({ ...base, isLatestGenerating: false }),
true,
);
assert.equal(shouldFreezeGeneration({ ...base, hasToolCalls: true }), true);
assert.equal(
shouldFreezeGeneration({
...base,
hasToolCalls: true,
grewThisTick: true,
}),
false,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS,
}),
true,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS - 1,
}),
false,
);
assert.equal(shouldFreezeGeneration({ ...base, isLoading: false }), true);
assert.equal(shouldFreezeGeneration({ ...base, endedAt: 1000 }), false);
});
test("messageHasToolCalls reads both top-level and additional_kwargs", () => {
assert.equal(messageHasToolCalls({}), false);
assert.equal(
messageHasToolCalls({
tool_calls: [{ id: "1", name: "search" }],
}),
true,
);
assert.equal(
messageHasToolCalls({
additional_kwargs: { tool_calls: [{ id: "1" }] },
}),
true,
);
});
@@ -0,0 +1,108 @@
/**
* Shared tok/s helpers for the chat / roundtable speed pills.
*
* Live rate uses a rolling window with a virtual tail at "now" so a pause
* (no new characters) decays toward 0 instead of freezing on the last two
* samples. Final rate prefers provider `output_tokens` over the segment
* duration, and only after that model-call segment has ended.
*/
export const CHARS_PER_TOKEN = 2;
export const SAMPLE_THROTTLE_MS = 60;
export const ROLLING_WINDOW_MS = 1000;
export const MAX_SPEED_SAMPLES = 256;
/** Idle after last visible growth before we treat this model call as finished. */
export const GENERATION_IDLE_MS = 400;
export type SpeedSample = { at: number; chars: number };
export function estimateTokensFromChars(chars: number): number {
return Math.max(0, chars) / CHARS_PER_TOKEN;
}
export function charsToTokensPerSec(chars: number, elapsedMs: number): number {
if (elapsedMs <= 0) return 0;
return (estimateTokensFromChars(chars) / elapsedMs) * 1000;
}
export function usageTokensPerSec(
outputTokens: number,
durationMs: number,
): number {
if (outputTokens <= 0 || durationMs <= 0) return 0;
return (outputTokens / durationMs) * 1000;
}
/**
* Instantaneous tok/s over the last `windowMs`.
*
* The tail is always `(now, lastChars)`. If every sample is older than the
* window, the result is 0 — a generation pause must not keep the last
* observed rate pinned on screen.
*/
export function rollingTokensPerSec(
samples: SpeedSample[],
lastChars: number,
now: number,
windowMs: number = ROLLING_WINDOW_MS,
): number {
if (samples.length === 0 || windowMs <= 0) return 0;
const windowStart = now - windowMs;
let headIdx = samples.findIndex((sample) => sample.at >= windowStart);
if (headIdx === -1) {
return 0;
}
if (headIdx > 0) {
headIdx -= 1;
}
const head = samples[headIdx];
if (!head) return 0;
const elapsedMs = now - head.at;
if (elapsedMs <= 0) return 0;
return charsToTokensPerSec(Math.max(0, lastChars - head.chars), elapsedMs);
}
export function pushSpeedSample(
samples: SpeedSample[],
at: number,
chars: number,
): void {
samples.push({ at, chars });
if (samples.length > MAX_SPEED_SAMPLES) {
samples.splice(0, samples.length - MAX_SPEED_SAMPLES);
}
}
export function shouldFreezeGeneration(args: {
endedAt: number | null;
isLoading: boolean;
isLatestGenerating: boolean;
hasToolCalls: boolean;
grewThisTick: boolean;
lastGrewAt: number;
now: number;
outputTokens: number;
}): boolean {
if (args.endedAt != null) return false;
if (!args.isLatestGenerating) return true;
if (args.hasToolCalls && !args.grewThisTick) return true;
if (
args.outputTokens > 0 &&
args.now - args.lastGrewAt >= GENERATION_IDLE_MS
) {
return true;
}
return !args.isLoading;
}
export function messageHasToolCalls(message: {
tool_calls?: unknown[] | null;
additional_kwargs?: { tool_calls?: unknown[] } | null;
}): boolean {
const direct = message.tool_calls;
if (Array.isArray(direct) && direct.length > 0) return true;
const extra = message.additional_kwargs?.tool_calls;
return Array.isArray(extra) && extra.length > 0;
}
@@ -6,8 +6,6 @@ import {
XCircleIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Streamdown } from "streamdown";
import {
ChainOfThought,
ChainOfThoughtContent,
@@ -19,7 +17,10 @@ import { ShineBorder } from "@/components/ui/shine-border";
import { useI18n } from "@/core/i18n/hooks";
import { hasToolCalls } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
import {
SafeStreamdown,
streamdownPluginsWithWordAnimation,
} from "@/core/streamdown";
import { useSubtask } from "@/core/tasks/context";
import { explainLastToolCall } from "@/core/tools/utils";
import { cn } from "@/lib/utils";
@@ -126,12 +127,12 @@ export function SubtaskCard({
{task.prompt && (
<ChainOfThoughtStep
label={
<Streamdown
<SafeStreamdown
{...streamdownPluginsWithWordAnimation}
components={{ a: CitationLink }}
>
{task.prompt}
</Streamdown>
</SafeStreamdown>
}
></ChainOfThoughtStep>
)}
@@ -1,6 +1,6 @@
"use client";
import { DownloadIcon, Loader2 } from "lucide-react";
import { DownloadIcon, InfoIcon, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -13,8 +13,10 @@ import {
} from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
import { DEFAULT_UPLOAD_POLICY, useUploadPolicy } from "@/core/uploads";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
@@ -56,20 +58,111 @@ function kindOf(filename: string): PreviewKind {
return "binary";
}
function withReplacedExtension(url: string): string {
// Replace the path extension with .md, keeping any query string intact.
function withAppendedMarkdownExtension(url: string): string {
const [base, query] = url.split("?");
const appended = `${base}.md`;
return query ? `${appended}?${query}` : appended;
}
function withLegacyReplacedExtension(url: string): string {
// Compatibility with uploads converted before sidecars switched from
// `report.md` to the collision-safe `report.docx.md` naming scheme.
const [base, query] = url.split("?");
const i = base.lastIndexOf(".");
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
return query ? `${swapped}?${query}` : swapped;
}
type ArtifactPreviewResponse = {
path: string;
name: string;
total_bytes: number;
content: string;
truncated: boolean;
strategy: string;
stats?: Record<string, unknown> | null;
warnings?: string[];
};
type PreviewMeta = Pick<
ArtifactPreviewResponse,
"total_bytes" | "truncated" | "strategy" | "warnings"
>;
function parseTotalBytes(response: Response): number | undefined {
const contentRange = response.headers.get("Content-Range");
const rangeTotal = contentRange?.match(/\/(\d+)$/)?.[1];
if (rangeTotal) return Number(rangeTotal);
const contentLengthHeader = response.headers.get("Content-Length");
if (!contentLengthHeader) return undefined;
const contentLength = Number(contentLengthHeader);
return Number.isFinite(contentLength) ? contentLength : undefined;
}
/** Read a streamed response up to maxBytes and cancel the rest. */
async function readBoundedTextResponse(
response: Response,
maxBytes: number,
): Promise<{ content: string; totalBytes: number; truncated: boolean }> {
if (!response.body) throw new Error("浏览器不支持流式预览");
const reader = response.body.getReader();
const decoder = new TextDecoder();
const declaredTotal = parseTotalBytes(response);
let received = 0;
let content = "";
let completed = false;
while (received < maxBytes) {
const { done, value } = await reader.read();
if (done) {
completed = true;
break;
}
if (!value) continue;
const remaining = maxBytes - received;
const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
received += slice.byteLength;
content += decoder.decode(slice, { stream: received < maxBytes });
if (value.byteLength > remaining || received >= maxBytes) {
await reader.cancel();
break;
}
}
content += decoder.decode();
const totalBytes = declaredTotal ?? received;
return {
content,
totalBytes,
truncated: !completed || totalBytes > received,
};
}
function strategyLabel(strategy: string): string {
if (strategy.includes("sampling") || strategy.includes("sample")) {
return "流式统计 + 开头/中段/结尾分块采样";
}
if (strategy === "complete-text") return "完整文本";
if (strategy === "bounded-prefix") return "有界前缀";
if (strategy === "client-bounded-prefix") return "兼容模式有界前缀";
return strategy;
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KiB`;
return `${bytes} B`;
}
/**
* Fetch an auth-gated artifact (the artifact route requires an
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display.
*/
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
export function useAuthedObjectUrl(
url: string | null,
enabled: boolean,
requestFile: typeof apiFetch = apiFetch,
) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -81,9 +174,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
}
let cancelled = false;
let created: string | null = null;
const controller = new AbortController();
setLoading(true);
setError(null);
apiFetch(url)
requestFile(url, { signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
@@ -102,9 +196,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
});
return () => {
cancelled = true;
controller.abort();
if (created) URL.revokeObjectURL(created);
};
}, [url, enabled]);
}, [url, enabled, requestFile]);
return { objectUrl, loading, error };
}
@@ -122,47 +217,152 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
export function UploadedFilePreviewDialog({
filename,
fileUrl,
threadId,
virtualPath,
requestFile = apiFetch,
onDownload,
downloading = false,
open,
onOpenChange,
}: {
filename: string;
/** Absolute artifact URL for the original uploaded file. */
fileUrl: string;
/** Used by the bounded preview endpoint; optional for old/public messages. */
threadId?: string;
virtualPath?: string;
/** External skill links supply an unauthenticated fetch, avoiding token leakage. */
requestFile?: typeof apiFetch;
/** Authenticated original-file download used by retrieved-file cards. */
onDownload?: () => void | Promise<void>;
downloading?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { data: uploadPolicy } = useUploadPolicy();
const effectivePolicy = uploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const maxPreviewChars = Math.max(
256,
Math.min(
32_000,
effectivePolicy.large_file.max_result_chars,
effectivePolicy.large_file.hard_max_result_chars,
),
);
const kind = useMemo(() => kindOf(filename), [filename]);
const ext = useMemo(() => extOf(filename), [filename]);
// image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled);
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
// text / office → fetch text (office reads the converted sibling .md).
const textUrl = useMemo(
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
const textUrls = useMemo(
() =>
kind === "office"
? Array.from(
new Set([
withAppendedMarkdownExtension(fileUrl),
withLegacyReplacedExtension(fileUrl),
]),
)
: [fileUrl],
[kind, fileUrl],
);
const previewPaths = useMemo(
() =>
virtualPath
? kind === "office"
? Array.from(
new Set([
withAppendedMarkdownExtension(virtualPath),
withLegacyReplacedExtension(virtualPath),
]),
)
: [virtualPath]
: [],
[kind, virtualPath],
);
const [text, setText] = useState<string | null>(null);
const [previewMeta, setPreviewMeta] = useState<PreviewMeta | null>(null);
const [textLoading, setTextLoading] = useState(false);
const [textError, setTextError] = useState<string | null>(null);
useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false;
const controller = new AbortController();
setTextLoading(true);
setTextError(null);
setText(null);
apiFetch(textUrl)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((body) => {
if (!cancelled) setText(body);
})
setPreviewMeta(null);
const load = async () => {
if (threadId && previewPaths.length > 0) {
for (const previewPath of previewPaths) {
try {
const endpoint = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/artifact-preview?path=${encodeURIComponent(
previewPath,
)}&max_chars=${maxPreviewChars}`;
const response = await apiFetch(endpoint, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = (await response.json()) as ArtifactPreviewResponse;
const rawContent = String(body.content ?? "");
const content = rawContent.slice(0, maxPreviewChars);
if (!cancelled) {
setText(content);
setPreviewMeta({
total_bytes: Number(body.total_bytes) || 0,
truncated:
Boolean(body.truncated) || rawContent.length > content.length,
strategy: String(body.strategy || "bounded-prefix"),
warnings: Array.isArray(body.warnings) ? body.warnings : [],
});
}
return;
} catch {
// Try a legacy Office sidecar, then fall through to the bounded
// Range fallback during a rolling upgrade or public share.
}
}
}
const maxBytes = Math.max(4_096, maxPreviewChars * 4);
let lastError: Error | null = null;
for (const textUrl of textUrls) {
try {
const response = await requestFile(textUrl, {
signal: controller.signal,
headers: { Range: `bytes=0-${maxBytes - 1}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bounded = await readBoundedTextResponse(response, maxBytes);
if (!cancelled) {
const content = bounded.content.slice(0, maxPreviewChars);
setText(content);
setPreviewMeta({
total_bytes: bounded.totalBytes,
truncated:
bounded.truncated || bounded.content.length > content.length,
strategy: "client-bounded-prefix",
warnings: [
"采样预览接口暂不可用,当前仅显示受限的文件开头,未读取剩余内容。",
],
});
}
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw lastError ?? new Error("无法读取文件预览");
};
void load()
.catch((err: unknown) => {
if (!cancelled)
setTextError(err instanceof Error ? err.message : String(err));
@@ -172,8 +372,9 @@ export function UploadedFilePreviewDialog({
});
return () => {
cancelled = true;
controller.abort();
};
}, [open, kind, textUrl]);
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
@@ -192,21 +393,36 @@ export function UploadedFilePreviewDialog({
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
minHeight: "min(420px, calc(100vh - 2rem))",
height: "min(720px, calc(100vh - 2rem))",
maxHeight: "min(720px, calc(100vh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
{onDownload ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3" />
{t.common.download}
</Button>
) : (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
)}
</DialogDescription>
</DialogHeader>
@@ -215,7 +431,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<div className="flex items-center justify-center">
<img
@@ -229,7 +445,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<iframe
src={objectUrl}
@@ -241,22 +457,45 @@ export function UploadedFilePreviewDialog({
textLoading ? (
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
<div className="space-y-3">
{previewMeta &&
(previewMeta.truncated ||
previewMeta.strategy !== "complete-text" ||
(previewMeta.warnings?.length ?? 0) > 0) && (
<div className="border-amber-300/70 bg-amber-50/70 text-amber-950 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 rounded-lg border px-3 py-2 text-xs">
<div className="flex items-start gap-2 font-medium">
<InfoIcon className="mt-0.5 size-3.5 shrink-0" />
<span>
{previewMeta.truncated ? "当前为截断/采样预览" : "预览读取方式"}
:{strategyLabel(previewMeta.strategy)};文件大小 {formatBytes(previewMeta.total_bytes)},本次最多展示 {maxPreviewChars.toLocaleString()} 个字符。
</span>
</div>
{previewMeta.warnings?.map((warning) => (
<p key={warning} className="mt-1 pl-5 opacity-80">
{warning}
</p>
))}
</div>
)}
{renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
)}
</div>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
)}
</div>
</DialogContent>
@@ -264,15 +503,34 @@ export function UploadedFilePreviewDialog({
);
}
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
fileUrl: string;
label: string;
onDownload?: () => void | Promise<void>;
downloading?: boolean;
}) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<p>暂不支持在线预览,请下载原文件查看。</p>
{onDownload ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
</Button>
) : (
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
)}
</div>
);
}
@@ -7,29 +7,37 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
import { getUsageMetadata } from "@/core/messages/usage";
import { textOfMessage } from "@/core/threads/utils";
/**
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
* speed indicator for mixed Chinese/English replies without dragging in a
* real tokeniser.
*/
const CHARS_PER_TOKEN = 2;
/** Sample throttle while a message is streaming. */
const SAMPLE_THROTTLE_MS = 60;
import {
GENERATION_IDLE_MS,
SAMPLE_THROTTLE_MS,
charsToTokensPerSec,
messageHasToolCalls,
pushSpeedSample,
rollingTokensPerSec,
shouldFreezeGeneration,
usageTokensPerSec,
type SpeedSample,
} from "./speed-metrics";
/**
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
* Per-AI-message metric: live rolling rate during that model-call segment,
* frozen once the segment ends (not when the whole agent turn ends).
*/
export interface MessageMetric {
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
tokensPerSec: number;
/**
* Wall-clock duration from when the message first appeared with text to
* either *now* (active) or to when the stream ended (frozen).
* Wall-clock duration of this model-call segment: first visible character
* → last visible character (tool-wait after the call is excluded).
*/
durationMs: number;
/** True while this is the currently streaming message. */
/** True while this message is still generating visible tokens. */
isActive: boolean;
/**
* True while this message is the live generator but the rolling window
* has seen no new characters — the pill should not keep a stale tok/s.
*/
isPaused: boolean;
/**
* Whether the speed value was computed from real usage_metadata output
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
@@ -45,15 +53,12 @@ export interface MessageMetric {
isTurnFinal: boolean;
}
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastGrewAt: number;
samples: SpeedSample[];
lastChars: number;
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
hadUsage: boolean;
};
function isAiMessage(message: Message): boolean {
@@ -82,11 +87,12 @@ function messageText(message: Message): string {
*
* Recording strategy:
* - Whenever a message renders with text, we anchor `firstSeenAt`.
* - On every text-length change we push a sample and recompute the rate.
* - When `isLoading` flips to false we mark *all* still-active messages as
* ended so subsequent renders return a frozen duration.
* - Token totals come from usage_metadata when present (most accurate);
* otherwise we fall back to char-count / 2.
* - On every text-length change we push a sample.
* - The segment freezes when this model call ends (next AI message, tool
* calls, usage + idle, or the run stops) so tool-wait is not in the
* denominator.
* - Live rate is a rolling window that decays to 0 on pause.
* - `usage_metadata.output_tokens` only corrects the frozen final value.
*/
export function useMessageMetrics(
messages: Message[],
@@ -104,7 +110,8 @@ export function useMessageMetrics(
// synchronously inside an effect so React isn't tracking the map identity.
useEffect(() => {
const now = performance.now();
let activeId: string | null = null;
const grewIds = new Set<string>();
let latestId: string | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
@@ -118,50 +125,62 @@ export function useMessageMetrics(
entry = {
firstSeenAt: now,
endedAt: null,
lastGrewAt: now,
samples: [{ at: now, chars: text.length }],
lastChars: text.length,
hadUsage: false,
};
timingRef.current.set(id, entry);
}
// Track the most recent AI message as the active one (while streaming).
activeId = id;
latestId = id;
// Sample only when actively streaming OR when content actually grew —
// throttle to keep arrays bounded.
const grew = text.length !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars: text.length });
entry.lastChars = text.length;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
if (grew) {
grewIds.add(id);
entry.lastGrewAt = now;
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
pushSpeedSample(entry.samples, now, text.length);
lastSampleAtRef.current = now;
}
lastSampleAtRef.current = now;
} else if (grew) {
// Still update lastChars so the next render captures it eventually.
entry.lastChars = text.length;
}
if (getUsageMetadata(message)) {
entry.hadUsage = true;
}
}
// When streaming stops, freeze any non-ended entry to the current time.
if (!isLoading) {
for (const entry of timingRef.current.values()) {
if (entry.endedAt === null) {
entry.endedAt = now;
}
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const entry = timingRef.current.get(id);
if (!entry) continue;
const grewThisTick = grewIds.has(id);
if (grewThisTick && entry.endedAt != null && isLoading) {
entry.endedAt = null;
continue;
}
const usage = getUsageMetadata(message);
if (
shouldFreezeGeneration({
endedAt: entry.endedAt,
isLoading,
isLatestGenerating: id === latestId,
hasToolCalls: messageHasToolCalls(message),
grewThisTick,
lastGrewAt: entry.lastGrewAt,
now,
outputTokens: usage?.outputTokens ?? 0,
})
) {
entry.endedAt = entry.lastGrewAt;
}
}
lastActiveIdRef.current = activeId;
lastActiveIdRef.current = latestId;
setTick((v) => v + 1);
}, [messages, isLoading]);
// Drive a slow tick while streaming so live durations refresh smoothly.
// Drive a slow tick while streaming so live durations / pause decay refresh.
useEffect(() => {
if (!isLoading) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
@@ -198,45 +217,36 @@ export function useMessageMetrics(
const entry = timingRef.current.get(id);
if (!entry) continue;
const isActive =
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
const isActive = isLoading && id === lastActiveIdRef.current;
let tokensPerSec = 0;
let fromUsage = false;
let isPaused = false;
const usage = getUsageMetadata(message);
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
fromUsage = true;
} else {
// Char-count fallback. Uses the rolling window during streaming so a
// mid-stream snapshot reflects current speed rather than average since
// the start.
if (isActive && entry.samples.length >= 2) {
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
}
}
if (entry.endedAt != null) {
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
fromUsage = true;
} else if (durationMs > 0) {
// Finished message without usage data: average over full duration.
const chars = entry.lastChars;
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
}
} else {
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
isPaused =
isActive &&
tokensPerSec <= 0 &&
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
}
out.set(id, {
tokensPerSec,
durationMs,
isActive,
isPaused,
fromUsage,
isTurnFinal: turnFinalIds.has(id),
});
@@ -25,6 +25,7 @@ export function ModelNameSelect({
className,
triggerClassName,
defaultLabel = "默认模型",
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
}: {
value: string | null;
onChange: (modelName: string | null) => void;
@@ -32,6 +33,7 @@ export function ModelNameSelect({
className?: string;
triggerClassName?: string;
defaultLabel?: string;
title?: string;
}) {
const { models } = useModels();
return (
@@ -42,7 +44,7 @@ export function ModelNameSelect({
>
<SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
title={title}
>
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
@@ -0,0 +1,86 @@
import {
FileText,
MessageSquareText,
Sparkles,
type LucideIcon,
} from "lucide-react";
import {
MY_PROJECT_CONVERSATION_LABELS,
type MyProjectConversationType,
} from "@/core/threads/project-metadata";
import { cn } from "@/lib/utils";
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
strategy:
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
solution:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
};
const ICONS: Record<MyProjectConversationType, LucideIcon> = {
qa: MessageSquareText,
strategy: Sparkles,
solution: FileText,
};
const ICON_STYLES: Record<MyProjectConversationType, string> = {
qa: "bg-sky-50 text-sky-600 dark:bg-sky-950/70 dark:text-sky-300",
strategy:
"bg-violet-50 text-violet-600 dark:bg-violet-950/70 dark:text-violet-300",
solution:
"bg-emerald-50 text-emerald-600 dark:bg-emerald-950/70 dark:text-emerald-300",
};
export function MyProjectThreadIcon({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
const Icon = ICONS[type];
const label = `我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`;
return (
<span
aria-label={label}
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-md",
ICON_STYLES[type],
className,
)}
role="img"
title={label}
>
<Icon aria-hidden="true" className="size-3.5" />
</span>
);
}
export function MyProjectThreadBadge({
type,
projectName,
className,
}: {
type: MyProjectConversationType;
projectName?: string | null;
className?: string;
}) {
const typeLabel = `${MY_PROJECT_CONVERSATION_LABELS[type]}项目`;
const ownerLabel = projectName?.trim() || "我的项目";
return (
<span
className={cn(
"inline-flex min-w-0 max-w-full items-center gap-1 self-start rounded border px-1.5 py-0.5 text-[9px] font-medium leading-none",
BADGE_STYLES[type],
className,
)}
title={`来自我的项目 · ${typeLabel} · ${ownerLabel}`}
>
<span className="shrink-0">{typeLabel}</span>
<span aria-hidden="true" className="opacity-55">·</span>
<span className="min-w-0 truncate">{ownerLabel}</span>
</span>
);
}
@@ -0,0 +1,49 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
extractContentFromMessage,
isHiddenFromUIMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
const SUMMARY_MAX_LENGTH = 80;
export type QuestionItem = {
anchorId: string;
summary: string;
};
/** Preserve the raw message index for historical messages without an id. */
export function getQuestionAnchorId(message: Pick<Message, "id">, index: number): string {
return `message-${message.id ?? index}`;
}
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > SUMMARY_MAX_LENGTH ? `${text.slice(0, SUMMARY_MAX_LENGTH)}…` : text;
}
/** Index only real, visible questions, using the same visibility policy as chat. */
export function buildQuestionItems(messages: Message[]): QuestionItem[] {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human" || isHiddenFromUIMessage(message)) return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (summary) items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}
@@ -11,68 +11,21 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
extractContentFromMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
import { cn } from "@/lib/utils";
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
import { Tooltip } from "./tooltip";
export { getQuestionAnchorId } from "./question-navigator-data";
/** Width of the expanded floating panel, in px. */
const PANEL_WIDTH = 300;
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
const PANEL_GUTTER_MARGIN = 24;
/** Max characters kept in a question summary card. */
const SUMMARY_MAX_LENGTH = 80;
/** Questions needed before the navigator shows up at all. */
const MIN_QUESTIONS = 2;
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
/**
* Stable DOM anchor id for a message. Prefers the server-side message id and
* falls back to the render index so messages without ids still get a target
* (requirement: `message-${index}` style fallback).
*/
export function getQuestionAnchorId(
message: Pick<Message, "id">,
index: number,
): string {
return `message-${message.id ?? index}`;
}
/**
* Turn the raw markdown of a user question into a short plain-text summary:
* strips upload/artifact/page-context envelopes and common Markdown syntax,
* collapses whitespace, and truncates with an ellipsis.
*/
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
if (text.length > SUMMARY_MAX_LENGTH) {
return `${text.slice(0, SUMMARY_MAX_LENGTH)}…`;
}
return text;
}
type QuestionItem = {
anchorId: string;
summary: string;
};
/** Walk up from a message anchor to the element that actually scrolls. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
@@ -128,16 +81,7 @@ export function QuestionNavigator({
const [sheetOpen, setSheetOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const questions = useMemo(() => {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human") return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (!summary) return;
items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}, [messages]);
const questions = useMemo(() => buildQuestionItems(messages), [messages]);
const visible = questions.length >= MIN_QUESTIONS;
@@ -47,9 +47,14 @@ import {
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -63,7 +68,9 @@ import {
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import {
isBootstrapThread,
isHiddenFromRecentChats,
@@ -75,9 +82,14 @@ import {
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
import {
mergeRecentWorkspaceItems,
useRecentStrategyProjects,
} from "@/strategy-selection/recent-projects";
export function RecentChatList({
compact = false,
@@ -92,7 +104,8 @@ export function RecentChatList({
}) {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const [internalCollapsed, setInternalCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
@@ -109,9 +122,10 @@ export function RecentChatList({
useParams<{
thread_id: string;
agent_id?: string;
}>();
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
}>();
const { data: threads = [] } = useThreads();
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
@@ -134,15 +148,15 @@ export function RecentChatList({
});
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
} else if (threads[threadIndex - 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
}
}
void router.push(nextThreadPath);
}
},
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
);
const handleRenameClick = useCallback(
@@ -195,15 +209,19 @@ export function RecentChatList({
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
const visibleThreads = sortThreadsPinnedFirst(
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
if (visibleThreads.length === 0) {
return null;
}
const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
const visibleThreads = sortThreadsPinnedFirst(
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
const projectThreadNames = useProjectThreadNames(visibleThreads);
const recentItems = mergeRecentWorkspaceItems(
visibleThreads,
recentStrategyProjects,
);
if (recentItems.length === 0) {
return null;
}
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems;
return (
<>
<SidebarGroup
@@ -224,9 +242,9 @@ export function RecentChatList({
: t.sidebar.demoChats}
</span>
{compact && (
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
{visibleThreads.length}
</span>
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
{recentItems.length}
</span>
)}
</span>
{!compact && (
@@ -242,32 +260,84 @@ export function RecentChatList({
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{shownThreads.map((thread) => {
const href = listPathOfThread(thread);
{shownItems.map((item) => {
if (item.kind === "strategy-project") {
const { project } = item;
const href = strategyProjectWorkbenchPath(
"/page/strategy/qa/projects",
project.id,
);
return (
<SidebarMenuItem
key={`strategy-project:${project.id}`}
className="group/side-menu-item"
>
<SidebarMenuButton
isActive={href === pathname}
asChild
className={cn(
"h-auto min-h-11 rounded-lg px-3 py-1.5 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]",
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
compact &&
"min-h-11 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
)}
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href}
onClick={() => {
if (isMobile) setOpenMobile(false);
}}
>
<MyProjectThreadIcon type="strategy" />
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
<span className="w-full truncate">
{desensitize(project.title)}
</span>
<MyProjectThreadBadge
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={project.title}
type="strategy"
/>
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
const { thread } = item;
const href = listPathOfThread(thread, pathname);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
<SidebarMenuItem
key={`thread:${thread.thread_id}`}
className="group/side-menu-item"
>
<SidebarMenuButton
isActive={isActive}
asChild
className={
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none" +
(compact
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
: "")
}
className={cn(
"h-7 rounded-lg px-3 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]",
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
compact &&
"h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
projectInfo && "h-auto min-h-11 py-1.5",
)}
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href}
href={href}
onClick={() => { if (isMobile) setOpenMobile(false); }}
>
{isBoot ? (
<Bot
@@ -279,8 +349,10 @@ export function RecentChatList({
}
aria-label="智能体创建会话"
/>
) : (
compact && (
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
compact && (
<MessageSquare
className={
"size-3.5 shrink-0 " +
@@ -294,9 +366,23 @@ export function RecentChatList({
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
<span
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -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>
);
}
Loaded 100 of 2002 files, more files were not shown because too many files have changed in this diff. Show more