Compare commits

...
705 Commits
Author SHA1 Message Date
hlh 9e14c2e289 禁用了知识库技能,添加了知识库memory 2026-06-08 18:35:05 +08:00
董宏凯 273503ddb9 Merge branch 'dhk-1' into 'master'
样式修改

See merge request knowledge-base-bj/deerflow-server!101
2026-06-07 15:54:16 +00:00
董宏凯 ce431ee155 样式修改 2026-06-07 23:53:52 +08:00
董宏凯 12844e341c Merge branch 'dhk-1' into 'master'
feat(roundtable): 业务链条「发布为公共」+ 我的/公共筛选

See merge request knowledge-base-bj/deerflow-server!100
2026-06-07 14:56:48 +00:00
董宏凯andCursor eaf1e075f2 fix(roundtable): 本地 SQLite 路径与离线并行席位禁用 web_search
将 backend 配置中的 SQLite/checkpointer 改为相对路径以便本地开发,并在离线部署下禁止并行席位使用 web_search,统一走技能检索。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 22:55:50 +08:00
董宏凯andClaude Opus 4.8 7affaaf7c0 feat(roundtable): 业务链条「发布为公共」+ 我的/公共筛选
后端:roundtable_chains 加 is_public 列;list_chains 支持 scope=mine|public(public=所有已发布,任何账号可查看/选入研讨);get_chain 放开公共可见;响应加 is_public/is_mine;update 支持 is_public(发布/取消,owner 限定,编辑/删除仍仅 owner)。迁移 20260608_03 接在合并 head 20260608_02 之后,保证单一 alembic head(多 head 会让启动自动迁移失败、静默降级 create_all → 列缺失 500)。

前端:listChains(scope) + setChainPublic;BusinessChain 加 isPublic/isMine;列表弹窗与研讨选择器加「我的/公共」筛选 tab;列表卡片发布/取消发布 + 公共徽标(别人的只读);配置页头部加发布按钮(已保存且本人)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:30:23 +08:00
董宏凯 90f3564d0b Merge branch 'dhk-1' into 'master'
fix(roundtable): 后台任务临时跳过 Step3 报告生成

See merge request knowledge-base-bj/deerflow-server!99
2026-06-07 13:28:21 +00:00
董宏凯 05d14bb364 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-07 21:25:54 +08:00
董宏凯andClaude Opus 4.8 7b8c11852e feat(roundtable): 圆桌 Step2 分层 DAG 编排(并行/沙箱双模式 + 流程图 + 按需取全文)
数据模型/编辑器:链条加 stages 分层(前后端 + alembic 20260607_03);BusinessChainEditorPage 竖列 Stage 编辑 + 底部只读流程图预览;lib/dag-types + chain-stages 纯逻辑(含单测)。

调度(runDagOrchestration,方案 A 总控驱动):每个 stage 由总控经 agent_orchestration 派活给计划钉死的席位,批内 Promise.all 并行、批间 barrier 串行;末尾交回 runOrchestration 收口。抑制 leader 预广播 + 干净席位任务,根治『席位模仿派活』。

执行子模式:快速(双列·产物走正文·禁写文件 seat_parallel)/ 沙箱(单列·写 .md·沙箱跟随预览 seat);统一禁 bash(防 .py 脚本),提示词禁 .py。

显示:头像条 + 两列/单列 + ref 全量累积 + 节流 flush(仅选中渲染);DAG 流程图弹窗(按 stage 分列,节点随执行点亮,只到共识);沙箱跟随当前席位(防错位 + 修第一个席位自动开沙箱)。

按需取全文:新增 harness 只读工具 read_peer_delivery(从 run context 取网关注入的前序席位完整交付),席位默认看摘要、需要时取全文。

修:宽表溢出、并行自动下滑、data-lov-id Fragment 警告等。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:20:58 +08:00
董宏凯andClaude Opus 4.8 5164fd362c feat: 重新设计浏览器标签页图标(智能助手中心)
玫红渐变磁贴 + 白色对话气泡 + 气泡内 AI 闪光星 + 角落小闪光,围绕「助手 / 多种智能工具」主题;并在 index.html 补上 favicon 链接。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:20:58 +08:00
董宏凯andClaude Opus 4.8 2e689e4482 docs(roundtable): 圆桌 DAG 编排 + 并行执行显示分步需求文档
锁定三项设计决策:分层 Stage(非自由连线)、并行跳过 leader 前端直派、主区固定 2 列(窄屏降 1 列)。含数据模型/编辑器交互/并行调度/禁写文件/高性能流式渲染/分步实现计划(Phase 1-8)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:20:58 +08:00
董宏凯andCursor 1ee2e6ca0a perf(roundtable): 轻量 thread 接口降低广播与席位状态核对开销
新增 messages/append 与 last-ai-message 接口,圆桌广播和总控派活轮改走服务端轻量读写,避免 loopback 传输整份 state。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 21:20:58 +08:00
董宏凯andCursor 924c8f2fac fix(roundtable): 优化 Step2 总控派活稳定性与并发编排
限制总控工具白名单、关闭圆桌记忆注入、综合轮 synthesis_mode 与编排 epoch 防双发,并优化广播异步化及 Postgres checkpointer 连接池。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 21:20:58 +08:00
fengxiangdebaozi 9c9c912657 refactor(strategy): 任务分析页移除演示数据并接入真实接口
舆情分析与规划任务分析看板统一改为后端任务列表拉取,去掉 mock/演示切换,并修正 running 列状态与日期筛选交互。
2026-06-06 20:23:58 +08:00
董宏凯 024c8ee5a1 Merge branch 'ji' into 'master'
feat: 重新设计浏览器标签页图标(智能助手中心)

See merge request knowledge-base-bj/deerflow-server!97
2026-06-06 01:21:19 +00:00
董宏凯 2e7985639e Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-06 09:19:15 +08:00
AdministratorandClaude Opus 4.8 e7c745a512 feat: 排行榜日快照分析 + 数据库连接池/索引调优
- 排行榜改为按天预计算快照(admin_leaderboard_daily_stats),请求只读取合并,重统计移至后台调度;过去日不可变、当天 TTL 刷新,避免在线聚合长时间占用数据库
- 为 runs/tool_call_metrics/llm_call_metrics/threads_meta 增加面向时间段+用户聚合的复合索引(model __table_args__ + migration 20260608_01)
- 连接池可调:DatabaseConfig 新增 max_overflow / pool_timeout,接入 postgres/mysql 引擎;config.yaml 上调 pool_size=20/max_overflow=20
- 新增手动执行的索引脚本 scripts/sql/leaderboard_indexes_mysql.sql(幂等,适用于已存在数据、create_all 不补索引且 alembic 关闭的部署)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:52:24 +08:00
董宏凯 1b75887615 Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-05 23:47:07 +08:00
董宏凯andClaude Opus 4.8 a1bcc6eed4 feat: 重新设计浏览器标签页图标(智能助手中心)
玫红渐变磁贴 + 白色对话气泡 + 气泡内 AI 闪光星 + 角落小闪光,围绕「助手 / 多种智能工具」主题;并在 index.html 补上 favicon 链接。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:45:28 +08:00
fengxiangdebaozi 0bf6285287 fix(roundtable): 后台任务临时跳过 Step3 报告生成
后台挂起路径在真模型下仍无法稳定写出报告 HTML,先只跑完 Step2 研讨并正确设置 furthest_step;测试可通过 enable_report 覆盖验证报告链路。
2026-06-05 23:41:28 +08:00
Administrator 4df71f32a8 fix: use sqlite backend config 2026-06-05 22:44:21 +08:00
fengxiangdebaozi 44b7f9fa99 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-05 22:40:44 +08:00
fengxiangdebaozi 03329387b3 样式修改 2026-06-05 22:22:38 +08:00
吉鑫升 e5ba898fc7 hide user leaderboard entry 2026-06-05 22:11:31 +08:00
吉鑫升 a41b5a6d4c fix: improve agent handoff and leaderboard scheduling 2026-06-05 21:46:04 +08:00
董宏凯 cbbde887d4 Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-05 21:17:31 +08:00
董宏凯 89724d6e4d Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-server!96
2026-06-05 13:17:03 +00:00
董宏凯 6d5325d444 代码修改 2026-06-05 21:16:35 +08:00
吉鑫升 db1b32612b fix: run leaderboard stats in background 2026-06-05 21:15:20 +08:00
吉鑫升 f4ccef8c70 fix: optimize leaderboard and agent handoff 2026-06-05 21:06:23 +08:00
董宏凯 99aa064e6c Merge branch 'ji' into 'master'
Ji

See merge request knowledge-base-bj/deerflow-server!95
2026-06-05 12:36:02 +00:00
吉鑫升 24a5ca0c06 Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-05 20:07:03 +08:00
吉鑫升 6eda572f31 tijiaodaim 2026-06-05 20:06:55 +08:00
董宏凯 7b6dd9ba6f Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-05 19:56:51 +08:00
董宏凯 1341767692 Merge branch 'dhk-1' into 'master'
feat: 多智能体会商草稿分享(import 复制 / view 公开结果页)

See merge request knowledge-base-bj/deerflow-server!94
2026-06-05 11:48:08 +00:00
董宏凯andClaude Opus 4.8 f845805706 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
解决 StrategyRoutes.tsx import 冲突:保留双方各自唯一的页面导入(AgentsPage/ChatPage/ChatsPage + AdminUserLeaderboardPage/SettingsPage),去掉与下方重复的 13 个 import。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:46:19 +08:00
董宏凯andClaude Opus 4.8 b2132dd668 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
解决 artifact 预览组件冲突:mermaid 渲染(dhk-1) 与 markdown 锚点跳转(master) 取并集;package-lock.json 按 master 删除(项目用 pnpm)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:43:02 +08:00
fengxiangdebaozi fc5eaa2941 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-05 19:28:19 +08:00
fengxiangdebaozi 045bb11abf 样式修改 2026-06-05 19:28:09 +08:00
董宏凯 9a31155581 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-05 19:00:28 +08:00
董宏凯andCursor 9e510ce088 feat: 优化会商公开分享页,展示总控结论与各席位交付
公开分享页拆分为落地页与独立报告页,后端补充 consensus_text 与 seat_deliveries 字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 19:00:13 +08:00
fengxiangdebaozi a642ba91fa Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-05 18:58:37 +08:00
fengxiangdebaozi b0fdeab995 代码修改 2026-06-05 18:58:28 +08:00
吉鑫升 1bf1fc6512 增加依赖 2026-06-05 17:36:53 +08:00
吉鑫升 392373b4b7 chore: add qwen model config 2026-06-05 17:32:53 +08:00
吉鑫升 f3def88342 feat: add sqlite checkpoint fallback switch 2026-06-05 17:28:44 +08:00
吉鑫升 690d09a3d1 feat: add workspace view mode settings 2026-06-05 17:21:49 +08:00
吉鑫升 9471e72171 feat: add checkpoint postgres migration admin 2026-06-05 17:16:31 +08:00
g zy 9ebde3b315 Merge branch 'gzy_iframedf' into 'master'
feat: iframe传 文件进入

See merge request knowledge-base-bj/deerflow-server!93
2026-06-05 07:10:59 +00:00
吉鑫升andClaude Opus 4.8 eaa061dd06 feat: 新增技能置顶功能
- 后端:新增 PUT /skills/custom/{name}/featured 接口,在 usage 中实现 set_featured 与 featured 字段(与 pinned 正交,仅影响排序)
- 前端:新增 setSkillFeatured API、置顶/取消置顶操作及中英文文案
- ScheduledTasksPage:优化助手选择下拉框的宽度与文本换行
- config.yaml:整理 log_system 配置块

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:02:50 +08:00
ZyGuo 5f6ab41798 feat: iframe传 文件进入 2026-06-05 14:15:42 +08:00
董宏凯 69c28e3563 代码修改 2026-06-05 13:04:44 +08:00
吉鑫升andClaude Opus 4.8 b117bb1dc2 Revert skill list layout and tune leaderboard report
- Revert skill settings to vertical list layout with pinned sorting
- Default leaderboard range 30d -> 7d, hide stats while report generating
- Reframe leaderboard report prompt as user-behavior analysis (cache v5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:34:39 +08:00
董宏凯 9c9330114f Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-05 11:20:22 +08:00
董宏凯andCursor 0fd314632a feat: 多智能体会商草稿分享(import 复制 / view 公开结果页)
支持会商草稿生成分享码、导入副本与公开只读结果页,并在侧边栏展示最近会商记录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:19:09 +08:00
吉鑫升 0d91c74289 style: enlarge leaderboard report dialog 2026-06-05 11:17:09 +08:00
吉鑫升 d59f9e218f feat: enhance leaderboard AI report 2026-06-05 11:00:08 +08:00
fengxiangdebaozi 9bbd04d6cf 后台挂起功能优化 2026-06-05 10:59:56 +08:00
吉鑫升 22be6325a8 Resolve merge conflicts 2026-06-05 10:28:45 +08:00
吉鑫升 e96cc97462 Merge origin/master into ji 2026-06-05 10:04:11 +08:00
Administrator 92f5cb8fe3 enhance admin leaderboard and previews 2026-06-05 01:51:48 +08:00
Administrator a5897caec4 enhance leaderboard reporting and grouping 2026-06-05 00:02:05 +08:00
Administrator 88c83b4dd7 refine admin leaderboard experience 2026-06-04 23:41:19 +08:00
Administrator fe0ede178e polish admin leaderboard analytics 2026-06-04 21:09:36 +08:00
fengxiangdebaozi cca3d8eb41 字体大小修改 2026-06-04 20:49:32 +08:00
Administrator 5590f7d85a style: polish admin user leaderboard 2026-06-04 20:22:37 +08:00
fengxiangdebaozi 90bced3ed2 第二次生成结果后台挂起 2026-06-04 20:18:34 +08:00
fengxiangdebaozi 19399482e1 后台挂起 2026-06-04 20:00:58 +08:00
吉鑫升 7657c45aae Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-04 19:37:19 +08:00
吉鑫升 e6e0832c8a chore: commit local changes 2026-06-04 19:35:56 +08:00
Administrator 8f80b7c1ef feat: add admin user leaderboard analytics 2026-06-04 19:35:11 +08:00
董宏凯 c766e3fa3b Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!92
2026-06-04 11:10:18 +00:00
fengxiangdebaozi 788118ce3d 下载图片 2026-06-04 19:08:52 +08:00
fengxiangdebaozi 77f8ded1e6 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-04 18:50:50 +08:00
fengxiangdebaozi 9d863f63e5 代码提交 2026-06-04 18:50:31 +08:00
Administrator 1e4ef070cf fix: 调整提示词和 agent 管理入口 2026-06-04 18:45:16 +08:00
吉鑫升 c1336ad5b3 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	frontend-web/src/components/workspace/settings/memory-settings-page.tsx
#	frontend-web/src/core/auth/sync.tsx
2026-06-04 17:49:26 +08:00
吉鑫升 143e0478ef Merge remote-tracking branch 'origin/ji' into ji 2026-06-04 17:46:50 +08:00
吉鑫升 cbd07c9d1b chore: update ji branch work 2026-06-04 17:46:11 +08:00
AdministratorandClaude Opus 4.8 e3b0958c23 fix: drive logout from real 401s, not local storage changes
apiFetch now redirects to login only on a genuine backend 401 (auth endpoints excluded, only when previously logged in). AuthSyncWatcher no longer treats localStorage.clear()/unrelated writes as a logout, fixing spurious sign-outs (e.g. when uploading skills).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:41:46 +08:00
g zy b843ab6542 Merge branch 'gzy_iframedf' into 'master'
feat: 中断回复

See merge request knowledge-base-bj/deerflow-server!91
2026-06-04 09:03:00 +00:00
ZyGuo dccce9355d feat: 中断回复 2026-06-04 17:00:56 +08:00
吉鑫升 6c30e649e3 Merge remote-tracking branch 'origin/ji' into ji 2026-06-04 16:59:05 +08:00
吉鑫升 bcebc8d5fb feat: 支持智能体置顶配置 2026-06-04 16:58:42 +08:00
AdministratorandClaude Opus 4.8 225398972d fix: add missing axios dependency to frontend-web
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:53:07 +08:00
AdministratorandClaude Opus 4.8 87f764bfa9 feat: improve markdown rendering, artifact file preview, and public scheduled task page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:52:30 +08:00
fengxiangdebaozi 47217e9158 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-04 15:41:03 +08:00
fengxiangdebaozi 10334ddfe1 feat: AI writing history, Word export, roundtable dispatch chain 2026-06-04 15:40:15 +08:00
董宏凯 002e71f971 Merge branch 'dhk-1' into 'master'
样式修改

See merge request knowledge-base-bj/deerflow-server!89
2026-06-04 06:55:59 +00:00
吉鑫升andClaude Opus 4.8 1f2b1e3b8a feat: 删除提问时级联删除其回答,并移除回答上的删除按钮
- 删除按钮只保留在用户提问气泡上;AI 回答不再单独提供删除
- 后端 DELETE /messages/{id} 改为按整轮删除:从提问删到下一条提问之前,
  返回实际删除的 deleted_ids,保持 checkpoint 合法(无孤立工具结果/回答)
- 前端隐藏后端返回的整批 id,删除后即时消失
- 更新删除相关中英文案

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:34:52 +08:00
吉鑫升andClaude Opus 4.8 5d12616211 chore: 将登录失败次数上限从 5 提高到 100
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:34:42 +08:00
吉鑫升 9704eff9f7 Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-04 14:28:22 +08:00
吉鑫升andClaude Opus 4.8 362a8268e8 feat: 支持删除单条聊天消息(用户提问与模型回答)
- 后端新增 DELETE /api/threads/{thread_id}/messages/{message_id},从最新 checkpoint 的 messages 通道过滤掉指定 id 并写回新 checkpoint
- 前端 useDeleteMessage 钩子 + DeleteMessageButton(带确认框)组件
- 在用户问题气泡与助手回答工具栏上各加删除按钮,仅对已落库消息显示
- MessageList 本地隐藏已删消息并在切换会话时重置;只读/分享场景禁用删除
- 新增 deleteMessage 相关中英文案

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:28:11 +08:00
fengxiangdebaozi c0e3c5d692 样式修改 2026-06-04 14:19:53 +08:00
AdministratorandClaude Opus 4.8 94c02ded2a feat: add user preferences, agent name desensitization and related improvements
- Add user preferences persistence (model, migration, router, frontend core)
- Add agent name desensitization config
- Add agent_id to user custom prompts with migration
- Enhance agents persistence (base, memory, model, sql) and scheduler service
- Improve frontend agent cards/gallery, scheduled tasks, custom prompt menu
- Add pagination UI component; remove stale package-lock.json

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:40:18 +08:00
董宏凯 998bd0ce06 Merge branch 'zhujingyi' into 'master'
'feat:技能样式改成卡片式'

See merge request knowledge-base-bj/deerflow-server!87
2026-06-04 03:13:33 +00:00
fengxiangdebaozi 6b6465b5e3 Merge remote branch into zhujingyi 2026-06-04 11:12:45 +08:00
董宏凯 57da50b8d6 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!88
2026-06-04 03:10:18 +00:00
fengxiangdebaozi f87f6031de Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-04 11:09:29 +08:00
fengxiangdebaozi 5b686824c5 样式修改 2026-06-04 11:08:42 +08:00
g zy 95487d362f Merge branch 'gzy_iframedf' into 'master'
feat: 从iframe对接模型配置

See merge request knowledge-base-bj/deerflow-server!86
2026-06-04 03:05:26 +00:00
ZyGuo f496546cc0 feat: 从iframe对接模型配置 2026-06-04 11:03:50 +08:00
zjy 9423d7e892 'feat:技能样式改成卡片式' 2026-06-04 10:44:55 +08:00
fengxiangdebaozi 062f984262 ai写作位置调整 2026-06-04 10:40:16 +08:00
吉鑫升andClaude Opus 4.8 925675ab54 chore: update ji local changes
- frontend: scheduled tasks pages, system-settings api, workspace nav menu
- backend: system_settings router, app_config, log_system_config, config.yaml

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:18:32 +08:00
董宏凯 c05513ae1e Merge branch 'dhk-1' into 'master'
fix(roundtable): 文件卡截断、Step3 完整 HTML 与跨标签页登录体验

See merge request knowledge-base-bj/deerflow-server!85
2026-06-03 13:46:33 +00:00
fengxiangdebaozi f257ccb682 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-03 21:45:21 +08:00
fengxiangdebaozi d5b0bd4d31 fix(roundtable): 文件卡截断、Step3 完整 HTML 与跨标签页登录体验
第二步消息列表长文件名不再溢出卡片;第三步绘制结束后从沙箱回灌完整 HTML(修复 append 续写 stub 被截短),进入第三步时关闭第二步沙箱;HTML 预览与提示词强调 utf-8/中文。跨标签页切换账号改为 toast 提示而非强制刷新;方案总结智能体 SOUL 去掉「AI 自动生成」标签要求。
2026-06-03 21:44:46 +08:00
董宏凯 5026bd1887 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-03 21:07:17 +08:00
董宏凯 b0b0cf7b9e 地址修改 2026-06-03 21:07:12 +08:00
fengxiangdebaozi bf96cf4cb4 接口地址修改 2026-06-03 19:12:30 +08:00
吉鑫升 aeef1dca59 Merge remote-tracking branch 'origin/ji' into ji 2026-06-03 19:01:56 +08:00
吉鑫升 a1d460e220 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	frontend-web/src/components/workspace/messages/message-list-item.tsx
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py
#	offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py
2026-06-03 19:01:45 +08:00
吉鑫升 e2a962de32 chore: update ji local changes 2026-06-03 18:57:49 +08:00
董宏凯 dbf1f46fa1 Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-server!84
2026-06-03 10:57:34 +00:00
fengxiangdebaozi ce07a27b8d Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-03 18:54:45 +08:00
fengxiangdebaozi 8f6e08eb2f Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-03 18:54:26 +08:00
董宏凯 a4b14e4911 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!78
2026-06-03 09:38:24 +00:00
fengxiangdebaozi 56114562cb fix: ²¹Æë Step3 ɳÏä²Ù×÷²¢Îȶ¨д×÷³ÎÇåÁ÷³Ì
Ϊ roundtable µÚÈý²½ɳÏ䲹»ØÐ´°¿ڴò¿ªÓëÏÂÔزÙ×÷£¬²¢ÐÞÕýд×÷ÅäÖóÎÇåµÄÌύÁ÷³ÌÓëĬÈÏËزÄÀ´Դ£¬±ÜÃâÓû§Ôڶ࿨Ƭ³¡¾°ÏÂÌáǰÌύ»ò½øÈ벻·ûºÏԤÆڵļìË÷·¾¶¡£
2026-06-03 17:36:52 +08:00
董宏凯 ba7834f222 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-03 17:27:18 +08:00
董宏凯andClaude Opus 4.8 d49406d2f1 Merge branch 'master' into dhk-1
解决 8 处冲突:
- app.py:同时挂载 public_embed + html_page_favorites 两个 router
- .env:保留 dhk-1 的 CORS 配置(含 5174、ALLOW_ALL=0)
- message-list.tsx:并入双方新增(onSetupWriting* 解构 + contentClassName/fitMessageRowsToContent 类型)
- WorkspaceRoutes.tsx:采用 master 的路由结构(含 chats_iframe/IframeChatPage),补回 dhk-1 的 BusinessChainEditorPage 导入与 roundtable/chains 路由
- LoginPage.tsx:合并两套登录(master 的 token 换登录 + dhk-1 的 online-ticket),并集依赖数组
- ScheduledTasksPage.tsx:采用 master 的 HTML 定时任务表单(isHtmlTask 分支)
- AIWritingForm.tsx:保留 dhk-1 的 useWritingForm 重构,补回 master 的笔记来源 useState;WritingFormContext.materialSource 扩展支持 'notebook'
- useAIWritingStream.ts:保留 dhk-1 重构(仅 reducer/helpers,useAIWriting 已移至 AIWritingContext),不并入 master 在此文件的旧实现

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:26:52 +08:00
fengxiangdebaozi a81d8adc90 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-03 16:34:25 +08:00
fengxiangdebaozi 3eae4a0f45 代码修改 2026-06-03 16:34:17 +08:00
董宏凯 016f921cf0 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-03 16:28:34 +08:00
董宏凯 9c8230a867 跨域修改 2026-06-03 16:28:29 +08:00
董宏凯andCursor aea7724cdb 修复 embed 跨域并持久化圆桌 Step3 结果
- Gateway 默认 CORS 白名单增加 5174,embed 文档补充跨域配置说明
- 圆桌草稿新增 step3 快照字段与迁移,结果绘制 HTML 可随草稿恢复

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 16:27:42 +08:00
fengxiangdebaozi 79d87ad661 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-03 16:22:51 +08:00
fengxiangdebaozi ad4b27b7ca feat: 无界 ticket 登录、AI 写作配置确认与定时任务弹窗修复
无界 onlineTicket 对接 MaxKey 与 consumer 接口并用 sub 登录;新增 setup_writing 写作配置确认流;定时任务创建弹窗限制多行高度并支持滚动。
2026-06-03 16:20:45 +08:00
董宏凯andCursor 40ba81ee6a 新增 iframe 结果接口与圆桌 Step3 结果绘制
- 公开 API:GET /api/public/embed/sessions/{sessionId}/result 返回 messages、generated_text、generated_files
- 圆桌 Step3:内置 roundtable-report 智能体、report/init 与结果绘制 UI

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 13:57:37 +08:00
xu hd d0e71a8104 Merge branch 'xuhd' into 'master'
技能详情对话框新增文件浏览面板:支持预览、编辑、下载、复制路径

See merge request knowledge-base-bj/deerflow-server!83
2026-06-03 04:36:31 +00:00
xuhd f2d7d43e38 Merge remote-tracking branch 'origin/master' into xuhd 2026-06-03 11:55:44 +08:00
g zy d7cb19ee96 Merge branch 'gzy_iframedf' into 'master'
Gzy iframedf

See merge request knowledge-base-bj/deerflow-server!82
2026-06-03 03:51:09 +00:00
ZyGuo 9d425a19e8 fix: 去掉iframe页顶部栏 2026-06-03 11:49:18 +08:00
xuhdandClaude Sonnet 4.6 48f37df3d5 chore(env): 更新本地开发环境配置
- 开启 GATEWAY_CORS_ALLOW_ALL=1(本地开发跨域放行)
- 更新前端 .env.development

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:45:54 +08:00
fengxiangdebaozi ab876fc7d8 feat(ai-writing): 支持用户粘贴已有大纲并按结构生成
表单可提交 userOutline,后端解析/归一用户大纲并关联素材;补充大纲测试用例与右侧编辑器滑词/段落菜单文档。
2026-06-03 11:43:44 +08:00
xuhdandClaude Sonnet 4.6 712326387f merge: 合并 origin/xuhd,解决 message-list-item 和 WorkspaceRoutes 冲突
- message-list-item: 保留 EmbeddedPageContext + 消息内联编辑两套功能
- WorkspaceRoutes: 保留 IframeChatPage + NotebookDetailPageWithKey

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:41:49 +08:00
AdministratorandClaude Opus 4.8 133054e2dd feat(skills/settings): 技能复制 + 设置持久化等会话前改动
- 技能复制:新增 POST 复制接口(_rewrite_skill_frontmatter_name 重写 frontmatter name + 校验 + 去重),前端 skills api/hooks 与技能设置页配套
- 设置持久化:lastAgentId、线程 RAG 模式锁(settings/index、local)
- i18n(en/zh/types)、agent-card/input-box/message-list 等页面文案与交互微调
- scheduler/element.py 小修

(本批为会话前已存在的工作区改动,按要求一并提交;构建产物 backend.zip 未提交)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:47 +08:00
AdministratorandClaude Opus 4.8 adcc5a358b feat(scheduled-tasks): 新增 HTML 页面定时任务(生成/预览/收藏/技能)
后端:
- 新增 task_kind=html_page 任务类型,与普通定时任务并存,配置存 execution_context(无需迁移)
- 执行流水线:智能体检索数据 -> 选定模型渲染 HTML -> 清洗/校验 -> 失败自动修复 -> 完整性闸门(缺 doctype/html/head/body/title 或残留脚本即失败) -> 归档 index.html -> 落库
- 新增 scheduled_html_pages / html_page_favorites 表与 store;收藏去重(不可重复)、可删除、收藏时提取可复用风格
- HTML 预览接口(鉴权 + 公开)、收藏接口
- 渲染注入已启用的 HTML 技能(metadata.kind=html_page);全程使用用户所选模型,去除硬编码 GLM-5
- 定时任务跳过 TitleMiddleware,避免标题生成回退默认模型

前端:
- 创建/编辑弹窗增加任务类型与 HTML 配置(页面要求/限制/参考收藏页/模型下拉)
- 运行详情页与公开页 iframe 沙箱预览 HTML;收藏按钮(已收藏禁用)
- 参考收藏页可选可删

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:35:38 +08:00
AdministratorandClaude Opus 4.8 5c2379577b fix(auth): token 登录将令牌放入 Authorization: Bearer 头
token 换登录调上游 getTokenInfo 时参数传错:原来发静态头
Authorization: Bearer admin + 令牌放 access_token cookie。上游实际
从 Authorization: Bearer <token> 头读取令牌。

- 鉴权头改为 "Bearer "+前端传入 token,删除 access_token cookie
- service_authorization 配置项弃用(保留兼容,不再参与请求)
- 新增回归测试 test_token_login_upstream_call.py
- 同步更新 AUTH_LOGIN.md / CLAUDE.md / config.yaml 注释 / login_config 文档

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:35:38 +08:00
AdministratorandClaude Opus 4.8 c5352f2ee7 本地启动环境修复与配置
- config.yaml: checkpointer 连接串改为相对路径(原为他机绝对路径,本地无法创建)
- frontend-web: 补充缺失的 dayjs 直接依赖(lockfile 已有,package.json 缺失)
- .gitignore: 忽略 backend/uv.lock 并取消其跟踪

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:35:38 +08:00
ZyGuo 5574364438 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy_iframedf 2026-06-03 11:33:50 +08:00
ZyGuo cb0b30690e fix: 去掉用户气泡中<>的显示 2026-06-03 11:33:23 +08:00
g zy 8442e6ec8d Merge branch 'gzy_iframedf' into 'master'
fix: 传图修复

See merge request knowledge-base-bj/deerflow-server!80
2026-06-03 03:12:40 +00:00
ZyGuo 8ba3a67401 fix: 传图修复 2026-06-03 11:12:10 +08:00
g zy f2dc03b51e Merge branch 'gzy_iframedf' into 'master'
Gzy iframedf

See merge request knowledge-base-bj/deerflow-server!79
2026-06-03 03:08:10 +00:00
ZyGuo 5d39aa55bd feat: 支持传图 2026-06-03 10:51:42 +08:00
xuhdandClaude Sonnet 4.6 22574d3ada fix(setup_agent): 限制技能数量最多 5 个
- middleware 拦截阶段静默截断 skills[:5],超出不报错不中断流程
- 工具执行阶段同样截断,双重兜底

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:48:13 +08:00
董宏凯 0727b4564b 圆桌规划第3步修改 2026-06-03 10:01:10 +08:00
董宏凯 6521218308 样式修改 2026-06-03 09:54:11 +08:00
ZyGuo 34a9f15cb4 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy_iframedf 2026-06-03 09:41:14 +08:00
ZyGuo a5b65f4087 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy_iframedf 2026-06-03 09:41:09 +08:00
AdministratorandClaude Opus 4.8 f590f672be feat(skills/settings): 技能复制 + 设置持久化等会话前改动
- 技能复制:新增 POST 复制接口(_rewrite_skill_frontmatter_name 重写 frontmatter name + 校验 + 去重),前端 skills api/hooks 与技能设置页配套
- 设置持久化:lastAgentId、线程 RAG 模式锁(settings/index、local)
- i18n(en/zh/types)、agent-card/input-box/message-list 等页面文案与交互微调
- scheduler/element.py 小修

(本批为会话前已存在的工作区改动,按要求一并提交;构建产物 backend.zip 未提交)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:48:19 +08:00
AdministratorandClaude Opus 4.8 d9afaca51f feat(scheduled-tasks): 新增 HTML 页面定时任务(生成/预览/收藏/技能)
后端:
- 新增 task_kind=html_page 任务类型,与普通定时任务并存,配置存 execution_context(无需迁移)
- 执行流水线:智能体检索数据 -> 选定模型渲染 HTML -> 清洗/校验 -> 失败自动修复 -> 完整性闸门(缺 doctype/html/head/body/title 或残留脚本即失败) -> 归档 index.html -> 落库
- 新增 scheduled_html_pages / html_page_favorites 表与 store;收藏去重(不可重复)、可删除、收藏时提取可复用风格
- HTML 预览接口(鉴权 + 公开)、收藏接口
- 渲染注入已启用的 HTML 技能(metadata.kind=html_page);全程使用用户所选模型,去除硬编码 GLM-5
- 定时任务跳过 TitleMiddleware,避免标题生成回退默认模型

前端:
- 创建/编辑弹窗增加任务类型与 HTML 配置(页面要求/限制/参考收藏页/模型下拉)
- 运行详情页与公开页 iframe 沙箱预览 HTML;收藏按钮(已收藏禁用)
- 参考收藏页可选可删

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:46:20 +08:00
董宏凯 3b5525a0a5 ai写作修改 2026-06-02 22:55:22 +08:00
董宏凯 fdfb888be9 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-02 21:26:21 +08:00
fengxiangdebaozi 02eee4bd3e 页面修改 2026-06-02 21:25:44 +08:00
董宏凯 b257963551 ai写作 2026-06-02 21:22:31 +08:00
fengxiangdebaozi fb43c036f6 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-02 21:15:53 +08:00
fengxiangdebaozi 9ae6184dc1 业务链条 2026-06-02 21:15:46 +08:00
董宏凯 7efedc7b44 ai写作修改 2026-06-02 20:36:22 +08:00
AdministratorandClaude Opus 4.8 e1e9be8daa fix(auth): token 登录将令牌放入 Authorization: Bearer 头
token 换登录调上游 getTokenInfo 时参数传错:原来发静态头
Authorization: Bearer admin + 令牌放 access_token cookie。上游实际
从 Authorization: Bearer <token> 头读取令牌。

- 鉴权头改为 "Bearer "+前端传入 token,删除 access_token cookie
- service_authorization 配置项弃用(保留兼容,不再参与请求)
- 新增回归测试 test_token_login_upstream_call.py
- 同步更新 AUTH_LOGIN.md / CLAUDE.md / config.yaml 注释 / login_config 文档

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:33:38 +08:00
Administrator 247ff86f2f Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-02 19:06:42 +08:00
吉鑫升 89cb5ab2f1 Merge remote-tracking branch 'origin/master' into ji 2026-06-02 17:40:56 +08:00
吉鑫升andClaude Opus 4.8 c484481964 登录鉴权:新增用户名口令门 + token 换登录两种模式
- config 新增 auth_login 段(login_config.py):
  - username_login.require_password 开启后,用户名直登必须带 ?password=,口令留空默认 123ewq
  - token_login 用上游 token 调 getTokenInfo 换用户名再登录,用户名不在库自动注册;含 verify_ssl/ca_cert_path 处理 HTTPS 自签证书
- auth.py:口令门、/login/token 端点、共享 _get_or_create_user_by_username 与 _fetch_username_from_token;登录类报错文案改中文
- auth_middleware:/login/token 加入免鉴权白名单
- 前端 api.ts/LoginPage:透传 ?password=、?authToken=;修复错误体为对象时显示 [object Object] 的问题
- 文档:backend/CLAUDE.md + docs/AUTH_LOGIN.md 接口说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:39:56 +08:00
xuhdandClaude Sonnet 4.6 613cece224 fix(studio): 笔记切换页面不刷新 + 侧边栏错误提示友好化
- WorkspaceRoutes 给 StudioNotebookDetailPage 加 key={notebookId},切换笔记时强制重新挂载
- createSearchSpace 接口失败时解析响应体 detail 字段,直接展示服务端错误信息

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:24:08 +08:00
吉鑫升andClaude Opus 4.8 5aa1fdca04 识图与文件上传/预览:可配置视觉模型 + 修复上传与回显
后端:
- 新增可配置识图模型(config: vision.model_name/prompt/max_tokens)。配置后 view_image 委托该模型识别并返回文字结果,文本主模型也能“看图”;未配置时回退到主模型自带视觉能力。新增 deerflow/models/vision.py 与 VisionConfig。
- 修复 Windows 上传全部报 unsafe:open_upload_file_no_symlink 不再强制要求 POSIX 专有的 O_NOFOLLOW,缺失时降级(仍用 lstat/fstat 防符号链接),Windows 追加 O_BINARY。
- 修复 normalize_input 丢弃 human 消息 additional_kwargs:保留 files/client_ts 等,使上传文件能回传到 UI 并被 UploadsMiddleware 识别。
- 约束 agent 行为:上传图片时引导只用 view_image,禁止用 read_file/grep/bash/python 解析图片字节;view_image 工具说明同步加固。
- 启用文档转换(uploads.auto_convert_documents),PDF/Word/Excel/PPT 自动转 Markdown 供 read_file/grep 读取。
- 订正若干模型错误的 supports_vision 标记。

前端:
- 修复上传文件不回显:parseUploadedFiles 不再因 "(empty)" 提前返回。
- 修复 artifact 401 破图:改用带 Authorization 头的 apiFetch 取 blob 显示(新增 useAuthedObjectUrl)。
- 新增上传文件预览弹窗:点击卡片可查看图片/文本/Markdown、Office(读转换后的 .md)、PDF;文件卡片改为可点击。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:43:53 +08:00
xuhdandClaude Sonnet 4.6 73700cca54 fix(ai_writing): 笔记来源素材使用真实检索分数并打印请求日志
- 笔记 chunk 的 relevance_score 改用接口返回的 score 字段(向量相似度/RRF),接口无分数时降级为位置估算
- _search_notebook 增加 INFO 级日志,输出每次请求的 payload 和响应 status/body,便于调试

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:31:57 +08:00
ZyGuo 3c92e87156 feat: 标题去除特殊tag 2026-06-02 16:24:01 +08:00
ZyGuo 0122d3e361 feat: 生成中状态 2026-06-02 16:18:00 +08:00
董宏凯 30410b0a93 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!77
2026-06-02 07:19:19 +00:00
fengxiangdebaozi 5c5632967c 样式修改 2026-06-02 15:16:39 +08:00
fengxiangdebaozi d872f4e177 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-02 14:11:24 +08:00
fengxiangdebaozi 677faab273 代码提交 2026-06-02 11:50:22 +08:00
董宏凯 d40ee80680 Merge branch 'zhujingyi' into 'master'
Zhujingyi

See merge request knowledge-base-bj/deerflow-server!76
2026-06-02 03:49:25 +00:00
董宏凯 79dd6b9be0 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!75
2026-06-02 03:48:56 +00:00
ZyGuo 49cfc687f9 fix: 对话区域显示问题修复 2026-06-02 11:26:28 +08:00
fengxiangdebaozi f0ccd6fb09 代码提交 2026-06-02 11:04:25 +08:00
zjy 372d4f7933 'feat:暗色模式样式修改' 2026-06-02 11:01:07 +08:00
fengxiangdebaozi 6621519f11 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-02 10:47:29 +08:00
fengxiangdebaozi 1777a0171b d 2026-06-02 10:47:01 +08:00
fengxiangdebaozi 93e702f9ca fix(frontend): show raw chat messages and improve roundtable draft saves 2026-06-02 10:45:20 +08:00
fengxiangdebaozi 0d8cdf3afb Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-01 22:17:44 +08:00
fengxiangdebaozi fa41d620fd 字号调整 2026-06-01 22:17:39 +08:00
AdministratorandClaude Opus 4.8 93543919d4 本地启动环境修复与配置
- config.yaml: checkpointer 连接串改为相对路径(原为他机绝对路径,本地无法创建)
- frontend-web: 补充缺失的 dayjs 直接依赖(lockfile 已有,package.json 缺失)
- .gitignore: 忽略 backend/uv.lock 并取消其跟踪

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:29:29 +08:00
董宏凯 001b04ac5a Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-01 20:00:46 +08:00
董宏凯 417ec01fb2 代码修改 2026-06-01 20:00:05 +08:00
fengxiangdebaozi 0c5938a5a3 代码代码修改 2026-06-01 19:54:45 +08:00
ZyGuo 2e6eff43ad feat: 去掉demo按钮 2026-06-01 18:13:23 +08:00
xuhdandClaude Sonnet 4.6 c714351819 feat: 我的笔记列表与详情页 UI 重构(Gemini 风格)
- StudioNotebooksPage: 卡片改为灰底无边框圆角网格,hover 显示三点菜单,支持重命名(PATCH)和删除(DELETE)
- StudioNotebookDetailPage: 详情页背景改为主题色渐变,内容垂直居中,输入框内联布局,模型选择器移至右侧,历史对话限显 5 条可展开/滚动
- api.ts: 新增 deleteSearchSpace、renameSearchSpace、uploadSourceText 接口
- 来源 Dialog:新增「复制的文字」面板,支持粘贴文本上传

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:36:42 +08:00
董宏凯 e9c56f8635 部署 2026-06-01 17:33:40 +08:00
ZyGuo 5f88efb2c9 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy_iframedf 2026-06-01 17:29:30 +08:00
ZyGuo e6af567206 iframe 页面优化 2026-06-01 17:27:57 +08:00
吉鑫升 43cbcd86be Merge remote-tracking branch 'origin/master' into ji 2026-06-01 17:11:08 +08:00
董宏凯 44931f8205 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!74
2026-06-01 09:01:23 +00:00
吉鑫升 bc7622b0bd Merge remote-tracking branch 'origin/master' into ji 2026-06-01 16:59:56 +08:00
xuhd 1a1d22d6ee 添加环境变量我的笔记的前端地址 2026-06-01 16:59:09 +08:00
xuhdandClaude Sonnet 4.6 ab9034633a feat: StudioNotebookDetailPage 添加「我的笔记」跳转
- 聊天视图顶栏和列表视图右上角均新增「我的笔记」链接
- 点击跳转目标系统 callback 页面并携带 token 和 redirect 参数
- 来源弹窗仅保留「上传文件」和「复制的文字」两个选项
- VITE_AIPI_BACKENP_URL 用于 Studio API 调用,VITE_AIPI_FRONTEND_URL 用于页面跳转
- useStudioNotebooks 新增 token 返回值供页面直接使用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:55:39 +08:00
吉鑫升andClaude Opus 4.8 c860ee85f7 技能展示适配:支持多结果集(result sets)渲染
- 后端 extensions_config 新增 SkillDisplayResultSetConfig,SkillDisplayConfig 增加 resultSets 字段
- threads 引用响应增加 resultSetKey / resultSetLabel,runtime/references 适配多结果集
- 前端 skill-display 适配器/运行时/类型支持多结果集渲染,聊天框、消息组、技能展示面板相应更新

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:42:39 +08:00
ZyGuo 16adb90653 feat: 登陆后跳转位置增加iframe判断 2026-06-01 16:13:47 +08:00
fengxiangdebaozi a5d877207e Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-01 15:11:14 +08:00
fengxiangdebaozi 685abb6aa9 深蓝色ui 2026-06-01 15:11:05 +08:00
xuhdandClaude Sonnet 4.6 7f0a0d92a3 feat: 素材来源新增「我的笔记」选项
- materialSource 枚举新增 notebook 值
- 选择「我的笔记」时展开笔记本 + 文档多选选择器
- 切换其他来源自动清空笔记选择
- 只有 notebook 模式才向后端传笔记参数

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:30:16 +08:00
xuhdandClaude Sonnet 4.6 e4983be9d7 fix: 解决 merge 冲突,恢复笔记来源字段
合并 master 时冲突导致 notebook_search_space_id / notebook_source_ids
被覆盖,现补回;同时兼容 master 新增的 material_source / model_name 字段。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:22:55 +08:00
xuhd e30b44dae1 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-06-01 14:21:20 +08:00
ZyGuo 0922c8299f feat: iframe page 2026-06-01 14:16:13 +08:00
董宏凯 8adb4d5cef 一键部署 2026-06-01 13:53:57 +08:00
董宏凯 5198f6c4df Merge branch 'dhk-1' into 'master'
圆桌草稿持久化:localStorage 迁至后端 MySQL

See merge request knowledge-base-bj/deerflow-server!73
2026-06-01 04:59:24 +00:00
董宏凯andClaude Opus 4.8 5d0bafd651 AI写作:新增素材来源选项(知识库/技能检索)
写作表单新增「素材来源」下拉:知识库(默认 web/intranet 检索)/
技能检索(用已启用技能编排检索)。用户选择后素材收集专家首次检索
据此决定检索方式。

- state 新增 material_source 通道(缺省 knowledge_base,兼容老 session)
- researcher 首次检索(无干预)时按来源决定 is_skill_search;
  PAUSE-1「启用技能检索」按钮仍独立有效
- 前端 AIWritingRequest.materialSource + 表单下拉 + hook 提交 material_source

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:58:50 +08:00
xuhdandClaude Sonnet 4.6 b749c78efa feat: AI写作集成笔记来源,支持多文档多选检索
- 前端 AIWritingForm 新增笔记来源选择器(笔记本单选 + 文档多选 checkbox)
- AIWritingRequest 类型新增 noteSearchSpaceId / noteSourceIds 字段
- AIMaterial.sourceType 新增 notebook 类型
- useAIWritingStream startWriting 时自动获取 notebook_token 并携带笔记参数
- 后端 AIWritingState 新增 notebook_search_space_id / notebook_source_ids 字段
- researcher 节点新增 _search_notebook,解析 API 返回的 documents/chunks,
  每个 chunk 作为独立素材(source_type=notebook)混入素材包

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 11:47:37 +08:00
xuhd 22b93e32eb Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-06-01 10:49:53 +08:00
ZyGuo c767566cb6 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy 2026-06-01 10:29:39 +08:00
ZyGuo a2cc1832f2 chore: ignore 2026-06-01 10:27:31 +08:00
fengxiangdebaozi 169346cdbd 样式修改 2026-06-01 10:06:28 +08:00
董宏凯 ea50540bd5 样式修改 2026-05-31 18:23:34 +08:00
董宏凯 8e909ef1c1 ui规范 2026-05-31 18:11:17 +08:00
董宏凯 7ad42ccf16 ui规范 2026-05-31 16:48:43 +08:00
董宏凯andCursor 633a350bfc AI写作:技能检索进度反馈与超时兜底
检索等待时 SkillStepsCard 显示 spinner;单技能检索 25s 超时避免卡死;前后端增加 skill-timing 调试日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:42:51 +08:00
董宏凯andCursor 23d6f9efc2 圆桌 Step1:支持多澄清实时展示与合并提交
流式解析 ask_clarification 逐题渲染澄清卡;草稿恢复 selectedAgents 修复参与角色;Step3 隐藏左侧栏让报告区全宽。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:23:43 +08:00
董宏凯andCursor 838cae0d2d 圆桌草稿持久化:localStorage 迁至后端 MySQL
新增 /api/roundtable-drafts 草稿 CRUD 与会话级推荐历史;前端 useDraftPersistence 改走 api/drafts,删除本地 drafts.ts。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 00:23:25 +08:00
董宏凯 24e92c437d ai写作bug修改 2026-05-29 22:16:19 +08:00
董宏凯 7d45c89d72 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-29 20:56:43 +08:00
董宏凯 8e931c5acc 代码 2026-05-29 20:56:07 +08:00
fengxiangdebaozi be37779472 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-29 20:17:07 +08:00
fengxiangdebaozi 27cfd53c06 样式修改 2026-05-29 20:16:41 +08:00
吉鑫升 00e74a5e45 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	offline-backend-20260512/backend/app/gateway/services.py
2026-05-29 18:36:29 +08:00
fengxiangdebaozi c890ae5a15 样式修改 2026-05-29 18:17:08 +08:00
fengxiangdebaozi 786ddaa38f 代码修改 2026-05-29 18:07:45 +08:00
董宏凯 d162e5533f Merge branch 'zhujingyi' into 'master'
'feat:智能体暗色模式样式修改'

See merge request knowledge-base-bj/deerflow-server!72
2026-05-29 07:30:17 +00:00
董宏凯 e68b726f9a Merge branch 'dhk-1' into 'master'
圆桌 Step2:固定协调员单例、优化调度性能、修正研讨中状态

See merge request knowledge-base-bj/deerflow-server!71
2026-05-29 07:29:17 +00:00
fengxiangdebaozi 8ed3b0eb91 路由样式修改 2026-05-29 15:26:41 +08:00
fengxiangdebaozi a9332c0ab0 技能联调 2026-05-29 15:22:28 +08:00
xuhd 423abf4fde 上传文件添加 2026-05-29 15:15:29 +08:00
fengxiangdebaozi 07ad87365f 作家写作 2026-05-29 11:42:30 +08:00
吉鑫升andClaude Opus 4.7 2c136c1fde 参考文献模式合并为单一 referenceBatch 并统一连续编号
将同一轮检索结果合并为一个 referenceBatch,由系统统一从 [1] 连续编号,避免每个检索词各自重新编号;提示词引导优先并行检索后再作答。智能体列表接口并发加载技能等轻量信息以渲染卡片徽章,并新增参考文献相关测试。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 11:31:31 +08:00
fengxiangdebaozi 1a122eb7fd 代码修改 2026-05-29 11:19:58 +08:00
xuhdandClaude Sonnet 4.6 4a63399363 feat: 新增工作室展示开关 VITE_STUDIO_ENABLED
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:47:50 +08:00
zjy ee8e2b3225 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into zhujingyi 2026-05-29 09:56:12 +08:00
zjy 8aa52e7e3c 'feat:智能体暗色模式样式修改' 2026-05-29 09:51:44 +08:00
董宏凯andCursor de597b602a 圆桌 Step2:固定协调员单例、优化调度性能、修正研讨中状态
将 roundtable-coordinator 改为内置单例避免重复创建;legacy sync 加 TTL 减少 runs/stream 延迟;前端仅当前发言者显示研讨中。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 23:23:40 +08:00
董宏凯 2371364911 代码优化 2026-05-28 22:35:05 +08:00
董宏凯 214e794451 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-28 21:22:54 +08:00
董宏凯 ab902f948a 代码合并 2026-05-28 21:22:49 +08:00
fengxiangdebaozi ce1e36f4fd 样式修改 2026-05-28 21:15:45 +08:00
fengxiangdebaozi 72eb5204ff Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-28 20:18:43 +08:00
xuhd e4da5bec8d 22222 2026-05-28 18:56:11 +08:00
xuhd 6102963624 000 2026-05-28 18:43:20 +08:00
xuhd b152cb43fc 222 2026-05-28 18:37:48 +08:00
xuhdandClaude Sonnet 4.6 ebe1678b30 Merge origin/master into xuhd
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 18:31:16 +08:00
fengxiangdebaozi 8a0962681a 代码修改 2026-05-28 18:29:50 +08:00
fengxiangdebaozi 9778f9521e 接口地址修改 2026-05-28 18:29:50 +08:00
fengxiangdebaozi 4a3cfb9dfa 任务理解修改 2026-05-28 18:29:50 +08:00
董宏凯 1cfa93e855 点击后打开沙箱 2026-05-28 18:29:50 +08:00
董宏凯 4fab13c728 文件浏览 2026-05-28 18:29:50 +08:00
fengxiangdebaozi a3c69dab07 代码修改 2026-05-28 18:29:50 +08:00
董宏凯 e3dd430e68 按钮修改 2026-05-28 18:29:44 +08:00
董宏凯 d018ecd805 速度优化 2026-05-28 18:29:44 +08:00
fengxiangdebaozi a4b7ab5e9d 代码修改 2026-05-28 18:29:37 +08:00
董宏凯 ef1481be63 智能体查询优化 2026-05-28 18:29:37 +08:00
董宏凯 3a2b5d5ccd 代码修改 2026-05-28 18:29:32 +08:00
吉鑫升andClaude Opus 4.7 78f6e37f76 新增智能体收藏、定时任务必须生成MD校验、问答时间显示及账号绑定的常用提示词
- 智能体支持从广场收藏到「我的」(新增 agent_favorites 表与收藏/取消收藏接口)
- 定时任务新增「必须生成Markdown文件」开关:注入提示词并校验生成的 .md 文件且正文超过100字,否则记为失败(存于 execution_context,无需迁移)
- 聊天问答展示时间(年月日 时分):问题在右下角、回答在左下角
- 常用提示词改为与账号绑定(新增 user_custom_prompts 表与 /api/user-prompts 接口),仅在首页输入框上方以居中标签 + 轮播展示,齿轮置于最右侧管理

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:29:32 +08:00
xuhdandClaude Sonnet 4.6 1ea521abab 前端优化:chat-box、消息列表、rehype 及 Studio 相关交互改进
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 18:29:10 +08:00
xuhd b8a97f2138 xxx 2026-05-28 18:29:10 +08:00
吉鑫升andClaude Opus 4.7 dac5ff38db 技能展示配置持久化并适配引用来源,新增技能文件预览组件
- 后端将技能展示适配配置存入数据库,引用批次按持久化映射进行适配
- 前端新增技能文件预览弹窗与文件树组件,完善技能来源展示
- 修正 .gitignore 中宽泛的 skills/ 规则误忽略前端组件目录的问题

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:29:10 +08:00
董宏凯 ac98d2f585 ai写作默认模型 2026-05-28 18:29:10 +08:00
董宏凯 37a9cca82d 代码修改 2026-05-28 18:29:10 +08:00
董宏凯 8c53594df7 多智能体会商沙箱 2026-05-28 18:29:10 +08:00
hkdongandClaude Opus 4.7 eec7567ac6 修复总控智能体共识后仍被错判为继续派活的死循环
_find_dispatch_message 原实现会一路向前翻历史,直到撞到任意一条带
tool_calls 的 AIMessage,导致本轮 LLM 即使共识(tool_calls=[]),也会
被上一轮的派活 AI 翻出来再用一次,前端因此把同样的 task 又发给子智能体,
形成"嘴上共识、手上派活"的死循环。

改成只看最新一条 AIMessage(跳过末尾 skill_stop / clarification ack
ToolMessage 即可),让 tool_calls 是否为空作为本轮的唯一权威信号。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:29:10 +08:00
fengxiangdebaozi 920fde9cd0 代码提交 2026-05-28 18:29:10 +08:00
董宏凯 742bbdf3ac 点击确认 2026-05-28 18:29:10 +08:00
董宏凯 4da8a1301e 报告沙箱 2026-05-28 18:29:10 +08:00
董宏凯 3bd5cfab9d 代码修改 2026-05-28 18:29:10 +08:00
董宏凯 4f8b0f1c67 ai写作修改 2026-05-28 18:29:10 +08:00
fengxiangdebaozi c59819f6dc 开发文档 2026-05-28 18:29:10 +08:00
fengxiangdebaozi 96d0f4ae73 接口修改 2026-05-28 18:29:03 +08:00
吉鑫升 a35aeee6a2 代码提交 2026-05-28 18:28:57 +08:00
吉鑫升 2688cda6b5 忽略RAG调试日志 2026-05-28 18:28:57 +08:00
吉鑫升 f668133297 实现RAG参考文献显示模式 2026-05-28 18:28:57 +08:00
吉鑫升andClaude Opus 4.7 89bd07be92 管理员智能体/技能管理 + 跨标签登录同步 + 上游响应异常重试
- 「设置和更多」新增「智能体管理」「技能管理」入口,与定时任务管理同级;
  独立的 AdminAgentsPage/AdminSkillsPage 复用 AgentCard/SkillRow,
  管理员可查看并编辑/删除所有用户的智能体与技能。
- 后端 /api/agents、/api/skills 增加 ?admin=true 越权列表;agents 端
  放宽 _get_editable_or_raise 让管理员可编辑/删除任意智能体;新增
  list_all/update_any/delete_any(agents)与 list_all(skills)。
- 新增 AuthSyncWatcher:监听 localStorage["deerflow.auth"] 的 storage
  事件,user_id 变化时自动 reload,修复多标签切换登录后老页面点击
  「新对话」偶发 404 的问题。
- LLMErrorHandlingMiddleware 将 bad_response_status_code / JSON
  decode 等上游网关乱码识别为可重试的 upstream 类故障。
- 侧边栏「任务管理」改名为「定时任务」。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:49 +08:00
吉鑫升andClaude Opus 4.7 a5fb806028 轮换 LLM 凭证与默认模型(DeepSeek → zai-org/GLM-5-FP8)
- backend/config.yaml、config.yaml 把默认模型从 deepseek-chat 切到
  zai-org/GLM-5-FP8(modal endpoint),并轮换对应 API key / Bearer
- .env 同步更新

注意:这些文件包含真实凭证,应用部署时务必通过 .env 或私有 config
覆盖,不要把仓库里的版本直接用于生产。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:49 +08:00
吉鑫升andClaude Opus 4.7 83714f628b 私密/公开互斥兜底 + 智能体搜索按显示后名称匹配 + 多处小修
- 后端 update_agent / set_agent_private 增加兜底:is_private=true 时强制
  published=false,杜绝同时落在公开+私密两个广场的灰色态
- agent-card 编辑弹窗暴露 editIsPrivate 开关,与公开发布开关在前端互斥校验
- agent-gallery 智能体模糊搜索改为前端针对 desensitize() 后的名字比对,
  解决脱敏显示与原始名字不一致导致看似搜索失效的问题
- useUpdateAgent 在 onSuccess 立刻把新数据 patch 进所有 ["agents"] 列表
  缓存,卡片在后台 refetch 完成前先行 re-render,避免「切换私密/发布后
  好像没反应」的体感延迟
- useGlobalShortcuts 对 event.key 为 undefined(IME 合成 / 扩展注入)做
  容错,否则会抛 TypeError 把后续所有快捷键吃掉

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:49 +08:00
吉鑫升andClaude Opus 4.7 ae3e2fb4e8 默认主题改为浅色 + 管理员可配置全局默认主题
- system_settings 新增 AppearanceSettings(default_theme),默认为 light,
  对应 GET /api/system-settings/appearance(任何人可读)+ PUT(admin 可写)
- App.tsx 把 NextThemesProvider 的 defaultTheme 从 dark 改为 light,并把
  QueryClientProvider 提到 ThemeProvider 外层以便后者使用 useQuery
- ThemeContext 新增 localStorage 标志 strategy-theme-user-set:用户调用
  toggleTheme / setTheme 时打标;每次启动若标志未设,则跟随 admin 配置的
  default_theme 同步主题,admin 改动用户刷新即生效;标志一旦存在永远尊重
  用户个人选择
- legacy 'theme' 键迁移视为用户显式选择,避免老用户被新默认覆盖
- frontend system-settings api/hook 增 appearance 拉取与更新

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:49 +08:00
吉鑫升 650102cedb feat: improve citation display and skill persistence 2026-05-28 18:28:45 +08:00
吉鑫升andClaude Opus 4.7 61babbdf7d 技能展示样本运行器 + 参考文献来源过滤调整
- skills 路由新增 _run_skill_python_sample,按本地 Python 候选实际执行
  技能脚本,作为展示适配器的真实样本来源
- skill-display 运行时与适配器调整,参考文献收集放宽起始消息约束

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:41 +08:00
吉鑫升andClaude Opus 4.7 3734b6a05f 新增智能体私密广场(is_private + 管理员密码门控)
- agents 表新增 is_private 列与迁移 20260526_01;列表 filter 拆分为公开
  广场用的 _listing_filter(剔除私密)与单条访问用的 _visible_filter(保留可达)
- system_settings 增 PrivateSquareSettings(enabled + password_hash),
  对应 GET/PUT /api/system-settings/private-square 由管理员管理
- /api/agents 接口:列表/创建/更新新增 is_private 字段;新增
  POST /api/agents/private-square/list(密码校验后返回私密智能体)与
  PUT /api/agents/{id}/private(管理员跨用户搬动智能体)
- 前端「智能体」页签新增「私密广场」入口(密码校验 + sessionStorage 缓存 +
  锁定按钮),卡片增加紫色私密徽章与盾牌切换按钮(owner 自助 / 管理员跨用户),
  「广场」与首页推荐候选均剔除私密智能体
- 新增管理员配置页 /settings/private-square 与侧边栏入口

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:41 +08:00
吉鑫升andClaude Opus 4.7 0256d527f0 技能展示适配持久化 + 参考文献收集与新对话流式修复
- 后端新增 skill_display_adapters 表,持久化每个技能的 display 配置与 sample
  payload;/api/skills 列表、详情、设置接口同步读写
- 新增 /api/skills/{name}/display-sample 端点,编辑器优先加载库中样例
- 前端展示适配面板改为从后端加载持久样例,无样例时提示"自动识别"
- 参考文献收集修复:仅在 bash/shell/python 等执行类工具上从参数推断技能
  名,避免 read_file / web_fetch 误匹配;引用列表纳入最新一条助手消息
- ChatBox 改用「最新 human → 最新 ai」窗口收集引用,并仅保留 citation
  模式
- 修复「新对话」点击后已发消息消失:useThreadStream 只在 threadId 切回
  空值时重置/中止 SSE,避免 onCreated 分配 id 时把刚开始的流打断;
  ChatPage 在 landing 状态隐藏 MessageList,移除 KeyedChatPage remount
- 技能详情对话框:所有者可查看源码与文件 tab(不再要求管理员)
- LocalSandbox:Windows PowerShell 5 路径下把 && 重写为 ;,并以
  UTF-8 解码 subprocess 输出,避免中文乱码
- knowledge-search-v2 mock 样例改为中文固定测试数据

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:41 +08:00
吉鑫升 7adb93fb8e 修复「新对话」流式中无法重置 + 参考文献开关默认关闭并加对勾
- 流式回答过程中点「新对话」:URL 同路径导致 react-router navigate 静默 no-op,
  下游 hook 收不到事件;改为显式 navigate(..., {replace:true, state:{resetAt}})
  保证每次点击刷新 location.key,并让 useThreadChat 监听 location.key 触发重置
- useThreadStream 在 threadId 变化时 stop() 掉上一条 SSE,避免旧流继续派发事件
- 引入 KeyedChatPage 用 location.key 作 ChatRuntime key,真正的导航触发整树 remount,
  解决 useStream 内部 stream.values 残留导致欢迎页底下还能看到旧消息的过渡帧问题
- 「参考文献」开关默认关闭(local.ts),开启态在文字前额外多一个 ✓ 对勾
2026-05-28 18:28:33 +08:00
吉鑫升 97f24f17eb 新增 RAG 引用展示模式:支持技能结果按编号引用/列表/表格渲染
- 前端新增 skill-display 适配层(runtime/display-adapter)与可视化适配面板,
  支持从技能返回 JSON 自动识别字段并配置 citation/list/table/card 展示
- 正文 [[N]] 编号渲染为可点击徽章,点击不跳 URL 而是定位到右侧参考文献面板;
  参考卡片样式重做(圆形编号、悬停高亮、过滤掉 "result" 占位类型)
- bash 工具调用按命令中的 skills/{public,custom}/<name>/ 路径反查展示适配,
  解决调用名为 "bash" 时无法匹配到具体技能的问题
- 工具结果 JSON 解析放宽:能从 stderr/exit code 噪声尾巴里抢救出 JSON 主体
- 展示适配自动识别接入 LLM:读 SKILL.md 让模型推断真实返回结构,失败回退 mock
- 展示适配面板修复保存回显:保存后表单跟随服务端最新值刷新,样例文本不再被抹掉
- 设置页加 "RAG 引用模式" 开关(默认开),关联 lead-agent prompt 注入引用规则
2026-05-28 18:28:33 +08:00
吉鑫升andClaude Opus 4.7 96b0bae6ca 技能详情对话框新增文件浏览面板:支持预览、编辑、下载、复制路径
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:27:12 +08:00
董宏凯 3632b0d726 代码合并 2026-05-28 17:49:18 +08:00
吉鑫升 6792b4f4c4 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts:
#	frontend-web/src/components/workspace/chats/chat-box.tsx
#	offline-backend-20260512/backend/app/gateway/routers/agents.py
2026-05-28 17:29:36 +08:00
吉鑫升andClaude Opus 4.7 10e35055e0 新增智能体收藏、定时任务必须生成MD校验、问答时间显示及账号绑定的常用提示词
- 智能体支持从广场收藏到「我的」(新增 agent_favorites 表与收藏/取消收藏接口)
- 定时任务新增「必须生成Markdown文件」开关:注入提示词并校验生成的 .md 文件且正文超过100字,否则记为失败(存于 execution_context,无需迁移)
- 聊天问答展示时间(年月日 时分):问题在右下角、回答在左下角
- 常用提示词改为与账号绑定(新增 user_custom_prompts 表与 /api/user-prompts 接口),仅在首页输入框上方以居中标签 + 轮播展示,齿轮置于最右侧管理

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:16:15 +08:00
董宏凯 7548620683 样式修改 2026-05-28 17:14:01 +08:00
董宏凯 d5e12e3506 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!70
2026-05-28 07:58:17 +00:00
fengxiangdebaozi 23b9d401b7 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-28 15:56:58 +08:00
董宏凯 44894765b6 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!69
2026-05-28 07:54:20 +00:00
fengxiangdebaozi c2568f4be9 代码修改 2026-05-28 15:50:31 +08:00
董宏凯 90adb9dfa3 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-28 15:48:15 +08:00
董宏凯 d2f3eba434 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
# Conflicts:
#	frontend-web/src/components/workspace/chats/chat-box.tsx
#	frontend-web/src/pages/StrategyRoutes.tsx
#	offline-backend-20260512/backend/app/gateway/routers/agents.py
2026-05-28 15:36:11 +08:00
fengxiangdebaozi 2fe058e0ee 接口地址修改 2026-05-28 15:24:00 +08:00
吉鑫升andClaude Opus 4.7 4410acaee8 技能展示配置持久化并适配引用来源,新增技能文件预览组件
- 后端将技能展示适配配置存入数据库,引用批次按持久化映射进行适配
- 前端新增技能文件预览弹窗与文件树组件,完善技能来源展示
- 修正 .gitignore 中宽泛的 skills/ 规则误忽略前端组件目录的问题

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:16:56 +08:00
fengxiangdebaozi d03f5fbbc0 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-28 15:10:25 +08:00
fengxiangdebaozi ae1c2f95a7 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-28 14:25:54 +08:00
fengxiangdebaozi 381547ec78 任务理解修改 2026-05-28 14:25:44 +08:00
董宏凯 f4672ea6b8 点击后打开沙箱 2026-05-28 13:56:05 +08:00
董宏凯 51a270a2f7 文件浏览 2026-05-28 13:52:25 +08:00
fengxiangdebaozi f3c8f2e1ab 代码修改 2026-05-28 11:45:25 +08:00
xuhd e2b676caaa 修复 2026-05-28 10:00:40 +08:00
董宏凯 68ac49259c 按钮修改 2026-05-28 09:09:02 +08:00
董宏凯 d94c0aae4b 速度优化 2026-05-27 23:12:02 +08:00
董宏凯 047e700d09 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-27 22:23:44 +08:00
董宏凯 9ccc473982 智能体查询优化 2026-05-27 22:23:37 +08:00
董宏凯 4142029f48 代码修改 2026-05-27 21:25:15 +08:00
fengxiangdebaozi dbf2cc8d80 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-27 20:57:19 +08:00
吉鑫升 f200972c00 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-05-27 20:28:38 +08:00
董宏凯 3033597f02 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!68
2026-05-27 12:28:25 +00:00
董宏凯 a16dd889cf Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-27 20:28:05 +08:00
董宏凯 dab6ecbf9a ai写作默认模型 2026-05-27 20:28:00 +08:00
董宏凯 8bdbf6ab15 代码修改 2026-05-27 19:43:28 +08:00
fengxiangdebaozi 8f75e12871 代码修改 2026-05-27 19:43:15 +08:00
xuhd 36311e6cef 999 2026-05-27 19:30:54 +08:00
fengxiangdebaozi 8f5af46ec2 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-27 19:15:19 +08:00
fengxiangdebaozi aeebf53739 api 2026-05-27 19:15:09 +08:00
吉鑫升 1d81ee9ed0 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-05-27 19:12:27 +08:00
董宏凯 e1c9d24aa4 Merge branch 'dhk-1' into 'master'
多智能体会商沙箱

See merge request knowledge-base-bj/deerflow-server!67
2026-05-27 11:12:07 +00:00
董宏凯 b7da062a4c 多智能体会商沙箱 2026-05-27 19:11:36 +08:00
吉鑫升 6fd6d564eb Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts:
#	frontend-web/src/components/workspace/messages/message-list-item.tsx
#	frontend-web/src/components/workspace/messages/message-list.tsx
#	offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py
#	offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
2026-05-27 19:10:27 +08:00
吉鑫升 106f1d1794 代码提交 2026-05-27 19:02:19 +08:00
董宏凯 3809d7f3e7 Merge branch 'tjl' into 'master'
修复总控智能体共识后仍被错判为继续派活的死循环

See merge request knowledge-base-bj/deerflow-server!66
2026-05-27 10:08:50 +00:00
hkdongandClaude Opus 4.7 c769dd20f7 修复总控智能体共识后仍被错判为继续派活的死循环
_find_dispatch_message 原实现会一路向前翻历史,直到撞到任意一条带
tool_calls 的 AIMessage,导致本轮 LLM 即使共识(tool_calls=[]),也会
被上一轮的派活 AI 翻出来再用一次,前端因此把同样的 task 又发给子智能体,
形成"嘴上共识、手上派活"的死循环。

改成只看最新一条 AIMessage(跳过末尾 skill_stop / clarification ack
ToolMessage 即可),让 tool_calls 是否为空作为本轮的唯一权威信号。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:07:30 +08:00
董宏凯 445865e422 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!65
2026-05-27 09:48:13 +00:00
fengxiangdebaozi 24d4934c27 代码提交 2026-05-27 17:47:20 +08:00
吉鑫升 fe7cdba07e 忽略RAG调试日志 2026-05-27 17:44:02 +08:00
吉鑫升 c56db15a03 实现RAG参考文献显示模式 2026-05-27 17:40:10 +08:00
董宏凯 deb9993a64 点击确认 2026-05-27 17:25:10 +08:00
董宏凯 1ff8860b98 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-27 17:20:47 +08:00
董宏凯 198fc18c50 报告沙箱 2026-05-27 17:20:41 +08:00
董宏凯 621fdc9244 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!64
2026-05-27 08:02:37 +00:00
fengxiangdebaozi 9ece6213a9 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-27 16:02:12 +08:00
fengxiangdebaozi 35c313b689 开发文档 2026-05-27 16:02:07 +08:00
董宏凯 d6b88b842d Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-27 15:31:00 +08:00
董宏凯 ff90cac755 代码修改 2026-05-27 15:30:51 +08:00
董宏凯 8f93606c81 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-27 14:19:18 +08:00
董宏凯 95baa44a1c ai写作修改 2026-05-27 14:19:11 +08:00
ZyGuo fa65e67069 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy 2026-05-27 12:07:07 +08:00
xu hd 066d5032f9 Merge branch 'xuhd' into 'master'
修复一下

See merge request knowledge-base-bj/deerflow-server!63
2026-05-27 03:44:16 +00:00
xuhd 3f8a520624 修复一下 2026-05-27 11:40:41 +08:00
fengxiangdebaozi 6ab6695be7 接口修改 2026-05-27 11:37:07 +08:00
吉鑫升andClaude Opus 4.7 eda0ccffdb 管理员智能体/技能管理 + 跨标签登录同步 + 上游响应异常重试
- 「设置和更多」新增「智能体管理」「技能管理」入口,与定时任务管理同级;
  独立的 AdminAgentsPage/AdminSkillsPage 复用 AgentCard/SkillRow,
  管理员可查看并编辑/删除所有用户的智能体与技能。
- 后端 /api/agents、/api/skills 增加 ?admin=true 越权列表;agents 端
  放宽 _get_editable_or_raise 让管理员可编辑/删除任意智能体;新增
  list_all/update_any/delete_any(agents)与 list_all(skills)。
- 新增 AuthSyncWatcher:监听 localStorage["deerflow.auth"] 的 storage
  事件,user_id 变化时自动 reload,修复多标签切换登录后老页面点击
  「新对话」偶发 404 的问题。
- LLMErrorHandlingMiddleware 将 bad_response_status_code / JSON
  decode 等上游网关乱码识别为可重试的 upstream 类故障。
- 侧边栏「任务管理」改名为「定时任务」。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:13:29 +08:00
xu hd 1d399c3c21 Merge branch 'xuhd' into 'master'
feat: 笔记本 Studio 模块 — RAG 聊天、引用弹窗、侧边栏工作室

See merge request knowledge-base-bj/deerflow-server!62
2026-05-27 03:10:13 +00:00
ZyGuo cfd0faf5e7 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy 2026-05-27 10:01:35 +08:00
ZyGuo ffee030548 chore: ignore 2026-05-27 10:01:15 +08:00
董宏凯 9d81858ff4 Merge branch 'dhk-1' into 'master'
代码提交

See merge request knowledge-base-bj/deerflow-server!61
2026-05-27 01:16:50 +00:00
fengxiangdebaozi f791210e70 代码提交 2026-05-27 09:16:25 +08:00
xuhdandClaude Sonnet 4.6 794ff93fa9 chore: 更新 .env 配置(notebook API 地址、数据库连接)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:56:14 +08:00
xuhdandClaude Sonnet 4.6 ff8eb930f7 feat: 笔记本 Studio 模块 — RAG 聊天、引用弹窗、侧边栏工作室
- 新增 Studio 笔记本列表页与详情页(RAG 流式聊天、历史线程切换)
- 修复 streamSubgraphs: true 使 AI 流式消息正常展示
- 引用圆圈 [[cite:N]] 渲染 + 点击弹窗显示文档 chunk 内容
- 侧边栏「工作室」区域支持折叠/展开,笔记本过滤 is_owner=true
- 后端 lead_agent 支持 notebook_source_ids / notebook_token 参数
- 线程 metadata 改为 thread_type=notebook,侧边栏过滤同步

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:55:08 +08:00
董宏凯 d71a21ca8b Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!60
2026-05-26 15:25:32 +00:00
fengxiangdebaozi 2bd23dd374 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-26 23:21:13 +08:00
fengxiangdebaozi a501463ac7 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-26 23:20:58 +08:00
fengxiangdebaozi 9bd049923e 多智能体页面结构修改 2026-05-26 23:20:50 +08:00
董宏凯 67152f1c83 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-26 21:10:33 +08:00
董宏凯 8ca3fdec75 ai写作 2026-05-26 21:10:27 +08:00
fengxiangdebaozi 8e7fc54548 代码修改 2026-05-26 21:09:02 +08:00
吉鑫升 1642c3e694 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	frontend-web/src/contexts/ThemeContext.tsx
#	offline-backend-20260512/backend/config.yaml
2026-05-26 19:34:43 +08:00
董宏凯 d738c13e9e Merge branch 'dhk-1' into 'master'
智能体列表

See merge request knowledge-base-bj/deerflow-server!59
2026-05-26 11:32:35 +00:00
fengxiangdebaozi 45eb46c3f3 智能体列表 2026-05-26 19:31:59 +08:00
吉鑫升andClaude Opus 4.7 7b629d0697 轮换 LLM 凭证与默认模型(DeepSeek → zai-org/GLM-5-FP8)
- backend/config.yaml、config.yaml 把默认模型从 deepseek-chat 切到
  zai-org/GLM-5-FP8(modal endpoint),并轮换对应 API key / Bearer
- .env 同步更新

注意:这些文件包含真实凭证,应用部署时务必通过 .env 或私有 config
覆盖,不要把仓库里的版本直接用于生产。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:31:10 +08:00
吉鑫升andClaude Opus 4.7 89a1fa5042 私密/公开互斥兜底 + 智能体搜索按显示后名称匹配 + 多处小修
- 后端 update_agent / set_agent_private 增加兜底:is_private=true 时强制
  published=false,杜绝同时落在公开+私密两个广场的灰色态
- agent-card 编辑弹窗暴露 editIsPrivate 开关,与公开发布开关在前端互斥校验
- agent-gallery 智能体模糊搜索改为前端针对 desensitize() 后的名字比对,
  解决脱敏显示与原始名字不一致导致看似搜索失效的问题
- useUpdateAgent 在 onSuccess 立刻把新数据 patch 进所有 ["agents"] 列表
  缓存,卡片在后台 refetch 完成前先行 re-render,避免「切换私密/发布后
  好像没反应」的体感延迟
- useGlobalShortcuts 对 event.key 为 undefined(IME 合成 / 扩展注入)做
  容错,否则会抛 TypeError 把后续所有快捷键吃掉

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:30:00 +08:00
吉鑫升andClaude Opus 4.7 c9c59b761f 默认主题改为浅色 + 管理员可配置全局默认主题
- system_settings 新增 AppearanceSettings(default_theme),默认为 light,
  对应 GET /api/system-settings/appearance(任何人可读)+ PUT(admin 可写)
- App.tsx 把 NextThemesProvider 的 defaultTheme 从 dark 改为 light,并把
  QueryClientProvider 提到 ThemeProvider 外层以便后者使用 useQuery
- ThemeContext 新增 localStorage 标志 strategy-theme-user-set:用户调用
  toggleTheme / setTheme 时打标;每次启动若标志未设,则跟随 admin 配置的
  default_theme 同步主题,admin 改动用户刷新即生效;标志一旦存在永远尊重
  用户个人选择
- legacy 'theme' 键迁移视为用户显式选择,避免老用户被新默认覆盖
- frontend system-settings api/hook 增 appearance 拉取与更新

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:29:45 +08:00
吉鑫升 8b2f398015 feat: improve citation display and skill persistence 2026-05-26 19:27:03 +08:00
董宏凯 5c5bf7a60e Merge branch 'dhk-1' into 'master'
提示词修改

See merge request knowledge-base-bj/deerflow-server!58
2026-05-26 06:49:51 +00:00
fengxiangdebaozi 6cf4404243 提示词修改 2026-05-26 14:48:06 +08:00
ZyGuo 56ff310f29 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy 2026-05-26 14:40:01 +08:00
吉鑫升 b671b49725 Merge remote-tracking branch 'origin/master' into ji 2026-05-26 14:25:02 +08:00
吉鑫升andClaude Opus 4.7 d3d7db1f34 技能展示样本运行器 + 参考文献来源过滤调整
- skills 路由新增 _run_skill_python_sample,按本地 Python 候选实际执行
  技能脚本,作为展示适配器的真实样本来源
- skill-display 运行时与适配器调整,参考文献收集放宽起始消息约束

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:24:47 +08:00
吉鑫升andClaude Opus 4.7 26a388e3bf 新增智能体私密广场(is_private + 管理员密码门控)
- agents 表新增 is_private 列与迁移 20260526_01;列表 filter 拆分为公开
  广场用的 _listing_filter(剔除私密)与单条访问用的 _visible_filter(保留可达)
- system_settings 增 PrivateSquareSettings(enabled + password_hash),
  对应 GET/PUT /api/system-settings/private-square 由管理员管理
- /api/agents 接口:列表/创建/更新新增 is_private 字段;新增
  POST /api/agents/private-square/list(密码校验后返回私密智能体)与
  PUT /api/agents/{id}/private(管理员跨用户搬动智能体)
- 前端「智能体」页签新增「私密广场」入口(密码校验 + sessionStorage 缓存 +
  锁定按钮),卡片增加紫色私密徽章与盾牌切换按钮(owner 自助 / 管理员跨用户),
  「广场」与首页推荐候选均剔除私密智能体
- 新增管理员配置页 /settings/private-square 与侧边栏入口

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:24:29 +08:00
董宏凯 6a9d8e903e Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!57
2026-05-26 06:19:16 +00:00
董宏凯 daba5c0599 代码修改 2026-05-26 14:18:00 +08:00
董宏凯 d1e6cd5a8f 多智能体协作修改 2026-05-26 13:01:01 +08:00
fengxiangdebaozi eeb7f30585 代码修改 2026-05-26 11:47:47 +08:00
董宏凯 29fdb06cea Merge branch 'dhk-1' into 'master'
修改

See merge request knowledge-base-bj/deerflow-server!56
2026-05-26 01:52:02 +00:00
fengxiangdebaozi 6d02e2c89e ai写作修改 2026-05-26 09:50:24 +08:00
fengxiangdebaozi cf8c17d08b 敏感词修改 2026-05-26 09:15:32 +08:00
吉鑫升andClaude Opus 4.7 ad5d55220c 技能展示适配持久化 + 参考文献收集与新对话流式修复
- 后端新增 skill_display_adapters 表,持久化每个技能的 display 配置与 sample
  payload;/api/skills 列表、详情、设置接口同步读写
- 新增 /api/skills/{name}/display-sample 端点,编辑器优先加载库中样例
- 前端展示适配面板改为从后端加载持久样例,无样例时提示"自动识别"
- 参考文献收集修复:仅在 bash/shell/python 等执行类工具上从参数推断技能
  名,避免 read_file / web_fetch 误匹配;引用列表纳入最新一条助手消息
- ChatBox 改用「最新 human → 最新 ai」窗口收集引用,并仅保留 citation
  模式
- 修复「新对话」点击后已发消息消失:useThreadStream 只在 threadId 切回
  空值时重置/中止 SSE,避免 onCreated 分配 id 时把刚开始的流打断;
  ChatPage 在 landing 状态隐藏 MessageList,移除 KeyedChatPage remount
- 技能详情对话框:所有者可查看源码与文件 tab(不再要求管理员)
- LocalSandbox:Windows PowerShell 5 路径下把 && 重写为 ;,并以
  UTF-8 解码 subprocess 输出,避免中文乱码
- knowledge-search-v2 mock 样例改为中文固定测试数据

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:16:51 +08:00
董宏凯 2ecd50f5dc Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-server!55
2026-05-25 14:52:19 +00:00
董宏凯 48d22e0d42 ui规范 2026-05-25 22:51:38 +08:00
董宏凯 096e85fbc3 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-25 22:39:56 +08:00
董宏凯 e3d8e534f6 样式修改 2026-05-25 22:39:50 +08:00
fengxiangdebaozi acadc246cb Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-25 21:22:09 +08:00
fengxiangdebaozi fd2c8d1d64 ui规范 2026-05-25 21:21:54 +08:00
董宏凯 f7575e4ddb Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!54
2026-05-25 12:35:09 +00:00
董宏凯 114ee8f6ac 代码修改 2026-05-25 20:34:46 +08:00
董宏凯 b91a45639f 模型下拉框 2026-05-25 20:23:33 +08:00
吉鑫升 c4277f8566 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	frontend-web/src/components/workspace/messages/message-list-item.tsx
2026-05-25 19:50:17 +08:00
吉鑫升 b7aa8b06ca 修复「新对话」流式中无法重置 + 参考文献开关默认关闭并加对勾
- 流式回答过程中点「新对话」:URL 同路径导致 react-router navigate 静默 no-op,
  下游 hook 收不到事件;改为显式 navigate(..., {replace:true, state:{resetAt}})
  保证每次点击刷新 location.key,并让 useThreadChat 监听 location.key 触发重置
- useThreadStream 在 threadId 变化时 stop() 掉上一条 SSE,避免旧流继续派发事件
- 引入 KeyedChatPage 用 location.key 作 ChatRuntime key,真正的导航触发整树 remount,
  解决 useStream 内部 stream.values 残留导致欢迎页底下还能看到旧消息的过渡帧问题
- 「参考文献」开关默认关闭(local.ts),开启态在文字前额外多一个 ✓ 对勾
2026-05-25 19:47:55 +08:00
董宏凯 26097b7cfe Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!53
2026-05-25 11:39:59 +00:00
董宏凯 6addf507cb 样式修改 2026-05-25 19:39:31 +08:00
董宏凯 f0bf29ba08 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-25 19:38:23 +08:00
董宏凯 6ef7701805 样式修改 2026-05-25 19:38:16 +08:00
fengxiangdebaozi 70814b1026 脱敏 2026-05-25 19:30:36 +08:00
fengxiangdebaozi ca1d7e77ba 脱敏 2026-05-25 19:30:26 +08:00
吉鑫升 a800baf8ae Merge remote-tracking branch 'origin/master' into ji 2026-05-25 18:59:32 +08:00
fengxiangdebaozi 2f7f07467e Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-25 18:20:50 +08:00
fengxiangdebaozi f472bba9f0 敏感词 2026-05-25 18:20:40 +08:00
董宏凯 48de418d21 Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-server!52
2026-05-25 10:19:58 +00:00
吉鑫升 8892637b97 新增 RAG 引用展示模式:支持技能结果按编号引用/列表/表格渲染
- 前端新增 skill-display 适配层(runtime/display-adapter)与可视化适配面板,
  支持从技能返回 JSON 自动识别字段并配置 citation/list/table/card 展示
- 正文 [[N]] 编号渲染为可点击徽章,点击不跳 URL 而是定位到右侧参考文献面板;
  参考卡片样式重做(圆形编号、悬停高亮、过滤掉 "result" 占位类型)
- bash 工具调用按命令中的 skills/{public,custom}/<name>/ 路径反查展示适配,
  解决调用名为 "bash" 时无法匹配到具体技能的问题
- 工具结果 JSON 解析放宽:能从 stderr/exit code 噪声尾巴里抢救出 JSON 主体
- 展示适配自动识别接入 LLM:读 SKILL.md 让模型推断真实返回结构,失败回退 mock
- 展示适配面板修复保存回显:保存后表单跟随服务端最新值刷新,样例文本不再被抹掉
- 设置页加 "RAG 引用模式" 开关(默认开),关联 lead-agent prompt 注入引用规则
2026-05-25 18:19:52 +08:00
董宏凯 8c55d4b55d 文档修改 2026-05-25 18:19:16 +08:00
董宏凯 dffb8a955b 暂停修改 2026-05-25 17:56:40 +08:00
董宏凯 86e3b4de06 Merge branch 'dhk-1' into 'master'
接口联调

See merge request knowledge-base-bj/deerflow-server!51
2026-05-25 09:50:01 +00:00
董宏凯 525218f9a7 多智能体协作 2026-05-25 17:48:59 +08:00
董宏凯 9a70329db2 代码提交 2026-05-25 16:08:18 +08:00
吉鑫升andClaude Opus 4.7 9a63a4b7ca 技能详情对话框新增文件浏览面板:支持预览、编辑、下载、复制路径
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:17:39 +08:00
董宏凯 357f245aec Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-25 14:14:39 +08:00
董宏凯 8f9451a24b 接口修改 2026-05-25 14:14:31 +08:00
fengxiangdebaozi 6396ccc701 接口修改 2026-05-25 14:14:01 +08:00
董宏凯 9361c069db Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-server!50
2026-05-24 14:16:58 +00:00
董宏凯 0b5cc2d639 代码修改 2026-05-24 22:16:25 +08:00
董宏凯 61358b6233 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!49
2026-05-24 13:13:48 +00:00
董宏凯 05815d9475 多智能问答 2026-05-24 21:13:19 +08:00
董宏凯 77db1a6297 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-23 23:36:55 +08:00
董宏凯 21254a060a 接口联调 2026-05-23 23:36:41 +08:00
董宏凯 f049936ec4 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!48
2026-05-23 15:05:37 +00:00
董宏凯 8673eb5eca 接口联调 2026-05-23 23:05:01 +08:00
董宏凯 43df9175df 修改 2026-05-23 17:46:31 +08:00
董宏凯andCursor 7fcf22de90 Merge origin/dhk-1: resolve config.yaml database backend conflict
Use sqlite as temporary fallback while MySQL is unavailable; keeps
local persistence instead of in-memory backend.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 17:44:52 +08:00
董宏凯 6f9d81ca68 样式修改 2026-05-23 17:43:46 +08:00
董宏凯 267ce81be2 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!47
2026-05-23 09:42:11 +00:00
fengxiangdebaozi 3987ee7ee3 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-23 17:23:22 +08:00
fengxiangdebaozi 87c9986b33 接口联调 2026-05-23 17:23:15 +08:00
fengxiangdebaozi a85f1571a0 接口修改 2026-05-23 10:49:16 +08:00
董宏凯 d6ff00226a Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-22 21:41:52 +08:00
董宏凯 7f1f34e34f 样式修改 2026-05-22 21:41:26 +08:00
fengxiangdebaozi edb3238f3e 接口修改 2026-05-22 20:40:36 +08:00
吉鑫升andClaude Opus 4.7 67d5e43ec4 标题生成强制关闭 think 模式,回答速度指示器默认开启
- TitleMiddleware 给模型实例注入 extra_body 关 think(覆盖 Zhipu GLM 与 Qwen/vLLM 两套写法),并兜底剥离不闭合/残留的 <think> 标签,避免 GLM5 起标题时长时间思考与标签泄漏。
- 前端 streamSpeedMeterEnabled 默认改为 true,设置项描述同步为"默认开启"。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:03:24 +08:00
吉鑫升andClaude Opus 4.7 f8e3a3e5b4 公开定时任务页:每条记录标题前加 M月D日 前缀
同名任务多次执行后列表全是同一行,加上日期前缀方便扫描;
用 muted-foreground + text-xs + font-normal 弱化,不喧宾夺主。
Tooltip / title / 删除确认弹窗内的文案也同步带上日期。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:38:44 +08:00
吉鑫升 ef410e298d Merge remote-tracking branch 'origin/master' into ji 2026-05-22 16:23:11 +08:00
吉鑫升andClaude Opus 4.7 80fc2a9240 新增技能目录重新扫描接口与分页,关掉技能安全扫描的配置开关
- 后端新增 POST /api/skills/reconcile-custom:扫描 custom 目录,把磁盘存在但 DB 无记录的技能登记到调用者名下(published=false),附 4 个单元测试
- 前端技能列表加"重新扫描技能目录"按钮,列表分页每页 10 条
- SkillsConfig 新增 security_scan.enabled(默认 true)用于关闭 LLM 安全扫描;scan_skill_content 在开关关闭时直接放行
- 智能体卡片/对话页长名 truncate 显示 + loading 占位

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:22:42 +08:00
xu hd 3ae1202536 Merge branch 'xuhd' into 'master'
feat: 统一 Admin 用户中心 + 修复默认对话记忆存储路径

See merge request knowledge-base-bj/deerflow-server!46
2026-05-22 08:05:32 +00:00
xuhdandClaude Sonnet 4.6 2c0bb2041b feat: 统一 Admin 用户中心 + 修复默认对话记忆存储路径
## 前端
- 新增 AdminUserHub 页面,合并用户管理/审计/记忆三个页面为统一中心(左侧用户列表 + 右侧 Tab)
- 旧三条 admin 路由重定向到新 /admin 路由
- 侧边栏导航菜单合并为单一入口"用户中心"
- memory-settings-page: 默认助手下拉选项传 agent=default(后端识别为全局哨兵)

## 后端
- memory.py: 将 "default" 加入全局哨兵 _GLOBAL_AGENT_VALUES,?agent=default 读全局记忆文件
- memory_middleware.py: _resolve_agent_id() 无 agent 上下文时返回 None,默认对话写入 users/{id}/memory.json
- manager.py: 移除 agent_id or "default" 回退,让 None 透传给 V1 provider
- admin_users.py: Admin 记忆 API 将"默认助手"桶改为读全局文件(agent_id=None)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:04:49 +08:00
董宏凯 ab6b6876aa 样式修改 2026-05-22 15:26:20 +08:00
董宏凯 284445e0c9 样式修改 2026-05-22 15:20:59 +08:00
董宏凯 05729505d8 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-22 15:13:44 +08:00
董宏凯 4004fb908b 接口地址 2026-05-22 15:13:28 +08:00
董宏凯 19660e49d2 样式修噶 2026-05-22 14:56:59 +08:00
吉鑫升 b6a2c9336e Merge remote-tracking branch 'origin/master' into ji 2026-05-22 14:25:23 +08:00
吉鑫升andClaude Opus 4.7 ba2bc46042 智能体对话选 Agent 直达、澄清问题批量提交、LLM 错误中文化等
- 前端:新会话选 Agent 后直接跳转并自动发送,智能体页输入框宽度与首页对齐
- 前端:澄清卡片支持多选+多问题合并提交,消除单题逐一发送的割裂感
- 前端:定时任务弹窗 Agent 下拉支持长描述截断,避免撑破布局
- 后端:ask_clarification 支持同回合多次调用以提多个独立问题
- 后端:LLM 错误处理与定时任务失败提示统一改为中文

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:24:29 +08:00
wg he ad8f74c964 Merge branch 'hwg' into 'master'
修改数据库连接

See merge request knowledge-base-bj/deerflow-server!45
2026-05-22 06:10:44 +00:00
wghe ded3123af2 修改数据库连接 2026-05-22 14:09:03 +08:00
董宏凯 e076abe759 ui规范 2026-05-22 12:34:11 +08:00
xu hd c1bb4864ab Merge branch 'xuhd-2' into 'master'
feat: V1记忆系统支持、管理员记忆编辑及对话记忆注入开关

See merge request knowledge-base-bj/deerflow-server!44
2026-05-22 04:34:07 +00:00
xuhdandClaude Sonnet 4.6 b3a39cbdf7 feat: V1记忆系统支持、管理员记忆编辑及对话记忆注入开关
- 新增 V1 记忆系统(memory.json)支持:提取结果强制中文、历史字段修复为增量累积日志
- 用户设置页面:V1 模式下新增 agent 选择器,可查看各 agent 的私有记忆
- 管理员记忆页面:区分 V1/V2 渲染,V1 支持完整 CRUD(用户画像/历史摘要/事实记录)
- 后端新增 V1 记忆 CRUD API(sections/facts),前端同步添加对应 hooks 和 API 调用
- 新建专家页面:标记 bootstrap 线程类型,跳过记忆保存,侧边栏过滤该类线程
- 输入框工具栏新增「记忆」开关:关闭时 V1/V2 均不注入,开启时按 config.yaml 版本正常注入
- 修复 agent 目录不存在时的 FileNotFoundError,回退至默认配置

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:32:47 +08:00
董宏凯 d59e0514b3 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!43
2026-05-22 03:12:49 +00:00
董宏凯 6e88c5a1c5 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-22 11:11:57 +08:00
吉鑫升 c74be2b789 Merge branch 'master' into ji 2026-05-22 10:58:20 +08:00
吉鑫升andClaude Opus 4.7 a258122e22 定时任务:修复智能体执行 422、新增描述字段、公开页删除按钮等
- 定时任务执行时按 agent.id 解析智能体,老数据中存的中文显示名会回退按 name 查找,避免 422
- scheduled_tasks 表新增 description 字段(迁移 20260522_01),创建/编辑表单支持,公开页标题下显示
- 公开页(/public/scheduled-tasks/{id})新增删除单次执行记录入口,使用样式化 Dialog 确认
- 公开页左侧标题区精简:去掉 schedule/timezone/最近状态两行;执行列表标题剥离尾部时间戳,"计划"改为"执行时间"
- 工作区定时任务列表:超长标题改为按字符换行不再截断;按 created_at 倒序(新建任务在前)
- 启动 Alembic 升级加超时与单独线程,MySQL 缺库时自动 CREATE DATABASE
- vite build 增加 8GB 内存上限

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:55:59 +08:00
董宏凯 a01d7123d7 来源新增修改 2026-05-22 10:43:12 +08:00
董宏凯 7652db4601 样式修改 2026-05-21 22:26:25 +08:00
董宏凯 433c540ee2 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!42
2026-05-21 12:49:11 +00:00
董宏凯 37e1f7104f 代码提交 2026-05-21 20:47:45 +08:00
董宏凯 6467c8a870 样式修改 2026-05-21 20:42:57 +08:00
吉鑫升andClaude Opus 4.7 a892f55cb2 Merge branch 'master' into ji
冲突解决:
- frontend-web/src/pages/AgentChatPage.tsx: 保留 HEAD 的 isLandingState +
  AgentWelcome 欢迎页扩展,丢弃 master 的旧版 InputBox 风格
- offline-backend-20260512/backend/config.yaml: 保留 HEAD 的对齐注释格式;
  master 引入的中文 bank_mission / bank_retain_mission 已自动合入

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:31:49 +08:00
吉鑫升 ac4424bab2 代码修改 2026-05-21 20:19:44 +08:00
wg he 99992c951d Merge branch 'hwg' into 'master'
feat: 智能体精选排序与新对话智能体选择器

See merge request knowledge-base-bj/deerflow-server!41
2026-05-21 11:49:54 +00:00
wghe c516b74b75 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into hwg 2026-05-21 19:44:15 +08:00
xu hd da53b81bf9 Merge branch 'xuhd' into 'master'
22

See merge request knowledge-base-bj/deerflow-server!37
2026-05-21 11:43:01 +00:00
xuhd f730924572 222 2026-05-21 19:42:46 +08:00
董宏凯 0fb69a38cd Merge branch 'dhk-1' into 'master'
笔记本页面

See merge request knowledge-base-bj/deerflow-server!39
2026-05-21 11:41:20 +00:00
董宏凯 125e8b271b 笔记本页面 2026-05-21 19:40:54 +08:00
董宏凯 8c2ab6b860 Merge branch 'dhk-1' into 'master'
意图理解

See merge request knowledge-base-bj/deerflow-server!38
2026-05-21 10:48:46 +00:00
董宏凯 d639b24c2d 意图理解 2026-05-21 18:48:15 +08:00
wghe 6ebbf3aa32 Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into hwg 2026-05-21 18:35:56 +08:00
wghe b97548f8d6 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into hwg 2026-05-21 18:30:28 +08:00
wghe 87eff3e133 22 2026-05-21 18:29:55 +08:00
xuhd 2e6ce67b64 22 2026-05-21 18:04:45 +08:00
吉鑫升andClaude Opus 4.7 843eda6fea fix: 公开接口豁免 CSRF 校验
/api/public/ 命名空间下的匿名 PUT/POST 请求没有会话 cookie,
被 CSRF 中间件拦在 403。auth 中间件已放行该前缀,CSRF 这里补齐豁免,
让公开定时任务页的编辑保存能正常调用。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:45:54 +08:00
吉鑫升 643f9b2c76 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	frontend-web/src/pages/AgentChatPage.tsx
2026-05-21 17:44:09 +08:00
董宏凯 2089f5e8ee Merge branch 'dhk-1' into 'master'
配置参数修改

See merge request knowledge-base-bj/deerflow-server!36
2026-05-21 09:38:12 +00:00
董宏凯 7e98d7dc52 配置参数修改 2026-05-21 17:37:39 +08:00
xu hd a514b2fe79 Merge branch 'xuhd' into 'master'
feat: 记忆配置改为数据库存储,用户只可配置召回模式

See merge request knowledge-base-bj/deerflow-server!35
2026-05-21 09:34:48 +00:00
xuhdandClaude Sonnet 4.6 d56230e04d feat: 记忆配置改为数据库存储,用户只可配置召回模式
- 新增 persistence/memory_config/ 模块:UserMemoryConfigRow ORM 模型 +
  SqlUserMemoryConfigRepository + MemoryUserMemoryConfigStore(内存兼容)
- 新增 Alembic 迁移脚本 20260521_01 创建 user_memory_configs 表
- memory_config.py:USER_WRITABLE_FIELDS 从 3 个缩减为 1 个
  (hindsight.recall_budget),移除文件 IO,改为进程内 cache + async DB 对接函数
- Gateway lifespan 启动时批量预热 recall_budget cache
- memory router PATCH/DELETE 改用 DB 存储,支持 400 校验非法 budget 值

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:34:25 +08:00
董宏凯 03e0d8248d Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!34
2026-05-21 09:23:14 +00:00
董宏凯 065bd8f28a 样式修改 2026-05-21 17:22:40 +08:00
董宏凯 ca13a9199f 样式修改 2026-05-21 16:39:48 +08:00
董宏凯 b59d476c2d Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!32
2026-05-21 08:31:57 +00:00
董宏凯 c69486556a Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-21 16:31:27 +08:00
董宏凯 bfd75da308 功能迁移,页面整体样式修改 2026-05-21 16:30:11 +08:00
吉鑫升andClaude Opus 4.7 53bbb9b15b feat: 公开定时任务页支持正文/MD 切换与内联编辑
- 公开查看器右侧改为 Tabs:MD 附件优先、然后是「正文」;标签使用文件名(去后缀)
- 新增内联编辑:正文与 MD 文件均可编辑保存,每个 Tab 支持 Word 下载
- 后端新增公开 PUT 端点(/runs/{run_id}/files/{i}、/runs/{run_id}/body),服务层提取 _apply_run_file_edit 并新增 admin_update_run_file / admin_update_run_body
- 列表过滤:仅展示 succeeded 且有正文或可用 MD 的执行记录;后端在响应中标记每个文件 available,前端隐藏磁盘已缺失的 MD Tab
- 左侧任务名与右侧执行记录标题均改为可换行 + Tooltip 显示完整内容
- .gitignore 增加 frontend-web/dist

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:53:43 +08:00
吉鑫升 f1d58983c2 feat: 智能体精选排序与新对话智能体选择器
- 后端新增 featured_order 字段及精选排序 API(/agents/selector、/agents/{id}/featured、/agents/featured/order),移除 user_agent_pin 表
- 前端新对话页 (ChatPage) 与输入框 (InputBox) 接入 agent-selector,选中后跳转 AgentChatPage 并通过 sessionStorage 透传首条消息
- 新增管理员智能体管理弹窗 (agent-manage-dialog) 支持精选添加/移除/上下移动
2026-05-21 14:39:12 +08:00
xu hd a8a545690f Merge branch 'xuhd' into 'master'
feat: 智能体创建流程优化与管理员记忆页面

See merge request knowledge-base-bj/deerflow-server!31
2026-05-21 03:50:02 +00:00
xuhdandClaude Sonnet 4.6 3748513996 feat: 智能体创建流程优化与管理员记忆页面
- 创建智能体页面:保存后自动跳转列表页、技能列表增加搜索过滤框
- Bootstrap 模式:开放全部技能可见性,注入专属提示词(skill_list 匹配意图、禁止 skill_manage)
- 管理员记忆页面:新增 AdminMemoryPage 及路由,支持查看/编辑用户记忆条目
- 内存配置与管理员用户接口同步更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:43:59 +08:00
董宏凯 18c2a4122f Merge branch 'dhk-1' into 'master'
更新

See merge request knowledge-base-bj/deerflow-server!30
2026-05-21 00:32:37 +00:00
董宏凯 04664c8d42 更新 2026-05-21 08:31:33 +08:00
董宏凯 628bbe14a8 Merge branch 'dhk-1' into 'master'
样式修改

See merge request knowledge-base-bj/deerflow-server!29
2026-05-20 14:47:15 +00:00
董宏凯 abae7c9d67 样式修改 2026-05-20 22:46:37 +08:00
董宏凯 eeee00f6cd 样式修改 2026-05-20 22:17:59 +08:00
吉鑫升andClaude Opus 4.7 6971ea5f9c fix: 修正 config.yaml 中 minimax 模型条目缩进,避免 YAML 解析失败
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:45:52 +08:00
吉鑫升 cee38f93c8 Merge remote-tracking branch 'origin/master' into ji 2026-05-20 19:38:00 +08:00
吉鑫升andClaude Opus 4.7 fa5a6b8176 fix: 技能详情弹窗宽度自适应
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:37:46 +08:00
董宏凯 97f8940b15 样式修改 2026-05-20 17:21:29 +08:00
董宏凯 556d23ade5 样式修改 2026-05-20 17:13:59 +08:00
吉鑫升 313290b22c Merge remote-tracking branch 'origin/master' 2026-05-20 17:06:06 +08:00
吉鑫升andClaude Opus 4.7 d339db4799 feat: 公开定时任务页面、Word 导出、定时任务实时刷新与技能详情重构
- 新增 /public/scheduled-tasks/:taskId 公开只读访问页与对应的
  /api/public/scheduled-tasks 后端路由(无需登录)
- 新增对话整体 / 单条消息 / Markdown 工件 "另存为 Word (.docx)" 能力
- 定时任务列表与运行列表在任务执行中自动轮询刷新,并新增"正在执行"标记
- 定时任务运行消息接口在事件存储为空时回退到 LangGraph checkpoint,
  让正在执行的任务也能立即看到提示词与流式输出
- 技能详情对话框由折叠面板改为 Tab 切换(Detail / SKILL.md),
  并支持 Rendered / Raw 切换、一键复制
- config.yaml 增加 database (MySQL) 与 checkpointer (SQLite) 配置块

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:04:58 +08:00
董宏凯 03dff2ce77 样式修改 2026-05-20 15:21:23 +08:00
董宏凯 c2c44f8d29 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-20 15:10:09 +08:00
董宏凯 13130c7f27 展开收起动画 2026-05-20 15:09:50 +08:00
董宏凯 30b986bb33 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!28
2026-05-20 06:06:30 +00:00
董宏凯 0196b0a445 文档修改 2026-05-20 14:05:53 +08:00
董宏凯 8edd06fb67 文档配置文件修改 2026-05-20 14:03:26 +08:00
ZyGuo 38b59cc198 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy 2026-05-20 13:42:37 +08:00
董宏凯 4fbd0bb5c5 ai写作 2026-05-20 13:06:29 +08:00
董宏凯 c2563661d0 Merge branch 'dhk-1' into 'master'
容错处理

See merge request knowledge-base-bj/deerflow-server!27
2026-05-20 04:54:00 +00:00
董宏凯 f358cfb7c7 容错处理 2026-05-20 12:51:04 +08:00
董宏凯 ef11d3e0ed Merge branch 'dhk-1' into 'master'
报错兜底

See merge request knowledge-base-bj/deerflow-server!26
2026-05-19 14:56:01 +00:00
董宏凯 f44e2ccf52 报错兜底 2026-05-19 22:54:36 +08:00
吉鑫升 7e75c5b9b9 Merge remote-tracking branch 'origin/master' into ji 2026-05-19 19:58:59 +08:00
董宏凯 15ae11f966 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!25
2026-05-19 11:54:42 +00:00
董宏凯 226439ef36 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-19 19:54:18 +08:00
董宏凯 81aca93b81 章节问答 2026-05-19 19:51:44 +08:00
吉鑫升andClaude Opus 4.7 777c3b0079 fix: 定时任务执行过程实时刷新——RunJournal 增加按时间 flush
RunJournal 原本只在缓冲满 20 条或运行结束时才把事件落库,短运行会在结束前一直查不到消息,导致定时任务执行详情页的「执行过程」一直显示「暂无执行消息」。

新增 _FLUSH_INTERVAL_SECONDS=3s 的按时间 flush(由 _put 驱动);flush 失败时退回缓冲并重新抛出以便区分成败;flush 成功后若缓冲仍有残留则补一次 flush,失败则不重试避免空转。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:18:14 +08:00
董宏凯 ab85e4df72 效果优化 2026-05-19 18:10:15 +08:00
吉鑫升 cf8185498a Merge branch 'ji' 2026-05-19 17:22:45 +08:00
吉鑫升andClaude Opus 4.7 bf34b7df4e fix: 修复 Memory V2 合并导致的启动崩溃
master 的 Memory V2 重构删除了 memory/updater.py,admin_users.py 仍从中导入 get_memory_data 导致启动 ImportError。改用 BuiltinFileProvider 读取 V2 的 USER.md / 各 agent MEMORY.md;前端 MemoryBucketView 同步改为渲染 V2 条目列表。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 17:22:27 +08:00
吉鑫升andClaude Opus 4.7 028c5a34e6 fix: 速度胶囊每轮只显示一次 + 技能调用按 run 合并统计
速度胶囊:原本每条 AI 消息都渲染,agentic 多轮 LLM 调用会出现多个胶囊。改为只在每轮 turn-final 消息(下一条 human 之前的最后一条 AI 消息)显示。

技能统计:tool_call_metrics 改为按 (run_id, skill_name) upsert 合并——同一 run 内同一技能的多次调用/重试合并为一行;任一成功即记成功,全部失败则记一行 error 并累积所有报错信息;非技能调用不受影响。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 17:22:17 +08:00
xuhd c0ea0b0f5a 标题生成 2026-05-19 16:30:41 +08:00
吉鑫升 4c38055d38 Merge remote-tracking branch 'origin/master' into ji 2026-05-19 16:28:39 +08:00
吉鑫升andClaude Opus 4.7 87fcbc5bfb chore: vite 构建产物使用相对路径 (base: "./")
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 16:28:29 +08:00
ZyGuo 043a1aab21 update git ignore 2026-05-19 16:12:12 +08:00
xu hd 9f86c8e40d Merge branch 'xuhd' into 'master'
feat(memory): Memory V2 重写 - 可插拔 provider + Hindsight 集成

See merge request knowledge-base-bj/deerflow-server!22
2026-05-19 08:10:25 +00:00
xuhd 3330da0e59 记忆的config.yaml修改 2026-05-19 16:06:51 +08:00
xuhd 64492bec43 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-05-19 16:05:11 +08:00
董宏凯 2402a84c71 检索逻辑修改 2026-05-19 15:14:51 +08:00
董宏凯 294d06f22b 文档修改 2026-05-19 13:00:40 +08:00
xuhd d6e788dc89 修复 2026-05-19 09:55:20 +08:00
xuhd 070cd07199 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-05-19 09:40:19 +08:00
xuhd 4ded0ccd4f 补充 2026-05-19 09:38:20 +08:00
xuhd 6253a2bca9 修改优化 2026-05-19 09:38:00 +08:00
吉鑫升 bd1efd8026 Merge branch ji into master: 工具/技能调用统计 + 用户活动审计 + 标签多选筛选 2026-05-19 09:28:44 +08:00
吉鑫升andClaude Opus 4.7 4940e0d8ed feat: 工具/技能调用统计 + 用户活动审计 + 标签多选筛选
- 任务1: 用户管理/定时任务管理移入左下角菜单, 从侧边栏删除原入口
- 任务2: 管理员查看用户问答(读 LangGraph checkpoint)与记忆, 新增 admin_users 路由与审计页
- 任务3: 标签筛选改为顶部多选标签栏(OR), agents/skills/scheduled_tasks 三端
- 任务4: 新增 tool_call_metrics 表 + ToolMetricsMiddleware + tool-metrics 路由,
  工具/技能调用统计并入大模型统计页, 含错误分类
- 技能识别: lead agent 经 read_file 加载 /mnt/skills/<cat>/<name>/SKILL.md 调用技能,
  中间件扫描工具参数里的 skills/{public,custom}/{name}/ 路径还原 skill_name
- 单元测试 tests/test_tool_metrics_middleware.py (18 项全过)
- 附: SQLite->MySQL 切换后清理脚本

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:59:15 +08:00
董宏凯 8d6cc65530 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!21
2026-05-18 15:08:25 +00:00
董宏凯 b8adefbb88 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-18 23:08:02 +08:00
董宏凯 2c56a84bb7 接口报错异常处理 2026-05-18 23:06:59 +08:00
董宏凯 613e22d6ca Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!20
2026-05-18 14:53:35 +00:00
董宏凯 73e76fc72f 依赖安装 2026-05-18 22:46:57 +08:00
董宏凯 1e5e2581c8 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-18 22:45:25 +08:00
董宏凯 b25c4e33cb 异常处理 2026-05-18 22:41:44 +08:00
董宏凯 17b28f43f8 大纲规划 2026-05-18 21:43:48 +08:00
吉鑫升andClaude Opus 4.7 e3cde9be25 feat: 定时任务运行详情执行过程视图 + 管理员下载删除 + MySQL 迁移修复
定时任务:
- 运行详情页新增「执行过程」标签页,按 agent_run_id 拉取该次运行的
  agent 消息(GET /scheduled-tasks/runs/{id}/messages),运行中每 4s
  自动刷新,可观察卡住的任务停在哪一步
- 详情页「返回」按钮回到来源列表(普通/管理员)并重新选中原任务
- 管理员可下载、删除任意用户的执行记录(DELETE /runs/{id} 加管理员兜底)

MySQL 兼容修复:
- 合并迁移 20260518_06 汇合 ai-writing 与 thread-shares 两个 head
- 20260517_02 改用 SQLAlchemy Core 构造,规避保留字 key 的 1064 报错
- list_messages_by_run 各事件存储补 user_id 形参以保持接口一致

测试: 新增 test_scheduled_task_admin_run_delete.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:13:58 +08:00
吉鑫升 449703a6f0 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	offline-backend-20260512/backend/app/gateway/deps.py
2026-05-18 19:35:16 +08:00
吉鑫升andClaude Opus 4.7 358d5c72a6 feat: 业务库切换 MySQL + 修复定时任务运行详情递归与重名 500
- 业务 ORM 数据支持 MySQL 后端(asyncmy),新增 PortableJSON/
  BeijingDateTime 可移植列类型、迁移脚本与切库脚本
- 修复 GET /api/scheduled-tasks/runs/{id} 的 _load_run 无限递归
  (误调用自身,应委托 service.get_run)导致的 RecursionError 500
- 定时任务名按用户唯一:create/update/admin_update 主动校验,
  冲突抛 DuplicateTaskNameError,路由转 409 而非裸 IntegrityError 500
- 补充回归测试 test_scheduled_task_run_lookup / _name_uniqueness

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:34:14 +08:00
董宏凯 abd2ced4b4 Merge branch 'dhk-1' into 'master'
ai写作

See merge request knowledge-base-bj/deerflow-server!19
2026-05-18 09:55:50 +00:00
董宏凯 49e5aaea6b Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-18 17:47:33 +08:00
董宏凯 d1f431d121 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-18 17:45:10 +08:00
fengxiangdebaozi fd759d6845 依赖更新 2026-05-18 17:44:08 +08:00
董宏凯 5be5531722 容错显示 2026-05-18 17:41:51 +08:00
吉鑫升 2a4fccde70 Merge remote-tracking branch 'origin/likun' 2026-05-18 15:41:36 +08:00
吉鑫升 d6f018e581 Merge branch 'master' into ji 2026-05-18 15:39:57 +08:00
吉鑫升andClaude Opus 4.7 e0c1cb6581 feat: 定时任务管理 + 会话只读分享
定时任务:
- 管理员可查看/编辑/删除/触发全部用户的定时任务(新增 /api/scheduled-tasks/admin/* 接口与管理页)
- 卡死任务恢复:单次执行超 30 分钟超时重试(最多 3 次),耗尽后标记 failed;看门狗回收服务重启遗留的「执行中」任务

会话分享:
- 新增「只读链接」分享模式,生成公开免登录的只读对话快照页(/share/:code)
- 公开接口 /api/public/shares/* 提供对话快照与图片附件

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:39:40 +08:00
xuhdandClaude Opus 4.7 f80a00e211 feat(skills): curator 面板加 pinned 计数 + 运行时输出技能名日志
- get_curator_status 的 agent_created_skills 增加 pinned 计数,与
  active/stale/archived 正交;前端技能进化面板同步展示「被钉住」一项
  (curator/types.ts、skill-evolution-panel.tsx、zh-CN/en-US/types i18n)。
- run_curator_review 每次运行时把涉及的技能名输出到日志:新增
  _log_scanned_skills(扫描到的技能名)与 _candidate_skill_names
  (候选技能名),logger 与 curator_log 双写。

新增 test_curator_status_counts.py、test_curator_skill_logging.py。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:25:13 +08:00
xuhdandClaude Opus 4.7 4aee413680 revert(memory): Hindsight document_id 改回每轮独立文档
commit dba821a 把 document_id 从带时间戳改成按 thread_id 确定性派生
(同一 thread 累积成一个文档)。经复核,改回原来的带微秒时间戳写法 ——
每轮对话的 retain 重新落到各自独立的 Hindsight 文档。

retain_every_n_turns 轮次节流(turn_index)与 aiohttp 客户端泄漏修复
保持不动,仅回退 document_id 这一处。

test_hindsight_provider.py 的 TestDocumentId 按"每轮独立文档"重写。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:24:13 +08:00
xuhd dba821a70e @
fix(memory): 修复 Hindsight provider 生命周期的三处缺陷

HindsightProvider 被 build_memory_manager 每轮重新构造,以下三个问题
都源于这个无状态特性未被正确处理:

1. document_id 每轮漂移 —— 原 _reset_retain_batch 用微秒时间戳拼接,
   导致同一 thread 的每轮 retain 落到不同文档,文档数随对话轮数线性膨胀。
   改为按 thread_id 确定性派生 (thread-{thread_id}),同一 thread 累积成一份。

2. retain_every_n_turns 失效 —— 进程内 _turn_counter 每轮被 initialize
   重置,retain_every_n_turns > 1 时永不触发。新增 turn_index 参数贯穿
   sync_turn/sync_all,由 MemoryMiddleware 按对话历史 (count_user_turns)
   算出并传入;_turn_counter 仅作缺省退路。

3. aiohttp ClientSession 泄漏 —— 缓存的客户端从不 shutdown,且 _run_coro
   每次新建事件循环使缓存客户端失效,触发 "Unclosed client session"。
   改为客户端即开即关:_run_with_client 在 finally 保证关闭,_client_factory
   作为可覆盖的测试注入点。

测试:test_hindsight_provider.py 全量重写 (含 TestDocumentId /
TestRetainEveryNTurnsAcrossRecreation / TestClientLifecycle),
新增 test_memory_message_processing.py,test_memory_manager.py 补
turn_index 透传用例。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-18 14:34:22 +08:00
董宏凯 836dd73446 容错处理 2026-05-18 11:29:40 +08:00
xuhd 8f88427d4e Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-05-18 10:25:51 +08:00
xuhd 817585a4ce Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd-v2 2026-05-18 10:22:23 +08:00
xuhdandClaude Opus 4.7 0386f54449 feat(skills): 接入技能使用埋点,修复活跃技能被 curator 误归档
技能正文不进 system prompt,Agent 按 Progressive Loading 用 read_file
加载 SKILL.md 才算真正用到该技能。此前 read_file 与 skill_manage 的
patch/edit 都不更新 .usage.json,curator 的年龄流转据 latest_activity_at
判断闲置,导致每天都在用的 agent 创建技能在 30/90 天后被误标陈旧并归档。

- sandbox/tools.py: 新增 _custom_skill_name_from_path / _record_skill_read,
  read_file 读取 custom 技能目录下文件时 bump_use(best-effort,失败不影响主流程)
- skill_manage_tool.py: patch/edit 经 _bump_patch_best_effort 记 patch_count
- tests/test_skill_usage_signals.py: 12 项,覆盖路径解析、埋点与异常兜底

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:22:15 +08:00
吉鑫升 4761677676 Merge remote-tracking branch 'origin/master' into ji 2026-05-18 10:03:49 +08:00
吉鑫升andClaude Opus 4.7 f61d053010 feat(skills): 新增技能详情字段并统一发布图标
- 技能新增 detail 字段(skills 表新增列 + Alembic 迁移),
  由管理员或技能发布者编辑,内置/公开技能仅管理员可编辑
- 新增 PUT /api/skills/{name}/detail 接口,GET/列表接口返回 detail
- 新增可复用的技能详情弹窗组件,支持 Markdown 渲染与内联编辑
- 智能体创建/编辑选技能、智能体卡片技能徽章均可查看技能详情
- 将定时任务/智能体/技能的发布图标统一为 Globe/Lock

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:03:37 +08:00
fengxiangdebaozi 8440d02198 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-18 10:03:06 +08:00
xuhd ebcdf847d2 222 2026-05-18 10:02:00 +08:00
fengxiangdebaozi 5c553aae19 文档更新 2026-05-18 09:58:34 +08:00
董宏凯 0af8ca5f78 历史记录回显 2026-05-17 22:55:01 +08:00
董宏凯 52ba178c4e 字数要求修改 2026-05-17 22:33:43 +08:00
董宏凯 166016a741 检索结果过程添加 2026-05-17 22:01:29 +08:00
董宏凯 20786e690d 自动下滑 2026-05-17 21:33:24 +08:00
董宏凯 c19417a706 暂停继续 2026-05-17 21:19:12 +08:00
董宏凯 b768e0affb 改写弹窗出现的位置 2026-05-17 20:38:35 +08:00
董宏凯 05ff10df1b ai改写调用 2026-05-17 20:18:11 +08:00
董宏凯 a0a6227368 文章类型添加 2026-05-17 19:50:52 +08:00
董宏凯 918e65d8d3 ai写作 2026-05-17 19:29:10 +08:00
董宏凯 6cab62d140 作家大纲拖拽调整顺序 2026-05-17 18:00:07 +08:00
董宏凯 9526268f1f 加载框顺序调整 2026-05-17 17:55:42 +08:00
董宏凯 016ec1877a ai写作 2026-05-17 17:39:41 +08:00
董宏凯 772e75f04d ai写作 2026-05-17 17:25:18 +08:00
董宏凯 10d7190ea4 ai写作 2026-05-17 14:22:25 +08:00
董宏凯 cc8703577d ai写作 2026-05-17 13:08:45 +08:00
董宏凯 4533755704 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-16 22:03:41 +08:00
董宏凯 da4a08dfe1 智能选中文字 2026-05-16 22:00:34 +08:00
xuhd 48ddbf0412 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-05-16 15:51:49 +08:00
xuhdandClaude Opus 4.7 39def1c20b feat(skills): curator 健壮性加固 + 评审模型可配置 + 手动触发体验
调度健壮性:
- 技能进化总开关 skill_evolution.enabled 真正生效;CuratorConfig
  与 CuratorSubConfig 默认 enabled 改为 false(显式 opt-in)
- 新增 per-user 运行锁(.curator_state 的 running/started_at),
  防定时器与手动触发并发重复运行;6h stale 检测防进程残留死锁
- maybe_run_curator 改用 ThreadPoolExecutor 限并发(上限 5),
  防首次部署所有用户同时到期的惊群
- 首次运行持续失败时按 6h 退避重试,不再每个 tick 烧 LLM 配额
- should_run_now 时间解析容忍 naive 时间串;损坏的 last_run_at
  触发重跑而非永久死锁
- SkillStore 新增 list_published_names 批量接口,curator 一轮
  只查一次 published,取代逐技能查库

LLM 评审超时:
- _run_llm_review 给 agent.invoke 套整体超时
  (curator.review_timeout_seconds,默认 600s),超时中止本次评审
  并在 curator 日志输出,curator 正常收尾、释放运行锁
- copy_context 把 user 上下文传入工作线程

模型可配置:
- 新增 curator.model_name;curator 评审与技能安全审核共用该模型,
  未配置则回退主模型
- 移除独立的 moderation_model_name 配置
- 前端进化面板新增模型下拉选择器

手动触发体验:
- /api/curator/run 派发后台线程前先预检,返回 {triggered, reason},
  不再无论开关状态都提示"已开始"
- 前端:总开关/curator 开关未开时禁用"立即执行"按钮,
  被预检拦下时按原因(开关未开 / 已有任务在跑)给出准确提示

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:50:15 +08:00
董宏凯 e8a31b5d37 代码修改 2026-05-16 14:24:44 +08:00
董宏凯 5a25dd08f6 Merge branch 'dhk-2' into 'master'
文档修改

See merge request knowledge-base-bj/deerflow-server!18
2026-05-16 01:07:56 +00:00
fengxiangdebaozi b34219ce86 文档修改 2026-05-16 09:07:18 +08:00
董宏凯 05548b0fa6 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!17
2026-05-15 15:28:24 +00:00
董宏凯 e08e7be24c Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-15 23:27:48 +08:00
董宏凯 1be600155b openCanvas 2026-05-15 23:27:34 +08:00
xuhd be757b1f2f @
feat(skills): skill_list 可见性过滤 + skill 只读工具常驻 + 进化面板对照展示

- skill_list:默认 lead agent 改为只暴露未归档、且内置或属于当前用户的技能;
  自定义 agent 仍严格按白名单。SkillStore 不可用时降级放行全部自定义技能。
- prompt:注入系统提示词的技能列表剔除已归档(state=archived)技能。
- tools:skill_list/skill_view 只读工具改为常驻提供;skill_manage 写工具仍按
  生效配置(含 per-user 覆盖)的 skill_evolution.enabled 门控。
- curator:should_run_now 解析异常时改为不运行;get_curator_status 返回上次
  LLM 建议与实际工具调用两份记录,供前端对照。
- 前端:技能进化面板新增建议 vs 实际动作对照展示 + 配套 i18n。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-15 20:39:13 +08:00
xuhdandClaude Opus 4.7 d592e1e535 fix(skills): curator 自我进化周期调度 + 并发/配置/安全修复
调度:
- app.py 新增常驻 curator 调度器,每小时检查一次"需不需要进化",
  替代原来"仅启动跑一次";多 worker 下用跨平台文件锁做 leader 选举,
  只有一个进程跑调度;启动延迟 5 分钟避开启动高峰

curator 逻辑修复:
- C1: should_run_now / apply_automatic_transitions / get_curator_status
  调用 _get_curator_config 时传 user_id,使 per-user 配置在运行时生效
- C3: usage.py 的 .usage.json 读-改-写加进程内锁,bump_* 改回调式,
  避免 curator 多线程并发丢状态更新
- C4a: curator 受限版 skill_manage 在工具层屏蔽 delete,
  堵住绕过 skill_archive 安全网删技能的口子
- C4b: 进化记录同时保存 LLM 自述(suggestions)与真实工具调用(actual_actions)
- 无候选技能时跳过 LLM 评审,省掉无意义的模型调用

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:05 +08:00
xuhd e381e9f52e 222 2026-05-15 18:00:11 +08:00
吉鑫升 3b951efd13 Merge remote-tracking branch 'origin/master' into ji 2026-05-15 16:54:56 +08:00
吉鑫升 33161c5bad Merge remote-tracking branch 'origin/likun' into ji
# Conflicts:
#	frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
#	frontend-web/src/components/workspace/code-editor.tsx
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/pages/ChatPage.tsx
#	offline-backend-20260512/backend/app/gateway/routers/artifacts.py
2026-05-15 16:54:10 +08:00
xuhd 8b6236c796 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd-v2 2026-05-15 16:42:48 +08:00
xuhdandClaude Opus 4.7 a59d80d7f3 feat(i18n): 全局将「专家」文案改为「智能体」
统一智能体功能的 UI 文案:侧边栏、智能体页、新建/保存流程、
审批卡片、登录提示及后端审批消息中的「专家」均改为「智能体」。
保留 prompt 人设、QA 子应用等非该功能语义的「专家」表述。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:42:40 +08:00
xuhdandClaude Opus 4.7 a5c76591ac feat(i18n): 全局将「专家」文案改为「智能体」
统一智能体功能的 UI 文案:侧边栏、智能体页、新建/保存流程、
审批卡片、登录提示及后端审批消息中的「专家」均改为「智能体」。
保留 prompt 人设、QA 子应用等非该功能语义的「专家」表述。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:42:11 +08:00
xuhd c2a975af9e @
feat(i18n): 全局将「专家」文案改为「智能体」

统一智能体功能的 UI 文案:侧边栏、智能体页、新建/保存流程、
审批卡片、登录提示及后端审批消息中的「专家」均改为「智能体」。
保留 prompt 人设、QA 子应用等非该功能语义的「专家」表述。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-15 16:41:44 +08:00
xuhd ab48cd5b55 111 2026-05-15 16:36:02 +08:00
吉鑫升andClaude Opus 4.7 ed4909a28d chore: 纳入 skill_evolution 运行时配置文件
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:29:26 +08:00
吉鑫升 afc517b3a7 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	frontend-web/src/components/workspace/agents/agent-gallery.tsx
#	frontend-web/src/components/workspace/settings/skill-settings-page.tsx
2026-05-15 16:27:56 +08:00
吉鑫升andClaude Opus 4.7 646cb8caeb feat: 标签管理功能 + 问答前缀对用户隐藏
标签管理:
- 新增 tags/tag_assignments 持久化层、迁移、/api/tags 路由
- 标签分智能体/技能/定时任务三类, 仅同类型可互打, 仅 admin 可增删改与打标签
- 三类资源列表支持按名称模糊检索与按标签筛选(走后端查询参数)
- 前端新增标签管理页、TagPickerDialog(点击即时绑定)、彩色标签 chip

问答前缀:
- 前缀不再拼入消息 content, 改存 additional_kwargs 由 PromptPrefixMiddleware
  在调模型前临时注入, 用户界面看不到前缀但模型仍收到
- 修正 thread_existed 闸门导致首轮前缀被跳过的问题

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:19:08 +08:00
xuhdandClaude Opus 4.7 9664cb653a feat(frontend): 记忆从设置弹窗移至侧边栏独立入口
- 新增 MemoryPage 及路由 /page/workspace/memory
- 侧边栏在「技能」下方新增「记忆」入口
- 设置弹窗注释掉「记忆」标签页

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:47:49 +08:00
xuhd 1574d44cc3 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-05-15 15:26:16 +08:00
xuhdandClaude Opus 4.7 6981fc06ca feat(skills): 点击技能展示 SKILL.md 详情弹窗
- 后端新增 GET /api/skills/{name}/content 接口,返回公共/自定义技能的 SKILL.md 原文
- 前端技能行内容区可点击,弹窗以等宽原文展示 SKILL.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:23:34 +08:00
xuhdandClaude Opus 4.7 12798c00bc feat(frontend): 设置弹窗移除「技能」标签页
技能管理已有侧边栏独立入口(/page/workspace/skills),设置弹窗内的入口冗余,移除。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:10:43 +08:00
xuhdandClaude Opus 4.7 ebb6be816b feat(frontend): 技能/专家广场展示调整 + 通知合并入设置菜单
- 技能与专家「广场」标签只展示内置和已发布项,Tab 顺序调为「我的」在前并默认选中
- 「智能体」全量更名为「专家」
- 通知由侧边栏铃铛改为「设置和更多」菜单内的弹窗入口,合并通知列表与通知设置

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:08:56 +08:00
xuhdandClaude Opus 4.7 013aafde17 feat(frontend): 记忆页面迁移到 Memory V2
前端记忆模块整体迁到后端 Memory V2 接口,修复调旧端点 /api/memory
返回 404 的问题。

- core/memory/{types,api,hooks}.ts 重写为 V2 数据模型与端点
  (USER.md / MEMORY.md 条目增删改、配置读写、Hindsight 检索)
- memory-settings-page.tsx 重写:用户画像 + 工作笔记两区的条目
  增删改、记忆设置面板(白名单字段)、Hindsight 检索面板
- 工作笔记按专家隔离,顶部专家下拉(默认助手 + useAgents 列出的
  用户专家),专家列表取自 agents 注册表
- settings-dialog 记忆分区移到技能下方
- i18n(zh-CN/en-US/types)memory 文案整体替换

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:14:08 +08:00
董宏凯 050b1ad78e 代码提交 2026-05-15 14:08:01 +08:00
xuhdandClaude Opus 4.7 21a76f5e7c refactor(memory): Memory V2 P3 - 舍弃后台抽取 + Gateway 端点收口
P3 在 P1/P2 基础上完成 Memory V2 收尾:

舍弃后台抽取:
- 删除 updater/queue/storage/prompt/summarization_hook 五个模块
- 移除 background_extraction 配置段、扁平字段兼容、storage 字段
- 理由:Hindsight auto_retain 已自动留存每轮对话,后台抽取冗余

Hindsight 召回注入改用 wrap_model_call:
- 由 before_model 改为 wrap_model_call —— 召回只注入单次模型请求、
  不落持久 state,从根上消除 <memory-context> 泄漏给前端的风险
- builtin frozen snapshot 仍走系统提示词

Gateway 端点收口为 /api/memory/v2*:
- 删除旧 facts/history 端点 (/api/memory、/memory/facts 等)
- 新增 entries 增删改 (POST/PUT/DELETE /memory/v2/entries)
- 新增 POST /memory/v2/search —— Hindsight recall/reflect 检索代理
- client.py memory 方法重写为 V2 等价方法

文档同步更新 CLAUDE.md 与 MEMORY_V2_DESIGN_ZH.md。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:08:18 +08:00
xuhdandClaude Opus 4.7 f9d7610679 feat(memory): Memory V2 重写 - 可插拔 provider + Hindsight 集成
以 Hermes 的可插拔记忆架构为骨架重写 deerflow 记忆模块,兼容原有
结构化数据,并新增 Hindsight 作为外部记忆后端。

P1 - 抽象层 + builtin 本地记忆:
- MemoryProvider 抽象基类 + MemoryManager 编排层(builtin + ≤1 外部)
- BuiltinFileProvider:USER.md/MEMORY.md 二元存储、字符配额、文件锁、
  原子写、frozen snapshot
- memory 工具(LangChain @tool):LLM 主动写入用户画像/工作笔记
- 记忆内容安全扫描(注入/外渗模式 + 不可见 unicode)
- memory_config 改为嵌套结构 + 旧扁平字段兼容 + per-user 三层覆盖
- 迁移脚本 migrate_memory_to_v2.py:旧 memory.json -> USER.md/MEMORY.md
- Gateway 新增 /api/memory/v2 与 v2/config 端点(用户可配置白名单字段)

P2 - Hindsight 外部 provider:
- HindsightProvider:双 bank 隔离(user bank 跨 agent 共享 + work bank
  agent 私有)、并发召回合并、auto-retain、on_memory_write 镜像
- hindsight_recall/reflect/retain 三个 LangChain 工具
- MemoryMiddleware.before_model 注入 Hindsight 动态召回
- context/tools/hybrid 三种 memory_mode
- hindsight-client 作为可选依赖,未装时自动退回纯 builtin

注入走系统提示词(builtin frozen snapshot)+ before_model(Hindsight
动态召回)。后台抽取保留但默认关闭,P3 期重接入。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:15:55 +08:00
吉鑫升 aeba15eb16 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts:
#	frontend-web/src/pages/WorkspaceRoutes.tsx
#	offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
2026-05-15 11:21:48 +08:00
董宏凯 b81f64a253 Merge branch 'dhk-2' into 'master'
Dhk 2

See merge request knowledge-base-bj/deerflow-server!16
2026-05-15 02:43:25 +00:00
fengxiangdebaozi a0d04cba50 文档修改 2026-05-15 10:42:44 +08:00
xu hd 43bdbcf89c Merge branch 'xuhd' into 'master'
feat(skills): 自我进化后端 — curator 配置可编辑 + 运行日志可读

See merge request knowledge-base-bj/deerflow-server!15
2026-05-15 01:36:33 +00:00
xuhdandClaude Opus 4.7 6947a3ffa7 chore(tests): ignore tests/ 目录 + 同步现有测试到新 skill_archive 签名
- .gitignore: 新增 offline-backend-20260512/backend/tests/* 规则,
  让后续新增的测试文件不再误进入版本控制
- test_skills_multitenancy.py: 同步上次 feat(skills) 提交里
  skill_archive(name, reason) 必填 reason 的签名变更

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:03:07 +08:00
xuhdandClaude Opus 4.7 62d525dae7 feat(skills): 自我进化 - 软归档/恢复 + Agent 归属 + 状态分层 UI
后端:
- 新增 PUT /api/skills/custom/{name}/state 与 /pinned 软归档/恢复 + 钉选 API
- skill_manage 工具: agent 创建写 DB 归属; 删除清理 .usage.json + DB row + cascade 引用; 加 ownership 预校验
- curator 全部软归档(文件保留在 custom/), 加 reason 必填(中文), 修 user_context 跨线程传播 / 协程泄漏 / archive 文件不再移动
- LLM 评审接 ToolErrorHandlingMiddleware, 工具异常自动重试不中止本轮; 替换 create_react_agent 为 create_agent
- skill_list 按当前 agent config.yaml 的 skills 字段过滤(custom agent), lead agent 仍可见全部
- 自我进化提示词对齐 Hermes 风格: 主动询问保存, reason 强制中文且面向用户
- 控制台日志增强: 自动归档/标过时/LLM 合并/删减建议 都按条目输出原因

前端:
- 技能管理页 Tab 改「全部 / 我的」, 正确包含他人发布的 custom 技能(修复 isCustom 互斥 bug)
- 新增「过时 N / 归档 N」右侧抽屉(恢复/查看/立即归档 ⋯ 菜单)
- 新增徽章: 钉选 / 来源(Agent 创建 / 用户上传) / 使用次数展示
- 内置技能徽章修复(原 isBuiltin && isCustom 永远 false)
- 自我进化日志 run_started sentinel 在前端展示为中文分隔条
- 移除冗余 enabled 开关(归档已覆盖此能力, 减少术语)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:00:52 +08:00
xuhd f8bc74b27f Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-05-14 23:45:10 +08:00
董宏凯 044370f13c 沙箱内容可保存 2026-05-14 22:12:20 +08:00
xuhd a2c45492cb 2222 2026-05-14 20:45:22 +08:00
xuhd 9a67ca7c03 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-05-14 20:45:02 +08:00
xuhdandClaude Sonnet 4.6 40cd8a24af feat: admin skill public upload + user management page
- Admin users can choose to install skills to public (built-in) or custom category via UI radio selector in InstallSkillDialog
- Backend: SkillStorage.ainstall_skill_from_archive accepts target_category param; non-admin 403 on public install
- Backend: AgentStore/SkillStore add count_owned(user_id); UserRepository/LocalAuthProvider add list_users()
- Backend: GET /api/v1/auth/users endpoint (admin-only) returns all users with skill_count and agent_count
- Frontend: admin-only "用户管理" nav item in sidebar; /page/workspace/admin/users route with user table
- i18n: added sidebar.users and adminUsers section in zh-CN/en-US

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:40:23 +08:00
吉鑫升andClaude Sonnet 4.6 28948975db Merge: 解决 ScheduledTaskRunDetailPage 合并冲突
保留 HEAD 的编辑功能 import(useCallback、Edit3、Loader2、Save、X),
同时采用 b78672 中的 useRouteNavigate 自定义导航 hook。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:29:35 +08:00
fengxiangdebaozi 1ce1db9689 代码处理 2026-05-14 19:28:07 +08:00
fengxiangdebaozi 86cdeed6ef Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-2 2026-05-14 19:26:01 +08:00
fengxiangdebaozi d6ec0afbd6 路由嵌套修改 2026-05-14 19:25:39 +08:00
吉鑫升 3ea33e1dee 大模型速度增加提示 2026-05-14 19:12:14 +08:00
xuhdandClaude Opus 4.7 3e6e3c08d5 feat(skills): curator 三条件过滤 — agent 创建 + 未发布 + 无引用
Curator 之前只过滤 source=='agent',导致两类 skill 仍会被自动归档:
1. owner 已 published 的 skill(user B 在用,结果被 curator 收走)
2. 加进 agent.skills 但 .usage.json 活动不够频繁的 skill

新规则:curator transitions 和 LLM 合并候选**只**作用于同时满足
三条件的 skill —— source=='agent' + published==False + 不被任何
agent.config.yaml 的 skills 字段引用。

curator.py
- 新 helper _collect_referenced_skill_names():扫所有
  .deer-flow/agents/*/config.yaml 把 skills 字段聚合成 set,一次
  curator run 内只调一次
- 新 helper _is_skill_published(name):通过 SkillStore 查 published
  状态,失败时返回 False(保守降级到旧行为)
- 新 helper _eligible_for_curation(name, entry, owned_set, referenced_set)
  三条件合一判定
- apply_automatic_transitions 应用三条件过滤;返回的 counters 加
  skipped_referenced / skipped_published 计数
- _render_candidate_list 应用同一过滤,LLM 看不到 protected skill
- _make_skill_archive_tool safety net:即使 LLM 编造名字调 archive,
  工具内部再做一次"被引用 + published"检查,违规拒绝并记 warning

测试 tests/test_skills_multitenancy.py 新增 5 用例:
- test_curator_skips_skill_referenced_by_any_agent
- test_curator_skips_published_skill
- test_curator_archives_orphan_unpublished_skill
- test_skill_archive_tool_refuses_referenced_or_published
- test_collect_referenced_skill_names_aggregates_from_disk

跑测试:tests/test_skills_multitenancy.py 17/17,全套 36/36 全过。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:49:22 +08:00
xuhdandClaude Opus 4.7 6155a2ec2b fix: curator + notifications react-query cache 按 user_id 隔离
切换账号在同一 tab 里时,旧 cache key (["curator","config"] 等) 会
让新账号"看到"上一个账号的值。如果用户没意识到、直接点保存,就会
把上一个账号的配置原样写到新账号的 per-user JSON 里去。

修复:在 useCuratorKeys / useNotificationKeys hook 里读
getStoredAuth().user_id 织进 queryKey。每个用户拥有独立 cache
桶,切换账号绝不命中对方的数据。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:20:41 +08:00
董宏凯 4e0787ec77 Merge branch 'dhk-2' into 'master'
页面接口整体修改

See merge request knowledge-base-bj/deerflow-server!14
2026-05-14 10:14:39 +00:00
fengxiangdebaozi 8ecc32da6d 页面结构修改 2026-05-14 18:13:58 +08:00
xuhdandClaude Opus 4.7 eb41da78f2 fix: 四处 router.push 缺 /page 前缀导致 404
HashRouter 顶层路由是 /page/*,所有 workspace 子路由必须带这个前缀
才能命中。以下四处 router.push 字面量遗漏,点击后落到 NotFoundPage:

- skill-evolution-panel.tsx 返回技能按钮
- NewAgentPage.tsx 返回 agents
- NewAgentPage.tsx 创建后“开始对话”
- agent-card.tsx 列表卡片“聊天”按钮

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:56:32 +08:00
xuhdandClaude Opus 4.7 a508969331 chore: 忽略 skill_evolution.json 运行时文件
前端 PUT /api/curator/config 会把 per-user 覆盖写到
backend/skill_evolution.json(全局 fallback),属于运行时
数据不该入库。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:46:36 +08:00
xuhdandClaude Opus 4.7 99febda256 feat(skills/notifications): 多租户阶段 4 — 测试 + 前端
后端:
tests/test_skills_multitenancy.py(12 个用例,全部通过)
- SkillStore: 可见性 / 全局重名拒绝 / owner-only update+delete /
  admin override / ensure_legacy 仅补 NULL owner
- 路由助手 _get_editable_skill_or_raise / _get_owned_skill_or_raise
  的 owner / admin / 403 / 404 分流
- cascade_skill_unpublished_or_deleted:
  · unpublish 同时扫两个用户的 agent,剥掉 skill;自己改自己不发
    通知,其他人收 agent_skill_removed
  · admin_takedown 额外给 skill owner 发 skill_taken_down
- NotificationStore CRUD + unread_count + 跨用户隔离
- 同步版 apply_automatic_transitions 用户隔离:A 跑 curator 不
  动 B 的 skill;curator_log per-user 分桶

前端 — core/notifications/{types,api,hooks,index}.ts
- 类型对齐 routers/notifications.py 的 NotificationItem
- listNotifications / getUnreadCount / markRead / markAllRead /
  deleteNotification
- useNotifications / useUnreadCount(refetchInterval=30s) / mutation 钩子

前端 — components/workspace/notification-bell.tsx
- 顶部铃铛图标 + 未读红点(>99 显示 99+)
- 复用 DropdownMenu 作为弹层(项目暂无 Popover 原语)
- 列表项:未读蓝点 + 标题 + body + 相对时间,悬停露出删除按钮
- 标记单条已读 / 全部已读 / 删除
- 接入 workspace-nav-menu.tsx,作为 SidebarMenuItem 放在设置入口上方

前端 — Skill 多租户字段
- core/skills/type.ts 加 owner_user_id / published / builtin
- core/skills/api.ts 加 setSkillPublished + deleteCustomSkill
- core/skills/hooks.ts 加 useSetSkillPublished + useDeleteCustomSkill

前端 — settings/skill-settings-page.tsx
- 抽出 SkillRow 子组件,每项渲染 owner badge(内置/我的/来自他人/
  已发布),样式抄自 agent-card.tsx 的 isBuiltin/isOwned/isFromOthers
- canManage = isOwned || isAdmin;canTogglePublish 同上但排除内置
- 发布/取消发布按钮(Eye / EyeOff),调用 setSkillPublished
- 删除按钮 + 二次确认 Dialog,调用 deleteCustomSkill
- 启用开关保持原样

前端 — i18n
- types.ts: 新增 NotificationsTranslations + Translations.notifications;
  settings.skills 加 badgeBuiltin/badgeMine/badgeFromOthers(+Title) /
  badgePublished(+Title) / publishAction / unpublishAction /
  publishSuccess / unpublishSuccess / deleteAction / deleteConfirm /
  deleteSuccess
- zh-CN.ts / en-US.ts 同步补齐对应文案

验证
- PYTHONPATH=. uv run pytest tests/test_skills_multitenancy.py -v →
  12 passed in 2.08s
- 与现有 test_agents_backend / test_skill_evolution_runtime /
  test_skills_install_upload 并行跑:41 通过 + 1 个 pre-existing 失败
  (test_validate_rejects_missing_skill_md 期望 "SKILL.md" 但收到
  "Skill archive is empty",与本期改动无关)
- pnpm typecheck:本次新增/修改的文件零新增错误;剩余报错全部为
  pre-existing 的 next/link、next/navigation 别名声明缺失、
  workspace-nav-menu.tsx 的 SettingsSection 类型不匹配、nextra
  缺失、markdown-docx 第三方库等

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:35:02 +08:00
fengxiangdebaozi 543d78e040 路由调整 2026-05-14 17:24:45 +08:00
xuhdandClaude Opus 4.7 4ef96c7930 feat(skills): 多租户阶段 3 — Curator + 配置 per-user
curator_log.py
- ring buffer 从单一 deque 改为 dict[user_id -> deque],每个用户
  自己的运行日志互不可见
- start_new_run / record / get_recent / clear 全部接 user_id
  关键字参数;未传时落入 __default__ 桶兼容旧调用
- 新增 clear_all() 用于跨用户清理(仅测试用)

curator.py
- 状态文件路径从 skills/.curator_state 改为
  .deer-flow/users/{user_id}/.curator_state(per-user 隔离)
- 所有公开 API 接受 user_id 参数:should_run_now /
  apply_automatic_transitions / _render_candidate_list /
  run_curator_review / _curator_thread_target /
  get_curator_status / _make_skill_archive_tool / _run_llm_review
- 新增 _list_user_owned_skill_names(user_id):通过 SkillStore
  查询当前用户拥有的 skill 名集合,用于过滤 transitions / LLM
  candidates,保证 user A 跑 curator 不会动 user B 的 skill;
  SkillStore 不可达时退化为"全部 agent-created skill"(兼容旧部署)
- _get_curator_config(user_id) 通过 load_skill_evolution_config_for_user
  叠加 per-user override,让阈值/间隔/moderation_model 也是 per-user
- maybe_run_curator 启动时枚举 UserRow,按用户检查 should_run_now
  并各起一个 daemon 线程跑

skill_evolution_runtime.py
- 全局 backend/skill_evolution.json 语义保留作为 fallback
- 新增 per-user API:resolve_user_path / load_user_overrides /
  save_user_overrides / get_user_overrides_mtime,文件落到
  .deer-flow/users/{user_id}/skill_evolution.json
- 新增 load_skill_evolution_config_for_user(user_id):读全局
  SkillEvolutionConfig 作为 baseline,浅合并 per-user JSON,
  返回 Pydantic 实例。验证失败时回落到 baseline

routers/skills.py
- /api/curator/status / run / config GET+PUT / logs 全部接
  _current_user_id 注入运行上下文
- update_curator_config 改为 per-user:保存到
  users/{user_id}/skill_evolution.json,不再 reset_app_config
- _build_curator_config_response 接受 SkillEvolutionConfig 实例
  (而不是 AppConfig)以适配 per-user 合并结果

旧 19 个测试继续通过;未引入新依赖。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:09:05 +08:00
fengxiangdebaozi 7a7c99f752 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-2 2026-05-14 16:51:58 +08:00
fengxiangdebaozi 6b1f17d6a8 页面导入 2026-05-14 16:51:32 +08:00
xuhdandClaude Opus 4.7 cf191ef874 feat(skills/notifications): 多租户阶段 2 — 路由 + 级联 + 启动迁移
后端工作:

deps.py
- 挂载 skill_store / notification_store 到 app.state
- 启动时 set_notification_store(...) 注入到 harness sender;
  关闭时清空,保证测试和 embedded 模式不留状态
- 加 get_skill_store / get_notification_store FastAPI 依赖

routers/skills.py
- 加 _current_user_id / _is_admin_user / _get_owned_skill_or_raise /
  _get_editable_skill_or_raise(对齐 routers/agents.py)
- SkillResponse 加 owner_user_id / published / builtin 字段,
  _skill_to_response 接受 SkillStore 记录映射
- GET /api/skills 与 GET /api/skills/custom 改 per-user 可见性过滤
- POST /api/skills/install + /install-upload 预解析 archive 名校验
  全局 name 不被他人持有;安装后 ensure_legacy 注册 owner=current
- DELETE /api/skills/custom/{name} 走 _get_editable_skill_or_raise,
  调 cascade(owner 自删 = delete,admin 跨用户 = admin_takedown),
  SkillStore.delete / delete_admin 同步清除 DB 记录
- 新增 PUT /api/skills/custom/{name}/published 切换发布状态;
  从 published → unpublished 时触发 cascade(unpublish / admin_takedown)

skills/cascade.py(新)
- 扫所有 .deer-flow/agents/{id}/config.yaml,把指定 skill 从其 skills
  数组移除,并通过 deerflow.persistence.notifications.notify 给受
  影响 agent 的 owner 发站内信;admin 强制下架时另给 skill owner 发
  一条
- 操作类型:unpublish / delete / admin_takedown,对应不同文案

routers/notifications.py(新)+ app.include_router
- GET /api/notifications(unread_only / limit)
- GET /api/notifications/unread_count(红点用)
- POST /api/notifications/{id}/read
- POST /api/notifications/read_all
- DELETE /api/notifications/{id}

app.py lifespan
- _sync_legacy_skills(app):每次启动扫 skills/custom/ 把没在 DB
  的 skill 落库为 admin owner + published=True,幂等、非阻塞失败
- 调用顺序:_ensure_admin_user → _sync_legacy_skills → 启动 curator

测试:19 个旧用例继续通过;阶段 4 会写 test_skills_multitenancy.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:24:42 +08:00
董宏凯 f6d3d013fd Merge branch 'dhk-2' into 'master'
样式修改

See merge request knowledge-base-bj/deerflow-server!13
2026-05-14 08:04:28 +00:00
fengxiangdebaozi e6ad3c414f 页面布局调整 2026-05-14 16:01:25 +08:00
xuhdandClaude Opus 4.7 8162719e36 feat(skills/notifications): 多租户阶段 1 — 持久化层
新增两套 persistence 模块,仿 persistence/agents/ 模板:

1. persistence/skills/
   - SkillRow ORM (PK=name, owner_user_id, published, timestamps)
   - SkillStore 抽象:list_visible / get_visible / get_any /
     get_owned / create / ensure_legacy / update / update_admin /
     delete / delete_admin
   - SqlAlchemy 实现 SkillRepository
   - MemorySkillStore 用于测试
   - 可见性 = owner OR published OR builtin

2. persistence/notifications/
   - NotificationRow ORM (id, user_id, type, title, body, payload JSON,
     read, created_at)
   - NotificationStore 抽象:create / list_for_user / unread_count /
     mark_read / mark_all_read / delete
   - SqlAlchemy 实现 NotificationRepository
   - MemoryNotificationStore 用于测试
   - sender.notify(user_id, type, ...) 全局接口;harness 不能 import
     app.*,所以用 set_notification_store / get_notification_store
     注入模式

3. persistence/models/__init__.py 注册 SkillRow + NotificationRow

后续阶段会接入路由、cascade、curator per-user 重构、前端。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:44:36 +08:00
xuhdandClaude Opus 4.7 f5bc02d598 fix: skills 路由两个小修复
skills.py:
- update_curator_config 直接 await get_curator_config() 报错
  ('Depends' object has no attribute 'skill_evolution')。FastAPI 路由
  默认参数 Depends(...) 只在请求时被框架解析,直接调用拿到的是依赖标记。
  抽出 _build_curator_config_response(config) 让 GET 和 PUT 共用。

skill_manage_tool.py:
- 模型把 action='list' 当成可用动作误调 skill_manage 报
  Unsupported action。补两个 read-only 别名 list/view,分别委派到
  skill_list_tool 和 skill_view_tool,docstring 同步更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:43:26 +08:00
fengxiangdebaozi 139dfb76e4 bug修改 2026-05-14 14:59:12 +08:00
fengxiangdebaozi f4769c4046 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-2 2026-05-14 14:28:31 +08:00
fengxiangdebaozi fea3cc741f 系统合并 2026-05-14 14:28:17 +08:00
xuhdandClaude Opus 4.7 4e764350a4 feat(agents): 创建专家页面加模型选择
NewAgentPage 在两处加 Select 下拉:
- 左侧聊天框上方 "当前对话模型":透传给 useThreadStream 的
  context.model_name,控制当前 bootstrap 对话调用的 LLM
- 右侧表单 "模型":写入 agent.model,控制专家保存后被使用时调用
  的 LLM

两个下拉互相独立,复用 useModels() 拉 /api/models。页面加载完
模型列表后自动 seed 到列表第一个(与 config.yaml 全局默认对齐),
用户可随时切换。

杂项:.gitignore 忽略 skills/ 与 hermes-agent-main/ 运行时目录。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:59:10 +08:00
xuhd c63811367e 123 2026-05-14 12:45:04 +08:00
xuhdandClaude Opus 4.7 4cc8b3198b fix: 修复 setup_agent 创建专家流程的多个问题
后端 (setup_agent_approval_middleware.py):
- 拦截策略从 wrap_tool_call + Command(goto=END) 改为
  after_model + jump_to="end"。在 tool_node 执行前直接终止图,
  避免模型并行调多个 tool_call 时 Command 输出被稀释、graph
  路由不稳定的问题(症状:审批 ToolMessage 发出后流不结束)。
- 用 @hook_config(can_jump_to=["end"]) 装饰器声明跳转能力,
  否则 LangChain 不会为该 hook 构造 conditional edge。

前端 (NewAgentPage.tsx):
- 聊天阶段不再调 ensureAgent / POST /api/agents,placeholder
  agent 不再落库;agent 仅在用户点右上角“保存”时创建。
- 自动生成的占位名 "未命名专家-xxx" 不再回写到输入框,保持空白
  直到用户输入或 setup_agent 审批回填。
- handleApproveSetupAgent 仅把模型 propose 的 payload 填入表单,
  不再 PUT /api/agents/{id};落库交给手动保存按钮。
- onToolEnd 不再 getAgentWithRetry —— 工具被 middleware 拦截、
  根本不会执行,没有 agent 状态可拉。清理对应死代码(getAgent
  import、wait()、AGENT_READ_RETRY_DELAYS_MS、getAgentWithRetry)。
- 路由跳转补齐 /page 前缀:返回按钮、“开始对话”按钮。
- 右侧表单:SOUL.md textarea 加 max-h-[400px],技能列表外框加
  max-h-[240px],各自 overflow-y-auto,避免长内容撑高整列。

前端 (agent-card.tsx):
- 列表卡片“聊天”按钮的跳转路径补齐 /page 前缀,避免点击后 404。

无新依赖。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:35:49 +08:00
xuhdandClaude Opus 4.7 5c6ac06656 feat(skills): 自我进化前端 — Skills 独立侧边栏 + /skills/evolution 页面
UI 重构:
- settings-dialog.tsx:把 Skills section 从设置弹窗移除
- workspace-nav-chat-list.tsx + WorkspaceRoutes.tsx + SkillsPage.tsx:
  Skills 提升为独立侧边栏导航项,/workspace/skills 路由直接渲染
  SkillSettingsPage(同事原型已就绪)
- skill-settings-page.tsx:顶部追加 "自我进化" 跳转按钮

自我进化页面:
- /workspace/skills/evolution 子路由 → SkillEvolutionPage 包装
- skill-evolution-panel.tsx:三段式
  · 状态卡:启用/暂停徽章 + 累计运行次数 + 上次运行时间/摘要 +
    技能计数 (total/active/stale/archived) + 立即执行按钮
  · 配置表单:enabled / curator.enabled / interval_hours /
    stale_after_days / archive_after_days / moderation_model_name
    本地暂存 → 保存调 PUT,热生效
  · 日志面板:拉 GET /api/curator/logs,2 秒自动刷新,monospace
    展示,warning 黄、error 红
- core/curator/api.ts + hooks.ts + types.ts:TanStack Query 包装
  status / config (GET/PUT) / run / logs

i18n:
- types.ts 新增 settings.skillEvolution.* 类型签名
- zh-CN.ts / en-US.ts:状态卡 / 配置表单 / 日志面板文案,
  以及 settings.skills.goToEvolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:00:54 +08:00
xuhdandClaude Opus 4.7 9b6b94d919 feat(skills): 自我进化后端 — curator 配置可编辑 + 运行日志可读
后端骨架:
- curator.py / usage.py / skill_tools.py:基于使用计数的生命周期管理
  + LLM 合并/归档建议(同事已有原型,集中提交)
- skill_evolution_config.py:CuratorConfig 嵌套模型
- app.py:启动时按间隔触发 curator 后台线程
- tools.py:开启自我进化时一并注册 skill_list / skill_view 工具
- skill_manage_tool.py:agent 创建技能时标记 source=agent

本次新增:
- skill_evolution_runtime.py:skill_evolution.json 读写,原子写入
- app_config.py:把 overrides 深合并到 AppConfig.skill_evolution,
  并把 overrides 的 mtime 纳入缓存失效条件,前端 PUT 后热生效
- curator_log.py:进程内 ring buffer (maxlen=200),所有 record/get
  操作内部兜异常,保证 logging 永远不会让 curator 跑挂
- curator.py 关键节点插桩:run_started / 自动转换计数 / 候选数 /
  LLM agent invoke / 解析结果 / archive 动作 / run complete
- routers/skills.py:GET/PUT /api/curator/config(写到
  skill_evolution.json + reset_app_config)+ GET /api/curator/logs

测试:
- tests/test_skill_evolution_runtime.py:overrides 往返、损坏 JSON
  容错、AppConfig 合并、ring buffer 线程安全、record 异常吞咽

杂项:
- .gitignore:忽略 .deer-flow/* 与 skills/* 等运行时目录
- uv.lock:跟随依赖锁更新
- 清理已废弃的 agent runtime 数据 SOUL.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:00:21 +08:00
董宏凯 8f4af5ece4 Merge branch 'dhk-2' into 'master'
添加提示词权限

See merge request knowledge-base-bj/deerflow-server!12
2026-05-14 03:52:27 +00:00
fengxiangdebaozi 2d9248889f Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-2 2026-05-14 11:51:51 +08:00
fengxiangdebaozi 804f37dcfa 提示词权限配置 2026-05-14 11:51:29 +08:00
董宏凯 bf98713a55 Merge branch 'dhk-2' into 'master'
推荐问题接口添加

See merge request knowledge-base-bj/deerflow-server!11
2026-05-14 03:08:14 +00:00
fengxiangdebaozi 2f2ba44c7c 添加推荐问题修改接口 2026-05-14 11:07:28 +08:00
fengxiangdebaozi 62292500ba Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-2 2026-05-14 10:52:08 +08:00
fengxiangdebaozi a7f1e89ce9 提示词修改 2026-05-14 10:51:29 +08:00
董宏凯 20c7e2841c Merge branch 'dhk-2' into 'master'
提示词修改

See merge request knowledge-base-bj/deerflow-server!10
2026-05-14 02:45:46 +00:00
fengxiangdebaozi c79f5c5a7a 样式修改 2026-05-14 10:42:37 +08:00
xu hd d3771ed31f Merge branch 'xuhd' into 'master'
feat: skill 名字允许下划线分隔

See merge request knowledge-base-bj/deerflow-server!9
2026-05-14 02:42:15 +00:00
xuhdandClaude Opus 4.7 230eec1baf feat: skill 名字允许下划线分隔
放宽 skill 命名校验:除连字符外也接受下划线作为分隔符,首尾和连续
分隔符的限制扩展到 _ 与混合分隔(--、__、-_、_- 都拒绝)。前置
frontmatter 校验与存储层正则同步放宽。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:41:37 +08:00
xu hd 0bae393e07 Merge branch 'xuhd' into 'master'
fix: /login/admin 自动把历史 admin 账户升级为 admin 角色

See merge request knowledge-base-bj/deerflow-server!8
2026-05-14 02:28:42 +00:00
xuhdandClaude Opus 4.7 434cf40b36 fix: /login/admin 自动把历史 admin 账户升级为 admin 角色
历史上以 username=admin 自动注册的账户曾被建成 system_role=user,
登录时按 username 修正一次,避免老库需要手工改表。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:25:24 +08:00
xu hd 7417a1c950 Merge branch 'xuhd' into 'master'
feat: admin可删除内置专家

See merge request knowledge-base-bj/deerflow-server!7
2026-05-14 02:18:33 +00:00
xu hd 799ccbe300 Merge branch 'xuhd' into 'master'
feat: admin可编辑内置专家 + agent列表Tab分组

See merge request knowledge-base-bj/deerflow-server!6
2026-05-14 02:06:42 +00:00
xuhdandClaude Opus 4.7 43044232bb feat: admin可删除内置专家
复用 _get_editable_or_raise 分支,给 AgentStore 增加 delete_builtin,
路由按 edit_mode 决定走 delete 还是 delete_builtin;前端 agent-card
为 admin 显示内置 agent 的删除按钮。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:53:13 +08:00
董宏凯 4567db8836 Merge branch 'dhk-2' into 'master'
页面结构调整

See merge request knowledge-base-bj/deerflow-server!5
2026-05-14 01:43:08 +00:00
fengxiangdebaozi e286e59431 页面结构调整 2026-05-14 09:42:12 +08:00
fengxiangdebaozi 65e89a4248 样式修改 2026-05-14 09:36:45 +08:00
fengxiangdebaozi db63086c38 文档修改 2026-05-14 09:35:15 +08:00
xuhdandClaude Sonnet 4.6 2abdf28eba feat: admin可编辑内置专家 + agent列表Tab分组
- /login/admin 登录时自动赋予 admin 角色,允许编辑内置专家
- agent列表页新增「全部」(内置+已发布) 和「自己的」两个Tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:16:18 +08:00
董宏凯 2476f04a26 Merge branch 'dhk-1' into 'master'
接口地址填写

See merge request knowledge-base-bj/deerflow-server!4
2026-05-13 14:29:34 +00:00
董宏凯 e458f1eab4 接口地址填写 2026-05-13 22:29:05 +08:00
fengxiangdebaozi b15b2649f8 文档修改 2026-05-13 22:22:59 +08:00
fengxiangdebaozi 4fce7ba09a Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server 2026-05-13 22:19:02 +08:00
fengxiangdebaozi 1a1369f115 代码提交 2026-05-13 22:18:55 +08:00
董宏凯 9434ffabdc Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!3
2026-05-13 14:17:47 +00:00
董宏凯 6aeb428796 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-05-13 22:17:25 +08:00
董宏凯 8cabf32ebe 提交 2026-05-13 22:16:59 +08:00
董宏凯 85a98fcfd6 Merge branch 'dhk-v3' into 'master'
ceshi1

See merge request knowledge-base-bj/deerflow-server!2
2026-05-13 14:09:07 +00:00
董宏凯 aa1f81a30a ceshi1 2026-05-13 22:08:37 +08:00
fengxiangdebaozi 1c50c73aa4 样式修改 2026-05-13 21:41:13 +08:00
fengxiangdebaozi 6cc28c7edd Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server 2026-05-13 21:25:40 +08:00
fengxiangdebaozi 1c2cf768f8 样式修改 2026-05-13 21:25:29 +08:00
吉鑫升 481e718d86 Merge remote-tracking branch 'origin/master' 2026-05-13 21:02:27 +08:00
吉鑫升andClaude Opus 4.7 29f74afbf6 fix(scheduler): give every scheduled run its own throwaway thread
All scheduled tasks for a user shared one scheduler thread, so each run
appended its messages to the same LangGraph checkpoint. Over time the
history blew past the model's input window and every task on that
account started failing with context-too-long.

_execute_task now mints a fresh thread_id per run and threads it through
ensure_thread_meta / snapshot / start_run / extract_*. The per-task
scheduler_thread_id column is left in place for backwards compatibility
but no longer drives the LLM call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:58:23 +08:00
fengxiangdebaozi 02111ab5b8 文档修改 2026-05-13 20:22:14 +08:00
吉鑫升 a2f7157bc3 chore: remove .claude/settings.local.json from tracking
Followup to previous commit which only landed the .gitignore rule but
left the file still tracked in the index.
2026-05-13 20:14:37 +08:00
吉鑫升 e6591abfbc chore: stop tracking .claude/settings.local.json
This file holds per-user Claude Code permission allowlists and should not
be shared across the team. Adding it to .gitignore and removing from the
index; existing working copies are preserved.
2026-05-13 20:14:00 +08:00
吉鑫升 50bda1ddee Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts:
#	frontend-web/src/components/workspace/agents/agent-card.tsx
#	frontend-web/src/pages/NewAgentPage.tsx
2026-05-13 20:10:21 +08:00
吉鑫升 31abad86d5 智能体配置 2026-05-13 20:04:06 +08:00
fengxiangdebaozi af7e90c87a Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server 2026-05-13 18:23:25 +08:00
fengxiangdebaozi 2e4bdf122e 页面结构修改 2026-05-13 18:18:05 +08:00
吉鑫升 83d7fa26fe Merge remote master docs 2026-05-13 17:28:04 +08:00
fengxiangdebaozi efc4d89fdf 文档 2026-05-13 17:17:46 +08:00
吉鑫升 a27206c543 Update local frontend and backend changes 2026-05-13 17:17:42 +08:00
吉鑫升 faca2c9a2d Merge branch 'likun' into master 2026-05-13 17:02:22 +08:00
fengxiangdebaozi 0cbd137b4f wendang1xiug1 2026-05-13 16:38:34 +08:00
吉鑫升 aa034f8669 定时任务订阅模式 2026-05-13 14:59:27 +08:00
吉鑫升 839386f0b4 test: verify new remote push 2026-05-13 14:23:55 +08:00
1513 changed files with 316089 additions and 28131 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "md-viewer-frontend",
"runtimeExecutable": "npx",
"runtimeArgs": ["ng", "serve", "--port", "4200"],
"port": 4200,
"cwd": "F:\\angular\\md-viewer\\frontend"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"PowerShell(uv --version)",
"PowerShell(netstat -ano)",
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })"
]
}
}
+1
View File
@@ -0,0 +1 @@
remote connectivity test - 2026-05-13
+53 -9
View File
@@ -1,13 +1,21 @@
# Workspace/editor
.vscode/
frontend-web/.vscode/
# Local runtime logs and pid files
.runtime/
# Workspace/editor
.vscode/
frontend-web/.vscode/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
# Local runtime logs and pid files
.runtime/
logs/
offline-backend-20260512/backend/logs/
# Frontend local/runtime artifacts
frontend-web/.env
frontend-web/.env.local
frontend-web/.env.*.local
frontend-web/node_modules/
frontend-web/.pnpm-store/
frontend-web/tsconfig.tsbuildinfo
frontend-web/vite-dev.log
frontend-web/vite-dev.err.log
@@ -20,8 +28,9 @@ frontend-web/src.zip
frontend-web/src9.zip
frontend-web/src-v1.zip
frontend-web/src-*.rar
frontend-web/dist
# Frontend build artifacts
frontend-web/dist/
frontend-web/dist-scheduled-check/
# Offline backend bundles and local deployment artifacts
@@ -43,17 +52,52 @@ offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/stop_backend.sh
# offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260512/.env
offline-backend-20260512/.env.example
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
skills/*
offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
deploy
.history
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
.codegraph
codegraph.mdc
docs/aidev-docs
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
+95
View File
@@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI super agent platform. The repo contains two sub-projects:
- `offline-backend-20260512/backend/` — Python backend (FastAPI Gateway + LangGraph agent runtime)
- `frontend-web/` — Vite + React 19 frontend (TypeScript, pnpm)
Managed together via shell scripts in `scripts/`.
## Commands
### Full Application (from repo root)
```bash
./scripts/start-all.sh # Start backend + frontend as background daemons
./scripts/stop-all.sh # Stop both
./scripts/restart-all.sh # Restart both
./scripts/status.sh # Check running status
```
Logs and PID files are written to `.runtime/logs/` and `.runtime/pids/`.
### Backend Only (from `offline-backend-20260512/backend/`)
```bash
make install # Install Python dependencies (uv)
make dev # Run Gateway API with hot-reload on port 8001
make gateway # Run Gateway API without reload
make test # Run all tests: PYTHONPATH=. uv run pytest tests/ -v
make lint # Lint with ruff
make format # Format with ruff
```
Run a single test file:
```bash
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
```
### Frontend Only (from `frontend-web/`)
```bash
pnpm install # Install dependencies
pnpm dev # Start Vite dev server on port 5174
pnpm build # Production build
pnpm typecheck # TypeScript type checking (tsc --noEmit)
```
## Service Ports
| Service | Port | Notes |
|---------|------|-------|
| Backend (Gateway API) | 8001 | FastAPI + LangGraph runtime |
| Frontend (Vite) | 5174 | React SPA |
Frontend connects to backend via env vars `VITE_BACKEND_BASE_URL` and `VITE_LANGGRAPH_BASE_URL` (injected by `scripts/start-frontend.sh`).
## Architecture
### Backend
The backend (`offline-backend-20260512/backend/`) is split into two layers:
- **`packages/harness/deerflow/`** — publishable agent framework (`deerflow.*` imports): LangGraph lead agent, middleware chain, sandbox, tools, MCP, memory, models, skills. **Never imports from `app.*`.**
- **`app/`** — application layer (`app.*` imports): FastAPI Gateway routers, IM channel integrations (Feishu, Slack, Telegram, DingTalk).
The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph entry point. It wraps a middleware chain (~18 middlewares assembled in strict order) around LLM calls, handling: thread isolation, sandbox lifecycle, uploads, memory, plan mode, vision, subagent delegation, and loop detection.
**Middleware order matters** — middlewares are appended in a fixed sequence; `ClarificationMiddleware` must always be last. See `offline-backend-20260512/backend/CLAUDE.md` for the full ordered list.
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
### Frontend
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks)
- **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)
### Configuration
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
## Detailed Backend Reference
The backend has its own comprehensive documentation at:
- `offline-backend-20260512/backend/CLAUDE.md` — architecture, middleware chain, all API routes, config schema, sandbox/memory/subagent systems
- `offline-backend-20260512/backend/README.md` — setup guide, technology stack
@@ -0,0 +1,139 @@
# Iframe Chat Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a standalone `/page/workspace/chats_iframe/*` chat page that reuses the existing thread system and history, while showing only `你好` and no input box before the first user message.
**Architecture:** Keep the existing workspace shell and chat runtime intact, then add a new route pair and a copied page component dedicated to the iframe path. The new page keeps the original chat behavior once a thread already has messages, but it owns its empty-state rendering and URL transition to `/page/workspace/chats_iframe/:thread_id`.
**Tech Stack:** React, TypeScript, React Router, Next navigation shim, existing LangGraph thread hooks
---
### Task 1: Add the iframe chat routes
**Files:**
- Modify: `frontend-web/src/pages/WorkspaceRoutes.tsx`
- [ ] **Step 1: Add `chats_iframe/new` and `chats_iframe/:thread_id` routes that render the new page inside the existing workspace layout and chat runtime**
```tsx
<Route
path="chats_iframe/new"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="chats_iframe/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
```
- [ ] **Step 2: Ensure the new route imports compile cleanly**
Run: `rg -n "IframeChatPage|chats_iframe" frontend-web/src/pages/WorkspaceRoutes.tsx`
Expected: `WorkspaceRoutes.tsx` contains one import for `IframeChatPage` and both `chats_iframe` route entries.
### Task 2: Add a standalone iframe chat page
**Files:**
- Create: `frontend-web/src/pages/IframeChatPage.tsx`
- Reference: `frontend-web/src/pages/ChatPage.tsx`
- [ ] **Step 1: Copy the current `ChatPage` implementation into `IframeChatPage` and preserve the existing chat runtime integration**
```tsx
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
// keep the rest of the existing chat hooks and handlers aligned with ChatPage
}
```
- [ ] **Step 2: Change the thread creation URL replacement to stay on the iframe route family**
```tsx
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
);
},
```
- [ ] **Step 3: Replace the default landing state with a simple `你好` view and do not render the input box while the iframe page is still a brand-new untouched chat**
```tsx
{isLandingState && mounted ? (
<div className="flex min-h-[240px] items-center justify-center">
<div className="text-2xl font-medium">你好</div>
</div>
) : null}
{!isLandingState &&
(mounted ? (
<div className="w-full -translate-y-4">
<InputBox
className="w-full"
isNewThread={false}
threadId={threadId}
autoFocus={false}
status={thread.error ? "error" : thread.isLoading ? "streaming" : "ready"}
context={settings.context}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading}
onContextChange={(context) => setSettings("context", context)}
promptPrefix={promptPrefixProp}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div>
) : (
<div aria-hidden="true" className="bg-background/5 h-32 w-full -translate-y-4 rounded-3xl border" />
))}
```
- [ ] **Step 4: Keep message rendering, thread title, export, token usage, todos, artifact triggers, and all post-first-message behavior identical to the original page**
Run: `rg -n "MessageList|ThreadTitle|ArtifactTrigger|TokenUsageIndicator|TodoList" frontend-web/src/pages/IframeChatPage.tsx`
Expected: The new page still renders the same core chat primitives used by `ChatPage`.
### Task 3: Verify the new page compiles
**Files:**
- Test: `frontend-web/src/pages/IframeChatPage.tsx`
- Test: `frontend-web/src/pages/WorkspaceRoutes.tsx`
- [ ] **Step 1: Run the frontend build**
Run: `cmd /c npm run build`
Expected: Build succeeds without TypeScript or bundling errors related to `IframeChatPage` or `chats_iframe`.
- [ ] **Step 2: Spot-check the changed routes in the built source**
Run: `rg -n "chats_iframe" frontend-web/src/pages`
Expected: Matches appear in `WorkspaceRoutes.tsx` and `IframeChatPage.tsx`.
- [ ] **Step 3: Commit after verification**
```bash
git add docs/superpowers/plans/2026-06-01-iframe-chat-page.md frontend-web/src/pages/WorkspaceRoutes.tsx frontend-web/src/pages/IframeChatPage.tsx
git commit -m "feat: add iframe chat page routes"
```
+43
View File
@@ -0,0 +1,43 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,29 @@
# 复制为 .env.development.local,仅本机生效,不提交 Git
# 直连本机 Gateway(需后端 CORS_ORIGINS 包含 http://127.0.0.1:5173 等)
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
+43
View File
@@ -0,0 +1,43 @@
# 环境变量说明(Vite 仅暴露以 VITE_ 开头的变量)
#
# ┌─────────────────┬──────────────────────────────────────────────────┐
# │ 命令 │ 生效文件 │
# ├─────────────────┼──────────────────────────────────────────────────┤
# │ pnpm dev │ .env.development + .env.development.local │
# │ pnpm build │ .env.production + .env.production.local │
# │ pnpm build:network │ .env.network + .env.network.local(内网地址) │
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
# └─────────────────┴──────────────────────────────────────────────────┘
#
# 推荐:
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build)
#
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
# --- 开发环境(见 .env.development)---
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
# VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
# VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
# VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
# 可选
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# VITE_STATIC_WEBSITE_ONLY=
# VITE_GITHUB_OAUTH_TOKEN=
+43
View File
@@ -0,0 +1,43 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
+35
View File
@@ -0,0 +1,35 @@
# 生产打包(pnpm build)— 部署前请改成实际内网/生产地址
# 打包后变量会写入 dist,运行时不可再改
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,8 @@
# 复制为 .env.production.local,打包前临时覆盖生产地址(不提交 Git)
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
# VITE_STUDIO_ENABLED=true
+35
View File
@@ -0,0 +1,35 @@
# SIT 环境打包(pnpm build:sit / npm run build:sit)— 接口地址为 UAT 的 sit 版本
# 打包后变量会写入 dist,运行时不可再改
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.sit.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.sit.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.sit.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.sit.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.sit.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.sit.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,228 @@
# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。
---
## 一、功能概览
| 交互 | 用户感知 | 组件 | 触发方式 |
|------|----------|------|----------|
| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` |
| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` |
| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 |
画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。
---
## 二、在 AI 写作中的挂载路径
```mermaid
flowchart TB
AIWritingPage --> CanvasResizableLayout
CanvasResizableLayout --> AIWritingDraftPanel
AIWritingDraftPanel --> DraftStreamingPreview
AIWritingDraftPanel --> TextArtifactRenderer
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
TextArtifactRenderer --> WritingBubbleMenu
TextArtifactRenderer --> WritingParagraphMenu
TextArtifactRenderer --> TiptapEditor
TextArtifactRenderer --> WritingAiMenu
```
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|------|----------|-----------------|
| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** |
| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** |
| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) |
相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
入口文件:
- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer`
- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单
---
## 三、技术栈:编辑器 vs 浮层
### 3.1 Tiptap 编辑器(有使用)
| 包 | 用途 |
|----|------|
| `@tiptap/react` | `useEditor`、`EditorContent` |
| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 |
| `@tiptap/extension-placeholder` | 空文档占位符 |
| `tiptap-markdown` | Markdown ↔ 文档互转 |
| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) |
扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。
### 3.2 浮层菜单(自研,非 Tiptap 官方插件)
| 包 | 是否用于滑词/段落菜单 |
|----|----------------------|
| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) |
| `@tiptap/react` 的 `<BubbleMenu>` | **未使用** |
两套菜单均采用:
1. 监听 Tiptap / DOM 事件更新位置与选区;
2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切;
3. `position: fixed` + `getBoundingClientRect()` 定位。
命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
---
## 四、核心文件索引
| 文件 | 职责 |
|------|------|
| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 |
| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight |
| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 |
| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable |
| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 |
| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 |
| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 |
| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 |
| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 |
| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 |
| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 |
| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 |
---
## 五、滑词:`WritingBubbleMenu`
### 5.1 行为
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。
- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。
- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。
### 5.2 实现要点
```text
editor.on("selectionUpdate")
→ 读取 editor.state.selection (from, to, empty)
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
```
关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。
点击「AI 改写」时:
1. `editor.commands.setAiHighlight(from, to)` 高亮选区;
2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。
---
## 六、段落菜单:`WritingParagraphMenu`
### 6.1 行为
- 鼠标在编辑器内移动时,检测光标下的**块级节点**;
- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮;
- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
### 6.2 实现要点
```text
editor.view.dom mousemove
→ editor.view.posAtCoords({ left, top })
→ doc.resolve(pos).before(depth) 定位块起点
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
```
点击 ✨ 时:
1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`);
2. `setAiHighlight` + `setWritingSelection`;
3. 触发与滑词相同的 `onAiRewrite()` 流程。
关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。
---
## 七、AI 改写数据流
```mermaid
sequenceDiagram
participant User
participant Menu as BubbleMenu / ParagraphMenu
participant Ctx as CanvasUIContext
participant Renderer as TextArtifactRenderer
participant API as rewrite API
User->>Menu: 选中文本或点段落 ✨
Menu->>Ctx: setWritingSelection
Menu->>Renderer: onAiRewrite()
Renderer->>Renderer: phase = menu, WritingAiMenu
User->>Renderer: 选择润色/扩写等
Renderer->>API: useWritingRewrite.rewrite
API-->>Renderer: 流式结果
Renderer->>Renderer: WritingRewritePreview
User->>Renderer: 接受改写
Renderer->>Renderer: applyReplace / applyInsert
```
- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。
- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
---
## 八、与顶部工具栏、目录栏的关系
同一 `TextArtifactRenderer` 编辑模式下还包含:
| 组件 | 作用 |
|------|------|
| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 |
| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading |
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。
---
## 九、开发与改造建议
### 9.1 若要改用官方 Bubble Menu
1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`);
2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准);
3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
### 9.2 新增浮层按钮
- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。
- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。
### 9.3 流式阶段禁止交互
不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。
---
## 十、相关文档
- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表
- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验)
---
## 十一、依赖版本(摘录)
以 [package.json](../package.json) 为准,编辑器相关主要为:
- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4`
- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现
- `tiptap-markdown`:Markdown 扩展
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。
@@ -0,0 +1,274 @@
# AI 写作 ·「用户自带大纲」手动测试用例
> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。
> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx)
---
## 一、测试目标
验证三件事:
1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。
2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。
3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。
---
## 二、前置条件
| 项 | 要求 |
|----|------|
| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 |
| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) |
| 账号 | 已登录(或无鉴权模式) |
| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 |
---
## 三、通用操作步骤
除非用例另有说明,每条用例都按以下步骤执行:
1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。
2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。
3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。
4. 文章类型 / 目标字数 / 素材来源保持默认即可。
5. 点击「开始 AI 写作」。
6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。
7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。
> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。
---
## 四、测试用例
### TC-01 Markdown 三级 + 文章标题
**输入大纲:**
```
# 新能源汽车出口为何高速增长
## 引言
### 出口数据创新高
#### 同比增幅
#### 全球排名变化
### 市场背景
## 增长动因
### 成本优势
```
**预期大纲结构:**
- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节)
- 章节1 `引言`
- 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化`
- 小节 `市场背景`(无子要点)
- 章节2 `增长动因`
- 小节 `成本优势`
**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。
---
### TC-02 中文编号三级(一、/(一)/ 1.)
**输入大纲:**
```
一、引言
(一)背景
(二)意义
二、现状分析
(一)市场数据
1. 国内市场
2. 海外市场
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场`
- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字)
**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。
---
### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1)
**输入大纲:**
```
1. 引言
1.1 研究背景
1.2 研究意义
2. 现状
2.1 行业概况
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `研究背景`、`研究意义`
- 章节2 `现状` → 小节 `行业概况`
---
### TC-04 缩进 bullet 表示层级
**输入大纲:**
```
- 引言
- 背景
- 意义
- 现状
- 数据
- 细节展开
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开`
**校验重点:** 缩进深度正确映射成层级。
---
### TC-05 只有一级目录(平铺 Markdown)
**输入大纲:**
```
## 引言
## 现状
## 对策
## 结论
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。
**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。
**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。
---
### TC-06 只有一级目录(中文编号)
**输入大纲:**
```
一、引言
二、现状
三、结论
```
**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。
---
### TC-07 纯文本平铺(无任何标记)
**输入大纲:**
```
引言
现状
对策
总结
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。
**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。
---
### TC-08 格式混乱 / 散文化(走 LLM 兜底)
**输入大纲:**
```
我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。
希望先讲背景,再讲现状,最后给出展望。
```
**预期行为:**
- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。
- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。
- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。
**校验重点:** 流程不崩、能产出可用大纲。
---
### TC-09 保真校验(标题原样保留)
**输入大纲:**
```
一、A股市场2024年回顾与展望
(一)宏观经济(GDP/CPI)影响
(二)流动性与利率
二、行业轮动主线
```
**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离:
- 章节1 `A股市场2024年回顾与展望`
- 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率`
- 章节2 `行业轮动主线`
**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。
---
### TC-10 大纲确认卡里再编辑 / 重新规划
**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。
**操作:**
1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。
2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。
**预期行为:**
- 手动改后确认:正文按改后的大纲生成。
- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。
**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。
---
### TC-11 回归:不填大纲(默认流程不受影响)
**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。
**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。
**校验重点:** 增量功能不污染老流程。
---
### TC-12 素材关联(仍走检索)
**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。
**预期行为:**
- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。
- 关联**只填素材、不改大纲标题与结构**。
**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。
---
## 五、缺陷记录模板
| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 |
|------|------------------|------|------|----------|------|
| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | |
| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | |
| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | |
| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | |
| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | |
| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | |
| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | |
| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | |
| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | |
| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | |
| TC-11 | 留空 | 走自动规划 | | ☐ | |
| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | |
---
## 六、备注
- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。
- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。
- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。
+436
View File
@@ -0,0 +1,436 @@
# 工作台问答 · iframe 嵌入开发说明
本文档说明如何将 DeerFlow **通用工作台问答**(与 `/page/workspace/chats/*` 同一套 LangGraph 流式能力)以**普通 iframe** 形式嵌入第三方页面,并在**父页面无法读取子 frame 内 `thread_id`** 的前提下,轮询「本次问答是否已结束」。
后端公开 API 实现见:`offline-backend-20260512/backend/app/gateway/routers/public_embed.py`
数据表:`embed_sessions`(迁移 `20260602_01`)
---
## 0. 速读
| 角色 | 要做的事 |
|------|----------|
| **父页面** | 自己生成 `sessionId`(UUID),拼进 iframe `src`;用 `sessionId` 调公开 API 轮询 `finished` |
| **iframe(DeerFlow)** | 打开 `/embed/chats/...`,自动登录、自动发问、登记 `sessionId → thread_id` |
| **不需要** | 无界(wujie)、父页面拿 `thread_id`、DeerFlow 登录态 Cookie |
**完成条件**:`GET .../sessions/{sessionId}/status` 返回 `finished === true`(且无进行中的 run,非澄清中断)。
---
## 1. 背景与约束
### 1.1 业务目标
- 第三方系统用 **iframe** 嵌 DeerFlow 问答 UI。
- 传入:**问题文案**、**用户名**(走现有 `login/username`)、**主题色**。
- 展示:**消息列表** + **沙箱/产物侧栏**(与主聊天相同的 `ChatBox`);**隐藏**页面左侧栏、Workspace 侧栏、底部输入框。
- 进入后 **自动发送** 首条用户消息并走流式问答。
### 1.2 为何需要 `sessionId`
iframe 与父页面通常 **跨域**,父页面 **不能** 读取子应用 URL 里的 `thread_id`(hash 路由在子 frame 内)。
因此约定:
1. **父页面**在打开 iframe 前生成全局唯一的 `sessionId`;
2. **子页面**在创建/确定 `thread_id` 后,调用后端 **登记接口** 写入 `session_id → thread_id`;
3. **父页面**只凭 `sessionId` 查询状态,必要时响应里会带上 `thread_id`(可选使用)。
---
## 2. 端到端流程
```mermaid
sequenceDiagram
participant Parent as 父页面
participant Iframe as DeerFlow iframe
participant API as Gateway /api/public/embed
Parent->>Parent: sessionId = crypto.randomUUID()
Parent->>Iframe: iframe src 含 sessionId、message、username、theme
Iframe->>Iframe: loginByUsername + 自动 sendMessage
Iframe->>API: POST /sessions { session_id, thread_id }
loop 每 1–3s
Parent->>API: GET /sessions/{sessionId}/status
API-->>Parent: registered, finished, thread_id
end
```
---
## 3. 前端路由与页面
### 3.1 路由(独立于 `/page/*`)
在 `App.tsx` 注册,**不经过** `PageRoutes` / `PageSidebar`,因此无外层导航与 Workspace 侧栏。
| 路由 | 组件 | 说明 |
|------|------|------|
| `/embed/chats/:thread_id` | `EmbedChatPage`(外包 `ChatRuntime`) | `thread_id` 可为 `new` 或已有 UUID |
示例(HashRouter,注意 `#`):
```
http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest&theme=red
```
开发环境端口以本地 Vite 为准(如 5173 / 5174)。
### 3.2 URL 查询参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `sessionId` 或 `session_id` | **是** | 父页面生成的关联 id,8–128 位,仅字母数字 `_` `-` |
| `message` / `question` / `text` | 建议 | 首条用户问题;有则进入后自动发送 |
| `username` / `userName` | 否 | 默认 `guest`,对应 `POST /api/v1/auth/login/username` |
| `theme` | 否 | `dark-blue` → 深色;`red` 等 → 浅色(与 `LoginPage` 一致) |
缺少 `sessionId` 时,嵌入页会提示错误,不会发起登记。
### 3.3 相关源码
| 路径 | 职责 |
|------|------|
| [src/pages/EmbedChatPage.tsx](../src/pages/EmbedChatPage.tsx) | 嵌入页:登录、自动发问、登记 session、仅消息列表 + ChatBox |
| [src/core/embed/params.ts](../src/core/embed/params.ts) | 从 URL 解析嵌入参数 |
| [src/core/embed/status-api.ts](../src/core/embed/status-api.ts) | 登记 / 轮询 API 封装 |
| [src/App.tsx](../src/App.tsx) | 路由注册 |
### 3.4 UI 行为说明
- **隐藏**:`PageSidebar`、`WorkspaceSidebar`、`InputBox`、顶栏标题栏等(相对完整 `ChatPage`)。
- **保留**:`MessageList`、流式指示、澄清卡片(若 agent 触发 `ask_clarification`)、右侧 **产物/沙箱**(`ChatBox` 内 60/40 分栏逻辑与主聊天一致)。
- **线程 id**:新建会话时客户端先分配 UUID,`onStart` 后 `history.replaceState` 更新 hash 为 `/embed/chats/{真实id}`(与主聊天 `ChatPage` 相同手法,避免整页重载)。
---
## 4. 公开 HTTP API(无需鉴权)
前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://47.88.25.99:7001/`)。
CSRF / Auth 中间件对 `/api/public/` 放行;请求 **不需要** Cookie。
### 4.1 登记映射(iframe 内自动调用)
```http
POST /api/public/embed/sessions
Content-Type: application/json
{
"session_id": "parent-generated-uuid",
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b"
}
```
**响应:**
```json
{
"session_id": "parent-generated-uuid",
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"registered": true
}
```
同一 `session_id` 再次 POST 会 **更新** `thread_id`(例如 `onStart` 后 id 变化)。
### 4.2 轮询状态(父页面使用)
```http
GET /api/public/embed/sessions/{session_id}/status
```
**尚未登记**(iframe 仍在加载或未 POST):
```json
{
"session_id": "parent-generated-uuid",
"registered": false,
"thread_id": null,
"finished": false,
"thread_status": "pending",
"has_active_run": false,
"latest_run_status": null
}
```
**已登记且问答进行中:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": false,
"thread_status": "idle",
"has_active_run": true,
"latest_run_status": "running"
}
```
**已登记且已结束:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": true,
"thread_status": "idle",
"has_active_run": false,
"latest_run_status": "success"
}
```
#### 字段说明
| 字段 | 含义 |
|------|------|
| `registered` | 是否已收到 iframe 的 POST 登记 |
| `finished` | **父页面应以此为准**:无 inflight run,且线程非 running/busy,且非 `interrupted`(澄清等待用户) |
| `thread_status` | 从 checkpoint 推导:`idle` / `interrupted` / `error` 等 |
| `has_active_run` | 是否存在 pending/running 的 LangGraph run |
| `latest_run_status` | 该线程最近一次 run 的状态:`success` / `error` / … |
| `thread_id` | 解析出的 DeerFlow 线程 id(父页面可选用,非必须) |
### 4.3 获取生成结果(父页面使用)
问答结束后(建议先确认 `finished === true`),拉取本次模型输出:
```http
GET /api/public/embed/sessions/{session_id}/result
```
**尚未登记:**
```json
{
"session_id": "parent-generated-uuid",
"registered": false,
"thread_id": null,
"finished": false,
"thread_status": "pending",
"has_active_run": false,
"latest_run_status": null,
"messages": [],
"generated_text": "",
"generated_files": []
}
```
**已登记且已有内容:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": true,
"thread_status": "idle",
"has_active_run": false,
"latest_run_status": "success",
"messages": [
{ "type": "human", "content": "请总结要点", "id": "..." },
{ "type": "ai", "content": "根据分析,要点如下:...", "id": "..." }
],
"generated_text": "根据分析,要点如下:...",
"generated_files": [
"/mnt/user-data/outputs/summary.md",
"/mnt/user-data/outputs/chart.png"
]
}
```
#### 结果字段说明
| 字段 | 含义 |
|------|------|
| `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) |
| `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) |
| `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) |
未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。
### 4.4 按 thread_id 查询(可选)
调试或父页面已持有 thread_id 时:
```http
GET /api/public/embed/threads/{thread_id}/status
GET /api/public/embed/threads/{thread_id}/result
```
状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。
### 4.5 前端封装
```ts
import {
getEmbedSessionResult,
getEmbedSessionStatus,
registerEmbedSession,
} from "@/core/embed/status-api";
// 父页面(或你们自己的脚本)
const status = await getEmbedSessionStatus(sessionId);
if (status.registered && status.finished) {
const result = await getEmbedSessionResult(sessionId);
console.log(result.generated_text, result.generated_files);
}
// 仅 iframe 内需要
await registerEmbedSession(sessionId, threadId);
```
`fetch` 使用 `credentials: "omit"`,不携带 DeerFlow 会话。
---
## 5. 父页面集成示例
### 5.1 拼接 iframe
```html
<iframe
id="deerflow-embed"
title="DeerFlow 问答"
style="width:100%;height:600px;border:0"
></iframe>
```
```javascript
const sessionId = crypto.randomUUID();
const base = "http://localhost:5173"; // 换成你们部署的前端 origin
const params = new URLSearchParams({
sessionId,
message: "请根据附件总结要点",
username: "guest",
theme: "red",
});
document.getElementById("deerflow-embed").src =
`${base}/#/embed/chats/new?${params.toString()}`;
```
### 5.2 轮询直到结束
```javascript
const API = "http://47.88.25.99:7001/"; // VITE_BACKEND_BASE_URL
async function pollUntilFinished(sessionId) {
const res = await fetch(
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
{ credentials: "omit" },
);
if (!res.ok) throw new Error(`status HTTP ${res.status}`);
const data = await res.json();
if (!data.registered) return { done: false, reason: "pending" };
if (data.finished) return { done: true, data };
return { done: false, reason: "running" };
}
const timer = setInterval(async () => {
try {
const poll = await pollUntilFinished(sessionId);
if (poll.done) {
clearInterval(timer);
const output = await fetch(
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
{ credentials: "omit" },
).then((r) => r.json());
console.log("问答已结束", output.generated_text, output.generated_files);
}
} catch (e) {
console.error(e);
}
}, 2000);
```
### 5.3 跨域注意
父页面从浏览器 `fetch` Gateway(如 `http://47.88.25.99:7001/`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
**本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置:
```bash
# offline-backend-20260512/.env 或启动脚本注入
GATEWAY_CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,http://localhost:5173,http://127.0.0.1:5173
```
修改后**重启 Gateway**(`make dev` / `make gateway`)。若父页面部署在其它域名,把该域名的 origin 一并加入逗号分隔列表。
临时调试(勿用于公网生产):
```bash
GATEWAY_CORS_ALLOW_ALL=1
```
常见报错:`No 'Access-Control-Allow-Origin' header` → 当前页面的 origin 未出现在 `GATEWAY_CORS_ORIGINS` 中。
### 5.4 其它部署注意
---
## 6. 部署与数据库
1. **跑迁移**(SQL 持久化后端):
```bash
cd offline-backend-20260512/backend
# 按项目惯例执行 alembic upgrade head
```
表:`embed_sessions(session_id PK, thread_id, created_at, updated_at)`。
2. **重启 Gateway**,确认路由已挂载:`public_embed.router`(`app/gateway/app.py`)。
3. **前端构建** 后,将嵌入地址配给第三方;`VITE_BACKEND_BASE_URL` 指向可达的 Gateway。
无 SQL 时开发环境会退化为 **内存** `MemoryEmbedSessionStore`(进程重启后映射丢失);生产请使用持久化库。
---
## 7. 安全说明
- `sessionId` 与 `thread_id` 均视为 **不透明能力令牌**:知晓即可查询状态(公开 API **无登录**)。
- 建议:父系统自行生成不可猜测的 UUID;敏感场景在贵司 API 网关再加鉴权或 IP 限制。
- 登记接口为 **POST upsert**,不校验「session 是否属于某租户」——若需多租户隔离,请在业务层约定 `sessionId` 命名空间或增加网关校验。
---
## 8. 与主聊天 `/page/workspace/chats` 的差异
| 项目 | 主聊天 | iframe 嵌入 |
|------|--------|-------------|
| 路由 | `/page/workspace/chats/:id` | `/embed/chats/:id` |
| 布局 | 完整 Workspace + 输入框 | 仅消息区 + 产物栏 |
| 登录 | 常规登录流 | URL `username` 自动 `login/username` |
| 首条消息 | 用户输入 | URL `message` 自动发送 |
| 完成态查询 | 无公开 API | `sessionId` + `/api/public/embed/sessions/.../status` |
能力上仍走同一 `useThreadStream` + `lead_agent` 流式链路,沙箱与产物行为与主聊天一致。
---
## 9. 常见问题
**Q:`registered` 一直为 false?**
A:检查 iframe URL 是否带 `sessionId`;子页是否登录成功;Network 里是否有 `POST /api/public/embed/sessions` 且 200。
**Q:`finished` 一直 false?**
A:可能仍在流式生成(`has_active_run: true`),或 agent 进入澄清(`thread_status: interrupted`)。嵌入模式无底部输入框,澄清需产品侧另行处理或避免触发澄清类工具。
**Q:父页面能否不用轮询?**
A:当前标准方案为公开 GET 轮询。若同源可考虑 `postMessage` 扩展(未默认实现)。
**Q:能否继续用已有 thread_id 嵌入?**
A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message` 追加一轮;登记会在已知 id 上 upsert。
---
## 10. 变更记录
| 日期 | 说明 |
|------|------|
| 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API |
| 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files |
+975
View File
@@ -0,0 +1,975 @@
# DeerFlow 前端开发文档
## 目录
1. [整体目录结构](#1-整体目录结构)
2. [路由结构](#2-路由结构)
3. [核心数据流与状态管理](#3-核心数据流与状态管理)
4. [主要页面组件](#4-主要页面组件)
5. [核心业务组件](#5-核心业务组件)
6. [核心 Hooks 与工具函数](#6-核心-hooks-与工具函数)
7. [API 调用层](#7-api-调用层)
8. [国际化机制](#8-国际化机制)
9. [主题与样式体系](#9-主题与样式体系)
10. [关键业务流程](#10-关键业务流程)
11. [项目配置与依赖](#11-项目配置与依赖)
12. [常见开发模式](#12-常见开发模式)
---
## 1. 整体目录结构
```
frontend-web/src/
├── App.tsx # 应用入口,顶层路由 + Provider
├── env.ts # 环境变量类型定义
├── pages/ # 路由级页面组件
│ ├── PageRoutes.tsx # /page/* 根路由容器(含外部系统预留位)
│ ├── WorkspaceRoutes.tsx # /page/workspace/* 工作区路由
│ ├── LoginPage.tsx # 登录页
│ ├── LandingPage.tsx # 营销落地页(强制 dark 主题)
│ ├── ChatsPage.tsx # 对话列表
│ ├── ChatPage.tsx # 单条对话(核心页面)
│ ├── AgentsPage.tsx # 智能体库
│ ├── AgentChatPage.tsx # 与智能体对话
│ ├── NewAgentPage.tsx # 创建智能体(两步流程)
│ ├── ScheduledTasksPage.tsx # 定时任务管理
│ ├── ScheduledTaskRunDetailPage.tsx # 定时任务执行详情
│ ├── ScheduledChatPage.tsx # 定时任务对话跳转页
│ └── NotFoundPage.tsx # 404 页面
├── components/ # 业务组件库
│ ├── ui/ # shadcn/ui 基础组件(Button、Card、Dialog 等 40+)
│ ├── ai-elements/ # AI 交互可视化组件
│ │ ├── prompt-input/ # 输入框 Context + 子组件系统
│ │ ├── artifact.tsx # 制品展示
│ │ ├── canvas.tsx
│ │ ├── conversation.tsx # 虚拟滚动对话容器
│ │ ├── message.tsx
│ │ ├── model-selector.tsx
│ │ ├── code-block.tsx
│ │ └── shimmer.tsx
│ ├── workspace/ # 工作区业务组件(详见第 5 节)
│ └── landing/ # 落地页组件
├── core/ # 核心业务逻辑层(非 UI)
│ ├── api/ # API 客户端(LangGraph SDK + Fetch)
│ ├── auth/ # 认证管理
│ ├── config/ # 环境配置 & Base URL
│ ├── i18n/ # 国际化(上下文、翻译文件、语言检测)
│ ├── threads/ # 对话线程管理(最核心)
│ ├── agents/ # 智能体 CRUD
│ ├── models/ # 模型列表
│ ├── settings/ # 本地设置(LocalStorage 发布订阅)
│ ├── uploads/ # 文件上传
│ ├── artifacts/ # 制品管理
│ ├── memory/ # 用户记忆
│ ├── skills/ # 技能管理
│ ├── scheduled-tasks/ # 定时任务
│ ├── messages/ # 消息处理工具函数
│ ├── tasks/ # 子任务 Context
│ ├── notification/ # 系统通知
│ └── utils/ # 通用工具(文件下载、日期格式化等)
└── hooks/ # 全局自定义 hooks
```
---
## 2. 路由结构
> 项目使用 **HashRouter**,所有路由访问形如 `http://host/#/xxx`
### 2.1 App.tsx 顶层路由
| 路径 | 组件 | 说明 |
|------|------|------|
| `/` | Navigate | 重定向到 `/login` |
| `/login` | LoginPage | 登录(自动登录,无表单) |
| `/login/:username` | LoginPage | 带用户名登录 |
| `/landing` | LandingPage | 营销落地页(固定 dark 主题) |
| `/page/*` | PageRoutes | 应用内容根容器 |
| `*` | NotFoundPage | 404 |
### 2.2 PageRoutes.tsx(`/page/*`)
```
/page/
├── index → Navigate to "workspace"
├── workspace/* → WorkspaceRoutes
└── [TODO 预留] → 外部系统路由挂载位置
```
### 2.3 WorkspaceRoutes.tsx(`/page/workspace/*`)
| 路径 | 页面组件 | 布局包裹 |
|------|---------|--------|
| `index` | Navigate → `chats/new` | - |
| `chats` | ChatsPage | WorkspaceLayout |
| `chats/:thread_id` | ChatPage | WorkspaceLayout + ChatRuntime |
| `agents` | AgentsPage | WorkspaceLayout |
| `agents/new` | NewAgentPage | WorkspaceLayout |
| `agents/:agent_id/chats` | Navigate → `/page/workspace/agents` | - |
| `agents/:agent_id/chats/:thread_id` | AgentChatPage | WorkspaceLayout + ChatRuntime |
| `scheduled` | Navigate → `.../scheduled/tasks` | - |
| `scheduled/tasks` | ScheduledTasksPage | WorkspaceLayout |
| `scheduled/runs/:run_id` | ScheduledTaskRunDetailPage | WorkspaceLayout |
### 2.4 布局 Provider 层级结构
```
WorkspaceLayout
├── SidebarProvider(侧边栏展开/收起状态)
│ ├── WorkspaceSidebar
│ │ ├── WorkspaceHeader(Logo、新建按钮)
│ │ ├── WorkspaceNavChatList(Chats / Agents / Scheduled 导航)
│ │ ├── RecentChatList(最近会话列表)
│ │ └── WorkspaceNavMenu(Settings / About / GitHub)
│ ├── SidebarInset(主内容区)
│ │ └── [Page Content]
│ ├── CommandPalette(快捷命令面板)
│ └── Toaster(全局通知)
ChatRuntime(仅 ChatPage / AgentChatPage 使用)
├── SubtasksProvider(子任务执行状态)
├── ArtifactsProvider(制品选中/打开状态)
└── PromptInputProvider(输入框内容/附件状态)
```
---
## 3. 核心数据流与状态管理
### 3.1 全局 Provider 架构
```
App
└── HashRouter
└── AppProviders(useLocation 驱动主题判断)
├── NextThemesProvider
│ └── attribute="class",/landing 强制 forcedTheme="dark"
├── I18nProvider
│ └── initialLocale = detectLocale()(从 cookie 或浏览器语言)
└── QueryClientProvider(TanStack React Query 全局缓存)
```
### 3.2 全局 Context 列表
| Context | 文件 | 职责 | 消费 Hook |
|---------|------|------|-----------|
| `I18nContext` | `core/i18n/context.tsx` | 当前语言 + 翻译对象 | `useI18n()` |
| `SubtaskContext` | `core/tasks/context.tsx` | 实时子任务执行状态 | `useSubtaskContext()` |
| `ArtifactsContext` | `components/workspace/artifacts/context.tsx` | 制品选中/面板开关 | `useArtifacts()` |
| `PromptInputContext` | `components/ai-elements/prompt-input/` | 输入框内容、附件列表 | `usePromptInputController()` |
| `ThreadContext` | `components/workspace/messages/context.tsx` | 当前 thread 对象下传 | 直接读 context.thread |
### 3.3 LocalStorage 数据
**操作层:** `core/settings/store.ts`(发布-订阅,`useSyncExternalStore` 驱动响应式)
| Key | 内容 |
|-----|------|
| `deerflow.auth` | `{ access_token, token_type, user_id, email }` |
| `deerflow.local-settings` | `{ notification, context: { model_name, mode, reasoning_effort } }` |
| `deerflow.thread-model.{threadId}` | 每条线程的模型覆盖 |
### 3.4 SessionStorage 数据
| Key | 内容 |
|-----|------|
| `deerflow.run-id.{threadId}` | WebSocket run_id,用于断线重连 |
### 3.5 React Query 缓存 Key
| Query Key | 数据 |
|-----------|------|
| `["threads"]` | 线程列表 |
| `["thread", threadId]` | 单条线程 |
| `["agents"]` | 智能体列表 |
| `["models"]` | 模型列表 |
| `["skills"]` | 技能列表 |
| `["memory"]` | 用户记忆 |
| `["scheduled-tasks"]` | 定时任务列表 |
| `["scheduled-runs", taskId]` | 任务执行记录 |
---
## 4. 主要页面组件
### 4.1 ChatPage.tsx — 单条对话页面
**职责:** 管理单条聊天线程的完整交互,包括消息流、输入、工具调用展示、制品等。
**关键状态:**
```typescript
const [threadId, setThreadId] = useState(routeThreadId || uuid());
const [isNewThread, setIsNewThread] = useState(!routeThreadId || routeThreadId === "new");
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const [showFollowups, setShowFollowups] = useState(false);
const [settings, setSettings] = useThreadSettings(threadId);
```
**核心 Hook:**
```typescript
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// 不刷新页面,直接更新 URL hash
history.replaceState(null, "", `...#/page/workspace/chats/${createdThreadId}`);
},
onFinish: (state) => {
// 后台通知
showNotification(state.title, { body: lastMessageText });
}
});
```
**主要子组件:**
| 组件 | 职责 |
|------|------|
| `<MessageList>` | 消息渲染(含工具调用、子任务、制品) |
| `<Welcome>` | 新对话欢迎屏 |
| `<InputBox>` | 输入框(模式选择、附件、发送) |
| `<ThreadTitle>` | 线程标题(自动生成后同步更新) |
| `<ExportTrigger>` | 导出对话按钮 |
| `<ArtifactTrigger>` | 制品面板开关 |
| `<TokenUsageIndicator>` | Token 用量展示(可选) |
| `<TodoList>` | 任务清单 |
| `<Suggestions>` | 追问建议 |
**数据流:**
```
用户输入 (InputBox)
↓ handleSubmit(message: PromptInputMessage)
↓ sendMessage(threadId, message)
↓ [内部] 上传文件 → 提交到 LangGraph API
↓ WebSocket 流返回消息
↓ thread.messages 更新 → MessageList 重渲染
```
---
### 4.2 AgentChatPage.tsx — 与智能体对话
与 ChatPage 结构基本一致,差异点:
- 从路由获取 `agent_id`,传入 `useThreadStream({ assistantId: agent_id })`
- Header 展示智能体名称 Badge
- "新建对话"跳转到 `/page/workspace/agents/${agent_id}/chats/new`
- URL 更新为 `#/page/workspace/agents/${agent_id}/chats/${threadId}`
---
### 4.3 NewAgentPage.tsx — 创建智能体(两步流程)
**Step 1:name(命名 + 选技能)**
```typescript
// 自动预选前 3 个启用的技能
useEffect(() => {
if (!isSkillSelectionTouched) {
setSelectedSkills(enabledSkills.slice(0, 3).map(s => s.name));
}
}, [enabledSkills]);
// 提交:验证名称 → 创建智能体
await checkAgentName(name); // 检查重名
await createAgent(request); // 创建,返回 agent.id
setStep("chat"); // 进入 Step 2
```
**Step 2:chat(初始化对话)**
```typescript
// 发送 bootstrap 消息触发智能体初始化
sendMessage(threadId, bootstrapMessage, { agent_id: agentId });
// 监听 setup_agent 工具完成事件
onToolEnd: (event) => {
if (event.name === "setup_agent") {
// 带重试轮询加载 agent 最新信息
retryLoadAgent(agentId);
}
}
```
---
### 4.4 ChatsPage.tsx — 对话列表
```typescript
const { data: threads } = useThreads({ limit: 50, sortBy: "updated_at" });
// 客户端过滤:排除定时任务线程 + 搜索关键词
const filteredThreads = useMemo(() =>
threads?.filter(t =>
t.metadata?.thread_type !== "scheduler" &&
titleOfThread(t).toLowerCase().includes(search)
), [threads, search]
);
```
---
### 4.5 LoginPage.tsx — 登录
```typescript
// 自动登录,无需用户操作
useEffect(() => {
loginByUsername(username || "guest")
.then(auth => {
setStoredAuth(auth);
router.replace("/page/workspace/chats/new");
})
.catch(err => setStatus("error"));
}, [username]);
```
---
### 4.6 ScheduledTasksPage.tsx — 定时任务
**功能:** 创建/编辑/发布/暂停/恢复定时任务,查看执行历史。
**Cron 表达式构建:**
```typescript
type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
function buildCron(config): string {
// hourly → "15 * * * *"
// daily → "0 9 * * *"
// weekly → "0 9 * * 1"
// monthly → "0 9 1 * *"
}
```
**核心操作:**
```typescript
const actions = useScheduledTaskActions();
actions.create.mutateAsync(payload);
actions.publish.mutateAsync(taskId);
actions.pause.mutateAsync(taskId);
actions.trigger.mutateAsync(taskId); // 立即执行一次
actions.subscribe.mutateAsync(params); // 订阅推送
```
---
## 5. 核心业务组件
### 5.1 消息系统(`components/workspace/messages/`)
| 文件 | 职责 |
|------|------|
| `message-list.tsx` | 消息列表容器,调用 `groupMessages` 分组后渲染 |
| `message-list-item.tsx` | 单条消息渲染(含 role 判断) |
| `message-group.tsx` | 消息组(human / assistant / processing 等) |
| `markdown-content.tsx` | Markdown 渲染(rehype 系列插件) |
| `subtask-card.tsx` | 子任务状态卡片 |
| `message-token-usage.tsx` | 单条消息 Token 用量 |
| `skeleton.tsx` | 流式加载骨架屏 |
| `context.tsx` | ThreadContext,向下传递 thread 对象 |
**消息分组类型(`groupMessages`):**
```typescript
type MessageGroup =
| "human" // 用户输入
| "assistant:processing" // 推理/工具调用中
| "assistant" // 最终响应
| "assistant:clarification" // 澄清请求
| "assistant:present-files" // 文件呈现
| "assistant:subagent"; // 子智能体消息
```
---
### 5.2 输入框(`components/workspace/input-box.tsx`)
**子组件层级:**
```
InputBox
└── PromptInput(Context Provider)
├── PromptInputBody
│ ├── PromptInputTextarea(文本输入)
│ ├── PromptInputAttachments(附件列表)
│ └── PromptInputTools(工具栏:附件上传、模式选择)
├── PromptInputFooter
│ ├── PromptInputSubmit(发送/停止按钮)
│ └── Suggestions(追问建议列表)
└── ModelSelector(模型下拉选择)
```
**四种会话模式:**
| Mode | 说明 |
|------|------|
| `flash` | 快速模式,无推理步骤 |
| `thinking` | 推理模式,开启深度思考 |
| `pro` | 专业模式,多模态 + 规划 |
| `ultra` | 超级模式,启用子智能体 |
**推理努力等级(mode ≠ flash 时可选):** `minimal` / `low` / `medium` / `high`
---
### 5.3 侧边栏(`components/workspace/workspace-sidebar.tsx`)
```
WorkspaceSidebar
├── SidebarHeader → WorkspaceHeader
│ ├── Logo(可点击,跳转 /page/workspace/chats/new)
│ └── 新建对话按钮
├── SidebarContent
│ ├── WorkspaceNavChatList
│ │ ├── 新建对话(/page/workspace/chats/new)
│ │ ├── 所有对话(/page/workspace/chats)
│ │ ├── 智能体(/page/workspace/agents)
│ │ └── 任务管理(/page/workspace/scheduled/tasks)
│ └── RecentChatList(最近会话,含搜索)
└── SidebarFooter → WorkspaceNavMenu
├── Settings(打开设置 Dialog)
├── About
└── GitHub
```
---
### 5.4 制品管理(`components/workspace/artifacts/`)
```typescript
interface ArtifactsContextType {
artifacts: string[]; // artifact ID 列表
selectedArtifact: string | null;
autoSelect: boolean; // 是否自动选中最新制品
open: boolean; // 面板是否展开
autoOpen: boolean;
select(artifact: string, autoSelect?: boolean): void;
deselect(): void;
setOpen(open: boolean): void;
}
```
**文件:**
| 文件 | 职责 |
|------|------|
| `context.tsx` | ArtifactsContext + Provider |
| `artifact-trigger.tsx` | 面板展开/收起按钮 |
| `artifact-file-list.tsx` | 制品文件列表 |
| `artifact-file-detail.tsx` | 单个制品详情(含代码编辑器) |
---
### 5.5 设置面板(`components/workspace/settings/`)
| 页面文件 | 内容 |
|---------|------|
| `appearance-settings-page.tsx` | 主题切换(light/dark/system)、语言切换 |
| `memory-settings-page.tsx` | 用户记忆(事实列表、总结管理) |
| `notification-settings-page.tsx` | 桌面通知开关 |
| `skill-settings-page.tsx` | 技能启用/禁用 |
| `tool-settings-page.tsx` | 工具配置 |
| `about-settings-page.tsx` | 版本号、仓库链接 |
---
### 5.6 命令面板(`components/workspace/command-palette.tsx`)
通过 `Cmd/Ctrl + K` 触发,提供快速导航和操作入口。
```typescript
const handleNewChat = useCallback(() => {
router.push("/page/workspace/chats/new");
setOpen(false);
}, [router]);
```
---
## 6. 核心 Hooks 与工具函数
### 6.1 useThreadStream(最核心 Hook)
**位置:** `core/threads/hooks.ts`
管理 LangGraph WebSocket 连接、消息流、乐观更新。
**签名:**
```typescript
const [thread, sendMessage, isUploading] = useThreadStream({
threadId?: string, // 有值则连接现有线程,无值表示新建
context: LocalSettings["context"], // 模型、模式等
isMock?: boolean,
assistantId?: string, // 默认 "lead_agent"
onStart?: (threadId: string) => void,
onFinish?: (state: AgentThreadState) => void,
onToolEnd?: (event: ToolEndEvent) => void,
});
```
**thread 对象关键属性:**
```typescript
thread: {
messages: Message[], // 含乐观消息的完整列表
isLoading: boolean, // 流是否进行中
isThreadLoading: boolean, // 初始线程数据是否加载中
values: AgentThreadState, // 线程最终状态
error?: Error,
stop: async () => void, // 终止当前流
}
```
**sendMessage 签名:**
```typescript
sendMessage(
threadId: string | undefined,
message: PromptInputMessage,
extraContext?: Partial<AgentThreadContext>,
options?: { isMock?: boolean }
): Promise<void>
```
**内部机制:**
- **乐观更新:** 用户发送后立即本地展示消息,不等服务端响应
- **文件上传:** 自动调用 `uploadFiles()` 后再提交
- **run_id 持久化:** sessionStorage 保存,支持页面刷新后断线重连
- **消息同步:** 流结束后通过 `queryClient.invalidateQueries(["threads"])` 刷新列表
---
### 6.2 useThreadSettings
**位置:** `core/settings/hooks.ts`
```typescript
const [settings, setSettings] = useThreadSettings(threadId);
// settings 结构
{
notification: { enabled: boolean },
context: {
model_name?: string,
mode: "flash" | "thinking" | "pro" | "ultra",
reasoning_effort?: "minimal" | "low" | "medium" | "high"
}
}
// 修改(合并更新)
setSettings("context", { mode: "pro" });
setSettings("notification", { enabled: true });
```
**工作原理:** `useSyncExternalStore` 监听 localStorage 变化,跨标签页同步。
---
### 6.3 useI18n
**位置:** `core/i18n/hooks.ts`
```typescript
const { t, locale, changeLocale } = useI18n();
t.sidebar.newChat // "New Chat"
t.agents.createPageTitle // "Create Agent"
t.toolCalls.moreSteps(3) // "Show 3 more steps"
changeLocale("zh-CN"); // 切换语言,自动存 cookie
```
---
### 6.4 useScheduledTaskActions
**位置:** `core/scheduled-tasks/hooks.ts`
```typescript
const actions = useScheduledTaskActions();
// 所有返回值均为 TanStack Query Mutation
actions.create // 创建定时任务
actions.update // 更新任务配置
actions.remove // 删除任务
actions.removeRun // 删除执行记录
actions.pause // 暂停
actions.resume // 恢复
actions.trigger // 立即触发一次
actions.publish // 发布(激活)
actions.unpublish // 取消发布
actions.subscribe // 订阅推送
actions.updateSubscription
actions.unsubscribe
```
---
### 6.5 工具函数
**消息处理(`core/messages/utils.ts`):**
```typescript
groupMessages(messages, mapper) // 按类型分组消息列表
extractTextFromMessage(message) // 提取纯文本
extractContentFromMessage(message) // 提取 Markdown/图像内容
extractReasoningContentFromMessage(msg) // 提取推理过程
hasToolCalls(message) // 是否含工具调用
hasPresentFiles(message) // 是否包含文件呈现
extractPresentFilesFromMessage(message) // 获取文件列表
isClarificationToolMessage(message) // 是否为澄清请求
```
**线程路由(`core/threads/utils.ts`):**
```typescript
pathOfThread(thread, context?)
// → "/page/workspace/agents/{agentRef}/chats/{threadId}"
// 或 "/page/workspace/chats/{threadId}"
titleOfThread(thread)
// → thread.values?.title ?? "Untitled"
textOfMessage(message)
// → 消息文本内容(string 或 content 数组中第一个 text)
```
**其他工具:**
```typescript
// core/utils/files.tsx
downloadFile(blob: Blob, filename: string)
// core/utils/datetime.ts
formatTimeAgo(dateString: string) // → "2 小时前" / "3 days ago"
```
---
## 7. API 调用层
### 7.1 API 客户端架构
**两层设计:**
**第一层:LangGraph SDK Client**(`core/api/api-client.ts`)
```typescript
const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock),
onRequest(url, init) {
const headers = new Headers(init.headers ?? {});
headers.set("Authorization", getAuthorizationHeaderValue());
return { ...init, headers };
}
});
export function getAPIClient(isMock?: boolean): LangGraphClient
```
**第二层:Fetch Client**(`core/api/fetch-client.ts`)
```typescript
function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
return fetch(input, {
...init,
headers: withAuthHeaders(init?.headers)
});
}
```
### 7.2 主要 API 模块
| 模块 | 文件 | 关键函数 |
|------|------|---------|
| **Auth** | `core/auth/api.ts` | `loginByUsername(username)` → `LoginByUsernameResponse` |
| **Agents** | `core/agents/api.ts` | `createAgent()`, `updateAgent()`, `listAgents()`, `deleteAgent()`, `checkAgentName()` |
| **Models** | `core/models/api.ts` | `loadModels()` → `Model[]` |
| **Threads** | (via LangGraph SDK) | `useThreadStream()`, `useThreads()`, `useDeleteThread()`, `useRenameThread()` |
| **Skills** | `core/skills/api.ts` | `loadSkills()`, `enableSkill(name, enabled)` |
| **Uploads** | `core/uploads/api.ts` | `uploadFiles(threadId, files[])`, `listUploadedFiles()`, `deleteUploadedFile()` |
| **Memory** | `core/memory/api.ts` | `loadMemory()`, `createMemoryFact()`, `updateMemoryFact()`, `deleteMemoryFact()` |
| **Scheduled Tasks** | `core/scheduled-tasks/api.ts` | CRUD、publish、pause、resume、trigger、subscribe |
### 7.3 Base URL 配置
**文件:** `core/config/index.ts`
```typescript
export function getBackendBaseURL(): string {
// env.NEXT_PUBLIC_BACKEND_BASE_URL
// 默认:""(当前域名根路径)
}
export function getLangGraphBaseURL(isMock?: boolean): string {
// mock 模式:${origin}/mock/api
// 正常:${origin}/api/langgraph
// 或读取 env.NEXT_PUBLIC_LANGGRAPH_BASE_URL
}
```
### 7.4 认证流程
```
LoginPage 调用 loginByUsername(username)
↓ POST /api/auth/login
↓ 返回 { access_token, token_type, user_id, email }
↓ setStoredAuth() 存入 localStorage["deerflow.auth"]
↓
后续所有 API 请求
↓ getAuthorizationHeaderValue()
↓ → "Bearer {access_token}"
↓ 自动添加到 Authorization header
```
---
## 8. 国际化机制
### 8.1 文件结构
```
core/i18n/
├── context.tsx # I18nProvider(包裹 App)
├── hooks.ts # useI18n()
├── locale.ts # detectLocale(),Locale = "en-US" | "zh-CN"
├── cookies.ts # cookie 读写(持久化语言选择)
├── translations.ts # 翻译映射表入口
└── locales/
├── types.ts # Translations 接口定义(所有翻译 key 的类型)
├── en-US.ts # 英文翻译
└── zh-CN.ts # 简体中文翻译
```
### 8.2 Translations 接口分类
```typescript
interface Translations {
locale: { localName: string } // 语言自身名称
common: { ... } // 通用词汇(确认、取消等)
welcome: { ... } // 欢迎屏
inputBox: { ... } // 输入框(模式、占位符)
sidebar: { ... } // 侧边栏导航
agents: { ... } // 智能体相关
chats: { ... } // 对话相关
workspace: { ... } // 工作区通用
settings: { ... } // 设置面板
toolCalls: { ... } // 工具调用展示
uploads: { ... } // 文件上传
subtasks: { ... } // 子任务
tokenUsage: { ... } // Token 用量
shortcuts: { ... } // 快捷键说明
}
```
### 8.3 语言检测优先级
1. 读取 cookie `deerflow.locale`
2. 读取浏览器 `navigator.language`
3. 回退到 `en-US`
---
## 9. 主题与样式体系
### 9.1 主题系统
**库:** `next-themes`
```typescript
// App.tsx
<NextThemesProvider
attribute="class" // 通过 <html class="dark"> 应用主题
enableSystem // 跟随系统偏好
disableTransitionOnChange // 切换时禁用过渡动画
forcedTheme={pathname === "/landing" ? "dark" : undefined}
>
```
**可选主题值:** `light` / `dark` / `system`
### 9.2 样式方案
**基础:** Tailwind CSS
**组件库:** shadcn/ui(`components/ui/` 目录,40+ 基础组件)
**主要 CSS 自定义属性:**
```css
--container-width-sm /* 小容器宽度 */
--container-width-md /* 中等容器宽度(对话主区域) */
--primary /* 主色 */
--background /* 页面背景 */
--foreground /* 主文本色 */
--muted-foreground /* 次要文本色 */
--border /* 边框色 */
--sidebar-foreground /* 侧边栏文本 */
```
### 9.3 样式使用规范
- 工具类优先(Tailwind)
- 动态样式用 `cn()` 工具函数合并(基于 `clsx` + `tailwind-merge`)
- 主题感知样式使用 CSS 变量(如 `text-muted-foreground`)
---
## 10. 关键业务流程
### 10.1 新建对话流程
```
1. 点击"新建对话" → navigate("/page/workspace/chats/new")
2. ChatPage 挂载,threadId = uuid(),isNewThread = true
3. 用户输入消息,点击发送
4. sendMessage(undefined, message) 调用
5. useThreadStream 内部:
a. 上传文件(若有)
b. 创建 LangGraph 线程,后端返回 threadId
c. onStart(threadId) 回调:
- setIsNewThread(false)
- history.replaceState 更新 URL hash
6. WebSocket 流开始接收 AI 消息
7. MessageList 实时渲染消息
8. 流结束,onFinish 回调,可能触发桌面通知
```
### 10.2 创建智能体流程
```
Step 1(name 步骤)
↓ 用户输入名称
↓ checkAgentName(name) → 验证是否重名
↓ createAgent({ name, skills: selectedSkills })
↓ 后端返回 agent.id,setStep("chat")
Step 2(chat 步骤)
↓ 生成临时 threadId
↓ useThreadStream({ assistantId: agent.id })
↓ 发送 bootstrap 消息(触发智能体自我设置)
↓ onToolEnd 监听:event.name === "setup_agent"
↓ 指数退避重试加载 agent 信息,确认已保存
↓ 显示"Agent Created"完成界面
↓ 用户可点击"开始对话"或"返回列表"
```
### 10.3 文件上传流程
```
1. 用户在 InputBox 选择/粘贴文件
2. PromptInputAttachments 展示预览列表
3. 用户点击发送
4. sendMessage 内部调用 uploadFiles(threadId, files)
5. POST /api/threads/{threadId}/upload
6. 后端返回 { files: [{ filename, size, virtual_path }] }
7. 构建 Message additional_kwargs.files
8. 完整 Message 提交到 LangGraph
```
---
## 11. 项目配置与依赖
### 11.1 主要依赖
| 包 | 版本 | 用途 |
|----|------|------|
| `react` | ^18 | 核心框架 |
| `react-router-dom` | ^6 | 客户端路由(HashRouter) |
| `@tanstack/react-query` | - | 服务端状态缓存 |
| `@langchain/langgraph-sdk` | - | LangGraph WebSocket 连接 |
| `next-themes` | - | 主题管理 |
| `sonner` | - | Toast 通知 |
| `lucide-react` | - | 图标库 |
| `tailwindcss` | - | CSS 框架 |
| `rehype` 系列 | - | Markdown 渲染 |
| `uuid` | - | 生成临时 thread ID |
### 11.2 环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `NEXT_PUBLIC_BACKEND_BASE_URL` | 后端接口基础 URL | `""` (当前域名) |
| `NEXT_PUBLIC_LANGGRAPH_BASE_URL` | LangGraph API URL | `/api/langgraph` |
| `NEXT_PUBLIC_STATIC_WEBSITE_ONLY` | 纯静态演示模式 | `"false"` |
---
## 12. 常见开发模式
### 12.1 添加新工作区页面
```
1. 创建 src/pages/MyPage.tsx
2. 在 WorkspaceRoutes.tsx 中添加路由:
<Route path="my-page" element={<WorkspaceLayout><MyPage /></WorkspaceLayout>} />
3. 在 workspace-nav-chat-list.tsx 中添加导航入口(如需要)
4. 更新相关的跳转路径
```
### 12.2 添加新 API 接口
```
1. 在 core/xxx/api.ts 中定义 API 函数(使用 apiFetch 或 getAPIClient())
2. 在 core/xxx/hooks.ts 中封装 useQuery / useMutation
3. 在页面或组件中调用 hook
```
### 12.3 添加新 Context
```
1. 创建 core/xxx/context.tsx:定义 Context + Provider + 默认值
2. 创建消费 hook(如 useXxx())
3. 在合适的组件树根部插入 Provider(WorkspaceRoutes 或 ChatRuntime)
```
### 12.4 添加国际化文本
```
1. 在 core/i18n/locales/types.ts 的 Translations 接口中添加字段
2. 在 core/i18n/locales/en-US.ts 添加英文内容
3. 在 core/i18n/locales/zh-CN.ts 添加中文内容
4. 组件中通过 useI18n() 访问:const { t } = useI18n(); t.xxx.yyy
```
### 12.5 引入外部系统路由
在 `src/pages/PageRoutes.tsx` 中找到 TODO 注释位置,填入:
```typescript
import AnotherSystemRoutes from "外部系统路径";
// 在 Routes 中添加:
<Route path="other/*" element={<AnotherSystemRoutes />} />
```
如外部系统有独立 Provider 依赖,在此文件或 App.tsx 中包裹。
---
## 附:关键文件速查
| 需求 | 关键文件 |
|------|---------|
| 修改路由结构 | `App.tsx`, `PageRoutes.tsx`, `WorkspaceRoutes.tsx` |
| 修改侧边栏导航 | `components/workspace/workspace-nav-chat-list.tsx` |
| 修改对话逻辑 | `pages/ChatPage.tsx`, `core/threads/hooks.ts` |
| 修改输入框 | `components/workspace/input-box.tsx`, `components/ai-elements/prompt-input/` |
| 修改消息渲染 | `components/workspace/messages/` |
| 添加 API 接口 | `core/xxx/api.ts` + `core/xxx/hooks.ts` |
| 修改全局设置 | `core/settings/store.ts`, `core/settings/hooks.ts` |
| 修改翻译文本 | `core/i18n/locales/en-US.ts`, `core/i18n/locales/zh-CN.ts` |
| 修改主题 | `App.tsx` (NextThemesProvider) |
| 修改认证逻辑 | `core/auth/api.ts`, `pages/LoginPage.tsx` |
| 定时任务 | `pages/ScheduledTasksPage.tsx`, `core/scheduled-tasks/` |
| 智能体管理 | `pages/NewAgentPage.tsx`, `core/agents/` |
+265
View File
@@ -0,0 +1,265 @@
# 多智能体协调 API 接口文档
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
路由前缀:`/api/multi-agent`
## 概览
提供一个"总控 + 多个子智能体"的协调编排接口。
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
调用方按下面顺序使用:
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
## 1. 初始化多智能体集群
### `POST /api/multi-agent/init`
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
#### 请求体
```json
{
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
"main_agent_name": "main-controller",
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
#### 响应(200)
```json
{
"status": "success",
"main_agent_name": "main-controller",
"thread_ids": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
"us-intelligence-gathering-agent": "thread-uuid-zzz"
}
}
```
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_names` 为空 |
| `401` | 未携带 `Authorization` 头 |
| `500` | 协调智能体创建失败且不是"已存在"错误 |
---
## 2. 流式调度
### `POST /api/multi-agent/run/stream`
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
#### 请求体
```json
{
"agent_type": "leader",
"d_agent_thread_id": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
},
"agent_name": "main-controller",
"new_message": "请分析特朗普的支持率",
"skill_stop_names": [],
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
#### 响应
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
##### a. 透传上游事件
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
##### b. 最后一帧:调度状态
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
**`agent_type == "leader"`:**
```
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
```
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
**`agent_type == "special"`:**
```
data: {"status": "子智能体xxx完成yyy工作"}
```
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
#### 副作用(重要)
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
| `401` | 未携带 `Authorization` 头 |
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
---
## 3. 前端调用流程示例
```ts
// 1. 拿 token
const loginResp = await fetch("/api/v1/auth/login/username", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "yourname" }),
});
const { access_token } = await loginResp.json();
const authHeaders = {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
};
// 2. 初始化
const initResp = await fetch("/api/multi-agent/init", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_names: [
"taiwan-intelligence-gathering-agent",
"us-intelligence-gathering-agent",
],
main_agent_name: "main-controller",
model: "deepseek-chat",
}),
});
const { thread_ids } = await initResp.json();
// 3. 流式调用 leader
async function streamLeader(message: string, model = "deepseek-chat") {
const resp = await fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "leader",
d_agent_thread_id: thread_ids,
agent_name: "main-controller",
new_message: message,
skill_stop_names: [],
model,
}),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
let dispatched: [string, string][] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
try {
const payload = JSON.parse(line.slice(5).trim());
if (Array.isArray(payload.status)) {
dispatched = payload.status;
}
// 其余事件按 LangGraph SDK 协议自行渲染
} catch {
/* 非 JSON 帧,可忽略或交给现有渲染器 */
}
}
}
return dispatched;
}
// 4. 对派活清单逐个调用 special
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
return fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "special",
d_agent_thread_id: thread_ids,
agent_name: agentName,
new_message: task,
skill_stop_names: [],
model,
}),
});
// 同样按 SSE 解析
}
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
const dispatched = await streamLeader("请分析特朗普的支持率");
for (const [agentName, task] of dispatched) {
await streamSpecial(agentName, task);
}
const next = await streamLeader("根据上面收集到的信息出最终结论");
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
```
---
## 4. 注意事项
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
---
## 5. 相关接口(底层依赖)
本接口内部转发以下 Gateway 原生接口,了解细节可查:
| 接口 | 路由 | 用途 |
|---|---|---|
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,514 @@
# 多智能体规划 · 后台挂起执行 + 进度可视化 —— 分步实现方案
> 目标:在「多智能体规划」第二步增加**后台挂起**能力——把当前任务挂到后台,自动一路跑到
> 最后一步(Step3 结果绘制完成)为止;历史记录下拉框里进行中的任务显示加载动画;点击进行中
> 的任务弹出一个**大弹窗**,里面是**横向的智能体派活链路流程图**(节点 = 各席位智能体,图标 +
> 名称,不同颜色,正在发言的节点和它的连接线带加载动画);弹窗里有「查看」按钮,点击进入正在
> 进行中的任务实时观察。
---
## 0. 现状与架构事实(动手前必读)
研究自现有代码,决定了整套方案的形态。**关键结论:当前编排循环跑在前端,离开页面即中断。**
### 0.1 前端现状
| 事实 | 位置 |
|------|------|
| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) |
| 循环由 `currentStep===2` 的 `useEffect` 启停;离开 Step2 的 cleanup 会 `orchestrationCancelRef.current=true` + `abortAllInFlight()` 中断 | `useStep2Orchestration.ts:1468-1541`(尤其 cleanup `1534-1537`、`1471-1477`) |
| 共识判定:`leaderRes.dispatched.length===0` → `setHasConsensus(true)` | `useStep2Orchestration.ts:1091-1099` |
| 进入 Step3:手动点按钮,前置 `hasConsensus===true` | `Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` |
| Step3「结果绘制」:前端流式调 `roundtable-report` 智能体,写出 `outputs/方案总览.html`,`phase→"done"` 即完成 | `src/roundtable-planning/hooks/useStep3Report.ts:36,51,165,560-596,636-652` |
| 草稿持久化:`/api/roundtable-drafts`,含 step1/step2/step3 三段快照 + `furthestStep(1|2|3)`,**无显式 status 字段** | `src/roundtable-planning/api/drafts.ts:25-110,112,184-225` |
| 历史下拉 UI(触发按钮 + 列表 + 重命名/删除) | `RoundtablePlanningPage.tsx:771-892` |
| 自动保存触发点(Step1 回复完 / Step2 每轮气泡收尾 / Step3 报告保存 / 步骤前进) | `useDraftPersistence.ts:248-316` |
| 加载草稿恢复:`handleLoadDraft` → `getDraft` → `cancelAllStreams` → hydrate → `setCurrentStep(furthestStep)` | `useDraftPersistence.ts:308-368` |
### 0.2 后端现状
| 事实 | 位置 |
|------|------|
| `/api/multi-agent/init`:为各席位并行建 thread + 单例总控 `roundtable-coordinator`,SSE 返回 `seat_ready`/`coordinator_ready`/`init_done(thread_ids)` | `app/gateway/routers/multi_agent.py:647-823` |
| `/api/multi-agent/run/stream`:驱动**单轮** leader 或 special 的 LLM 执行,末尾 JSON 帧 `status`(派活列表 / `[]` 共识 / `clarification` / `error`) | `multi_agent.py:1076-1142` |
| `/api/multi-agent/report/init`:为 Step3 报告建 thread | `multi_agent.py`(report/init) |
| **后端不存在编排循环**——leader 派活由前端解释后再逐个发 `run/stream`;后端只做单轮执行 + 把派活/完成「广播」追加进各 thread 历史消息 | `multi_agent.py:24-29,487-502,1108-1142` |
| 派活格式:`agent_orchestration` 工具调用 `{agent_name, task}`(旧式 bash 解析兼容) | `multi_agent.py:525-552` |
### 0.3 可复用模板:AI 写作的「后台多阶段流程」
AI 写作已经把「后台跑多阶段流程 + 前端 SSE 订阅 + 可 resume + 定时清理」跑通了,**圆桌后台作业直接照搬这套**:
| 模板要素 | 位置 |
|------|------|
| LangGraph 图驱动多阶段(researching/writing_outline/writing_draft/reviewing/…),条件路由推进,`interrupt()` 暂停点等用户输入 | `packages/harness/deerflow/agents/ai_writing/graph.py`、`nodes/*`(`pause_material_confirm` 等 `45-290`) |
| Session 持久化表 `ai_writing_sessions`:`id/user_id/title/status/draft_*/transcript(LONGTEXT)/...`,`status∈{in_progress,done,error}` | `packages/harness/deerflow/persistence/ai_writing_sessions/model.py:14-39` |
| `transcript` 用 `PortableLongText`(MySQL LONGTEXT,避开 64KB),时间用 `BeijingDateTime` | 同上 `27-38` |
| 进度走 LangGraph Server 标准流 `stream_mode=["messages-tuple","values"]`,**graph 在 langgraph runtime 里跑,任意 worker 可 join stream**(这是后台健壮性的关键) | `ai_writing.py:1-17`、`langgraph.json:16-18` |
| Checkpointer 共享 `AsyncSqliteSaver`,落 `.deer-flow/data/checkpoints.db`(需挂数据卷) | `runtime/checkpointer/async_provider.py` |
| Cleanup cron(leader 文件锁、活跃保护、先删 CP 再删 DB、admin 手动触发) | `app/gateway/ai_writing_cleanup.py:26,34-59,111-135,193-215,307-367` |
| **多 worker 坑**:模块级内存 dict(`_event_queues`)每 worker 一份会丢会话;SSE 进程内直调会整段 buffer。解法:状态进 DB+checkpointer、SSE 走真 TCP loopback `127.0.0.1`(`trust_env=False` 禁代理) | `multi_agent.py:271-293`、`AIWritingPage.tsx` 文件头注释 §3/§5 |
### 0.4 决策(已与产品确认)
1. **后端真后台执行**:把编排循环搬到后端 run,刷新 / 关页 / 换设备都不中断。
2. **流程图节点 = 智能体派活链路**:横向排列的席位节点,正在发言的节点 + 连接线加载动画。
> ⚠️ **本方案最大的工作量在 Phase 2**:把目前跑在前端 TS 里的编排循环(`runOrchestration`/`runChainOrchestration`)
> 移植成一个后端 **LangGraph「圆桌总控编排图」**。这是与 AI 写作一致、可在 LangGraph runtime 里
> 后台跑且天然 checkpoint 的健壮路径。下文每个阶段都给出验收标准,可独立提测。
---
## 1. 总体架构
```
┌─────────────────────────────── 前端 (frontend-web) ───────────────────────────────┐
│ Step2Panel ──「后台挂起」按钮──▶ POST /api/roundtable-jobs (start) │
│ HistoryDropdown ── 进行中任务显示 spinner ──▶ 点击 ──▶ DispatchChainModal(大弹窗) │
│ DispatchChainModal ── 订阅 GET /api/roundtable-jobs/{id}/stream(SSE) │
│ 横向派活链路流程图(节点=席位, 活动节点+连线动画) ──「查看」──▶ 进入任务实时观察 │
└───────────────────────────────────────────────────────────────────────────────────┘
│ SSE 进度 / 快照
┌─────────────────────────────── 后端 (backend) ─────────────────────────────────────┐
│ /api/roundtable-jobs 路由层(start/get/stream/resume/cancel) ── 仿 ai_writing.py │
│ │ 启动后台 run │
│ LangGraph「圆桌总控编排图」 roundtable_orchestrator ── 仿 ai_writing graph │
│ init → leader_call → parse_dispatch → dispatch_seats(顺序) → loop ↺ │
│ → consensus → report_draw(写 HTML) → done (clarification 点 interrupt()) │
│ │ 每次状态转移持久化进度 │
│ roundtable_jobs 表(status/phase/dispatch_chain/active_speaker/...) + 共享 checkpointer│
│ cleanup cron (仿 ai_writing_cleanup.py) │
└───────────────────────────────────────────────────────────────────────────────────┘
```
**核心思路**:后台作业 = 一个 LangGraph 图的一次 run。图节点内部复用现有的单轮执行能力
(`multi_agent.py` 里 leader/special 的 run 逻辑),把「解释派活 → 顺序派活 → 回到 leader」的
循环从前端搬进图节点。前端从「驱动者」退化为「观察者」:通过 SSE 订阅进度渲染。
---
## 2. 数据契约(前后端共享,先定义后实现)
### 2.1 作业状态机 `JobStatus`
```
queued # 已创建,等待 worker 拉起
running # 编排进行中(Step2 派活循环 / Step3 绘制)
awaiting_input # 命中 clarification,等用户回答(interrupt 挂起)
done # 已跑到 Step3 绘制完成
error # 出错终止
cancelled # 用户取消
```
### 2.2 作业阶段 `JobPhase`(细化给流程图/进度条用)
```
initializing # 建 thread / 总控就绪
leader_thinking # 总控在决策派活
dispatching # 正在顺序派活给席位(配合 active_speaker)
consensus # 共识达成(dispatched=[])
report_drawing # Step3 报告绘制中
report_done # 绘制完成
```
### 2.3 派活链路节点 `DispatchChainNode`(流程图核心数据)
```ts
interface DispatchChainNode {
agentId: string; // 席位 agent_id(小写)
name: string; // 显示名
avatarType: string; // 复用 getAvatarTypeFor(agentId, index) → 决定颜色
order: number; // 横向排列次序(chain 模式=链条顺序;recommend 模式=席位/发言顺序)
state: 'pending' | 'active' | 'done'; // 节点状态(驱动颜色/动画)
lastSpokeCycle?: number; // 最近发言轮次(可选,做 tooltip)
}
```
### 2.4 进度事件 `JobProgressEvent`(SSE 推送的归一化事件)
```ts
type JobProgressEvent =
| { type: 'status'; status: JobStatus }
| { type: 'phase'; phase: JobPhase; cycle: number }
| { type: 'chain'; nodes: DispatchChainNode[] } // 派活链路全量/增量
| { type: 'active'; agentId: string | null } // 当前发言席位(null=总控/无)
| { type: 'consensus'; percentage: number }
| { type: 'dialogue'; bubble: Step2DialogueLike } // 新增/收尾的对话气泡(供「查看」实时渲染)
| { type: 'clarification'; question: string } // 命中澄清
| { type: 'done'; step3: DraftStep3Snapshot } // 终态:最终报告快照
| { type: 'error'; detail: string };
```
> SSE 通道可直接复用 LangGraph Server 的 `values` 流(图状态快照),由前端 hook 把 `values`
> 映射成上述归一化事件;也可在路由层做映射后再下发。**推荐前端映射**(少一层后端耦合)。
### 2.5 作业记录 `RoundtableJob`(持久化 + 列表展示)
```ts
interface RoundtableJob {
id: string;
draftId: string; // 关联的草稿
userId: string;
status: JobStatus;
phase: JobPhase;
cycle: number; // 当前轮次(0..MAX_CYCLES)
dispatchChain: DispatchChainNode[];
activeAgentId: string | null;
consensusPercentage: number;
pendingClarification: string | null;
threadIds: Record<string,string> | null; // 复用 ThreadIdMap
coordinatorName: string | null;
orchestrationMode: 'recommend' | 'chain';
chain?: { id: string; title: string } | null;
error: string | null;
createdAt: string;
updatedAt: string;
}
```
---
## Phase 1 —— 后端:圆桌作业持久化模型
**目标**:建一张 `roundtable_jobs` 表(仿 `ai_writing_sessions`),存作业状态/阶段/派活链路,
并在 `roundtable_drafts` 列表里能 join 出当前作业状态。
**涉及文件(新增/改)**
- 新增 `packages/harness/deerflow/persistence/roundtable_jobs/model.py`(ORM 模型,仿 `ai_writing_sessions/model.py:14-39`)
- 新增 `.../roundtable_jobs/sql.py`(Repository:`create / get / update_progress / list_by_user / list_older_than / delete`,仿 `ai_writing_sessions/sql.py`)
- 新增 alembic 迁移 `persistence/migrations/versions/<date>_create_roundtable_jobs.py`
- 改 `roundtable_drafts` 的列表查询:返回里带上 `status`/`jobId`(LEFT JOIN roundtable_jobs,或在 draft 行上冗余一个 `status` 字段,二选一,见下)
**步骤**
1. 字段设计(对齐 §2.5):`id(PK)`、`draft_id`、`user_id`、`status`、`phase`、`cycle(Int)`、
`dispatch_chain(PortableLongText/JSON)`、`active_agent_id`、`consensus_percentage(Int)`、
`pending_clarification(Text)`、`thread_ids(JSON)`、`coordinator_name`、`orchestration_mode`、
`chain(JSON)`、`error(Text)`、`created_at/updated_at(BeijingDateTime)`。
2. `dispatch_chain` / `thread_ids` / `chain` 用 `PortableLongText` 存 JSON 字符串,repo 层序列化。
3. 列表 join:给 `DraftMeta` 增加 `status?: JobStatus` 与 `jobId?: string`。
**推荐方案**:草稿与作业 1:1(一个草稿最多一个活跃作业),在 draft 列表查询 LEFT JOIN
`roundtable_jobs` 取最新作业的 `status`。
4. 索引:`(user_id, updated_at)`、`(draft_id)`、`(status)`(cleanup/列表用)。
**验收**
- 迁移可 `alembic upgrade head` 跑通,MySQL 上 `dispatch_chain` 为 `longtext`。
- 单测:create→update_progress→get 往返;`list_by_user` 按 `updated_at` 倒序;`list_older_than` 分批。
---
## Phase 2 —— 后端:圆桌总控编排 LangGraph 图(核心 / 最大工作量)
**目标**:把前端的编排循环移植成一个后端 LangGraph 图,可在 LangGraph runtime 里后台跑、天然
checkpoint,一路跑到 Step3 绘制完成。
**涉及文件(新增)**
- `packages/harness/deerflow/agents/roundtable_orchestrator/graph.py`(图装配,仿 `ai_writing/graph.py`)
- `.../roundtable_orchestrator/state.py`(图状态 schema:intent、selectedAgents、threadIds、coordinatorName、
mode/chain、dialogues、dispatchChain、activeAgentId、cycle、consensus、phase、step3 等)
- `.../roundtable_orchestrator/nodes/*.py`(各节点)
- 在 `langgraph.json` 注册新 assistant `roundtable_orchestrator`(仿 ai_writing assistant 注册,参考 `langgraph.json:16-18`)
**图节点设计(把前端循环逐段翻译过来)**
| 节点 | 职责 | 对应前端逻辑 |
|------|------|------|
| `init` | 复用 `_create_thread_direct` 批量建席位 thread + 总控就绪(或接收前端已建好的 `threadIds` 直接用) | `useStep2Orchestration.ts:1485-1531` / `multi_agent.py:647-823` |
| `leader_call` | 跑一轮总控(agent_type=leader),拿 `status`(派活列表 / `[]` / `clarification`) | 单轮逻辑 `multi_agent.py:1076-1142` |
| `route_after_leader`(条件边) | `clarification`→`interrupt`;有派活→`dispatch_seats`;`[]`→`consensus` | `useStep2Orchestration.ts:1091-1181` |
| `dispatch_seats` | **顺序**派活:对 dispatched 里每个席位依次跑 special run;每开始一个就更新 `activeAgentId` + 进度持久化 | `useStep2Orchestration.ts:1101-1181`(务必串行,勿并行,详见前端 §5.4 注释) |
| `clarification_pause` | `interrupt()` 挂起,`status→awaiting_input`,写 `pending_clarification` | 仿 ai_writing `pause_*` `45-290` |
| `consensus` | `setHasConsensus`、`consensus=100`,落库后进入 `report_draw` | `1091-1099` |
| `report_draw` | 跑 `roundtable-report`(先 report/init 建 thread),流式写 `outputs/方案总览.html`,产出 `DraftStep3Snapshot` | `useStep3Report.ts:91-149,636-652,560-596` |
| `done` | `status→done`、写 `step3` 进作业 + 回写草稿 step3 快照 | `RoundtablePlanningPage.tsx:613-617` |
**关键实现要点**
1. **复用单轮执行**:节点内部不要重写 LLM 调用,抽出 `multi_agent.py` 里 leader/special/report 的
单轮 run 为可被图节点直接 `await` 的内部函数(去掉 HTTP 层,进程内调 LangGraph)。
2. **派活广播**:保留现有把「总控给 X 派了活 / X 完成」追加进各 thread 历史的广播
(`multi_agent.py:487-502`),让席位读到全局上下文。
3. **MAX_CYCLES 兜底**:图里维护 `cycle`,≥8 强制收敛(对齐 `roundtable-constants.ts:13`)。
4. **chain 模式**:`orchestration_mode==='chain'` 时按 `chain.seats` 固定顺序派活,跳过总控自由决策的
部分(对齐 `runChainOrchestration` `1228-1439`)。
5. **进度落库**:每个节点结束 `update_progress(jobId, {phase, cycle, dispatchChain, activeAgentId,
consensus, dialogues_delta})`,前端 SSE 才有实时数据。
6. **dialogues 累积**:把每轮气泡写进图状态 `dialogues`,既供「查看」实时渲染,也供作业结束回写草稿 step2。
**验收**
- 用脚本(仿 `scripts/probe_ai_writing_assistant.py`)直接对图发起一次 run,不开前端,能从 intent +
selectedAgents 一路跑到写出 `方案总览.html`,作业 `status` 终态 `done`。
- 命中 clarification 时图 `interrupt`,`status=awaiting_input`,`pending_clarification` 有值。
- chain 模式按链条顺序派活,recommend 模式由总控自由派活,两条路径都能收敛。
---
## Phase 3 —— 后端:作业控制 + 进度 SSE 接口
**目标**:暴露 start/get/stream/resume/cancel,后台 run 走 LangGraph Server(任意 worker 可 join)。
**涉及文件(新增/改)**
- 新增 `app/gateway/routers/roundtable_jobs.py`(仿 `ai_writing.py` 的 CRUD + 控制壳)
- 在 gateway `app.py` 注册路由
- 新增 `app/gateway/roundtable_jobs_cleanup.py`(仿 `ai_writing_cleanup.py`)+ 在启动处挂 cron
**接口契约**
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/roundtable-jobs` | 入参:`{draftId, intent, selectedAgents, threadIds?, coordinatorName?, model, orchestrationMode, chain?}`。建作业记录(`queued`) + 启动图 run(LangGraph Server thread),返回 `RoundtableJob` |
| `GET` | `/api/roundtable-jobs/{id}` | 作业快照(轮询兜底 / 弹窗首帧) |
| `GET` | `/api/roundtable-jobs/{id}/stream` | SSE 进度流:透传图 `values`/`messages-tuple`,或映射成 §2.4 事件下发。**走真 TCP loopback**(`multi_agent.py:271-293`),禁代理 |
| `POST` | `/api/roundtable-jobs/{id}/resume` | 入参 `{answer}`,回填 clarification → 图 resume,`status→running` |
| `POST` | `/api/roundtable-jobs/{id}/cancel` | 取消图 run,`status→cancelled` |
| `GET` | `/api/roundtable-jobs?draftId=` | 按草稿查活跃作业(页面恢复用) |
| `POST` | `/api/roundtable-jobs/admin/cleanup` | 管理员手动清理(仿 ai_writing admin cleanup `307-367`) |
**多 worker 健壮性(务必遵守,照搬 ai_writing 教训)**
- 作业状态只存 **DB + checkpointer**,**不要**用模块级内存 dict 存 per-session 状态。
- SSE 必须经真 TCP `127.0.0.1` loopback,不能 `httpx.ASGITransport(app=...)` 进程内直调(会整段 buffer)。
- 图 run 跑在 LangGraph runtime(独立于处理 HTTP 的 worker),刷新/换 worker 都能重新 join stream。
- 数据卷:`.deer-flow/data/checkpoints.db` 必须挂出,否则容器重启丢状态。
**验收**
- `POST /start` 后立刻 `GET /{id}` 能拿到 `queued/running`;关掉 SSE 再重连能继续收进度。
- 杀掉发起 start 的 worker(或多 worker 轮询),`resume`/`stream` 仍命中同一作业。
- cleanup 干跑:活跃作业永不删,先删 CP 再删 DB。
---
## Phase 4 —— 前端:作业 API 客户端 + 数据模型扩展
**目标**:前端有一层干净的作业 API 与类型,历史列表能拿到 status。
**涉及文件(新增/改)**
- 新增 `src/roundtable-planning/api/roundtable-jobs.ts`:
```ts
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
export async function getJob(id: string): Promise<RoundtableJob>
export function streamJob(id: string, on: (e: JobProgressEvent) => void): () => void // 返回 unsubscribe
export async function resumeJob(id: string, answer: string): Promise<void>
export async function cancelJob(id: string): Promise<void>
```
- `streamJob` 复用项目现有 SSE/`streamMultiAgent` 的封装方式(`api/multi-agent.ts:403+`),把
LangGraph `values` 映射成 §2.4 的归一化事件。
- 改 `src/roundtable-planning/api/drafts.ts`:`DraftMeta` 增 `status?: JobStatus; jobId?: string`
(`drafts.ts:76-83` + 字段映射 `122-178` 加 snake_case 转换)。
- 新增 `src/roundtable-planning/lib/job-types.ts`:把 §2.1–2.5 的类型集中导出。
**验收**:`pnpm typecheck` 通过;`getJob`/`streamJob` 能拉到 Phase 3 的数据。
---
## Phase 5 —— 前端:Step2「后台挂起」按钮
**目标**:Step2 顶栏/底栏加按钮,点击把当前任务交给后端后台跑,前端停止本地循环、转为观察者。
**涉及文件(改)**
- `src/roundtable-planning/components/Step2Panel.tsx`(按钮放在现有「进入第三步」附近 `370-381`)
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(接线 + 观察者模式)
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`(暴露「停止本地循环并交后台」的方法)
**步骤**
1. Step2Panel 加「⏼ 后台挂起」按钮,`onBackgroundSuspend` 回调。
2. 页面 `handleBackgroundSuspend`:
- `ensureDraft(2)` 拿到 `draftId`(`useDraftPersistence.ts` ensureDraft)。
- 收集当前状态:`intentReady`、`selectedAgents`、`threadIds`、`coordinatorName`、`model`、
`orchestrationMode`、`chain`(全部来自 `step2`/`step1` 现有状态)。
- `startJob(...)` → 拿到 `jobId`。
- **停止前端本地编排**:调用 `step2.stopOrchestration()` + 置一个 `backgroundedJobId` 标记,
让 `currentStep===2` 的 useEffect **不再自启本地循环**(在 `useStep2Orchestration.ts:1468-1541`
的启动条件里加 `&& !backgrounded`)。
- toast「已转入后台,可离开页面,任务会自动跑到结果绘制完成」。
- 刷新历史列表(`drafts` 重新拉,进行中项即出现 spinner)。
3. **观察者模式**:若当前停留在 Step2 且该 draft 有 running 作业,订阅 `streamJob`,把进度事件渲染成
对话气泡/活动席位(复用现有 Step2 渲染),而不是本地驱动。
**注意:避免「双跑」**——后台已接管后,前端绝不能再并行驱动同一批 thread。用 `backgroundedJobId`
互斥;加载草稿时若发现有 running 作业,直接进观察者模式。
**验收**:点「后台挂起」→ 可立即切到别的任务/路由;回来仍在跑;不会出现前后端同时派活。
---
## Phase 6 —— 前端:历史下拉框「进行中」加载动画
**目标**:历史下拉里 `status==='running'`(或 `awaiting_input`)的草稿条目显示加载动画;点击进行中
条目不直接 load,而是打开流程图弹窗。
**涉及文件(改)**
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` 的 `HistoryDropdown`(`771-892`)
**步骤**
1. 列表项左侧名称前/后,按 `d.status` 渲染状态标记:
- `running` → 旋转的 `Loader2`(`animate-spin`)+「进行中」。
- `awaiting_input` → 呼吸点 +「待澄清」(琥珀色)。
- `done`/无 → 维持现状(显示 `Step {furthestStep}`)。
- `error` → 红点 +「出错」。
2. 点击行为分流:
- `running`/`awaiting_input` → `openDispatchChainModal(d.jobId)`(Phase 7),**不**直接 `onLoad`。
- 其它 → 维持 `onLoad(id)`。
3. 列表需要 `status`,确保 Phase 4 的 `DraftMeta.status` 已就位;下拉打开时 `refresh()` 拉最新。
**验收**:后台挂起后,历史下拉对应条目转圈;点击弹出流程图弹窗。
---
## Phase 7 —— 前端:派活链路流程图弹窗(横向 / 大弹窗 / 动画)
**目标**:大弹窗,横向排列的智能体派活链路。节点 = 席位(图标 + 名称),不同颜色;正在发言的
节点和它的连接线带加载动画;含「查看」按钮。
**涉及文件(新增)**
- `src/roundtable-planning/components/DispatchChainModal.tsx`
**实现选型**:链路是「总控 → 席位序列」的线性结构 + 需要对**单个节点和单条连线**做精确动画,
**推荐手写 flex 横向布局 + CSS 动画连接线**(轻、可控)。项目已装 `@xyflow/react@12`(带 `animated`
边)作为备选,但手写对本场景的「节点 loading + 连线流动」更直接。
**结构(手写方案草图)**
```tsx
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[88vh] flex-col gap-0 sm:max-w-5xl"> {/* 大弹窗 */}
<DialogHeader>… 任务标题 + 阶段标签(leader_thinking/dispatching/report_drawing…) …</DialogHeader>
{/* 横向链路:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶ [共识] ──▶ [报告绘制] */}
<div className="custom-scrollbar overflow-x-auto py-8">
<div className="flex items-center gap-0 min-w-max px-6">
<CoordinatorNode active={phase==='leader_thinking'} />
{nodes.map((n,i)=>(
<Fragment key={n.agentId}>
<Connector active={n.state==='active'} /> {/* 活动连线:流动虚线动画 */}
<ChainNode node={n} /> {/* 活动节点:彩色+脉冲光环 */}
</Fragment>
))}
<Connector active={phase==='consensus'} />
<MilestoneNode label="共识" done={consensus===100} />
<Connector active={phase==='report_drawing'} />
<MilestoneNode label="报告绘制" active={phase==='report_drawing'} done={phase==='report_done'} />
</div>
</div>
<DialogFooter className="border-t pt-3 sm:justify-between">
<span className="text-xs text-muted-foreground">轮次 {cycle}/8 · {statusLabel}</span>
<Button onClick={onView}>查看</Button> {/* Phase 8 */}
</DialogFooter>
</DialogContent>
</Dialog>
```
**节点颜色**:`avatarType = getAvatarTypeFor(agentId, order)` → `chainTagClasses(avatarType)`
(复用 `lib/business-chain.ts:56-72` + `lib/roundtable-constants.ts`),与业务链条卡片同一套色。
**节点状态样式**
- `pending`:灰色描边、低透明度。
- `active`:席位色填充 + 外圈 `animate-ping`/脉冲光环 + 图标旋转或呼吸。
- `done`:席位色实心 + 右上角 ✓。
**连接线加载动画(active 连线)**:CSS 流动虚线(marching-ants)或渐变扫光:
```css
/* roundtable-planning.css 追加 */
@keyframes chain-flow { to { background-position: 20px 0; } }
.chain-connector--active {
background-image: linear-gradient(90deg, var(--brand-500) 50%, transparent 50%);
background-size: 20px 100%;
animation: chain-flow 0.6s linear infinite;
}
```
**数据来源**:弹窗 mount 时 `getJob(jobId)` 取首帧,随后 `streamJob(jobId, onEvent)` 实时更新
`nodes`/`activeAgentId`/`phase`/`consensus`/`cycle`;`done` 事件后停止动画、全节点置 `done`。
弹窗关闭时 `unsubscribe`。
**验收**:弹窗够大、横向滚动;活动席位节点和它左侧连线在动;阶段推进时高亮顺移;`done` 后静止。
---
## Phase 8 —— 前端:「查看」进入进行中任务并实时观察
**目标**:弹窗「查看」→ 进入该任务,停在 Step2 实时看后台进度(气泡/活动席位随 SSE 刷新),
跑完自动呈现 Step3 结果。
**涉及文件(改)**
- `RoundtablePlanningPage.tsx`(接 `onView`)
- `useDraftPersistence.ts`(`handleLoadDraft` 增「带作业」分支)
- `useStep2Orchestration.ts`(观察者渲染)
**步骤**
1. `onView`:关弹窗 → `handleLoadDraft(draftId)` 恢复该草稿到 Step2(`useDraftPersistence.ts:308-368`),
但**跳过本地编排自启**(识别到有 running 作业 → 进观察者模式,对齐 Phase 5 的互斥标记)。
2. 订阅 `streamJob(jobId)`,把 `dialogue` 事件 append 进 `step2RoundtableDialogues`、`active` 事件
驱动 `activeSpeakerId` 高亮、`consensus` 更新百分比。
3. 命中 `awaiting_input`:在 Step2 弹出澄清输入(复用现有 `pendingClarification` UI),用户回答 →
`resumeJob(jobId, answer)`(而非本地 `submitClarification`)。
4. `done` 事件:用返回的 `DraftStep3Snapshot` 走 `hydrateStep3` + 允许切到 Step3 查看报告
(`useStep3Report.hydrateSaved` `708-767`);作业列表该项变「已完成」。
**验收**:从历史「查看」进入能实时看到后台在跑;澄清能在前端回答并续跑;跑完能看到最终报告。
---
## Phase 9 —— 边界、健壮性与收尾
1. **双跑互斥**:任一 draft 同一时刻只允许一个执行者(前端本地 or 后台作业)。加载草稿/进入 Step2
前先 `GET /api/roundtable-jobs?draftId=`,有 running 就进观察者模式。
2. **后台命中澄清**:`awaiting_input` 时历史下拉显示「待澄清」徽标;流程图弹窗显示「等待澄清」;
只有「查看」进去才能回答(或在弹窗里直接给一个回答框,调用 `resumeJob`)。
3. **取消**:流程图弹窗/Step2 提供「停止后台」→ `cancelJob`,`status→cancelled`,前端可恢复本地编辑。
4. **错误恢复**:`error` 事件展示 detail;允许「重试」= 重新 `startJob`(沿用已建 threadIds)。
5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 Step3。
6. **多 worker**:严格遵守 Phase 3 的「状态进 DB+checkpointer、SSE 走 loopback」;部署若多 worker
需 sticky 或全靠 DB 态(推荐后者)。
7. **清理**:`roundtable_jobs` cleanup cron(活跃保护、先删 CP 再删 DB),与 draft 生命周期对齐。
8. **历史列表性能**:draft 列表 join 作业 status 时注意 N+1;用一次 LEFT JOIN 取最新作业。
---
## 端到端验证清单(提测前逐条过)
- [ ] Step2 点「后台挂起」→ 立刻可离开页面 / 切到别的任务,后台继续跑。
- [ ] 历史下拉里该任务显示加载动画(转圈)。
- [ ] 点击进行中任务 → 大弹窗、横向派活链路、活动节点+连线在动、节点不同颜色。
- [ ] 阶段推进(leader→派活→共识→报告绘制)时弹窗高亮顺移。
- [ ] 弹窗「查看」→ 进入任务实时观察气泡/活动席位。
- [ ] 后台命中澄清 → 列表「待澄清」+ 弹窗提示 + 可回答续跑(`resumeJob`)。
- [ ] 一路自动跑到 Step3「绘制完成」,最终报告可在 Step3 查看,作业 `done`。
- [ ] 浏览器刷新 / 关页重开后,任务仍在后台跑、状态可恢复(真后台验证)。
- [ ] 取消后台 → `cancelled`,无残留双跑。
- [ ] 多 worker(或杀 worker)下 stream/resume 不丢会话。
- [ ] cleanup 干跑不误删活跃作业。
---
## 工作量与排期建议
| 阶段 | 内容 | 粗估 | 依赖 |
|------|------|------|------|
| P0 | 数据契约定型(本文 §2) | 0.5d | — |
| P1 | 后端作业表 + repo + 迁移 | 1d | P0 |
| **P2** | **后端编排图(移植循环)** | **3–5d(核心难点)** | P0,P1 |
| P3 | 后端控制 + SSE + cleanup | 1.5d | P1,P2 |
| P4 | 前端作业 API + 类型 + draft status | 0.5d | P3 |
| P5 | Step2 后台挂起按钮 + 互斥 | 1d | P4 |
| P6 | 历史下拉 loading 动画 | 0.5d | P4 |
| P7 | 派活链路流程图弹窗 | 1.5d | P4 |
| P8 | 「查看」实时观察 + 澄清续跑 | 1.5d | P5,P7 |
| P9 | 边界/健壮性/清理 | 1d | 全部 |
> 建议先打通 **P1→P2→P3** 的后端最小闭环(脚本验证图能后台跑到出报告),再做前端 P4–P8。
> P2 是整条链路成败的关键,务必先用脱离前端的探针脚本(仿 `scripts/probe_ai_writing_assistant.py`)跑通。
---
## 附:关键参考代码索引
- 前端编排循环(待移植到后端):`useStep2Orchestration.ts:992-1204,1228-1439,1468-1541`
- 共识判定 / 进 Step3:`useStep2Orchestration.ts:1091-1099`、`Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595`
- Step3 绘制完成标志:`useStep3Report.ts:51,165,560-596,636-652`
- 草稿模型 / API / 自动保存 / 加载恢复:`api/drafts.ts:25-110,184-225`、`useDraftPersistence.ts:248-316,308-368`
- 历史下拉 UI:`RoundtablePlanningPage.tsx:771-892`
- 后端单轮执行 / 派活 / 广播:`multi_agent.py:647-823,1076-1142,525-552,487-502`、loopback `271-293`
- AI 写作模板:`agents/ai_writing/graph.py`、`ai_writing_sessions/model.py:14-39`、`ai_writing.py`、`ai_writing_cleanup.py:307-367`、`AIWritingPage.tsx` 文件头注释
- 颜色体系复用:`lib/business-chain.ts:56-72`、`lib/roundtable-constants.ts`(`getAvatarTypeFor`)
- 流程图备选库:`@xyflow/react@12`(已安装)
```
@@ -0,0 +1,124 @@
# 多智能体「后台挂起」—— 当前状态、遗留问题与下次继续指南
> 配套设计文档:`multi-agent-background-run-dev.md`(分 Phase 0-9 的总方案)。
> 本文记录**截至本次会话的实际进展 + 还没解决的遗留问题**,方便下次直接续接。
---
## 一、整体状态:跑通到哪了
「后台挂起」= Step2 把研讨交给后端**进程内驱动真模型**(leader→各席位→共识→报告),
survive 刷新/关页,对话与报告作为一个 **run 写进草稿**(和手动跑同一存取),可在历史回看。
**真网关已在本地验证可跑通**(2026-06-04 日志确认:roundtable-coordinator + 各席位 +
roundtable-report 真调 deepseek、真搜索、跑到 report run success)。但还有几个**内容/产物层面的遗留问题**(见第三节)。
### 已完成(可用)
- ✅ 后端持久化:`roundtable_jobs` 表(迁移 `20260604_01` / `20260605_01` dialogues / `20260605_02` step3)。
- ✅ 编排引擎 `packages/harness/deerflow/agents/roundtable_orchestrator/`(recommend + chain + 报告,纯逻辑可单测)。
- ✅ 控制面 `app/gateway/routers/roundtable_jobs.py`:start/get/by-draft/stream(SSE)/resume/cancel/admin-cleanup。
- ✅ 执行器 `app/gateway/roundtable_job_executor.py`:后台任务 + `set_current_user` + 生命周期 + **每轮把对话作为 `job-<id>` run 写进 `draft.step2.runs[]`(同手动存取)** + done 写 step3。
- ✅ 真网关 `app/gateway/roundtable_inprocess_gateway.py`:`SimpleNamespace(app)` + `start_run` 进程内驱动 agent;复用 `multi_agent._extract_dispatch` 解析派活;checkpointer 读最终 messages。
- ✅ 模拟网关 `roundtable_orchestrator/simulated_gateway.py`(不调真模型,前端联调用)。
- ✅ 前端:`api/roundtable-jobs.ts`、`lib/job-types.ts`、Step2「后台挂起」按钮、历史下拉 loading、`DispatchChainModal`/`DispatchChainJobModal`(流程图 + 对话面板 markdown + 加宽 + 澄清作答 + 取消 + 查看)。
- ✅ 上下文「广播」(inline):席位/总控 prompt 带上「任务背景 + 前序各席位交付 + 别提问」约束(修「席位拿不到关键信息、反问用户、总控瞎编」)。
- ✅ 后端测试:`tests/test_roundtable_jobs.py` / `test_roundtable_orchestrator.py` / `test_roundtable_job_executor.py` / `test_roundtable_jobs_api.py` 全过。
---
## 二、怎么启动 / 验证(每次开工先做)
1. **开真网关**(默认是模拟网关):已写进 `offline-backend-20260512/.env`:
```
ROUNDTABLE_JOB_GATEWAY=inprocess
```
启动日志应出现 `[roundtable-jobs] gateway = inprocess (real models)`。
2. **必须用 `make gateway`(不带 --reload)跑后端!**
`make dev` 的 `--reload` 监视 `backend/` 目录,agent 运行时往 `.deer-flow/` 写文件
(memory.json / checkpoint / 沙箱输出)会触发热重载,**把长时后台任务杀掉**。
(改进项:给 uvicorn 加 `--reload-exclude '.deer-flow/*'`,或始终用 `make gateway`。)
3. **直接查库验证**(最有效,PowerShell 控制台是 GBK,导出到文件看中文):
```bash
cd offline-backend-20260512/backend
# 查最新作业的 dialogues / step3
PYTHONPATH=. uv run python -c "import sqlite3,json; con=sqlite3.connect('.deer-flow/data/deerflow.db'); ..."
```
- 作业表:`.deer-flow/data/deerflow.db` → 表 `roundtable_jobs`(`dialogues`/`step3` 列)。
- 草稿表:`roundtable_drafts` → `step2` 列里应有 `runs[ {id:"job-<jobId>", jobId, jobStatus, step2RoundtableDialogues:[...], step3:{...}} ]`。
4. **前端验证**:后台挂起 → 等跑完 → 从历史加载该草稿 → Step2 应显示后台研讨完整对话(走现有 `normalizeStep2Runs`/`hydrateStep2Run`)。
---
## 三、遗留问题(下次重点,已按优先级排)
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)
- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads/<id>/user-data/outputs`;
`run.step3.html` 回退成报告 agent 的**纯文字**(不含 `<html>`),第3步画不出东西。
- **已做**:`build_report_prompt`(`roundtable_orchestrator/engine.py`)已对齐前端
`useStep3Report.buildReportPrompt`,明确「用 write_file 写入 /mnt/user-data/outputs/方案总览.html」;
`_read_report_html`(`roundtable_inprocess_gateway.py`)已改为扫 outputs 目录任意 `.html`。
- **仍没解决**:报告 agent(deepseek-chat)还是没调 `write_file`。
- **下次怎么查**:
1. 对比**前端那条能写出 HTML 的报告 run** 的确切调用(`useStep3Report.ts` 里 `streamMultiAgent`
调 `/api/multi-agent/run/stream` 的 body:`agent_type`/`excluded_tools`/`context` 等),
和后台 `_run_agent_turn`(`roundtable_inprocess_gateway.py`,agent_name=roundtable-report,
`excluded_tools=_REPORT_EXCLUDED`)逐项比对,找出差异(怀疑 reasoning_effort / thinking /
某个 context 字段 / recursion 步数导致它没走到 write_file)。
2. 看报告 run 的最终 messages(checkpoint)里有没有 write_file 的 tool_call(被 dangling 截断?)。
3. 报告 agent 的 SOUL:`app/gateway/routers/_roundtable_seed/roundtable-report/SOUL.md`——确认它默认行为。
### P1 —— 内容质量(本次刚加 inline 广播,待真机验证)
- **已做**:席位/总控 prompt 注入「任务背景 + 前序交付 + 别提问」。
- **待验证**:真机跑一次,看席位是否基于真实上下文产出(不再反问/凭记忆瞎编)。
- **若 inline 不够**:考虑实现**真·thread 广播**(像 `multi_agent._append_thread_message` 那样把
「用户意图 / 派活 / 各席位交付」追加进各 thread 历史)。进程内要避开 HTTP loopback 鉴权,
得用 checkpointer 直接 append human 消息(参考 `threads` 路由 POST `/state` 的内部逻辑)。这块较重,先看 inline 够不够。
### P2 —— 对话「没有步骤(steps)」
- **现象**:手动跑的气泡带工具调用步骤卡(`onPhaseChange` → `appendStepIfNew` → `Step2Dialogue.steps`);
后台对话只存了最终正文,没有 steps。
- **下次怎么做**:后台 `_run_agent_turn` 跑完后,从 checkpoint 的 messages 里抽出 tool_calls,
映射成 `Step2Dialogue.steps`(`{id,key,label,ts,args}`),在执行器 `_to_step2_dialogue` 里带上。
纯展示增强,不影响内容/报告。
### P3 —— 双跑浪费 + --reload 坑(工程健壮性)
- 进 Step2 时前端会自动跑一遍本地研讨(真模型、真线程);点后台挂起后前端那条 run 在后端还会跑完
(`stopOrchestration` 只断客户端、停不了后端 run)→ 和后台作业短暂双跑、双倍 token。
- 优化方向:进 Step2 改成「显式开始」而非自动跑;或后台挂起时真正 cancel 前端那条 run。
- `make dev` 的 `--reload` 会杀后台任务(见第二节);改进项:`--reload-exclude '.deer-flow/*'`。
---
## 四、关键文件清单(下次定位用)
**后端**
| 文件 | 作用 |
|------|------|
| `app/gateway/roundtable_inprocess_gateway.py` | 真网关:进程内驱动 leader/seat/re 编排循环 + `build_report_prompt` + inline 广播(`_seat_message`/`_deliveries_block`) |
| `.../roundtable_orchestrator/progress.py` | 进度落库 + `on_dialogue` 每轮回调 |
| `.../persistence/roundtable_jobs/` | job 表 model/sql |
| `app/gateway/deps.py` | `ROUNDTABLE_JOB_GATEWAY` 选网关 + 注入 executor(draft_store) |
**前端**
| 文件 | 作用 |
|------|------|
| `src/roundtable-planning/api/roundtable-jobs.ts` | 作业 API + SSE |
| `src/roundtable-planning/lib/job-types.ts` | 类型(RoundtableJob/JobDialogue/...) |
| `src/roundtable-planning/components/DispatchChainModal.tsx` / `DispatchChainJobModal.tsx` | 流程图弹窗 + 对话面板 |
| `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 后台挂起按钮、历史下拉、**runs[] 多运行模型**(`hydrateStep2Run`/`getStep2SnapshotWithRuns`/`normalizeStep2Runs`) |
| `src/roundtable-planning/api/drafts.ts` | `DraftStep2RunSnapshot`(带 jobId/jobStatus/step3) |
---
## 五、关键架构事实(避免重复踩坑)
1. **真网关进程内驱动**:`start_run(body, thread_id, SimpleNamespace(app=app))` + `set_current_user`,
不走 HTTP(loopback 调 `/api/multi-agent/*` 不可行:`_auth_headers` 只转发用户 token + owner check)。
2. **后台作业首启自建全新线程**(`StartParams.is_resume`),绝不复用前端本地研讨的线程(会撞 409)。
3. **存取统一**:后台作业 = `draft.step2.runs[]` 里一个带 jobId 的 run,和手动跑同一存取/hydrate 路径,
**不要再开 observe/回显分支**(之前试过,被否)。
4. **聊天记录确实存上了**(job.dialogues + draft 的 run);之前"没存上"是测了旧代码 / 看的是前端本地残缺数据。
5. 多 worker / SSE:状态进 DB、SSE 走 DB 轮询;真网关进程内无 loopback。
@@ -0,0 +1,400 @@
# 业务链条模式 · 需求与分步实现文档
> 面向「智能体推荐弹窗」的扩展:在现有**智能推荐**之外,新增**业务链条选择**与**业务链条配置**两块能力;选定业务链条后,Step 2 圆桌研讨**按链条内的固定顺序**逐个发言(而非现在的总控派活)。
>
> 配套阅读:
> - 前端总览:`multi-agent-frontend-dev.md`(推荐弹窗 §6.2、Step 2 编排 §5.4、草稿持久化 §5.7)
> - 后端总览:`multi-agent-backend-dev.md`(Step 2 §6、草稿/历史持久化 §7A)
>
> 本文档约定:**「席位 / 智能体」**指一个 agent;**「业务链条 / chain」**指一组有序排列的 agent 配置,是可命名、可复用、可持久化的实体。
---
## 0. 需求原文拆解
用户原始诉求逐条拆成可实现条目:
| # | 原始诉求 | 拆解 |
|---|----------|------|
| 1 | 推荐弹窗要有「模式选择」 | 弹窗顶部加模式切换(智能推荐 / 业务链条),两种模式互斥,确认后都产出**有序**的 `SelectedAgent[]` |
| 2 | 「智能推荐」就是现在这个 | 现有 `RecommendAgentsDialog` 整体收进「智能推荐」模式,行为不变 |
| 3 | 「业务链条选择」是卡片列表 | 卡片 = 标题 + 一排智能体名 tag;tag 不同颜色;tag 之间有**向左箭头**连接,表达链条次序 |
| 4 | 业务链条需要一个**配置页面** | 左侧智能体列表(复用 `GET /api/agents`),右侧有序长条卡片(名称 + 描述);支持右侧内部拖拽排序、左→右拖入指定位置 |
| 5 | 配置页用弹窗还是新页面? | **本文档决策:独立路由页面**(理由见 §2.1) |
| 6 | 选定链条后,Step 2 用链条内智能体研讨 | 复用既有 Step 2 init/stream;**总控(leader)按链条顺序逐个派活**(§6) |
| 7 | 规划成分步可实现的需求文档 | 见 §7 分阶段实现 |
---
## 1. 目标与非目标
### 1.1 目标
- 推荐弹窗成为「**席位来源选择器**」:无论智能推荐还是业务链条,最终都向 `onConfirm` 交付一个**有序的 `{ agent_id, name }[]`** + 一个 `orchestrationMode`。
- 业务链条是**用户可创建、命名、保存、复用、删除**的有序智能体编排模板。
- Step 2 支持两种编排:
- `recommend` 模式 → 现有总控**自由派活**循环(不动)。
- `chain` 模式 → 总控仍是派活方,但**被约束按链条顺序逐个派活**(新增)。
### 1.2 非目标(本期不做,留扩展点)
- 链条内每个席位的 per-seat prompt/temperature 真实覆盖(仍仅前端记录,沿用现状)。
- 链条的分支 / 条件流转(只做线性顺序链)。
- 链条跨用户共享 / 市场(先做「本人私有链条」)。
- 链条版本管理 / 协作编辑。
---
## 2. 关键设计决策
### 2.1 决策 A:配置页面用「独立路由页面」而非弹窗 ✅
**结论:业务链条的「编辑/管理」用独立路由页面 `/page/workspace/roundtable/chains`;业务链条的「选择」仍内嵌在推荐弹窗里(卡片列表)。**
理由:
| 维度 | 弹窗 | 独立页面 ✅ |
|------|------|------------|
| 双栏 + 拖拽 + 长条卡片 | 弹窗宽度(`sm:max-w-3xl`)+ `max-h-[85vh]` 拖拽空间局促,左右栏挤 | 整屏宽度,拖拽手感好 |
| 复用入口 | 只能从推荐弹窗进 | 可从侧栏、推荐弹窗「管理链条」按钮、深链接进入 |
| 可深链接/刷新 | 不可 | `/roundtable/chains` 可直达、可分享 |
| 推荐弹窗内嵌编辑器 | 弹窗套弹窗 / 模式套编辑,状态复杂 | 关注点分离:弹窗只「选」,页面只「编」 |
**分工**:
- **推荐弹窗**:模式选择 + 业务链条**选择**(只读卡片列表,可点「去配置」跳到页面)。
- **配置页面**:业务链条的**增删改**(双栏拖拽编辑器 + 链条列表管理)。
> 备选:若产品坚持不加路由,可改为**全屏 Dialog(`max-w-[90vw] h-[90vh]`)**承载编辑器,从推荐弹窗内打开。结构与页面版一致,仅外壳不同。本文档按「独立页面」实现,§7 标注了切换成本。
### 2.2 决策 B:链条持久化 —— 后端 MySQL(确定) ✅
**结论:业务链条存后端 MySQL,仿 `roundtable_drafts`(§7A)新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由,按登录用户隔离、跨设备保留。** 不走 localStorage。
理由:链条是可复用、需跨设备/会话保留的模板,与草稿/推荐历史性质一致;草稿当年正是因 localStorage 换设备即丢才迁后端(§7A),链条不重复踩坑。
落地:
- 表/路由设计见 §5.2,是「复制 §7A 草稿持久化、把字段换成 chain」的机械工作。
- 前端 `api/chains.ts` 直接打后端接口(snake_case↔camelCase 在这层收口,仿 `api/drafts.ts`),**不提供 localStorage 实现**。
- 后端表必须先于前端 UI 落地(§7 阶段 0 即包含后端表/路由)。
### 2.3 决策 C:拖拽库选型 —— `@dnd-kit`
仓库**当前无任何拖拽库**(`package.json` 无 `@dnd-kit` / `react-dnd` / `sortablejs`;`@xyflow/react` 是节点图,不适合列表排序)。
| 方案 | 取舍 |
|------|------|
| **`@dnd-kit/core` + `@dnd-kit/sortable`**(推荐) | React 19 友好、可访问性好、跨容器拖入 + 列表排序 + 插入指示器开箱即用;新增 ~30KB gzip 依赖 |
| 原生 HTML5 DnD(零依赖兜底) | 不加依赖,但跨容器插入位置指示、键盘可达性都要手写,易出 bug |
**建议**:新增 `@dnd-kit/core`、`@dnd-kit/sortable`、`@dnd-kit/utilities`。若产品方禁止新增依赖,退回原生 HTML5 DnD(§7 阶段 2 标注差异)。
### 2.4 决策 D:Step 2「总控按链条顺序派活」,后端零改动 ✅
**形态:总控(leader)仍是唯一派活方,但每一轮被前端约束「只能派给链条里的下一个席位」,逐个走完整条链,最后由总控收口。** 区别于 recommend 模式的「总控自由决定派给谁」。
为什么后端无需改动:
- Step 2 后端 `streamMultiAgent({ agentType: "leader" })` 每轮返回 `dispatched = [[agent, task], ...]`;`agentType: "special"` 单独驱动某席位跑一轮。这两个原语已足够。
- 每个 special 跑完,后端 `_broadcast` 会把「子智能体 X 完成…交付内容为:…」append 进**其它所有席位 + 总控 thread** 的历史(后端 §6.4),所以链路上下文天然累积。
- chain 模式只是**改变前端怎么提示 leader、以及怎么校验它的派活**——把每轮 leader 的提示词约束成「请向链条第 i 位【X】派活」,并对返回的 `dispatched` 做「必须命中链条下一席位」的校验/纠偏。**后端路由、SOUL、表结构都不动。**
→ chain 模式 = 在 `useStep2Orchestration` 里新增 `runChainOrchestration`:按链条游标逐位调 leader(约束派给该位)→ 拿到 task → 调 special → 下一位 → 链尾后调一次 leader 收口。详见 §6。
> 与「前端绕过 leader 直接顺序 special」的区别:本方案**保留总控在每一步的协调/串接职责**(leader 结合前序进展为下一席位编写针对性任务、并在派活说明里承上启下),更符合「总控按链条顺序派活」的语义,圆桌可视化里总控气泡也照常出现。
---
## 3. 数据模型与类型
新增前端类型,建议放 `frontend-web/src/roundtable-planning/lib/business-chain.ts`:
```ts
/** 业务链条里的一个有序席位。 */
export interface ChainSeat {
agent_id: string;
/** 落库时的快照名/描述,避免 agent 改名后链条显示错乱;展示优先用实时 agent 数据,回退到快照。 */
name: string;
description?: string;
}
/** 一条可复用的业务链条。 */
export interface BusinessChain {
id: string;
title: string;
description?: string;
/** 有序席位列表;seats[0] 是第一个发言者。 */
seats: ChainSeat[];
createdAt?: string;
updatedAt?: string;
}
/** 推荐弹窗确认后回传给主页面的统一结果。 */
export interface SeatSelectionResult {
/** 有序席位(与现有 handleConfirmAgents 入参兼容)。 */
agents: Array<{ agent_id: string; name: string }>;
/** 决定 Step 2 走哪种编排。 */
mode: "recommend" | "chain";
/** chain 模式下带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
chain?: Pick<BusinessChain, "id" | "title">;
}
```
**约束**:链条席位数沿用圆桌的 `2 ≤ N ≤ 8`(`MIN_SELECTED`/`MAX_SELECTED`),配置页保存时校验。
**tag 配色**:复用 `roundtable-constants.ts::getAvatarTypeFor(agentId, index)`,得到 `blue|purple|emerald|amber|pink|human`,再映射到已有的 avatar 配色 token(与圆桌可视化、Step 2 气泡同一套色板,视觉统一)。
---
## 4. 现有代码改动点速查
| 位置 | 改动 | 说明 |
|------|------|------|
| `components/RecommendAgentsDialog.tsx` | 包一层「模式选择」外壳,现有内容收进「智能推荐」Tab | `onConfirm` 签名从 `agents[]` 升级为 `SeatSelectionResult`(或加可选第二参 `mode`,见 §7 阶段 3 的兼容策略) |
| `pages/RoundtablePlanningPage.tsx` | `handleConfirmAgents` 接收 `mode`,存到新状态 `orchestrationMode` | 透传给 `useStep2Orchestration` |
| `hooks/useStep2Orchestration.ts` | 新增 `runChainOrchestration` + `orchestrationMode` 分支 | init effect 里按 mode 选择 `runOrchestration` 或 `runChainOrchestration` |
| `lib/roundtable-constants.ts` | 复用 `getAvatarTypeFor` / `SelectedAgent` | 不改,仅引用 |
| `hooks/useDraftPersistence.ts` + `api/drafts.ts` | Step2 快照新增 `orchestrationMode` + `chain` 字段 | 草稿能记住「这次是链条模式」 |
| `pages/WorkspaceRoutes.tsx` | 注册 `roundtable/chains` 路由 | 仿 `roundtable/planning` |
| `components/page-sidebar.tsx`(如有侧栏入口) | 加「业务链条」入口(可选) | 也可只从推荐弹窗进 |
| `api/chains.ts`(新增) | 链条 CRUD 客户端 | 直接打后端 `/api/roundtable-chains` |
| 后端 `roundtable_chains` | 表 + 路由(MySQL) | 仿 `roundtable_drafts`,**必做** |
---
## 5. 链条持久化 API 设计
### 5.1 前端客户端 `api/chains.ts`(直接打后端)
```ts
listChains(): Promise<BusinessChain[]> // 当前用户全部链条
getChain(id: string): Promise<BusinessChain>
createChain(input: Omit<BusinessChain,"id"|"createdAt"|"updatedAt">): Promise<BusinessChain>
updateChain(id: string, patch: Partial<BusinessChain>): Promise<BusinessChain>
deleteChain(id: string): Promise<void>
```
- 实现用 `apiFetch` 打 `/api/roundtable-chains`(§5.2),snake_case↔camelCase 在这层收口(仿 `api/drafts.ts`)。
- 不提供 localStorage 实现;后端表是前端 UI 的硬依赖。
### 5.2 后端表 + 路由(仿 §7A `roundtable_drafts`)
- 表 `roundtable_chains`:`id`(PK) / `user_id`(idx) / `title`(512) / `description`(text) / `seats`(`PortableLongText`,存 `ChainSeat[]` JSON) / `created_at` / `updated_at`(`BeijingDateTime`)。
- 路由 `app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`:`GET ``(列表)/ `POST ``(建)/ `GET /{id}` / `PUT /{id}`(partial,`exclude_unset`)/ `DELETE /{id}`。
- 鉴权/属主:`_current_user_id(request)`,跨用户访问一律 404,与 `roundtable_drafts` 完全一致。
- 启动 `Base.metadata.create_all` 自动建表(MySQL 生产 / sqlite 本地走同一套 SQLAlchemy 代码,`PortableLongText` 各自降级),登记进 `persistence/models/__init__.py`,store 接到 `app.state` 并 `include_router`。
> 整体是「复制 §7A 的草稿持久化、把字段换成 chain」的机械工作,风险低。
---
## 6. Step 2 链条模式编排设计(`runChainOrchestration`)
### 6.1 与现有 `runOrchestration` 的差异
| 维度 | `runOrchestration`(recommend) | `runChainOrchestration`(chain,新增) |
|------|--------------------------------|----------------------------------------|
| 谁派活 | 总控 leader | 总控 leader(**不变**) |
| 派给谁由谁定 | leader 自由决定 | 前端按链条游标**约束 leader 只能派给下一席位** |
| 发言顺序 | leader 每轮自由派 | **链条数组的固定顺序** |
| 循环结构 | `while` 直到 leader 返回 `[]` 共识 / MAX_CYCLES | 按链条游标走一趟(每位:leader 派活 → special 交付) |
| 收口 | leader 返回 `[]` 即共识 | 链尾后再调一次 leader 做总结收口,产出 `lastLeaderContent` + `hasConsensus` |
> **核心**:leader 始终在场,只是**每轮的派活目标被前端钉死成链条的下一席位**——前端在 leader 的 `newMessage` 里写明「本轮请通过 `agent_orchestration` 仅向【X】派活」,并对 `leaderRes.dispatched` 做命中校验/纠偏。
### 6.2 伪代码
```
runChainOrchestration(seedMessage, ids, coord, chainSeats):
isOrchestrating = true
cancel = false
N = chainSeats.length
for (let i = 0; i < N; i++): // 链条游标,逐位推进
if cancel: break
seat = chainSeats[i]
seatMeta = agentNameToRole[seat.agent_id]
// ── (a) 总控派活给「链条下一席位」──────────────────────────
highlightSpeaker(null) // leader 阶段不高亮任何 sub
leaderBubble = appendStreamingDialogue("总控协调", confidence:"链条派活中")
leaderTask = buildChainLeaderTask({
seedMessage, // 任务目标/约束(首轮)
position: i, total: N,
nextSeat: seat, // 「本轮请仅向【X(seat.agent_id)】派活」
isFirst: i === 0,
})
leaderRes = await streamMultiAgent(leader, ids, coord, leaderTask, model=globalModel)
// 约束/纠偏:取 dispatched 中命中 seat.agent_id 的那条 task;
// 若 leader 没派或派错(派给非 seat、或派给多个),前端兜底:
// - 命中就用它给出的 task 文本(保留总控的承上启下措辞);
// - 没命中则用 leader 的正文 content 作为兜底 task,或回退到模板 task。
task = pickChainTask(leaderRes, seat)
// leader 若返回 clarification → 暂停链条,走与现状一致的澄清条(§6.3)
finalizeDialogue(leaderBubble, leaderRes.content, "派活说明")
// ── (b) 被派席位交付(special,顺序、不并行)──────────────────
markSpeakerStreaming(seatMeta.speakerId)
seatBubble = appendStreamingDialogue(seatMeta.displayName, confidence:"调度中")
perSeatModel = agentConfigs[seatMeta.speakerId]?.model || globalModel
specRes = await streamMultiAgent(special, ids, seat.agent_id, task, model=perSeatModel)
finalizeDialogue(seatBubble, specRes.content, "子智能体交付")
markSpeakerIdle(seatMeta.speakerId)
// 后端 _broadcast 已把本席位交付注入其余 thread 历史 → 下一席位天然可见
if cancel: return
// ── (c) 总控收口:综合全链交付,产出最终结论 ──────────────────
leaderBubble = appendStreamingDialogue("总控协调", confidence:"汇总链条结论")
leaderRes = await streamMultiAgent(leader, ids, coord,
"各链条席位已按顺序完成发言,请综合所有交付,输出最终方案,不要再派活。")
finalizeDialogue(leaderBubble, leaderRes.content, "全案盖印")
setLastLeaderContent(leaderRes.content)
setHasConsensus(true); setConsensusPercentage(100)
isOrchestrating = false
```
要点:
- **派活方仍是总控**,符合「总控按链条顺序派活」的语义;圆桌可视化里每位席位前都先出现一个总控派活气泡,再出现该席位交付气泡。
- **约束手段是 prompt + 前端校验,不动后端**:`buildChainLeaderTask` 把「本轮只能派给【X】」写进 leader 的 `newMessage`;`pickChainTask` 对 `leaderRes.dispatched` 做命中校验,派错/未派时兜底,保证链条游标严格推进。
- **复用现有三段式气泡 / 高亮 / abort 基建**(`appendStreamingDialogue` / `finalizeDialogue` / `markSpeakerStreaming` / `activeAbortersRef` / `orchestrationCancelRef`),只是驱动顺序不同。
- **special 仍是顺序**(不并行),与现状不变量一致(文件顶部注释 §1)。
- **收口 leader 调用默认开**(产出 Step 3 结论);若链条末位本身是「总结陈述」类,可关掉收口,直接拿末位交付当 `lastLeaderContent`。配置位 `chainNeedsLeaderSummary`(默认 true)。
### 6.3 澄清 / 挂起 / 恢复 / 干预
- **澄清**:链条中途某轮 leader 返回 `clarification` → 与现状一致,暂停链条、底部弹澄清条;用户回答后从**当前游标**继续(需记录 `chainCursor`)。
- **挂起/恢复**:`isRoundTableRunning` toggle 复用现有机制;恢复时从 `chainCursor` 续跑。MVP 若不做断点续跑,可先只支持「整体停止 / 从头重跑」,UI 文案说明(扩展点,§7 阶段 4 标注)。
- **干预**:可在两席位之间插入一条用户指令,由下一轮 leader 派活前消化。MVP 同上可后置。
### 6.4 多轮链条(扩展点,非 MVP)
单趟遍历是 MVP。若要「链条跑完后总控判断是否再来一轮」,可在收口 leader 处复用 `dispatched===[]?` 判定:收口 leader 若仍派活则回到链条头再跑一轮,直到共识或 MAX_CYCLES。MVP 不做,避免复杂度。
### 6.5 init 复用
chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = chainSeats.map(s => s.agent_id)`(**按链条顺序**传入,使 `buildRoleTemplates` 的 seat id 顺序 = 链条顺序,且后端注入 coordinator 的「可调度席位清单」也按此序)。init effect 里按 `orchestrationMode` 决定 init 完成后调 `runOrchestration` 还是 `runChainOrchestration`。
---
## 7. 分阶段实现计划
> 每阶段都可独立交付、独立验收。建议顺序:**数据层 → 配置页 → 弹窗模式 → 链条选择 → Step2 编排 → 持久化兼容与收尾**。
### 阶段 0 · 后端链条表 + 脚手架(1.5 天) ✅ 已完成
**目标**:打地基(含后端持久化),不含前端 UI。
- [x] **后端**:仿 §7A `roundtable_drafts` 新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由(§5.2):
- [x] `persistence/roundtable_chains/`(model + sql repo + `make_roundtable_chain_store`),登记进 `persistence/models/__init__.py`。
- [x] `app/gateway/routers/roundtable_chains.py`(GET 列表 / POST / GET{id} / PUT{id} / DELETE),鉴权属主同 `roundtable_drafts`;席位 2–8 + 去重校验。
- [x] `deps.py` 接 `app.state.roundtable_chain_store`,`app.py` `include_router`。
- [x] 后端测试 `tests/test_roundtable_chains.py`:CRUD + 部分更新 + 跨用户隔离(5 用例全过)。
- [x] **前端**:新增 `lib/business-chain.ts`(`ChainSeat` / `BusinessChain` / `SeatSelectionResult`,§3)+ `api/chains.ts`(直接打后端,§5.1)。
- [x] (决策 C)安装 `@dnd-kit/core` `@dnd-kit/sortable` `@dnd-kit/utilities`。
- **验收**:✅ 后端 5 用例通过;✅ 表注册进 `Base.metadata`、路由 `/api/roundtable-chains` 加载正常;✅ ruff 0 错;✅ 前端 typecheck 新增文件 0 错。
### 阶段 1 · 业务链条配置页面(2–3 天) ✅ 已完成
**目标**:能创建/编辑/保存/删除链条。
- [x] 路由:`WorkspaceRoutes.tsx` 注册 `roundtable/chains`(套 `WorkspaceLayout`;本页**不需要** `ChatRuntime`,无沙箱依赖)。
- [x] 页面 `roundtable-planning/pages/BusinessChainEditorPage.tsx`:
- [x] 顶部:链条列表下拉(`<select>` 列出已存链条 + 新建项)/ 新建按钮 / 标题+描述输入 / 保存 / 删除(`window.confirm`)。
- [x] **左栏**:候选智能体(`listAgents()` + `filterRecommendCandidates`),竖排卡片,可拖、可点「+」快捷加入;已加入的置灰「已加入」。
- [x] **右栏**:有序长条卡片(序号 tag + 名称 + 描述 + 移除),`SortableContext` 内拖拽排序;空态有占位提示。
- [x] 拖拽用 `@dnd-kit`:左栏 `useDraggable`,右栏 `useDroppable` + `SortableContext`/`useSortable`,`onDragEnd` 计算插入 index(pool→右栏按落点插入;seat→seat `arrayMove`);`DragOverlay` 跟手卡片。
- [x] 校验:`2 ≤ seats ≤ 8`(前端禁用保存 + toast,后端 `_validate_seats` 兜底),重复 agent 去重提示。
- [x] 保存走 `api/chains.ts`(create 用 `nanoid()` 客户端 id,update 走同一 id)。
- **验收**:✅ typecheck 0 新增错误(76 个预存量错误均在无关模块)。**人工验收待跑**:新建「情报→方案→风险→总结」链保存 → 刷新后下拉重载 → 右栏拖拽排序 → 左栏拖入指定位 → 2–8 边界禁用保存。
- **切换成本备注**:若改全屏 Dialog 方案,编辑器组件原样复用,仅去掉路由、外面套 `Dialog`。
- **入口说明**:圆桌本身未在侧栏(`sidebar-menu.ts` 入口被注释),本页暂经直链 `/page/workspace/roundtable/chains` 或顶栏「返回圆桌」往返;阶段 3 推荐弹窗「去配置」按钮会导航到此。
- **视觉(后续重画)**:本页**不**套圆桌 `roundtable-planning` scope,直接遵循《浅红色/深蓝色 UI 规范》——用 app 语义 token(光 `--primary` 红 hue27 / 暗 蓝 hue259,已内置自适应)+ 规范小圆角(输入/按钮/tag/卡片内项 2px = `R_CTRL`,分栏/面板 4px = `R_CARD`)+ 克制阴影(`PANEL_SHADOW`)。筛选框复用通用 `TagSearchBar`,配色随 app 中性 token,不再被红 scope 染色。
### 阶段 2 · 推荐弹窗「模式选择」外壳(1 天) ✅ 已完成
**目标**:弹窗顶部模式切换,智能推荐行为不变。
- [x] `RecommendAgentsDialog` 顶部加**分段按钮**模式切换:`智能推荐 | 业务链条`(active 态品牌色 + 卡底;DialogDescription 随模式切换文案)。
- [x] 现有全部内容原样收进「智能推荐」面板(`{mode === "recommend" && (<>…</>)}` 包裹,**零行为变更**)。
- [x] 「业务链条」面板放占位:居中提示 + 「去配置业务链条」按钮(`handleGoConfig` → 关弹窗 + 跳 `roundtable/chains`)。
- [x] `onConfirm` 统一升级为回传 `SeatSelectionResult`(含 `mode`):智能推荐确认回传 `{ agents, mode: "recommend" }`;主页面 `handleConfirmAgents` 同步改签名取 `result.agents`(`result.mode` 预留给阶段 4)。
- **验收**:✅ typecheck 0 新增错误(总数仍 76);智能推荐分支 JSX/逻辑原样保留(流式/勾选/进入研讨未改);模式切换只切显示、`state`/`selectedIds` 不卸载 → 候选池不丢。**人工验收待跑**:切到业务链条→再切回,推荐结果与勾选仍在。
### 阶段 3 · 业务链条选择卡片列表(1.5 天) ✅ 已完成
**目标**:弹窗「业务链条」面板里选链条。新增 `components/BusinessChainPicker.tsx`,替换阶段 2 的占位。
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
- [x] 卡片标题(链条 title)+ 「N 席」徽章 + 选中 ✓;描述行。
- [x] 标题下一排**智能体名 tag**:按链条顺序**从左到右**、不同颜色(`getAvatarTypeFor`→`chainTagClasses`),tag 之间插 `→`(`ArrowRightIcon`)箭头连接(`seats[0]` 最左、先发言)。
- [x] 底部「配置链条」按钮(跳 `roundtable/chains`)。
- [x] 空态:无链条时引导「去配置页面创建第一条业务链条」+ 按钮。
- [x] 选中一条 → 高亮(`ring-primary`);底部「进入研讨」`onConfirm({ agents: 存活席位有序, mode:"chain", chain:{id,title} })`。
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
### 阶段 4 · Step 2 链条顺序编排(2–3 天) ✅ 已完成
**目标**:chain 模式按链顺序逐席发言。**后端零改动。**
- [x] 主页面新增 `orchestrationMode` 状态,`handleConfirmAgents` 从 `SeatSelectionResult.mode` 写入(chain 模式 toast 带链名);`handleStartNewTask` 复位为 `recommend`;透传给 `useStep2Orchestration`。chain 顺序 = `selectedAgents`(确认时已按链条顺序写入),无需单独 `chainSeats`。
- [x] `useStep2Orchestration` 新增 `runChainOrchestration`(§6.2)+ `orchestrationModeRef`/`selectedAgentsRef`;复用现有三段式气泡/高亮/abort 基建。
- [x] 新增 `runActiveOrchestration` 分发器:init / 恢复 / 干预 / 澄清回复 4 处调用点统一改走它,按 `orchestrationModeRef` 选 `runOrchestration`(recommend)或 `runChainOrchestration`(chain)。
- [x] `runChainOrchestration`:按 `selectedAgents` 游标,每位先 leader 约束派活(`buildChainLeaderTask` 钉死目标席位 + `pickChainTask` 命中校验/纠偏,派错/未派都兜底推进)→ 再对**该席位**跑 special;中途 leader 若 `clarification` 则暂停(MVP 回复后整链重跑)。
- [x] `initMultiAgent` 的 `agentNames` 即 `roundtableAgentIds`(由 `selectedAgents` 派生,已是链条顺序,§6.5)。
- [x] 收口 leader(`finishedAllSeats` 时触发),产出 `lastLeaderContent` + `hasConsensus=true` + `consensusPercentage=100` 解锁 Step 3。
- **验收**:✅ typecheck 0 新增错误(总数仍 76);✅ 唯一直接引用 `runOrchestration` 的是 dispatcher。逻辑层:每席「总控派活气泡 → 席位交付气泡」、严格顺序、单 special 在跑、`_broadcast` 注入前序上下文、leader 纠偏推进、跑完解锁 Step 3、abort 可中断。**人工 UI 验收待跑**。
- **MVP 边界**:chain 模式的「挂起→断点续跑」「逐席间干预」未做断点续跑,`clarification`/恢复/干预当前为**整链重跑**(`chainCursor` 列为扩展点)。
### 阶段 5 · 草稿持久化兼容 + 收尾(1 天) ✅ 已完成
**目标**:草稿记住模式,回归不破。
- [x] `Step2Snapshot`(`useStep2Orchestration.ts`)+ `DraftStep2Snapshot`(`api/drafts.ts`)新增 `orchestrationMode?: "recommend"|"chain"` 与 `chain?: {id,title}|null`。
- [x] 恢复路径:`orchestrationMode`/`chainMeta` 是**主页面 state**,`getStep2Snapshot` 写入;**包裹版 `hydrateStep2`** 在加载时 `setOrchestrationMode(snapshot.orchestrationMode ?? "recommend")` + `setChainMeta(snapshot.chain ?? null)` 再调 `step2.hydrateFromDraft`;老草稿无字段 → 默认 `recommend`(向后兼容)。`handleStartNewTask` 复位两者。
- [x] 文档回写:`multi-agent-frontend-dev.md` §5.7(快照形状 + `orchestrationMode/chain` 说明)、§6.2(席位来源选择器导语);`multi-agent-backend-dev.md` 新增 §7B(`roundtable_chains` 表/路由)。
- **验收**:✅ `pnpm typecheck` 0 新增错误(总数仍 76)。逻辑:chain 会话保存草稿 → 重载 `orchestrationMode="chain"` + 链信息恢复;老草稿(无字段)默认 recommend 照常加载。**人工 UI 验收 + `pnpm build` 待跑**。
### 工期粗估
| 阶段 | 估时 |
|------|------|
| 0 后端链条表 + 脚手架 | 1.5d |
| 1 配置页 | 2–3d |
| 2 弹窗模式壳 | 1d |
| 3 链条选择卡片 | 1.5d |
| 4 Step2 链编排 | 2–3d |
| 5 持久化兼容收尾 | 1d |
| **合计** | **约 9–11 人日**(含后端 `roundtable_chains` 表/路由) |
---
## 8. 验收清单(端到端)
1. 推荐弹窗顶部有「智能推荐 / 业务链条」切换;智能推荐与现状逐像素一致。
2. 配置页 `/page/workspace/roundtable/chains` 左栏列出智能体、右栏有序长条卡片;左→右拖入指定位、右栏内拖拽排序均生效;2–8 校验生效;保存/重载/删除正常。
3. 业务链条卡片:标题 + 彩色智能体 tag + ← 箭头连接,渲染正确。
4. 选定链条 → Step 2 席位顺序 = 链条顺序,并**按顺序逐个发言**,后序能看到前序交付。
5. chain 跑完解锁 Step 3;草稿记住 chain 模式并可重载。
6. 删除链条中引用的 agent 后,选择端有降级提示,不崩。
---
## 9. 风险与开放问题
| # | 项 | 说明 / 建议 |
|---|----|-------------|
| 1 | 配置页:弹窗 vs 页面 | 本文档定为**独立页面**(§2.1)。若产品要弹窗,编辑器组件可复用,仅换外壳。**请确认。** |
| 2 | 链条持久化 | **已定:后端 MySQL `roundtable_chains`**(§2.2 / §5.2),仿草稿持久化。 |
| 3 | Step 2 链编排 | **已定:总控按链条顺序派活**(§2.4 / §6),后端零改动。 |
| 4 | 新增 `@dnd-kit` 依赖 | 若禁止新增依赖,退原生 HTML5 DnD(手感与可达性下降)。**请确认。** |
| 5 | ← 箭头方向 / tag 阅读顺序 | 默认 `seats[0]` 在最左、先发言;箭头仅作视觉连接。**请确认是否要「从右往左读」。** |
| 6 | chain 模式是否需要收口 leader 总结 | 默认需要(产出 Step 3 结论)。若末位席位自带总结可关掉。 |
| 7 | leader 派活纠偏策略 | leader 派错/未派时前端兜底推进游标(§6.2 `pickChainTask`);可加 toast 提示「已按链条强制推进」。 |
| 8 | chain 模式断点续跑 / 逐席干预 | MVP 不做,列为扩展点(§6.3 / §7 阶段 4)。 |
| 9 | 链条是否私有 | 本期仅本人私有(仿草稿按 `user_id` 隔离)。共享/市场留扩展。 |
---
## 10. 路径速查(新增/改动)
| 资源 | 路径 | 状态 |
|------|------|------|
| 链条类型 | `frontend-web/src/roundtable-planning/lib/business-chain.ts` | 新增 |
| 链条 API 客户端 | `frontend-web/src/roundtable-planning/api/chains.ts` | 新增 |
| 配置页 | `frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx` | 新增 |
| 链条选择面板 | `frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx` | 新增 |
| 链条卡片 | `frontend-web/src/roundtable-planning/components/BusinessChainCard.tsx` | 新增 |
| 推荐弹窗(加模式壳) | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` | 改 |
| 主页面(mode 透传) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 改 |
| Step2 编排(加 chain) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` | 改 |
| 草稿快照(加 mode) | `hooks/useDraftPersistence.ts` + `api/drafts.ts` | 改 |
| 路由注册 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | 改 |
| 后端链条持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/` | 新增 |
| 后端链条路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py` | 新增 |
| 后端 store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_chain_store`)+ `app.py` | 改 |
</content>
</invoke>
@@ -0,0 +1,781 @@
# 多智能体圆桌研讨 · 前端开发文档
本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。
---
## 0. 速读:你需要先知道的事
- **三步 UI 通过 `currentStep ∈ {1,2,3}` 切换**,不使用 React Router 子路由。主页面 `RoundtablePlanningPage.tsx`(≈ 1 150 行)只负责组合 + 编排,业务状态全部下沉到三个 hook(见下面)。
- **业务状态分三块**:`useStep1Intent`(任务理解 + 澄清)、`useStep2Orchestration`(编排循环 + 对话流 + Persona)、`useDraftPersistence`(草稿)。改 Step X 的逻辑请直接进对应 hook,不要回到主页面里写。
- **消息气泡有共享件**:Step 1 / Step 2 的对话气泡共用 `MessageBubble.tsx` 里的 `MessageBubble` + `MessageLoadingPlaceholder` + `StreamingCursor` + `MarkdownContent`。改气泡通用样式(头像、header、loading、流式 cursor、Markdown 渲染配置)请改这里,不要在两个 Panel 各改一遍。
- **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。
- **`<think>` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `<think>...</think>`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
- **草稿/历史持久化在后端(2026-06)**:草稿不再存浏览器 `localStorage`,改走后端 MySQL(`/api/roundtable-drafts`,按登录用户隔离、跨设备保留)。`api/drafts.ts` 封装 CRUD,`useDraftPersistence` 异步对接、对外接口不变。**推荐弹窗带「会话级推荐历史」**:每个草稿一串历史,重开弹窗先显示上次推荐结果,可「重新分析」重跑(详见 §5.7 / §6.2)。后端实现见 backend doc 的「圆桌草稿持久化」一节。
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
- **协调智能体单例(2026-05)**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"` 是固定常量,不再每会话生成新 id。后端 seeder 自动落地,「本次席位列表」改由后端通过 thread 历史 human 消息注入,前端无感。`newCoordinatorName()` 仅作为兼容性壳保留。
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
---
## 1. 功能定位
**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」)
**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
**核心能力**:
| 步骤 | 用户目标 | 真实后端对接 |
|------|----------|--------------|
| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` |
| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` |
| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 |
| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` |
**步骤门禁**:
- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。
- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。
---
## 2. 目录结构
```
frontend-web/src/roundtable-planning/
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
├── pages/
│ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行)
├── api/
│ ├── intent.ts # Step 1:initIntent、streamIntent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent;**+ 推荐历史 listRecommendHistory / saveRecommendHistory**
│ ├── drafts.ts # **草稿后端 CRUD**:listDrafts / getDraft / createDraft / updateDraft / deleteDraft / deriveDraftName(替代旧 localStorage utils/drafts.ts)
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
├── components/
│ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器
│ ├── MessageBubble.tsx # Step 1/2 共享:消息壳 + loading 占位 + 流式 cursor + MD 配置
│ ├── MessageStepsCard.tsx # Step 1/2 共享:工具调用 + 思考过程的折叠卡(对齐主聊天 MessageGroup)
│ ├── Step1Panel.tsx # Step 1 对话面板(不含状态,只读 props)
│ ├── Step2Panel.tsx # Step 2 主面板(圆桌对话流 + 澄清条 + 干预输入)
│ ├── Step3Panel.tsx # Step 3 薄壳,内部仍是 HighFidelityReport
│ ├── PersonaDrawer.tsx # Step 2 Persona 抽屉(System Prompt / Temp / Model)
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
├── hooks/
│ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步
│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona
│ └── useDraftPersistence.ts # 草稿(后端版):异步走 api/drafts;自动保存(前进切换)/手动保存/加载/重命名/删除/ensureDraft
├── lib/
│ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
│ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `<think>...</think>` 思考过程
│ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等)
│ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染
├── utils/ # (旧 drafts.ts 已删除 —— 草稿改走后端 api/drafts.ts)
└── styles/
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
```
**模块依赖方向(自上而下)**:
```
pages/RoundtablePlanningPage
├─ uses → hooks/useStep1Intent
├─ uses → hooks/useStep2Orchestration
├─ uses → hooks/useDraftPersistence (依赖 step1 / step2 的 getSnapshot + hydrate)
├─ uses → components/Step{1,2,3}Panel (纯 props 驱动)
├─ uses → components/RecommendAgentsDialog
└─ uses → components/Avatars
hooks/useStep1Intent → api/intent
hooks/useStep2Orchestration → api/multi-agent + components/PersonaDrawer 的类型
hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型
```
主页面通过 ref 桥接两个 hook 之间的依赖(`buildInitialIntent` 在 Step 2 init 时从 Step 1 状态读取),避免 hook 互相引用造成的循环导入。
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
```tsx
<Route path="roundtable/planning" element={<WorkspaceLayout><RoundtablePlanningPage /></WorkspaceLayout>} />
```
侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。
**环境变量**(由 `scripts/start-frontend.sh` 注入):
- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀
- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装
---
## 3. 三步数据流(前端视角)
```
[进入 Step 1]
useEffect → runStep1Init()
initIntent(model) → { agent_id, thread_id }
setIntentThreadId、setTimelineEntries
interactiveMessages seed 欢迎语(本地 seed,非 API)
[用户发送 / 选项卡片提交]
sendIntentMessage(text)
追加 user 气泡 + agent 占位(streaming + phase)
streamIntent({ onTextDelta, onPhaseChange, signal })
终态:
done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗
clarification → IntentClarificationCard + pendingIntentClarificationMsgId
asking → 继续对话
error → toast + 红色 (错误) 文案
[点击「进入多智能体研讨」且 intentReady]
setIsRecommendDialogOpen(true)
RecommendAgentsDialog
listAgents() → filterRecommendCandidates()
streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange })
用户勾选 2–8 个 → onConfirm(agentIds)
handleConfirmAgents
setSelectedAgents(agentIds.map(...))
step2InitRef.current = false // 复位 Step 2 init 闸门
setCurrentStep(2)
[进入 Step 2]
useEffect(step2InitRef 闸门)→ initMultiAgent(SSE)
onSeatReady:右侧席位面板逐个「在线」
onCoordinatorReady / init_done → threadIds + coordinatorName
runOrchestration(buildInitialIntent(), threadIds, coordinatorName)
while !cancelled && cycles<8:
leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条
否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合…
dispatched.length === 0 → hasConsensus = true,可进 Step 3
[进入 Step 3]
HighFidelityReport
consensusText ← lastLeaderContent
seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付")
Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId)
文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl)
```
---
## 4. API 层
### 4.1 `api/intent.ts` — Step 1
| 函数 | 说明 |
|------|------|
| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` |
| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 |
**`StreamIntentRequest` 重要字段**:
- `threadId`:必填,跨多轮对话保持
- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消)
- `onTextDelta(text, messageId)`:每个 AI 文本增量
- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling`
- **`onClarification(clarification, index)`(2026-06)**:每解析到一条 `ask_clarification` 工具结果帧就回调一次,让前端**实时**逐个渲染澄清卡。任务理解智能体**一轮可能问多个问题**,这是「实时接收显示协助过程」的关键入口。
**`StreamIntentResult`**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 |
| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) |
| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 |
| `clarification` | `ClarificationPayload \| null` | **第一个**澄清(向后兼容字段) |
| `clarifications` | `ClarificationPayload[]` | **本轮全部澄清问题**(2026-06)。来源是流式 `type:"tool"` 帧而非终态帧——**终态帧只带第 1 个**,多问题必须靠它。每条带 `id`(= `tool_call_id`)供前端按题维护选择。 |
**SSE 解析要点**:
- `event: metadata` → phase `connected`
- `event: messages` + `extractAiTextDelta` → `onTextDelta`
- `event: messages` + `type:"tool"` 且 `name==="ask_clarification"` → `extractClarificationToolMessage` 解出完整澄清(options 已是 `{id,label}`),按 `tool_call_id` 去重后 `onClarification` 实时回调并收进 `result.clarifications`
- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名)
- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`);`status==="clarification"` 且**未**收到任何流式 tool 澄清帧时,才用终态帧字段兜底合成单条澄清
**Abort 行为**:
- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef`
- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft`
- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」
### 4.2 `api/recommend.ts` — 推荐弹窗
| 函数 | 说明 |
|------|------|
| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` |
| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) |
| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 |
**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见):
- `roundtable-intent`
- `roundtable-recommender`
- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳)
**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似):
- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"`
- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象
- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示)
- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id)
- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`)
**终态语义**:
- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选
- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集
### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件
| 函数 | 说明 |
|------|------|
| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` |
| `streamMultiAgent(req)` | leader / special 一轮 |
| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` |
| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL |
**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段):
```ts
{ event: "seat_ready", agent_name, thread_id, display_name, description }
{ event: "coordinator_ready", main_agent_name, thread_id }
{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record<agent_name, thread_id>
{ event: "error", detail }
```
**`streamMultiAgent` 终态**(`isFinalStatusFrame`):
| `status` | 含义 | 前端处理 |
|----------|------|----------|
| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 |
| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 |
| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 |
| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence |
| `"error"` | 失败 | toast + 红色气泡 |
**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。
**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。
**调试开关**:
```js
window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total)
window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream:<agentType>:<agentName>]
```
> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。
---
## 5. 主页面状态(`RoundtablePlanningPage`)
> **注意**:自 2026-05 重构后,绝大多数业务状态从主页面下沉到 3 个 hook。下面列出的 state 仍按"位置"分组以便对照原代码,实际所有者请看右列。
| 维度 | 所有者 | 暴露给主页面的方式 |
|------|--------|-------------------|
| `currentStep`、`showToast`、`selectedModel`、`roles`、`isRecommendDialogOpen` | 主页面 | useState |
| Step 1 全部 state + handler | `useStep1Intent` | hook 返回值 |
| Step 2 全部 state + handler + 编排循环 | `useStep2Orchestration` | hook 返回值 |
| 草稿(drafts / currentDraftId / 重命名 / 历史下拉) | `useDraftPersistence` | hook 返回值 |
| `loadedFromDraftRef` 共享 latch | `useDraftPersistence` 拥有,Step 2 init effect 通过 options 读 | hook 间共享 ref |
### 5.1 步骤与门禁
- `currentStep`: `1 | 2 | 3`
- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」
- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用
### 5.2 Step 1 关键 state
| 状态 | 用途 |
|------|------|
| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 |
| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 |
| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) |
| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) |
| `intentClarificationSelections` | `Map<msgId, {selectedIds, custom}>`,多选 / 多消息时各自维护 |
| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) |
| `intentAborterRef` | 当前 stream 的 AbortController |
| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 |
| `step1InitRef` | 闸门:true 即认为已 init 过 |
| `step1InitGenRef` | **单调递增**的代数标记,详见下文 |
| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) |
**`step1InitGenRef` 的存在原因**:
React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。
**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则):
- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块
- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染
- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐
### 5.3 推荐与 Step 2 席位
| 状态 | 用途 |
|------|------|
| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) |
| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) |
| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName |
| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` |
| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 |
| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) |
| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 |
| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) |
| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) |
| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 |
| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) |
| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 |
| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 |
| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set |
| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) |
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
**协调智能体命名(2026-05 改造)**:现在是**固定单例 id** `roundtable-coordinator`(见 `lib/roundtable-constants.ts::COORDINATOR_AGENT_ID`)。`newCoordinatorName()` 函数为兼容性保留,内部直接返回这个常量字符串。后端 `ensure_roundtable_functional_agents()` 缺失自动落地,不再每次新建。`filterRecommendCandidates` 在 `EXCLUDED_AGENT_IDS` 集合里同时排除:
- `roundtable-coordinator`(新版单例 id,永远过滤);
- `roundtable-coordinator-*` 前缀(历史残留,管理员清理之前先在 UI 隐藏)。
> 历史草稿兼容性:用户加载 2026-05 前保存的草稿时,存储的 `coordinatorName` 仍是 `roundtable-coordinator-<ts36>` 形式。只要那个旧 agent 目录还在磁盘上(即运维没手动删过),加载后继续在 Step 2 干活就能正常工作;如果旧 agent 被清理掉了,需要让用户「重新起草」从头跑一遍 Step 1 → Step 2。
### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
伪代码:
```
isOrchestrating = true
orchestrationCancelRef = false
nextLeaderTask = seedMessage
cycles = 0
MAX_CYCLES = 8
while !cancelled && cycles < MAX_CYCLES:
cycles += 1
highlight(speakerId=2) // 总控
leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中")
res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange)
if res.clarification:
finalize(leaderBubble, content, "等待用户澄清")
setPendingClarification(res.clarification)
break // 等用户回复后由 submitClarification 重启循环
finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明")
if res.content: setLastLeaderContent(res.content)
if res.dispatched.length === 0:
setHasConsensus(true); setConsensusPercentage(100); break
for [subName, task] of res.dispatched: // **顺序**,不是并行
meta = agentNameToRole[subName.toLowerCase()]
markSpeakerStreaming(meta.speakerId)
perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL
subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中")
specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter)
finalize(subBubble, specRes.content, "子智能体交付")
markSpeakerIdle(meta.speakerId)
if cancelled: break
setConsensusPercentage(prev => min(95, prev + 8))
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
if cycles >= MAX_CYCLES:
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
clearAllStreamingSpeakers()
isOrchestrating = false
```
要点:
- **`buildInitialIntent()`**:
- 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语
- 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字
- 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活
- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)`
- **挂起 / 恢复**:
- 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()`
- 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)`
- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread
### 5.5 Persona 抽屉(Step 2 圆桌可视化点击)
- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉
- 抽屉控件:
- **System Prompt**:仅前端记录(后端覆写未开放)
- **Temp**:仅前端记录
- **推理模型**:选项来自 `useModels()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel`
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
- 把新的 `(seatId, model)` 写入 `agentConfigs`
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数
### 5.6 全局模型选择器(右侧 sidebar 顶部)
- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段)
- **state**:`const [selectedModel, setSelectedModel] = useState<string>("")`
- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项
- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖
- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」
- **可改换为 Radix Select**:当前用原生 `<select>` 与 Persona 抽屉保持一致;如需统一样式可改为 `@/components/ui/select`,行为不变
### 5.7 草稿持久化(`api/drafts.ts` + 后端 MySQL,2026-06 改造)
> **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。
- **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages, orchestrationMode?, chain?}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
- **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。
- **`orchestrationMode?` / `chain?`(2026-06 业务链条)**:Step 2 编排模式(`recommend`=总控自由派活 / `chain`=按业务链条顺序派活)+ 来源链条 `{id,title}`。是**主页面 state**,由页面在 `getStep2Snapshot` 写入、在**包裹版 `hydrateStep2`** 里读出后 `setOrchestrationMode`/`setChainMeta`(step2 hook 的 `hydrateFromDraft` 不直接消费)。老草稿无字段 → 默认 `recommend`。业务链条完整设计见 `multi-agent-business-chain-dev.md`。
- `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。
- **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。
- **手动保存 / `ensureDraft`**:`handleManualSave` 走同一条保存队列;`ensureDraft(furthestStep?)` 幂等保证后端有一条对应草稿并返回 id(**推荐弹窗打开前调用**,让推荐历史有 `draft_id` 归属)。
- **加载草稿 `handleLoadDraft`**:
- 走 `getDraft(id)` **实时拉服务端最新全量**(不再读内存里可能过期的列表快照);404 → toast「草稿已不存在」并刷新列表。
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`(顺序不能反)。
- `setStep2InitGate(draft.furthestStep >= 2)`:曾走到 Step 2 才跳过自动 init。
- 角色面板基线:被恢复时全部「在线」,否则 init 流程逐个「在线」。
**本次随手修复的 4 个旧 localStorage 版 bug**(对接后端时一并解决):
1. **自动保存覆盖手动重命名** —— 现在所有 `updateDraft` 一律**不带 title**;标题只由 create(派生)与显式「重命名」改写。
2. **快速连续前进(1→2→3)产生重复草稿** —— 所有保存经一条**串行队列**(`saveQueueRef`)+ 同步镜像 `currentDraftIdRef`,create 完成即写 id,后续保存读到 id 走 update 而非再 create。
3. **加载读到内存旧列表快照** —— 改为 `getDraft(id)` 实时拉。
4. **localStorage 配额静默写失败 / 数据丢失** —— 迁后端后不复存在。
---
## 6. 组件说明
### 6.1 `IntentClarificationCard`
- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent`、**`deferSubmit?`(2026-06)**
- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`)
- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`:
- 仅选项 → `我选择:A、B`
- 仅自定义 → 直接发自定义文本
- 同时存在 → `我选择:A、B;补充:…`
- **单问题 vs 多问题的判断(2026-06)**:`Step1Message` 按 `clarifications.length` 分流,**默认单选**(后端 `allow_multiple` 缺省 false,`resolveAllowMultiple` 兜底):
- **单问题(最常见)**:`deferSubmit={false}`,沿用原交互 —— 单选**点击选项即发送**;多选 / 允许自定义则走卡片自带的「发送回答」按钮。**不**显示合并按钮。
- **多问题**:`deferSubmit={true}` —— 连单选点击也只**记录**选择(不立即发送),卡片自身隐藏按钮;父组件在卡片下方渲染**一个**「发送全部回答」按钮,统一提交。
- 该判断纯在前端(不动工具/后端):依据 `clarifications.length` + `allow_multiple`,后端 `ask_clarification` 本就默认单选。
- **合并提交逻辑**:`submitIntentClarifications(msgId, clarifications, overrideByKey?)` 逐题取选择、格式化、拼成一条消息(多题带 `1. <问题>\n答:<答案>` 编号;单题直接发答案;至少答一题才发)。`overrideByKey` 供「单题单选点击即发送」用——此时选择还没写进 state map(setState 异步),把即时选择按 `clarificationKey` 传进去直接用。
- **逐题选择隔离**:选择 Map 改为 `Map<string, IntentClarificationSelection>`,key 由 `clarificationKey(msgId, clar.id, index)` 生成(`clar.id` = `tool_call_id`,缺省退化为 `idx-N`)。
- 展示条件:`isAgent && clarifications.length>0 && !intentReady && !parts.summary`;**流式期也展示**(卡片 `disabled`,只读,体现「协助过程」实时出现),仅当终态且 `pendingIntentClarificationMsgId === msg.id && !streaming` 时才可交互。出现 `[INTENT_READY]` / summary 后隐藏。
- **步骤链强化**:`onClarification` 同时往 `msg.steps` 追加一条 `追问 N:<问题>`,让 `MessageStepsCard` 把每个 `ask_clarification` 调用显示成独立步骤。
### 6.2 `RecommendAgentsDialog`
> **席位来源选择器(2026-06 业务链条)**:弹窗顶部加了**模式分段切换** `智能推荐 | 业务链条`。智能推荐 = 本节描述的现状(零行为变更,整体收进 `{mode==="recommend"}` 分支);业务链条 = `BusinessChainPicker`(选一条已配置链条,卡片标题 + 彩色席位 tag + `→` 箭头)。`onConfirm` 统一回传 `SeatSelectionResult{ agents, mode, chain? }`;`mode==="chain"` 时 Step 2 由总控**按链条顺序派活**(`useStep2Orchestration.runChainOrchestration`)。配置页与编排详见 `multi-agent-business-chain-dev.md`。
- **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。
- 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。
- 阶段 UI(`InternalState.phase`):
- `idle` → `loading_agents` → **`loading_history`** → **`history`**(展示上次推荐,可直接进入研讨或「重新分析」)
- 或 `loading_agents` → `recommending`(顶部「模型生成内容」流式;推荐卡 streaming=true)→ `ready` / `fallback` / `error`
- **推荐历史(会话级)**:
- 打开时若该 `draftId` 有历史,进入 `history` 态:蓝色提示条标注上次推荐时间,picks 默认勾选、rationale 回显,**不**自动重跑模型。
- 「**重新分析**」按钮(`history` 态显示该文案,其它态为「重新推荐」)→ `start(true)` 强制跑新推荐,跳过历史。
- 每次**新推荐**完成(`ready`/`fallback`)后 `saveRecommendHistory(draftId, {objective,status,model,rationale,picks,candidates})` best-effort 追加一条(失败仅告警不阻断)。
- `history`/`fallback`/`ready` 三态都允许 `onConfirm`(用户可直接采纳历史 picks)。
- **布局分两段**:
- **顶部「模型生成内容」**:`RecommenderOutput` —— 状态行(`spinner + 调度推荐 / 已连接 / 正在生成`)+ `MessageStepsCard` 步骤卡 + `ReasoningBlock`(从 `<think>` 拆出来)+ markdown rationale + 末尾 `StreamingCursor`。视觉与 Step 1/Step 2 气泡的内容区一致,参考主聊天 `chats/new`。
- **底部「推荐参会智能体」**:候选卡片(`AgentCard`),分「推荐参会 / 其他候选」两组。
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
- 反竞态:内部 `generationRef`,每次 `start()` 自增,所有 setState(含历史读取/流式回调)都校验 `myGen === generationRef.current`,避免「重新分析」时旧 stream / 旧历史写脏 state
- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
### 6.3 `HighFidelityReport`(Step 3)
- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset`
- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行)
- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉
- **文件预览策略**(`shouldPreviewInModal`):
- 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text)
- 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer
- 命中规则:
- mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*`
- 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml`
- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者)
- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`)
- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程
- **Step 3 不显示左侧栏(2026-06)**:`LeftSidebar` 在 `currentStep === 3` 时 `return null`(原「参与与结论贡献」列已移除),右侧栏在 Step !== 1 时本就 `return null`,因此中间 `middle-viewport`(`flex-1`)会自适应撑满整行,报告区获得最大宽度。
### 6.4 `ArtifactPreviewModal`
- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文
- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm
- 其它文本类 → `<pre>` 直出
- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
### 6.5 共享 UI 模式与辅助
- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
- `connecting` → 「调度中」
- `connected` → 「已连接,等待响应」
- `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:<name>」
- `tool_result` → 「处理工具结果」
- `streaming` → leader 时「正在派活说明」、special 时「正在输出」
- Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
---
## 7. 澄清交互(与主聊天对齐)
| 能力 | 实现位置 |
|------|----------|
| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
**关键差异**:
- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
---
## 8. 与主聊天的差异
| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
|------|-----------------------------------|----------|
| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
| 暂停 | LangGraph interrupt | `AbortController.abort()` |
复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
---
## 9. 关键交互清单
| 交互 | 行为 |
|------|------|
| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
---
## 10. 类型与常量速查
```ts
// Step 1 输出 → Step 2 输入
interface IntentSummary {
objective: string;
constraints: string[];
assumptions?: string[];
}
// Step 2 线程映射(init_done.thread_ids)
type ThreadIdMap = Record<string, string>;
// 派活
type LeaderDispatch = [string, string]; // [agent_name, task]
// 当前选中的研讨席位
type SelectedAgent = { agent_id: string; name: string };
// Step 1 单条澄清问题
interface ClarificationPayload {
question: string;
clarificationType: string;
context: string;
options: unknown[];
allowCustom: boolean;
allowMultiple?: boolean;
}
// Step 2 stream 终态
interface StreamMultiAgentResult {
dispatched: LeaderDispatch[];
message: string | null;
content: string;
agentName: string | null;
clarification: ClarificationPayload | null;
}
```
**核心常量**:
| 常量 | 值 | 位置 |
|------|----|------|
| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
| ~~`STORAGE_KEY`~~ | 已废弃 —— 草稿改存后端 `/api/roundtable-drafts`,不再用 localStorage | (旧 utils/drafts.ts,已删除) |
| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
---
## 11. 故障排查
| 现象 | 可能原因 | 排查 |
|------|----------|------|
| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
**本地调试开关**:
```js
window.__MULTI_AGENT_INIT_TIMING__ = true; // init 各阶段耗时(默认开)
window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
```
---
## 12. 扩展指南
### 12.1 新增默认研讨席位
1. 后端 `.deer-flow/agents/roundtable-xxx/`
2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
3. 无需改 API 层(`agent_names` 由推荐结果传入)
### 12.2 调整 Step 1 澄清轮次上限
逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
### 12.3 调整 Step 2 编排上限
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
### 12.4 把 special 改回并行
不推荐;若一定要做:
- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
- 用更高效的派发结构(如按 id 分桶 `Map<id, Bubble>` + ref 缓存)改善之后再启用
### 12.5 接入真实 Persona 覆盖
当前 prompt / temp 只在前端记录;要落地需:
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
- 前端:`agentConfigs[meta.speakerId]` 一并传入
### 12.6 拆分页面(已完成 · 2026-05)
主页面早期是 4 000+ 行单文件,2026-05 重构按"hooks 抽逻辑、components 抽 UI"原则拆分:
- `hooks/useStep1Intent.ts` —— Step 1 全部状态机、init effect、INTENT_READY 同步
- `hooks/useStep2Orchestration.ts` —— Step 2 状态、编排循环、对话流、Persona、澄清
- `hooks/useDraftPersistence.ts` —— 草稿自动保存 / 手动保存 / 加载 / 重命名 / 删除
- `components/Step{1,2,3}Panel.tsx` —— 纯 UI,只接收 props,不持有业务状态
- `components/PersonaDrawer.tsx` —— Persona 抽屉
- `components/Avatars.tsx` —— 所有 SVG 头像 + getAvatar
- `lib/roundtable-constants.ts` —— 常量、类型、纯工具函数(buildRoleTemplates 等)
- `lib/step-display.tsx` —— ChainOfThought 折叠卡的图标 + 文案 + 子节点渲染
**主页面只保留**:顶栏(StepNavigation / HistoryDropdown)、左右两侧栏(LeftSidebar / RightSidebar)、步骤路由、推荐弹窗,以及编排 hook 与跨步 handler(`handleConfirmAgents` / `handleStartNewTask`)。
继续重构请遵守:
1. **不要在 Panel 组件里写 fetch / setState 业务流程** —— Panel 只渲染,事件透传到 hook。
2. **hook 之间不要互相 import** —— 跨 hook 的数据通过主页面的 ref / callback 桥接(参考 `buildInitialIntent` 与 `loadedFromDraftRef` 的实现)。
3. **草稿 snapshot 形状**(Step 1 的 `Step1Snapshot` / Step 2 的 `Step2Snapshot`)与 `api/drafts.ts` 的 `DraftStep{1,2}Snapshot` 是同一份语义,改字段时两边都要改;后端以 JSON 整存,不强约束嵌套字段。
4. **lint/typecheck**:重构后 `pnpm typecheck` 与 `pnpm build` 必须保持 0 个新增错误(预存量错误来自其它无关模块如 `strategy-components` / `next/link` shim,与本特性无关)。
### 12.7 文档协同
`@/roundtable-planning/` 内一切公共 API 都应当:
1. 在 `api/*.ts` 文件级注释里说明 endpoint
2. 在本页第 4 节 / 第 10 节同步对照
3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
---
## 13. 路径速查
| 资源 | 路径 |
|------|------|
| 主页面(组合 + 顶栏 + 侧栏) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| Step 1 hook | `frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts` |
| Step 2 hook(含编排循环) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` |
| 草稿 hook | `frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts` |
| Step 1 面板 | `frontend-web/src/roundtable-planning/components/Step1Panel.tsx` |
| Step 2 面板 | `frontend-web/src/roundtable-planning/components/Step2Panel.tsx` |
| Step 3 面板 | `frontend-web/src/roundtable-planning/components/Step3Panel.tsx` |
| 消息气泡共享件 | `frontend-web/src/roundtable-planning/components/MessageBubble.tsx` |
| 步骤折叠卡(工具/思考) | `frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx` |
| `<think>` 提取工具 | `frontend-web/src/roundtable-planning/lib/reasoning.ts` |
| Persona 抽屉 | `frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx` |
| 头像集合 | `frontend-web/src/roundtable-planning/components/Avatars.tsx` |
| 常量/工具函数 | `frontend-web/src/roundtable-planning/lib/roundtable-constants.ts` |
| ChainOfThought 渲染 | `frontend-web/src/roundtable-planning/lib/step-display.tsx` |
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 推荐 API(含推荐历史) | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| 草稿 API(后端 CRUD) | `frontend-web/src/roundtable-planning/api/drafts.ts` |
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
| 后端草稿/历史路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py` |
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
@@ -0,0 +1,404 @@
# 多智能体会商 Step2 多结果线方案
## 背景
当前「多智能体会商 / 多智能体规划」里,一个历史草稿只保存一份 `step2` 快照。用户从历史记录加载草稿后,如果再次点击「智能体推荐」并在弹窗里点击「进入研讨」,新的 Step2 研讨会继续写入同一份 `draft.step2`,结果是第一次生成内容会被第二次生成内容覆盖,或者页面只能展示最新一次结果。
产品期望是:
- 用户从历史记录进入已有草稿时,进入第二步后,左下角能看到第一次生成结果。
- 用户再次点击「智能体推荐」并点击「进入研讨」时,要开启一条新的研讨结果线。
- 第二次生成结果显示在左下角,但不能影响第一次生成结果。
- 用户之后再次查看同一个历史草稿时,可以分别查看第一次生成结果和第二次生成结果。
因此,这个需求的核心不是简单增加一个展示区域,而是把 Step2 从「单结果」升级为「一个草稿下多条研讨 run」。
## 现状
相关位置:
- `src/roundtable-planning/api/drafts.ts`
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`
- `src/roundtable-planning/components/Step2Panel.tsx`
- `src/roundtable-planning/components/RecommendAgentsDialog.tsx`
当前 `DraftStep2Snapshot` 结构大致是:
```ts
export interface DraftStep2Snapshot {
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
}
```
这意味着一个草稿只有一份:
- 参会智能体
- 多智能体 thread 映射
- 研讨气泡
- 总控最终内容
- 共识状态
- 沙箱产物 stub
- 编排模式和业务链条
当前 `RoundtablePlanningPage.tsx` 里的 `handleConfirmAgents` 在用户点击「进入研讨」后会:
1. 规范化选中的 agents。
2. 写入 `step2.selectedAgents`。
3. 调用 `step2.resetInitGate()`。
4. 设置 `orchestrationMode` / `chainMeta`。
5. 切到 `currentStep = 2`。
它没有先把当前 Step2 结果归档成「第一次生成结果」,也没有为新的研讨创建独立 run。因此第二次研讨天然会复用同一个 `draft.step2` 写入位置。
## 目标模型
建议引入 Step2 run 概念:一个草稿可以有多条 Step2 结果线,每条结果线保存一份完整的 Step2 快照。
```ts
interface Step2RunSnapshot {
id: string;
title: string;
createdAt: string;
source: "initial" | "recommend" | "chain" | "manual";
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
step3?: DraftStep3Snapshot | null;
}
```
`DraftStep2Snapshot` 保留现有字段作为当前激活 run 的镜像,同时新增 `runs` 和 `activeRunId`:
```ts
export interface DraftStep2Snapshot {
activeRunId?: string;
runs?: Step2RunSnapshot[];
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
}
```
这样做的好处:
- 老代码仍然可以从 `draft.step2.step2RoundtableDialogues` 读取当前结果。
- 新 UI 可以从 `draft.step2.runs` 展示第一次、第二次、第三次生成结果。
- 老草稿没有 `runs` 时,可以在 hydrate 时把原来的 `step2` 包装成一条默认 run。
- 暂时不需要改数据库表结构,因为 `step2` 本身就是 JSON 快照。
## 行为设计
### 1. 历史记录加载
用户点击历史记录加载草稿时:
1. 拉取完整草稿。
2. 如果 `draft.step2.runs` 存在,使用 `activeRunId` 找到当前结果线。
3. 如果 `draft.step2.runs` 不存在,但旧字段里有 `step2RoundtableDialogues`,自动包装成一条 run:
```ts
{
id: "legacy-run",
title: "第一次生成结果",
createdAt: draft.updatedAt,
source: "initial",
...draft.step2
}
```
4. hydrate 当前 active run 到 Step2 runtime。
5. 左下角展示 run 列表。
### 2. 再次点击智能体推荐进入研讨
用户在已有历史草稿中再次点击「智能体推荐」并确认进入研讨时:
1. 先把当前页面里的 Step2 状态保存为当前 active run。
2. 创建一条新的 run:
```ts
{
id: crypto.randomUUID(),
title: `第 ${runs.length + 1} 次生成结果`,
createdAt: new Date().toISOString(),
source: result.mode,
selectedAgents: normalizedAgents,
threadIds: null,
coordinatorName: null,
step2RoundtableDialogues: [],
lastLeaderContent: "",
hasConsensus: false,
consensusPercentage: 0,
budgetLimit: DEFAULT_BUDGET,
seatStubMessages: {},
orchestrationMode: result.mode,
chain: result.mode === "chain" ? result.chain ?? null : null
}
```
3. 将 `activeRunId` 切到新 run。
4. 清空 Step2 runtime。
5. 设置新选中的 agents / mode / chain。
6. `resetInitGate()`,进入 Step2 后重新创建一组独立 thread。
7. 自动保存草稿。
关键点:第二次 run 必须使用新的 `threadIds`,不能沿用第一次 run 的 `threadIds`,否则后端 thread 历史会混在一起。
### 3. 左下角结果列表
左下角建议展示一个「生成结果」区域:
- 第一次生成结果
- 第二次生成结果
- 第三次生成结果
每条展示:
- 标题
- 创建时间
- 状态:研讨中 / 已达成共识 / 未完成 / 已生成报告
- 使用模式:智能体推荐 / 业务链条
点击某条结果时:
1. 保存当前 active run。
2. 切换 `activeRunId`。
3. hydrate 被点击的 run。
4. 中间 Step2 对话区显示该 run 的研讨气泡。
5. 如果该 run 已有 `step3`,进入 Step3 时展示对应报告。
### 4. Step3 结果归属
目前 `draft.step3` 是草稿级别的一份报告。如果只保留这一份,第二次结果生成报告后仍会覆盖第一次报告。
建议:
- `draft.step3` 继续作为当前 active run 的镜像,兼容现有 Step3 页面。
- 每条 `Step2RunSnapshot.step3` 保存自己的报告。
- `onReportSaved` 时同时写入当前 active run 的 `step3`。
这样用户切换到第一次生成结果时,可以看到第一次的 Step3 报告;切换到第二次生成结果时,可以看到第二次的 Step3 报告。
## 建议改动点
### 1. `api/drafts.ts`
扩展类型:
- 新增 `Step2RunSnapshot`。
- `DraftStep2Snapshot` 增加 `activeRunId?: string` 和 `runs?: Step2RunSnapshot[]`。
同时保留旧字段,避免影响现有保存、加载和 Step3。
### 2. `useStep2Orchestration.ts`
新增或暴露两个能力:
- `getCurrentRunSnapshot()`:从当前 Step2 runtime 生成一条 run 快照。
- `hydrateRunSnapshot(run)`:把某条 run 恢复到 Step2 runtime。
`hydrateFromDraft` 继续存在,但内部可以优先选择 active run 来 hydrate。
### 3. `RoundtablePlanningPage.tsx`
新增页面级状态:
```ts
const [step2Runs, setStep2Runs] = useState<Step2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
```
调整几个流程:
- `getStep2Snapshot` 输出 `runs` 和 `activeRunId`。
- `hydrateStep2` 兼容旧草稿并恢复 runs。
- `handleConfirmAgents` 在新建研讨前先归档当前 active run,再创建新 run。
- `onReportSaved` 把 Step3 报告写回当前 active run。
### 4. `Step2Panel.tsx`
新增左下角结果列表展示。建议作为一个独立组件,避免 Step2Panel 继续变大:
```tsx
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={handleSelectStep2Run}
/>
```
组件职责只做展示和选择,不直接读写 draft。
### 5. `Step3Panel.tsx`
传入当前 active run 的报告:
- 如果当前 run 有 `step3`,展示它。
- 如果没有,按现有逻辑生成新报告。
- 生成完成后通过 `onReportSaved` 写回 run。
## 兼容策略
老草稿没有 `runs` 字段时,不做数据迁移,前端加载时即时包装。
判断规则:
```ts
function normalizeStep2Runs(step2: DraftStep2Snapshot): {
runs: Step2RunSnapshot[];
activeRunId: string | null;
} {
if (step2.runs?.length) {
return {
runs: step2.runs,
activeRunId: step2.activeRunId ?? step2.runs[0].id,
};
}
const hasLegacyContent =
step2.step2RoundtableDialogues?.length > 0 ||
Boolean(step2.threadIds) ||
Boolean(step2.lastLeaderContent);
if (!hasLegacyContent) {
return { runs: [], activeRunId: null };
}
const legacyRun: Step2RunSnapshot = {
id: "legacy-run",
title: "第一次生成结果",
createdAt: new Date().toISOString(),
source: step2.orchestrationMode ?? "initial",
selectedAgents: step2.selectedAgents,
threadIds: step2.threadIds,
coordinatorName: step2.coordinatorName,
step2RoundtableDialogues: step2.step2RoundtableDialogues,
lastLeaderContent: step2.lastLeaderContent,
hasConsensus: step2.hasConsensus,
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
seatStubMessages: step2.seatStubMessages,
orchestrationMode: step2.orchestrationMode,
chain: step2.chain,
};
return { runs: [legacyRun], activeRunId: legacyRun.id };
}
```
## 风险点
### 1. 自动保存覆盖
Step2 现在会在每个气泡收尾时自动保存。如果引入多 run,自动保存必须先把当前 runtime 合并回 active run,再写入 `draft.step2.runs`。
否则会出现 UI 切到第二次结果,但保存时仍更新第一次 run 的情况。
### 2. threadIds 混用
每条 run 必须拥有自己的 `threadIds`。新建 run 时要清空 `threadIds` 并让 Step2 重新 init。
如果复用旧 thread,会导致两次研讨的后端上下文互相污染。
### 3. Step3 报告覆盖
如果只保存草稿级 `step3`,多 run 的报告无法分别查看。
建议把报告写入当前 run,同时保留草稿级 `step3` 作为 active run 镜像。
### 4. 正在运行中的 run
如果配合后台任务能力,run 还需要带 job 信息:
```ts
jobId?: string | null;
jobStatus?: JobStatus | null;
```
这样左下角可以显示「第二次生成结果正在运行」,并打开派活链路弹窗。
## 分阶段实现建议
### P0:纯前端多 run 快照
目标:先解决“第一次 / 第二次结果分别可查看”。
- 扩展 `DraftStep2Snapshot` 类型。
- 页面维护 `step2Runs` / `activeStep2RunId`。
- `handleConfirmAgents` 新建 run。
- 左下角展示 run 列表。
- 切换 run 能恢复不同 Step2 对话。
不改后端表结构。
### P1:Step3 绑定 run
目标:每次研讨结果对应自己的报告。
- `Step2RunSnapshot.step3` 保存报告。
- `onReportSaved` 写回 active run。
- 切换 run 时同步 Step3 显示。
### P2:后台任务绑定 run
目标:和后台挂起执行方案打通。
- run 增加 `jobId` / `jobStatus`。
- 历史列表和左下角都能显示某条 run 正在执行。
- 点击正在执行的 run 打开派活链路弹窗。
### P3:后端 run 表
如果后续一个草稿下 run 数量很多,或者需要服务端按 run 查询、删除、重命名,再考虑新增后端表:
- `roundtable_runs`
- `draft_id`
- `run_id`
- `title`
- `status`
- `step2_snapshot`
- `step3_snapshot`
- `created_at`
- `updated_at`
短期不建议一开始就上表,先用现有 `step2` JSON 快照落地更快。
## 验收清单
- 从历史记录加载旧草稿时,左下角出现「第一次生成结果」。
- 点击「第一次生成结果」能看到原来的 Step2 研讨气泡。
- 在历史草稿中重新点击「智能体推荐」并「进入研讨」后,左下角新增「第二次生成结果」。
- 第二次研讨开始时,Step2 对话区为空或只显示新 run 初始化内容。
- 第二次 run 使用新的 `threadIds`,不会复用第一次 run 的后端线程。
- 切回「第一次生成结果」时,第一次的对话、共识状态、沙箱产物仍可查看。
- 切回「第二次生成结果」时,第二次的对话继续显示。
- 刷新页面后再次加载该历史草稿,两条结果线都还在。
- 如果两次都生成了 Step3 报告,切换不同结果线时能看到各自对应的报告。
@@ -0,0 +1,610 @@
# 圆桌 DAG 编排 + 并行执行显示 —— 分步实现需求文档
> 目标读者:实现本功能的前端 / 后端工程师。
> 本文是**可逐步落地**的需求 + 设计文档,每个 Phase 都能独立交付、独立验收。
---
## 0. 背景与目标
### 0.1 现状
Step 2 圆桌目前有两种编排模式(`orchestrationMode`,见 `hooks/useStep2Orchestration.ts`):
- **`free`(自由模式)**:总控(leader)自由决定派活给谁,`runOrchestration` 循环驱动。
- **`chain`(线性链条)**:用户把席位排成一条**线性**链 `A → B → C`,`runChainOrchestration` 按顺序逐个派活,最后总控收口。链路用 `DispatchChainModal.tsx` 的**横向流程图**展示(总控 → 席位1 → 席位2 → … → 共识 → 报告)。
链条数据现在只是一个**有序数组** `selectedAgents`(线性),节点状态类型见 `lib/job-types.ts`(`ChainNodeState = pending | active | done`)。
### 0.2 目标(本次新增)
1. **DAG 编排**:用户可视化指定**智能体之间的直接依赖关系**——有的串行(`A → B → C`),有的并行(`A B C` 同时跑)。编排结果是一张**流程图(DAG)**。
2. **并行执行**:DAG 里同一层(互不依赖)的智能体**并行**跑,墙钟时间从「串行求和」降到「并行取最慢」。
3. **并行禁止写文件**:并行的智能体**禁用文件写入 / 沙箱命令**,避免它们同时打开沙箱互相打架。产物以消息正文交付;落文件留给串行 / 收口阶段。
4. **并行高性能显示**:并行时收起左侧、顶部头像条切换、主区同时显示 2 个智能体的消息流,其余**只接流不渲染**,避免页面卡死。
### 0.3 设计总原则
- **复用** 现有 special run / leader run / rollback 抢占 / 轻量交付状态接口(`GET /threads/{id}/last-ai-message`),不另起炉灶。
- **线性链是 DAG 的特例**:`chain` 模式可平滑迁移成"每层 1 个节点"的 DAG,老数据不破坏。
- **分层(Stage)优先于自由连线**:编辑交互首选「分层」模型(每个 stage 放若干并行席位,stage 间串行),实现成本低、覆盖 95% 的"串 / 并"诉求;自由连线画布作为后续可选增强(见 §2.3)。
### 0.4 已确认的设计决策(✅ 本期按这些实现)
> 以下三项已与需求方确认锁定,实现时直接按此,不再二选一。
1. **✅ 编排模型 = 分层 Stage**:本期只做分层(每个 stage 放若干并行席位、stage 间串行)。**自由连线画布本期不做**,仅在数据模型层预留(§1.2)以免将来返工。
2. **✅ 并行跳过 leader、前端直派**:DAG 由用户显式指定,执行时前端**直接**对 stage 内席位发起 special run,**不**每个 stage 再跑一轮总控派活;只在最后 `finalSynthesis` 跑一轮 leader 综合(即 §3.2 的方案 B)。
3. **✅ 主区固定 2 列,窄屏降 1 列**:并行时主区最多并排显示 2 个被选中智能体的消息流;窄屏(断点见 §5.3)降为 1 列 + 头像条横向滚动。不做 3+ 列。
---
## 1. 数据模型
### 1.1 DAG 的「分层」表示(推荐,主模型)
把 DAG 表示成**有序的 stage 列表**,每个 stage 内的席位并行、stage 之间串行:
```ts
// lib/dag-types.ts (新建)
/** 一个并行批次:stage 内所有席位并行执行。 */
export interface OrchestrationStage {
/** stage 稳定 id(拖拽重排 / diff 用)。 */
id: string;
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
agentIds: string[];
}
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
export interface OrchestrationPlan {
/** 编排模式标识,持久化进 step2。 */
mode: "dag";
/** 串行执行的批次;每批内并行。 */
stages: OrchestrationStage[];
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
finalSynthesis: boolean;
}
```
- **串行** `A → B → C` = `stages: [{agentIds:[A]}, {agentIds:[B]}, {agentIds:[C]}]`。
- **并行** `A B C` = `stages: [{agentIds:[A,B,C]}]`。
- **混合** `A →(B C)→ D` = `stages: [{[A]}, {[B,C]}, {[D]}]`。
> 为什么用分层而不是「邻接表 / 边」:执行调度本就是「拓扑分批」,分层就是分批的结果,前端不必再做拓扑排序,也天然避免环;编辑器交互更直观(拖卡片进 stage)。真正的自由连线 DAG 见 §2.3(可选)。
### 1.2 通用 DAG 表示(§2.3 自由连线时才需要)
若将来做自由连线画布,再引入边表示,并在执行前做一次**拓扑排序 → 分层**,落回 §1.1 的 stages 结构给调度器用:
```ts
export interface DagEdge { from: string; to: string } // from 完成后才能跑 to
export interface DagGraph { nodes: string[]; edges: DagEdge[] }
// 执行前:toposortToStages(graph) -> OrchestrationStage[]
```
### 1.3 持久化
- 复用 `roundtable_drafts.step2` JSON(见后端 `app.gateway.routers` roundtable-drafts + `hooks/useStep2Orchestration.ts` 里对 `step2` 的读写)。
- 在 `step2` 增加字段:`orchestrationPlan: OrchestrationPlan | null`。
- `orchestrationMode` 扩展为 `"free" | "chain" | "dag"`;`chain` 旧数据读取时即时转换为单节点-stage 的 `dag`(兼容层,不改老 draft)。
### 1.4 校验(纯函数,可单测)
`lib/dag-types.ts` 内提供:
```ts
validatePlan(plan: OrchestrationPlan, selectedAgentIds: string[]): {
ok: boolean;
errors: string[]; // 面向用户的中文提示
}
```
校验项:
- 每个 `agentId` 必须在本次选定席位 `selectedAgents` 内(防止编排了不存在的席位 → 复用后端 `_partition_dispatch` 同思路的"必须在 roster 内")。
- 不允许空 stage;不允许同一席位出现在多个 stage(一个席位只跑一次)。
- 至少 1 个 stage。
- (可选)单 stage 并行度上限提示(如 > 6 并行给 warning:远程模型并发 + 沙箱压力)。
---
## 2. 编排编辑器(流程图构建)交互
### 2.1 入口与整体布局
- 在 Step 2 顶部模式切换里新增 **「依赖编排」**(`dag`)模式(与现有 自由 / 链条 并列)。
- 选择 `dag` 后弹出 / 进入**编排编辑器**(复用 Dialog 大弹窗,参考 `DispatchChainModal` 的尺寸 `w-[94vw] max-w-[1240px]`)。
### 2.2 推荐交互:分层(Stage)编辑器(Phase 2 实现)
```
┌ 依赖编排 ───────────────────────────────────────── [校验✓] [保存] ┐
│ 可选席位(点/拖加入某个 stage) │
│ [情报收集] [环境评估] [方案设计] [风险审查] [执行规划] [前端实现] │
│ ─────────────────────────────────────────────────────────────── │
│ Stage 1(并行) Stage 2(并行) Stage 3 │
│ ┌──────────┐ ─────▶ ┌──────────┐ ┌────────┐ ─▶ ┌──────────┐ │
│ │ 情报收集 │ │ 方案设计 │ │风险审查│ │ 执行规划 │ │
│ └──────────┘ └──────────┘ └────────┘ └──────────┘ │
│ [+ 拖席位到此] [+] [+] │
│ [+ 新建 Stage(串在最后)] │
│ ─────────────────────────────────────────────────────────────── │
│ 说明:同一 Stage 内的席位会**并行**执行;Stage 之间**串行** │
│ (后一个 Stage 能看到前面所有 Stage 的交付摘要)。 │
└───────────────────────────────────────────────────────────────────┘
```
交互要点:
- **加入 stage**:从顶部可选席位**拖拽**到某个 stage,或点席位 →「加入 Stage N」。
- **移动 / 重排**:席位可在 stage 间拖动;stage 可整体左右拖动重排(改串行顺序)。
- **删除**:席位 / 空 stage 可删。
- **并行度提示**:stage 内 ≥2 席位即标「并行」徽标;过多给 warning。
- **实时校验**:`validatePlan` 实时跑,错误在顶部红条提示,「保存」在校验通过前禁用。
- **预览流程图**:编辑区下方实时渲染只读流程图缩略(复用 §7 的 DAG 流程图组件)。
> 拖拽可用现成轻量方案(HTML5 DnD 或 `@dnd-kit`,看项目是否已有依赖;没有就先用「点选 + 加入/移出按钮」的无拖拽版,Phase 2 先上无拖拽、Phase 2.5 再加拖拽)。
### 2.3 可选增强:自由连线画布(后续,非本期必须)
- 节点自由摆放 + 拉线建边 → `DagGraph`(§1.2)。
- 实现成本高(画布 / 连线 / 命中检测 / 自动布局),且分层模型已覆盖绝大多数诉求。**本期不做**,仅在数据模型上预留 §1.2,避免将来返工。
### 2.4 验收(Phase 2)
- 能拖 / 点把席位分配到多个 stage,保存进 `step2.orchestrationPlan`。
- 刷新 / 重进 draft 能恢复编排。
- 非法编排(空 stage / 席位重复 / 席位不在 roster)有明确中文提示且无法保存。
---
## 3. 执行调度(前端编排循环 + 后端复用)
### 3.1 新增 `runDagOrchestration`(`useStep2Orchestration.ts`)
与现有 `runOrchestration` / `runChainOrchestration` 并列,逻辑:
```
for (stageIndex, stage of plan.stages):
if isStale() break // 复用现有 epoch 抢占(人工干预/暂停)
// 1) 总控按本 stage 的席位并行派活(一轮 leader,dispatched = stage.agentIds)
// —— 或跳过 leader,前端直接对 stage 内每个席位发起 special run(见 3.2)
// 2) 并行跑 stage 内所有席位
await Promise.all(stage.agentIds.map(seat =>
streamMultiAgent({ agentType:"special", agentName:seat,
newMessage: buildStageTask(seat, plan, stageIndex),
parallelNoFile: stage.agentIds.length > 1, // §4
... })))
// 3) 等本 stage 全部 ✅(Promise.all 天然 barrier)再进入下一 stage
// 4) finalSynthesis:最后一轮 leader(synthesisMode=true)综合所有 stage 交付
```
要点:
- **批内并行**:`Promise.all`(不是现在的 `for await` 串行)。
- **批间串行**:`Promise.all` 即 barrier,自动等齐。
- **依赖可见性**:进入 stage N 的席位时,把**前面所有 stage 已交付席位的摘要**拼进它的 `newMessage`(复用 §交付状态:网关 `_collect_delivery_status` / `GET last-ai-message?max_chars=N`,前端也可在 stage 边界拉一次摘要拼进 task)。这样 `(B C)` 能看到 `A` 的产出。
- **抢占 / 干预 / 暂停**:每个 stage 边界检查 `isStale()`(epoch),与现有自由 / 链条模式一致。
- **rollback**:special run 已统一 `multitask_strategy=rollback`,并行多席位各自不同 thread,互不冲突。
### 3.2 是否每 stage 都走 leader? — ✅ 已定:前端直派(方案 B)
**本期采用方案 B**:DAG 由用户**显式**指定,不需要 leader 再"决定派给谁"。前端**直接**对 stage 内每个席位发起 special run,省掉每 stage 的 leader LLM 往返(更快、更可控);只在所有 stage 完成后、`finalSynthesis` 为真时跑**一轮** leader(`synthesisMode=true`)综合所有交付。
> (备选方案 A:每 stage 跑一轮 leader 派活——更贴现有架构但每 stage 多一次 leader LLM、更慢。**本期不采用**,留档备查。)
### 3.3 后端改动
- **基本不需要新接口**:special run(`POST /api/multi-agent/run/stream` agent_type=special)、leader run、轻量交付读(`GET /threads/{id}/last-ai-message`)、轻量追加(`POST /threads/{id}/messages/append`)都已就绪。
- 仅需 §4 的「并行禁写文件」run policy 开关。
### 3.4 验收(Phase 3)
- `(B C)` 两席位**同时**出现在执行(后端日志两条 special run 时间重叠;前端两个流并行增长)。
- stage 间严格串行(stage 2 不早于 stage 1 全部完成)。
- stage N 席位的输入里能看到 stage <N 的交付摘要。
---
## 4. 并行时禁止写文件(防沙箱打架)
### 4.1 后端 run policy
`app/gateway/roundtable_run_policy.py` 现有角色:`leader` / `seat` / `report`。新增一个角色或开关:
- 方案:给 `seat` 增加一个「并行变体」`seat_parallel`,其 `excluded_tools` 在 `seat` 基础上**再禁** `write_file` / `bash` / `str_replace` / `present_files`(与 `report` 角色禁写的思路一致)。
- `_special_run`(`multi_agent.py`)按请求里的一个新 flag(如 `no_file: true`)选择 `seat_parallel` policy。
### 4.2 前端传参
- `streamMultiAgent` 请求体新增 `parallel_no_file?: boolean`(或复用 `no_file`)。
- `runDagOrchestration` 在 `stage.agentIds.length > 1`(真并行)时置 `true`;单节点 stage(实际串行)不限制,可正常写文件。
### 4.3 产物归属
- 并行席位的产物 = **消息正文**(被 `_collect_delivery_status` / 综合块读取)。
- 需要落文件 / 画图的(如 Step3 报告、`roundtable-report`)放在**串行的收口阶段**单独跑,不在并行批次里。
### 4.4 验收(Phase 4)
- 并行 stage 的席位 run 日志显示 `Excluded ... ['write_file','bash','str_replace','present_files', ...]`。
- 并行期间不再出现多个席位抢同一沙箱 / 写文件冲突。
---
## 5. 并行执行的前端显示
### 5.1 布局总览(并行进行时)
```
┌ 多智能体圆桌会商中心 ─────────────────────────────────────────────┐
│ [头像条:进行中的并行智能体] [模型选择 ▾] [暂停会商] │
│ (情报)● (方案)● (风险)● (执行) … │
│ ↑选中 ↑选中 loading hidden │
│ ───────────────────────────────────────────────────────────────── │
│ ┌─ 方案设计(选中)──────────┐ ┌─ 风险审查(选中)──────────┐ │
│ │ 一、整体架构…(流式渲染) │ │ 1. 合规风险 IDFA/OAID… │ │
│ │ … │ │ … │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
(左侧"参与角色"栏在并行时整体收起,腾出横向空间放两列消息)
```
### 5.2 关键交互(与需求逐条对齐)
1. **收起左侧**:并行执行时把左侧「参与角色 / 人工参与」栏整体折叠(一个 `isParallelRunning` 状态控制;非并行恢复)。
2. **头像条**:位置在**消息列表上方、模型选择器左侧**。展示**当前并行批次的所有进行中智能体头像**:
- **进行中** → 头像加 loading 动画(复用 `DispatchChainModal` 的 `animate-ping` 光环 + `Loader2Icon animate-spin` 角标)。
- **已完成** → 打勾(复用现有 `CheckIcon` 角标)。
- **选中** → 高亮描边(当前正在主区显示的那 2 个)。
3. **主区同时显示 2 个**:被选中的 2 个智能体的消息列表**并排两列**渲染。
4. **多于 2 个先隐藏**:未选中的智能体**不渲染 DOM**(见 §6 性能)。
5. **点头像切换**:点击未选中的头像 → 把它设为选中之一(替换当前 2 个里的一个,或按"最近点击保留 2 个"的 LRU 策略);被换下的智能体**停止渲染但继续接流**。
### 5.3 选中策略 — ✅ 固定 2 列,窄屏降 1 列
- 维护 `selectedParallelSeats: string[]`,**最多 2**(固定 2 列)。**不做 3+ 列**。
- **响应式**:宽屏 2 列并排;窄屏(建议断点 `< 1024px` / `lg`)降为**1 列**,头像条横向滚动切换;此时 `selectedParallelSeats` 仍可存 2 个,但主区只渲染当前 1 个(点头像切换哪个在前)。
- 默认选中本批次**最先开始**的(宽屏 2 个 / 窄屏 1 个)。
- 点头像:若已选中则取消;未选中则加入(超过上限时按 LRU 踢掉最早选中的)。被换下的席位**停止渲染但继续接流**(§6)。
### 5.4 验收(Phase 5)
- 并行时左侧收起、出现头像条、主区两列。
- 进行中头像有 loading;完成打勾;选中有高亮。
- 点头像能切换主区显示的智能体,且切换是**即时**的(数据已在内存,见 §6)。
---
## 6. 流式高性能策略(避免页面卡死)
> 核心矛盾:N 个并行席位同时高频 `onTextDelta`,若都 setState + Markdown 渲染会卡死。
### 6.1 全接收、惰性渲染
- **所有**并行 run 的流都正常订阅(不丢数据):每个席位的增量文本**累积进 store / ref**(`Map<agentId, { text, status, charCount }>`)。
- **只有被选中(主区显示)的席位**才把累积文本挂到 DOM 渲染(Markdown)。
- **未选中**席位:只更新内存 + 头像条上的轻量进度(字数 / loading),**不挂载消息 DOM**。
- 切换选中时:把该席位已累积的全文一次性渲染 + 继续接增量。
### 6.2 渲染节流
- 选中席位的流式渲染**节流/合帧**:用 `requestAnimationFrame` 或 ~50–100ms 批量 flush 累积的 token,而不是每个 token 一次 setState。
- Markdown 渲染对"流式中"可降级(纯文本 / 轻量渲染),**完成后**再做完整 Markdown(现有 `MessageBubble` 的 `streaming` 标志可复用:streaming 时简渲、done 后全渲)。
### 6.3 头像条进度
- 头像条只显示**廉价**信息:loading 动画 + 可选字数(`charCount`,从内存 Map 取,节流更新,例如每 500ms 刷一次)。不触发消息体渲染。
### 6.4 数据流(建议结构)
```
streamMultiAgent(seat).onTextDelta(text) →
parallelStore.append(seatId, text) // 纯内存累积,O(1)
if seatId ∈ selectedParallelSeats: // 仅选中的进 React state(节流)
scheduleFlush(seatId) // rAF / 节流 setState
else:
bumpCharCount(seatId) // 仅更新头像条进度(节流)
```
### 6.5 验收(Phase 6)
- 6+ 席位并行时页面不卡(输入 / 滚动 / 切换流畅)。
- 切到一个之前隐藏的席位,能立刻看到它**到目前为止的全部**输出(证明流没丢、只是没渲染)。
---
## 7. 流程图执行态可视化(DAG 版)
- 把现有 `DispatchChainModal.tsx`(横向**线性**流程图)扩展 / 复刻成 **DAG 流程图**:
- 按 stage 分**列**,stage 内席位**纵向并列**,stage 间用连接线。
- 节点状态复用 `ChainNodeState`(pending / active / done)+ 现有动画(`animate-ping` / `Loader2Icon` / `CheckIcon`)。
- 末尾接 `共识 → 结果绘制` 里程碑(沿用现有)。
- 用途:编辑器里的只读预览(§2.2 底部)+ 执行时的总览(可选小窗 / 顶部缩略)。
```
总控 ─▶ ┌ Stage1 ┐ ─▶ ┌ Stage2 ┐ ─▶ ┌ Stage3 ┐ ─▶ 🤝共识 ─▶ 📊报告
│ 情报● │ │ 方案✓ │ │ 执行○ │
└────────┘ │ 风险● │ └────────┘
└────────┘
```
### 7.1 验收(Phase 7)
- 编辑器底部 / 执行时能看到 DAG 流程图,节点状态随执行实时更新(active/done)。
### 7.2 实施结论(2026-06-07,✅ 已完成)
- **新组件** `components/DagFlowChart.tsx`:纯展示组件,复刻 `DispatchChainModal` 的节点 / 连线 / 里程碑视觉(`animate-ping` 光环 + `Loader2Icon` 转圈 + `CheckIcon` 打勾 + `rt-chain-connector` 连线动画),但按 **stage 分列**:总控 ─▶ ┌Stage1┐ ─▶ ┌Stage2┐ ─▶ 🤝共识 ─▶ 📊结果绘制,stage 内席位**纵向并列**。`compact` 紧凑模式给缩略图用。所有状态由 props 传入(`stages` / `resolveSeat` / `seatStates` / `coordinatorState` / `consensusState` / `reportState`);不传 `seatStates` → 全 pending(只读预览)。`aggregateStageState` 把 stage 内席位聚合成列状态(任一 active→active、全 done→done)驱动 stage 间连线。
- **编辑器只读预览**(§2.2 底部):`BusinessChainEditorPage` 底部加「编排预览」section,把 `stageGroups`(去空 stage) 映射成 `DagFlowChart` 的 stages + `resolvePreviewSeat`(席位名/配色),`compact`、全 pending、随编辑实时变。
- **执行态总览**(Step 2 顶部缩略):`useStep2Orchestration` 新增 `dagNodeStates: Record<agentId, ChainNodeState>`,`runDagOrchestration` 跨 stage 维护(进入 stage→该批 active、席位终态→done、`resetStep2State` 清空);经 `RoundtablePlanningPage` 把 `orchestrationMode/orchestrationPlan/dagNodeStates` 传到 `Step2Panel`,dag 模式下会话区顶部 `compact` 渲染 DagFlowChart。总控/共识里程碑状态由 `Step2Panel` 从节点状态 + `isOrchestrating`/`hasConsensus` 派生(中转轮时无席位 active + isOrchestrating → 总控 active)。
- 改动文件:`components/DagFlowChart.tsx`(新)、`pages/BusinessChainEditorPage.tsx`、`hooks/useStep2Orchestration.ts`、`components/Step2Panel.tsx`、`pages/RoundtablePlanningPage.tsx`。typecheck 零新增(基线 65)。
- **下次实地验收**:①编辑器底部随分层编辑实时画出流程图;②Step2 dag 模式顶部出现缩略流程图,跑起来后当前 stage 列点亮(active 转圈)、完成打勾、中转时总控亮、最后共识亮。
---
## 8. 分步实现计划(每步可独立交付 + 验收)
| Phase | 内容 | 主要文件 | 依赖 | 可独立验收 |
|---|---|---|---|---|
| **1** | DAG 数据模型 + 校验 + 持久化字段 | `lib/dag-types.ts`(新)、draft step2 读写 | — | 纯函数单测:`validatePlan` / `chain→dag` 兼容转换 |
| **2** | 分层编排编辑器(先无拖拽:点选加入/移出) | `components/DagPlanEditor.tsx`(新)、Step2 模式切换 | P1 | 能编排并保存/恢复,非法编排有提示 |
| **2.5** | 编辑器拖拽 | 同上 + `@dnd-kit`(若引入) | P2 | 拖拽分配/重排 |
| **3** | `runDagOrchestration`:批内并行 + 批间串行 + 依赖摘要注入 | `hooks/useStep2Orchestration.ts`、`api/multi-agent.ts` | P1 | 后端日志并行 special run 时间重叠;stage 严格串行 |
| **4** | 并行禁写文件 run policy | `roundtable_run_policy.py`、`multi_agent.py`、`api/multi-agent.ts` | P3 | 并行 run 的 excluded_tools 含 write_file/bash/... |
| **5** | 并行显示骨架:收起左侧 + 头像条 + 两列 + 选中切换 | `components/Step2Panel.tsx`、`components/ParallelSeatStrip.tsx`(新) | P3 | 见 §5.4 |
| **6** | 流式高性能:全接收 / 惰性渲染 / 节流 | `hooks/useStep2Orchestration.ts`(parallelStore)、`Step2Panel` | P5 | 见 §6.5(6+ 并行不卡) |
| **7** | DAG 流程图执行态可视化 | `components/DagFlowChart.tsx`(复刻自 `DispatchChainModal`) | P2、P3 | 见 §7.1 |
| **8** | 边界 / 异常 | 各处 | 全部 | 见 §9 |
> 落地顺序建议:**P1 → P3 → P4 → P5 → P6**(先把"能并行跑 + 能看 + 不卡"打通),**P2 编辑器**可与 P3 并行开发(先用一份 hardcode/简单 UI 的 plan 驱动 P3 联调),**P7 / P2.5 / 2.3** 作为增强后置。
---
## 9. 边界与异常(Phase 8)
- **某并行节点失败 / 超时**:单个席位失败不拖垮整 stage;标该节点 error,stage 其余 ✅ 仍推进;收口时把"X 失败"如实告知总控(复用交付状态 ⬜ + 失败标记)。
- **依赖未满足**:调度器保证 stage 串行,理论不会发生;防御性校验 stage N 启动前 stage <N 必须全部终态(done/failed)。
- **人工干预 / 暂停 / 恢复**:在 stage 边界检查 `isStale()`(epoch);干预指令插入后,从"下一个未开始的 stage"继续(已完成 stage 不重跑)。
- **并行禁写文件 vs 席位本想写文件**:在席位 SOUL / 派活提示里说明"本轮为并行研讨,产出请直接写在回复正文,不要写文件",降低模型困惑。
- **窄屏**:主区两列降为一列 + 头像条横向滚动。
- **单 stage 全并行(一次性 N 个)**:给并行度 warning(远程模型并发限流 / 成本)。
---
## 10. 与现有代码的衔接点速查
- 编排循环:`hooks/useStep2Orchestration.ts`(`runOrchestration` / `runChainOrchestration` 旁新增 `runDagOrchestration`;复用 `isStale()` epoch、`abortAllInFlight`、`appendStreamingDialogue` / `appendToDialogue`)。
- 流式 API:`api/multi-agent.ts`(`streamMultiAgent`,新增 `parallel_no_file` 入参;`onTextDelta(text, messageId)` 已是按席位回调,天然支持并行分流)。
- 显示:`components/Step2Panel.tsx`(消息列表 `step2RoundtableDialogues.map`;新增头像条 `ParallelSeatStrip` + 两列容器 + 折叠左侧)。
- 流程图:`components/DispatchChainModal.tsx` / `lib/job-types.ts` / `lib/business-chain.ts`(复刻成 DAG 版)。
- 常量 / 元数据:`lib/roundtable-constants.ts`(`BUILTIN_AGENT_META` 头像/中文名、`buildAgentNameToRole`)。
- 后端 run policy:`app/gateway/roundtable_run_policy.py`(新增 `seat_parallel`)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `no_file` flag)。
- 交付摘要:`GET /api/threads/{id}/last-ai-message?max_chars=N`(依赖注入 stage N 输入用)。
---
## 11. 一句话总结
把「线性链条」升级为「分层 DAG」:**编辑器**用分层 stage(拖卡片)表达串/并;**调度**用 `Promise.all` 批内并行、批间 barrier 串行,依赖摘要在 stage 边界注入;**并行禁写文件**避免沙箱打架;**显示**收起左侧、头像条切换、主区两列、未选中只接流不渲染(rAF 节流)保证不卡。全部建立在现有 special/leader run + 轻量交付接口之上,后端几乎零新接口。
---
## 12. 已知问题与排查记录(2026-06-06)
### 12.1 ❗并行席位「步骤条」不显示 —— 根因:`seat_parallel` 工具面禁得过多
**现象**:并行两列里的席位气泡看不到步骤条(`MessageStepsCard`),而串行席位有。
**排查结论(已定位,待修)**:
1. **步骤条只渲染「工具调用」步骤**。`MessageStepsCard` 的 `isToolStep` 只认 `tool_calling:<toolName>` 的步骤进折叠卡;纯阶段步骤(`connecting:` / `streaming:` 这类 reasoning)只渲染成**底部一行小灯泡**,不出大卡(见 `MessageStepsCard.tsx` L80-100)。
2. **并行席位几乎调不动任何会产生步骤的工具**。后端 `app/gateway/roundtable_run_policy.py`:
- `_SEAT_EXCLUDED = [web_search, ask_clarification, present_files, view_image, agent_orchestration]` —— **注意 `web_search` 对所有席位(串行也)就禁了**(注释「圆桌研讨里联网搜索一律禁用」)。
- `_SEAT_PARALLEL_EXCLUDED = _SEAT_EXCLUDED + [write_file, bash, str_replace]`。
- 于是并行席位可用工具几乎只剩 `read_file / ls / task / write_todos / memory / hindsight_*`,研究/分析类席位基本一个都不会调 → **没有任何 `tool_calling` 步骤** → 步骤条(大卡)不出现。
3. **对比串行为何「有步骤条」**:串行席位(`seat` policy)**能 `write_file`**,会产生 `tool_calling:write_file` 步骤 → 大卡出现。并行被禁了 write_file + 又没有 search,所以一个工具步都没有。
**用户诉求**:并行时**只禁真正会冲突的写操作**,搜索信息 / 调用技能等**必要操作不要禁**。
**关键事实(影响方案选择)**:圆桌每个席位是**独立 thread = 独立 sandbox**(后端 per-thread 隔离 `.deer-flow/users/{uid}/threads/{tid}/`)。所以并行席位写文件**在后端并不会互相冲突**;当初 §4「并行禁写文件」的「打架」其实主要是**前端单沙箱预览**(`activeArtifactThreadId` 只能同时显示一个)+ 产物归集到正文的简化考量,并非后端真冲突。→ 这意味着「并行禁写」的必要性比原设想弱,可放宽。
**✅ 决策:选 A(2026-06-06 已实施)**:把 `seat_parallel` 放宽到**只禁 `write_file / bash / str_replace`**(防前端单沙箱预览串台 + 产物走正文)+ 仍禁 `ask_clarification / present_files / view_image / agent_orchestration`(打断/越权类),但**放开 `web_search` 与技能**(搜索不碰 sandbox;技能靠 `read_file`)。改在 `app/gateway/roundtable_run_policy.py` 的 `_SEAT_PARALLEL_EXCLUDED`(**不再继承** seat 的 web_search 禁用,显式列举),测试 `tests/test_roundtable_run_policy.py` 同步更新(`test_seat_parallel_allows_web_search_and_skills`)。
→ 并行席位现在能联网搜索/用技能 → 产生 `tool_calling` 步骤 → 步骤条显示。
> 备选 B(连写文件也放开,沙箱按 thread 隔离)/ C(重新评估「圆桌串行也一律禁 web_search」)**本期不做**,留档。串行席位目前仍禁搜索(`seat` policy 不变)。
### 12.2 其它已修(本轮反馈)
- 最左侧**全局工作区侧栏**并行时收起:改用与「打开沙箱」同一路径 `useSidebarSafe()?.setOpen(false)`(之前只收了圆桌「参与角色」栏 `step2SidebarExpanded`)。并行结束**不自动展开**(与沙箱收起不回弹一致)。
- 并行两列改回**完整 Markdown**(撤销 §6.2 流式纯文本降级 `plainWhileStreaming`),与串行同款 `Step2Message`。
- 头像条**不再显示字数**(去掉 charCount 徽标 + 字段)。
- **致命 bug 已修**:用户干预 / 并行跑完后**不再重跑整张 DAG**。`dagPlanExecutedRef` 闸门 + DAG 各阶段跑完后释放锁 `await runOrchestration(handoff)` 交回总控继续派活/收口;后续干预/恢复一律走 leader 自由派活。
- **并行两列宽表溢出已修(2026-06-07)**:模型输出的 Markdown **表格**(如「法律免责条款」表)没有宽度约束 / 横向滚动容器,在并行两列的窄列里会**超出左列、盖到相邻列**。修法:`MessageBubble.sharedMarkdownComponents.table`(+ `markdown-components.tsx`)把 `<table>` 套进 `<div class="max-w-full overflow-x-auto">`——表格保持自然宽度、超出时**列内横向滚动**而非撑破列宽。`min-w-0` 链(grid `minmax(0,1fr)` → 列 `min-w-0` → 气泡 `flex-1 min-w-0`)本就齐全,只差 table 这一层。
- **关于「并行席位无步骤条」**:步骤条(`MessageStepsCard` 大卡)只在席位产生 `tool_calling` 步骤时出现(§12.1)。纯分析型席位若本轮**直接写分析正文、没调用任何工具**(web_search 等),就只显示一行「正在输出」思考指示、不出大卡——这是设计使然,非解析失败。要确认是否真调了工具:浏览器 devtools 设 `window.__MULTI_AGENT_DEBUG__=true` 看 `frame# event=messages` 里有无 tool_calls,或查后端 special run 日志。
### 12.3 ✅需求变更:Stage 间「经总控中转 + 广播」(2026-06-06 提出,2026-06-07 已实施)
**当前实现(方案 B,§3.2)**:DAG 各 stage 由前端**直接**派活,stage 间不跑 leader,靠前端把 `buildDependencySummary` 拼进下一 stage 的 task(`priorSummary`)。各 stage 跑完后才 `await runOrchestration` 交回总控收口。
**新诉求**:业务链条调用时,**每一轮 stage 之间都要经总控中转 + 广播**,而不是前端直接串下去。具体:
1. 第 1 轮并行调用 stage1 的 A、B、C;
2. A/B/C **全部完成后**,把这一批的**输出广播出来**(广播给其它席位 + 总控 thread);
3. **总控(leader)接收**这批交付 → 再**往下继续派活**(派 stage2 的 D、E);
4. 派活时把(承上的)消息**广播给下一轮的 D、E**;
5. 以此类推,直到所有 stage 跑完再收口。
> 即把 §3.2 的「方案 B 前端直派」改成 **「方案 A' = 计划约束下的总控中转」**:stage 边界插入一轮 leader,由它综合上一批交付并驱动下一批(下一批席位仍由编排计划**钉死**为 D、E,不是 leader 自由乱派——leader 负责「承上启下 + 广播上下文」,计划负责「派给谁」)。
**实施要点(下次会话)**:
- **每个 stage 完成 → 广播该批输出**:后端 `_special_run` 已对每个 seat 做 detached 后广播(「子智能体X完成…交付内容为:…」→ 其它席位 + 总控)。但它是 **detached/best-effort + 截断**;要让总控**可靠**地在下一轮前看到,需在 stage barrier 处**等广播落地**(或前端在 stage 边界用 `GET /threads/{id}/last-ai-message` 主动收齐各席位交付再喂给 leader)。
- **插入 leader 中转轮**:`runDagOrchestration` 在进入下一 stage 前,跑一轮 leader(`synthesisMode=true` 让后端注入上一批**完整**交付),让总控产出一段「承上启下」说明;把这段说明(而非机械 `priorSummary`)作为下一 stage 席位 task 的上下文 / 或广播给下一批席位 thread(复用 `POST /threads/{id}/messages/append`)。
- **下一批仍由计划钉死**:leader 中转轮**不**自由决定派给谁(计划已定 D、E);可参考 `buildChainLeaderTask` 的「约束 leader 只派给指定席位」思路,或干脆 leader 只做综合、前端照计划派 D、E 并把 leader 综合 + 广播注入其 task。
- **与「并行禁写」「§12.1 放开搜索」并存**:中转/广播不影响工具面。
- **与致命 bug 修复并存**:最后一个 stage 跑完仍走「交回总控(`runOrchestration`)收口/继续派活」;用户干预仍走 leader、不重跑 DAG。
**影响文件(预估)**:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` 的 stage 循环里加 leader 中转 + 广播收齐)、可能复用后端 `_leader_run`(synthesisMode 注入全文)/ `/messages/append`(广播)/ `/last-ai-message`(收齐交付)。后端基本无需新接口。
**✅ 实施结论(2026-06-07)**:后端**零改动**,全部在前端 `runDagOrchestration` 内完成,复用既有「传摘要按需传全文」优化链路:
1. **中转轮位置**:每个**非最后**的 stage barrier 到达后(`Promise.all` 收齐本批交付),插入**一轮 leader**「中转轮」,展示为「总控协调(承上启下)」气泡。最后一个 stage 后**不**插中转,综合/收口仍交给末尾的 `runOrchestration` 接管(与致命 bug 修复一致)。
2. **可靠收齐交付(传摘要)**:中转轮跑 `streamMultiAgent({agentType:"leader", synthesisMode:false})`。后端 `_leader_run` 本就在每轮 leader 前用 `_collect_delivery_status`(走**轻量 `GET /last-ai-message?max_chars=600` 接口**)**客观核对**各席位真实交付并把**摘要**注入总控输入(`_build_seat_status_block`)——比 detached 广播可靠(直接实读各席位 thread,不依赖 best-effort 广播是否落地)。故中转轮传 `synthesisMode:false`:只综合本批、**不收口**,只注入摘要;**完整交付正文**留到末尾收口的 synthesis 轮(`_build_full_deliverables_block`,全 ✅ 才注入)按需读取——即「**传摘要按需传全文**」。
3. **承上启下驱动下一批**:中转轮产出的综合说明存进 `leaderBridge`,作为**下一 stage** 席位 task 的上下文(`buildStageTask({priorSummary: leaderBridge, priorIsBridge:true})`,措辞为「承上启下综合说明」而非机械「交付摘要」),替代原来的 `buildDependencySummary(deliveries)`。中转失败/无产出时回退机械依赖摘要,不阻断。
4. **下一批由计划钉死**:中转轮提示(`buildDagTransitTask`)明确告知总控「下一阶段席位已由编排计划钉死为 X、Y,你**不要**自行 `agent_orchestration` 派活、**不要**收口」,前端仍照计划直派下一批;即便弱模型仍派了活,前端忽略其 `dispatched`、只取 `content` 当 bridge。
5. **广播**:本批输出广播给其它席位 + 总控 = 后端 `_special_run` 既有 detached 后广播(截断摘要);承上消息给下一批 = 注入其 task `new_message`(席位 run 输入必见,比 `/messages/append` 更可靠,省一次写盘)。
6. **澄清/抢占/失败**:中转轮触发澄清 → 暂停 DAG(回复后走 leader 自由派活、不重跑 DAG,沿用 `dagPlanExecutedRef` 闸门);被 `isStale()`/abort 抢占 → 静默收尾;异常 → 该气泡标错误、清空 bridge 继续。
**纯函数**:`lib/dag-types.ts` 新增 `buildDagTransitTask`(中转轮提示)+ `buildStageTask` 加 `priorIsBridge` 形参;`lib/dag-types.test.ts` 加 4 个单测(共 27 个全过)。
> ⚠️ **§12.3 的「单独中转轮 + 前端直派」已被 §12.4 取代**(2026-06-07 同日):见下。`buildDagTransitTask` / `buildStageTask` / `buildDependencySummary` 仍保留在 lib(带单测),但 `runDagOrchestration` 不再调用它们。
### 12.4 ✅ 需求再变更:DAG 改成「总控驱动每 stage 派活」(2026-06-07,§3.2 方案 A)
**新诉求(用户原话)**:把并行(DAG)逻辑改得和串行一样——串行时总控给多个智能体派活、它们完成后汇报给总控、总控再继续往下派活;并行也改成「由总控判断要执行哪些步骤,这些步骤完成后再汇报给总控,总控继续往下派活」。
**已确认的两个关键决策**:
1. **每批「派给谁」**:✅ **计划钉死成员,但走总控派活**(不是总控自由乱派)。即 stage 成员仍由编排计划定死,但改成总控每轮**真正** `agent_orchestration` 派活给这些席位(约束只派给它们、且每个都派到)。= §3.2「方案 A」+ 批内并行。
2. **适用范围**:✅ **只改 dag 模式**(recommend 仍串行逐个、chain 不动)。
**实现(后端零改动,全在 `runDagOrchestration`)**:每个 stage 两步——
- **(a) 总控派活轮**:跑一轮 `leader`,提示 `buildDagStageLeaderTask`(首 stage 带任务背景;后续 stage 靠后端注入的此前交付摘要承上;列出本 stage 钉死席位的 `agent_name`,要求逐个派到、不派清单外、不收口)。`synthesisMode:false` → 后端 `_build_seat_status_block` 走轻量 `last-ai-message` 注入各席位**摘要**(传摘要按需传全文)。
- **(b) 批内并行**:本 stage 席位并行跑总控派给各自的 task——`pickDagSeatTask(leaderRes.dispatched, content, agentId, name)` 命中其 `agent_name`;总控漏派/写偏则回退总控正文/模板,**不借用别席位 task**(多席位并行避免张冠李戴)。完成后交付经后端 `_special_run` detached 后广播回总控;下一 stage 的派活轮即可看到。
- barrier 后进入下一 stage,总控再派下一批。全部 stage 跑完 → 交回 `runOrchestration` 收口(synthesisMode 按需注入全文)。
**相比 §12.3 的变化**:§12.3 是「前端直派 + stage 间单独插一轮只综合的中转轮」;§12.4 把**每个 stage 本身**就改成「总控派活轮 + 批内并行」——总控既承上(注入的摘要)又派活,不再需要单独的中转轮。与 chain 模式同构,区别是一次钉死一批、批内并行。
**纯函数**:`lib/dag-types.ts` 新增 `buildDagStageLeaderTask`(每 stage 派活提示)+ `pickDagSeatTask`(取某席位 task,多席位不张冠李戴);`lib/dag-types.test.ts` 加 5 个单测(共 32 个全过)。`runDagOrchestration` 不再用 `buildStageTask/buildDependencySummary/buildDagTransitTask`(保留在 lib)。
**Phase 7 流程图兼容**:总控派活轮无席位 active + `isOrchestrating` → 流程图「总控」节点显示 active(承上派活中),随后该 stage 列点亮,无缝衔接。
**下次实地验收**:①每个 stage 前出现「总控协调(派活中/承上派活中)」气泡,内容是总控对本 stage 席位的派活说明;②后端日志该轮 leader 的 `_partition_dispatch` 派给的正是本 stage 钉死席位;③这些席位并行跑(special 日志时间重叠);④完成后下一 stage 总控派活轮能看到上一批交付摘要(`[leader-roster]` 日志)。
### 12.5 ✅ 流程图弹窗化 + DAG「快速/沙箱」执行子模式(2026-06-07)
三个改动一起做:
**(1) 流程图改成弹窗**:Step2 顶部不再内联 `DagFlowChart`(挤占两列消息高度,截图 `样式优化-1.png`)。改成一条细栏「分层编排执行中 · 共 N 个阶段 + [查看编排流程图]」按钮,点开 `Dialog` 看**全尺寸**流程图(非 compact)。编辑器(`BusinessChainEditorPage`)底部预览保持内联不变。改 `Step2Panel.tsx`(加 `dagFlowOpen` state + Dialog)。
**(2) 推荐弹窗新增 DAG 执行子模式(快速/沙箱)**:`BusinessChainPicker` 在选中链条**有分层并行**(走 dag)时,「进入研讨」旁出现「执行方式:快速·双列禁写 / 沙箱·单列可写」二选一(只 dag 时出现,§已确认)。
- **快速(`fast`,默认)**:同 stage 席位**并行**、主区**双列**、`parallelNoFile=true`(seat_parallel **禁写文件**)。= 之前的行为。
- **沙箱(`sandbox`)**:同 stage 席位**仍并行**,但主区**单列**(`selectedParallelSeats` 最多 1)、`parallelNoFile=false`(seat policy **允许写文件**);点头像切换查看,沙箱跟随当前席位。
- 数据流:`SeatSelectionResult.dagExecMode` → `RoundtablePlanningPage` state(持久化进 `DraftStep2(Run)Snapshot.dagExecMode`,mirror orchestrationPlan)→ `useStep2Orchestration` option → `runDagOrchestration`。
**(3) 沙箱跟随当前显示席位(防 bug 重点,用户特别强调)**:`useStep2Orchestration`
- `sandboxFollowThreadIdRef` = 当前显示席位的 thread;`openSeatVirtualArtifact` 的 auto 守卫:sandbox 模式下,**后台并行席位**写文件时若不是当前显示席位,**不自动打开**(否则沙箱内容和主区列表错位)。
- 切换显示席位的 effect(keyed on `selectedParallelSeats` + `dagExecMode`):该席位**有产物**(stub 里有 write_file/str_replace)→ `openSeatLatestArtifact` 强制打开它最新的文件;**无产物**→ 关闭沙箱面板(`setActiveArtifactThreadId(null)` + `deselectArtifact` + `setArtifactsOpen(false)`)。
- 当前显示席位 live 写文件 → `handleArtifactToolPhase` auto-open,守卫放行(follow===该席位)。后台席位写 → 守卫拦掉、只缓存 stub;切到它时 effect 再打开。
- `toggleParallelSeat`:sandbox 单列 = 点头像切换(始终保留 1 个,不取消到空);fast 双列 = 原 LRU 2 个。
**改动文件**:`lib/business-chain.ts`(SeatSelectionResult.dagExecMode)、`components/BusinessChainPicker.tsx`(子模式 UI)、`api/drafts.ts`(快照 dagExecMode)、`pages/RoundtablePlanningPage.tsx`(state/snapshot/hydrate/confirm/reset 全链)、`hooks/useStep2Orchestration.ts`(option + parallelNoFile/单列 + 沙箱跟随 + toggle cap)、`components/Step2Panel.tsx`(流程图弹窗 + 单列 grid)。typecheck 零新增(基线 65),dag-types 32 单测全过。
**下次实地验收(沙箱跟随是重点)**:①推荐弹窗选有并行的链条 → 出现「快速/沙箱」二选一;②快速=双列禁写(无沙箱);③沙箱=单列、席位能写文件、点头像切换列表时沙箱内容**同步**切换:有产物显示、无产物关闭;④后台席位写文件不会把沙箱抢到非当前列表;⑤流程图点按钮弹窗、全尺寸、节点随执行点亮。
### 12.6 ✅ 修:并行席位「跟着派活」(2026-06-07)
**现象**(截图 `bug-1.png`):总控派活后,并行席位的气泡里显示的是**总控的整段派活叙述**(「我来调度各智能体…第1阶段任务清单:席位1…席位2…」),还去调 `skill_list` 找 `agent_orchestration`——席位在**模仿派活**。
**根因**:总控这轮**没真正调用 `agent_orchestration` 工具**(弱模型 deepseek 用文字罗列了派活计划)→ 后端解析的 `dispatched=[]` → 前端 `pickDagSeatTask` 回退把**总控整段叙述**喂给**每个**席位 → 席位照抄叙述、模仿派活。(席位本就被 run policy 禁了 `agent_orchestration`,**派不了活**,只是空转一轮叙述 + 调 `skill_list`。)
**修 v1(护栏,效果不足)**:`pickDagSeatTask` 加角色边界护栏 + 回退措辞改「挑出属于你的那份」;`buildDagStageLeaderTask` 加「必须真正调用工具」。→ **没拦住**:总控对 deepseek 仍只写文字不调工具(`dispatched` 还是空),席位拿到的**任务正文本身**就是那段「我来调度…席位1/2/3」,护栏被淹没;3/4 席位仍模仿派活,有的甚至绕道 `bash`+`python requests.get('/api/agents')` 调内部接口派活(bug-1~4)。
**✅ 修 v2(根治:回退绝不注入派活叙述)**:`pickDagSeatTask` 去掉 `leaderContent` 入参——
- **命中**总控结构化派活(真调了工具)→ `请你作为「X」,完成总控派给你的本阶段任务:<dispatched task>` + 护栏;
- **未命中 / `dispatched` 空**(总控只写文字)→ **绝不**把总控派活叙述喂给席位,改给**干净本职任务**:「请你作为「X」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。任务背景与此前各阶段交付已在你的会话上下文里(后端广播注入),据此承接」+ 护栏。护栏强化到「不要用 bash/python/脚本访问内部接口(/api/agents、agent_orchestration)派活、不要复述『我来调度』总控口吻」。
- `buildDagStageLeaderTask` 的「必须真正调用工具」保留(让命中率更高、尽量走 exact-match)。
- `dag-types.test.ts` 改 2 单测(共 33 过)。typecheck 65。
> ⚠️ **残留次要污染源(若仍复发再处理)**:DAG 每个 stage 的 leader 轮,后端 `_leader_run` 会把 leader 的输入(含「请你作为总控…派活」)**预广播**进所有席位线程(`总控智能体收到用户消息:…`)。席位的**当轮任务**已干净(修 v2),这条只是历史上下文,理论上被「最新的干净任务 + 护栏」压制。若实测仍模仿派活,下一步:后端给 DAG leader 轮加 `suppress_pre_broadcast` / 或广播一条**净化**过的背景(去掉派活措辞)。
### 12.7 ✅ 修 4 项(2026-06-07):席位仍派活(v3 后端)+ 沙箱关不掉 + 并行不自动下滑 + 流程图只到 Step2
实测 §12.6 仍有席位派活(视觉设计师 `skill_manage` 找 agent-orchestration、列 dispatch all 4)——**坐实**了「残留次要污染源」:后端 leader 预广播把派活 prompt 推进席位线程,弱模型从历史里捡到派活意图。一起修了 4 项:
1. **席位仍派活(根治·后端)**:`_leader_run` 加 `suppress_pre_broadcast` 参数;DAG「每 stage 派活轮」前端传 `suppressPreBroadcast: true`(`api/multi-agent.ts` → `suppress_pre_broadcast`,run/stream 读取)。为真时:**不**把总控派活 prompt 预广播给席位、**也不**把「总控给 X 派活」detached 广播给兄弟席位。recommend/chain 不传该 flag,行为不变。配套:`pickDagSeatTask` 加 `seedMessage` 入参——预广播抑制后席位线程没有用户原始消息了,把**任务背景**直接写进席位 task(此前各阶段交付仍由 special 完成后广播注入)。→ 席位线程里**任何地方都没有派活语句**,根治模仿。
2. **沙箱关闭按钮关不掉**:沙箱跟随 effect 之前每次 render(deps 身份变)都重跑 → 把用户刚关的沙箱强制重开。加 `lastReconciledSeatRef` 守卫:**只在显示席位真正变化时**才开/关沙箱;用户手动关闭(席位没变)时 effect 早退,不再回弹。
3. **并行时自动下滑失效**:平铺列表 ↔ 网格(两列/单列)切换时 `scrollHeight` 突变、`scrollTop` 被浏览器 clamp → `useAutoScroll` 误判「用户上滚」而停跟随。`Step2Panel` 加 effect:`isParallelRunning` / `selectedParallelSeats` 变化时 `scrollStep2ToBottom()` 重新拉到底 + 恢复跟随。
4. **流程图只展示 Step2**:`DagFlowChart` 加 `showReportMilestone`(默认 true);Step2 弹窗 + 编辑器预览传 `false` —— 只到「共识」(Step2 终点),去掉「结果绘制」(那是 Step3)。
**改动**:后端 `multi_agent.py`(`_leader_run` suppress 两处广播 + run/stream 读 flag);前端 `api/multi-agent.ts`、`lib/dag-types.ts`(pickDagSeatTask + seedMessage)、`hooks/useStep2Orchestration.ts`(传 flag + seed + 沙箱跟随守卫)、`components/Step2Panel.tsx`(自动下滑 effect + showReportMilestone)、`components/DagFlowChart.tsx`、`pages/BusinessChainEditorPage.tsx`。`dag-types.test.ts` 共 **34 单测过**;前端 tsc 65 基线零新增;后端 `py_compile` OK。
### 12.8 ✅ 席位产出 md 文件 + 禁写 .py(2026-06-07)
**现象**:某次 7 席位**全都没产出 md 文件**;且一直有的问题——席位**有时写 .py 脚本**(用户要求:不写 .py,其它格式可以)。
**根因(没 md)**:两层都堵了——(a)**快速模式** `seat_parallel` policy 禁了 `write_file`(原「产物走正文」设计);(b)DAG 席位**提示词**写的是「直接输出你的交付正文」,即使沙箱模式(允许写)也不会去写文件。
**修**:
1. **后端 policy 放开写文件、统一禁 bash**(`roundtable_run_policy.py`):
- `seat_parallel`:去掉 `write_file` / `str_replace` 禁用(**可写 md**),保留禁 `bash` + 打断/越权类;仍放开 `web_search`。
- `seat`:新增禁 `bash`(原来允许)——圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 再跑生成产物,乱且抓不到预览);改为**直接 write_file 写成品文档**。
- `report`:`bash` 已随 `seat` 禁,改为 `[*_SEAT_EXCLUDED, "str_replace"]`(集合不变)。
- 测试 `test_roundtable_run_policy.py`:`test_seats_can_write_files_but_not_run_bash`(seat / seat_parallel 都可写文件、都禁 bash),6 用例过。
2. **前端提示词**(`pickDagSeatTask`)加「产物要求」:**把完整交付写成一个 .md 文件**(write_file 到 outputs)+ 正文给摘要;**禁写 .py 或任何脚本文件**,.md/.html/.txt 等其它格式都可以,唯独不要 .py。
3. **快速模式(双列)不自动开沙箱**(`openSeatVirtualArtifact` 新增守卫):放开写文件后,双列里多席位并发写文件会让单沙箱预览反复串台——fast 模式 DAG 并行批次**完全不自动开沙箱**(文件仍写盘,Step3/文件卡可查看);沙箱模式(单列)仍按「沙箱跟随当前席位」预览。
**关于 .py 的强度**:`write_file` 是全局工具(所有 agent 共用),无法在网关层按扩展名硬拦,故 .py 禁止走**提示词**(强约束)+ **禁 bash**(写了也跑不了,脚本生成产物的动机被掐断)。若实测仍偶发写 .py 且需**硬拦**,下一步:给 lead_agent 的 `write_file` 工具加一个「按 run context 禁某些扩展名」的开关,网关 `_run_payload` 对圆桌席位置 `[".py"]`(只影响圆桌 run,不动其它 agent)。
**改动**:`roundtable_run_policy.py`、`test_roundtable_run_policy.py`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(35 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65、后端 py_compile OK + 6 policy 单测过。⚠️ **改了后端,需重启 Gateway**。
### 12.9 ✅ 席位「按需取前序全文」只读工具 read_peer_delivery(2026-06-07)
**诉求**:A,B,C → D,E 时,D,E 默认拿 A,B,C 的**摘要**,但识别到需要**全文**时也能取到。之前只有总控(leader)能「摘要默认+按需全文」(跨 thread 读),下游席位**不能**(席位间 thread 隔离、只有 1500 字截断广播)。
**实现**(真·按需,全文只在调用工具时才进 LLM 上下文):
1. **新 harness 只读工具** `read_peer_delivery(seat_name)`(`packages/harness/deerflow/tools/builtins/roundtable_peers_tool.py`):从 `runtime.context["roundtable_peer_deliveries"]`(网关注入的 `[{name,content}]`)按席位名取全文;空名→列出可取席位;非圆桌场景→「无」。只读 context dict,**不跨 thread、不碰 sandbox、无网络**,纯 `deerflow.*`(不违反 harness→app 防火墙)。注册进 `tools/tools.py` 的 builtin(始终在,只读无副作用)。
2. **网关注入 context**:`_run_payload` 加 `extra_context`;`_special_run` 收 `peer_deliveries` → 注入 `context.roundtable_peer_deliveries`;run/stream 读 `peer_deliveries`(规整 `[{name,content}]`)。**不进 prompt → 不调工具就不耗 token**。
3. **run policy**:`read_peer_delivery` 从 `leader`(总控自己跨 thread 读,不需要)+ `report`(Step3 不读前序)排除;**席位(seat/seat_parallel)保留**。
4. **前端**:`runDagOrchestration` 跨 stage 累积**完整交付** `priorDeliveries`,stage>0 席位的 special run 带 `peerDeliveries`(`api/multi-agent.ts` → `peer_deliveries`);`pickDagSeatTask` 加 `hasPeers` 形参,有前序时在 task 里提示「需要某前序席位全文时调用 read_peer_delivery('<名>'),平时用摘要」。
**最终语义**:D、E 默认看 A、B、C 的**摘要**(1500 字广播);识别到不够 → 调 `read_peer_delivery('情报收集')` 取**全文**。总控仍是摘要默认+综合轮全文。**= 用户要的「摘要默认、按需全文」对席位也成立了。**
**改动**:后端 `roundtable_peers_tool.py`(新)、`tools/tools.py`、`roundtable_run_policy.py`、`multi_agent.py`;前端 `api/multi-agent.ts`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(36 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65;后端 py_compile OK + 6 policy 单测过 + 工具导入/行为 smoke 通过。⚠️ **改了后端 + 新增 harness 工具,需重启 Gateway**。
### 12.10 ✅ 修:沙箱模式第一个席位不自动开沙箱(2026-06-07,前端)
**现象**:沙箱(单列)模式并行时,**第一个**席位的消息列表不自动打开沙箱(要手动开);切换到其它席位时正常自动打开。
**根因**:沙箱跟随 effect(§12.5)在选中席位**还没写产物**时走「关闭沙箱」分支,里面同时调了 `deselectArtifact()` **和** `setArtifactsOpen(false)`。后者是 context 的 **wrapped setOpen**(`context.tsx:73`):`关闭时若 autoOpen 为真 → 把 autoOpen 置 false`。于是第一个席位刚选中(还没写文件)→ autoOpen 被关 → 该席位**随后 live 写文件**时,`openSeatVirtualArtifact` 的 `!flags.autoOpen` 守卫拦掉自动打开。切换到**已写过**文件的席位能开,是因为那条路走 `openSeatLatestArtifact`(force,auto=false,绕过 autoOpen 守卫)。
**修**(一行):关闭分支只保留 `deselectArtifact()`(它走 raw `setOpen(false)`,**不**动 autoOpen),删掉多余的 `setArtifactsOpen(false)`。这样面板关掉、但 autoOpen 仍为真 → 第一个席位 live 写文件时正常自动开沙箱。(若用户**手动**关过沙箱,autoOpen 本就为 false,不会自动重开 —— 尊重用户。)改 `hooks/useStep2Orchestration.ts` 沙箱跟随 effect。tsc 65。
### 12.11 ✅ 修:快速模式又生成文件了 + Fragment 警告(2026-06-07);记录无关 404
1. **快速模式不应生成文件(回退 §12.8 的过度放开)**:§12.8 为「让席位产 md」把 `seat_parallel`(快速模式)也放开了 write_file —— 但**快速模式(双列)的设计就是产物走正文、不写文件**,写文件归**沙箱模式**(单列 + 沙箱预览)。修:
- `roundtable_run_policy.py`:`seat_parallel` 重新**禁 write_file / str_replace**(保留禁 bash);`seat`(沙箱模式)仍可写。
- `useStep2Orchestration.ts`:`parallelNoFile = dagExecMode !== "sandbox"`(快速模式一律禁写,含单节点 stage;沙箱模式放开)。
- `pickDagSeatTask` 加 `allowFiles` 形参:沙箱(true)=写 .md 文件 + 禁 .py;快速(false)=产物走正文、不写文件。hook 按 `dagExecMode === "sandbox"` 传。
- 测试:`test_sandbox_seat_writes_files_fast_parallel_does_not`(6 过);`dag-types.test.ts` 拆成沙箱/快速两个产物要求用例(37 过)。
- **语义**:快速=双列、产物走正文、不写文件;沙箱=单列、写 .md、沙箱跟随预览。
2. **`Invalid prop data-lov-id supplied to React.Fragment`**:`DagFlowChart` 的 stage 循环用了具名 `<Fragment key>`,dev 代码标注插件(lovable-tagger)给它注入 `data-lov-id`,而 Fragment 只接受 key/children → React 警告。修:换成 `<div className="contents" key=...>`(display:contents 不生成盒子,Connector/StageColumn 仍是外层 flex 直接子项,布局不变,且能接收 data-*)。移除 `Fragment` import。
3. **(无关,非本期引入)`/api/getTokenByEmail?email=…%40local.deerflow` 404 ×60**:来自 `src/core/studio/api.ts` 的 `fetchStudioToken`(studio/expert-auth 引导),用户邮箱被映射到 legacy `@local.deerflow` 域、后端 `getTokenByEmail` 不认 → 404,React Query 跨多个 `useStudioToken` 调用点重试 → 重复多次。**与圆桌 DAG 改动无关**(圆桌头像是本地 SVG,本期未新增任何前端 GET)。需要的话单独排查 studio token 链路。
---
## 13. 实施进度(截至 2026-06-06,下次会话续)
| Phase | 内容 | 状态 |
|---|---|---|
| 1 | DAG 数据模型 + 校验 + 持久化字段(`lib/dag-types.ts`,24 单测) | ✅ 完成 |
| 2 | 分层编辑器(**做进业务链条页**):链条加 `stages` 字段(前后端 + alembic `20260607_03` + 11 后端单测);`BusinessChainEditorPage` 竖列分组 Stage(DnD + 按钮);纯逻辑 `lib/chain-stages.ts`(19 单测) | ✅ 完成 |
| 3 | `runDagOrchestration`:批内 `Promise.all` 并行 + 批间 barrier 串行 + 依赖摘要注入(方案 B 前端直派) | ✅ 完成 |
| 4 | 并行 run policy `seat_parallel`(`roundtable_run_policy.py` + `multi_agent.py` 读 `parallel_no_file`,6 后端单测);**§12.1 方案 A 已放开 web_search/技能** | ✅ 完成 |
| 5 | 并行显示:收起左侧 + 头像条 + 两列 + 选中切换 | ✅ 完成(本轮修了侧栏/Markdown/字数) |
| 6 | 流式高性能:ref 全量累积 + 节流 flush(仅选中渲染) | ✅ 完成 |
| 7 | DAG 流程图执行态可视化(`DispatchChainModal` 复刻成按 stage 分列;后改成**弹窗**见 §12.5) | ✅ 完成(2026-06-07) |
**端到端闭环已通**:链条页编排分层 → 存库 → 选链条进 Step2 → `runDagOrchestration` 并行直派 → 各阶段完交回总控派活/收口。
**✅ §12.3 已实施(2026-06-07)**:见 §12.3「实施结论」。`runDagOrchestration` 在每个非末尾 stage barrier 后插入 leader 中转轮(`synthesisMode:false` 走轻量 `last-ai-message` 注入摘要、收口轮才按需全文),承上启下驱动下一批;后端零改动;`dag-types.ts` 加 `buildDagTransitTask` + `buildStageTask.priorIsBridge`,27 单测全过。
**下次会话 TODO(优先级从高到低)**:
1. **运行时实地验收 §12.3**:①每个 stage 之间出现「总控协调(承上启下)」气泡,内容是对上一批的综合;②后端日志中转轮 `[leader-roster] ... 注入席位交付状态` 显示上一批 ✅、未跑的 ⬜;③下一 stage 席位 task 里带的是总控承上说明(非机械摘要);④中转轮不应真的派活(即使派了前端也忽略)。
2. 运行时实地验收(旧):①§12.1 后并行席位能搜索、步骤条显示;②并行 special 日志时间重叠 + `Excluded` 不含 web_search 含 write_file;③用户干预后是「总控派活」而非重跑 DAG;④最左全局侧栏 + 参与角色栏并行收起且不回弹。
3. **✅ Phase 7 DAG 流程图可视化已完成(2026-06-07)**:见 §7.2。`DagFlowChart` 组件 + 编辑器只读预览 + Step2 执行态缩略(`dagNodeStates` 实时驱动)。
4. (可选)串行也确定性注入上一轮交付摘要。
**关键文件速查**:
- 前端:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` / `dagPlanExecutedRef` / 并行 store+flush)、`components/Step2Panel.tsx`(两列+头像条)、`components/ParallelSeatStrip.tsx`、`lib/dag-types.ts`、`lib/chain-stages.ts`、`pages/BusinessChainEditorPage.tsx`、`pages/RoundtablePlanningPage.tsx`(侧栏收起 effect + handleConfirmAgents dag 分支)、`components/BusinessChainPicker.tsx`(链条→dag)。
- 后端:`app/gateway/roundtable_run_policy.py`(`seat_parallel`,**待放宽**)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `parallel_no_file`)、`persistence/roundtable_chains/`(`stages` 列)、`migrations/versions/20260607_03_chain_stages.py`。
- 测试:`frontend-web` 用 esbuild 转译 `.test.ts` 后 `node --test`(项目无 vitest);后端 `PYTHONPATH=. uv run pytest tests/test_roundtable_chain_stages.py tests/test_roundtable_run_policy.py`。
@@ -0,0 +1,207 @@
# 圆桌 Step 2 沙箱预览 · 开发进度
> 最后更新:2026-05-27
> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口)
> 对照参考:主聊天 `/page/workspace/chats/:threadId`(`ChatBox` + `ArtifactsProvider` + `message-group.tsx`)
---
## 1. 目标
在 Step 2「多智能体圆桌研讨」中,当子智能体调用 `write_file` / `str_replace` / `present_files` 时,**行为与主聊天一致**:
| 能力 | 主聊天 | Step 2 目标 |
|------|--------|-------------|
| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 虚拟 URL |
| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ |
| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ✅ `PresentFilesRow` |
| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 |
| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` |
| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages`(双通道:`onPhaseChange` + `onUpstreamFrame`) |
| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ✅ `present_files` 卡片走 HTTP |
| 草稿 reload 后历史文件可预览 | ✅ | ✅ `seatStubMessages` 一并入草稿 |
---
## 2. 已完成
### 2.1 Step 2 布局调整
| 项 | 状态 | 说明 |
|----|------|------|
| 推理模型下拉 | ✅ | 从右侧栏迁至 Step 2 顶栏「多智能体圆桌会商中心」右侧 |
| 意图理解 | ✅ | 移至左侧栏顶部,支持展开/收起;收起时仅显示「目标」 |
| 任务概览 | ✅ | 放在「人工参与」下方 |
| 右侧栏 Step 2/3 隐藏 | ✅ | `RightSidebar` 仅在 `currentStep === 1` 渲染 |
| 左侧栏整体收起 | ✅ | 宽度 56px;展开按钮在顶部;研讨中角色 ring + ping 动画 |
| 沙箱打开 → 左栏收起 | ✅ | `Step2SandboxSidebarSync` 监听 `useArtifacts().open` |
**主要文件**:`RoundtablePlanningPage.tsx`(`step2SidebarExpanded`、`LeftSidebar`、`Step2SandboxSidebarSync`)
### 2.2 沙箱基础设施(Phase 1)
| 项 | 状态 | 说明 |
|----|------|------|
| 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` |
| 60/40 分栏壳 | ✅ | `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` |
| `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel |
| `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 |
### 2.3 沙箱打开逻辑(Phase 2)
| 项 | 状态 | 说明 |
|----|------|------|
| `write_file` 自动打开 | ✅ | `handleArtifactToolPhase` 在 `tool_calling` + 完整 `args.path` 时触发 |
| 步骤卡点击打开 | ✅ | `MessageStepsCard` → `openArtifactFromStep` |
| 虚拟 URL 机制 | ✅ | `write-file:<path>?message_id=...&tool_call_id=...`(对齐主聊天) |
| Stub messages 注入 | ✅ | `seatStubMessages` + `upsertSeatToolCall` → `loadArtifactContentFromToolCall` |
| 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect |
| `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) |
### 2.4 上游帧兜底注入(Phase 3)
| 项 | 状态 | 说明 |
|----|------|------|
| `onPhaseChange` 主通道 | ✅ | `tool_calling` 阶段每帧把完整 `args` 喂进 stub;首次出现 `path` 即触发自动打开 |
| `onUpstreamFrame` 兜底通道 | ✅ | `handleUpstreamFrame` 扫描 messages-tuple 帧的 `tool_calls[]`,对 `write_file`/`str_replace` 持续 upsert `args.content`;覆盖 phase 已切走但 tool_calls 又回包的边界 |
| 双通道幂等 | ✅ | `upsertSeatToolCall` 按 `(toolName, path)` 去重,两个通道并发写入安全 |
### 2.5 `present_files` 文件卡(Phase 4)
| 项 | 状态 | 说明 |
|----|------|------|
| 气泡下方文件卡行 | ✅ | `Step2Message` → `collectPresentedFiles` → `PresentFilesRow` |
| 卡片样式 | ✅ | 文件名 + 扩展名徽章(FILE_EXT_LABELS),最小宽度 30/最大 50(hover 边框亮色) |
| 点击 → HTTP 预览 | ✅ | `openArtifactPreview(agentThreadId, path)`,与步骤卡 `present_files` 走同一通道 |
| 席位 thread 缺失保护 | ✅ | 没有 `agentThreadId` 时 disabled + tooltip |
| 同气泡多次 present_files | ✅ | `filepaths` 顺序去重,全部聚合到一行卡片 |
### 2.6 草稿持久化(Phase 5)
| 项 | 状态 | 说明 |
|----|------|------|
| `seatStubMessages` 进 `Step2Snapshot` | ✅ | `useStep2Orchestration.ts` Step2Snapshot 新增字段 |
| `seatStubMessages` 进 `DraftStep2Snapshot` | ✅ | `drafts.ts` 加 optional 字段,老草稿 reload 时回退到 `{}` |
| `hydrateFromDraft` 还原 stub | ✅ | 同步写 `seatStubMessagesRef` + state,加载草稿后点击历史 write_file 步骤可直接预览 |
| `getStep2Snapshot` 透出 stub | ✅ | `RoundtablePlanningPage.tsx` 给 `useDraftPersistence` 的 getter 加上该字段 |
**主要文件**:
```
frontend-web/src/pages/WorkspaceRoutes.tsx
frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx
frontend-web/src/roundtable-planning/components/Step2Panel.tsx
frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx
frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
frontend-web/src/roundtable-planning/utils/drafts.ts
```
### 2.7 与主聊天对齐的核心数据流
```
special seat SSE (streamMultiAgent)
├── onPhaseChange("tool_calling", "write_file", args)
│ ↓
│ upsertSeatToolCall(seatThreadId, "write_file", args)
│ → seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }]
│ ↓
│ buildWriteFileVirtualUrl(path, messageId, toolCallId)
│ → "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..."
│ ↓
│ openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true)
│ → selectArtifact(virtualUrl) + setArtifactsOpen(true)
│
└── onUpstreamFrame(frame) ← 兜底通道
↓
handleUpstreamFrame —— 持续 upsert tool_calls 里完整的 args(不打开沙箱)
Step2SandboxLayout
ThreadContext.thread.messages = seatStubMessages[activeThreadId]
ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId)
↓
useArtifactContent → loadArtifactContentFromToolCall
→ 从 thread.messages 读 tool_call.args.content(不发 HTTP)
```
**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。
---
## 3. 未完成 / 待验证
### 3.1 功能缺口(按优先级)
| 优先级 | 项 | 状态 | 说明 |
|--------|----|------|------|
| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL + 双通道 + 文件卡 + 草稿持久化全链路尚未在浏览器里跑一遍 |
| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace 累加 |
| P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 |
| P3 | Step 3 与沙箱衔接 | ❌ | Step 3 仍用 `HighFidelityReport` + `listSessionArtifacts`;与 Step 2 沙箱选中态无联动 |
| P3 | 文档同步 | ❌ | `multi-agent-frontend-dev.md` 仍描述旧布局(右侧栏模型选择器等),需更新 § 布局 + 沙箱章节 |
### 3.2 已知风险 / 边界
1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发自动打开(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致。
2. **多 write_file 同气泡**:`upsertSeatToolCall` 按 `(toolName, path)` 幂等;同 path 多次 write 会更新 args,不会新建多条 stub tool_call。
3. **leader 席位**:当前仅 special 流绑定 `agentThreadId` 并接 `onUpstreamFrame`;总控若直接 write_file,需单独接 thread id 与帧回调。
4. **`present_files` 文件大小**:流式期间 backend 尚未给出真实大小;卡片只显示扩展名徽章,不显示 size。落盘后用户点开走 HTTP API 自然能看到完整内容。
5. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。
---
## 4. 分阶段计划(原始规划 vs 现状)
| 阶段 | 内容 | 状态 |
|------|------|------|
| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 |
| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 完成 |
| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ✅ 完成(双通道兜底) |
| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ⚠️ 卡片完成;Step 3 衔接遗留 |
| Phase 5 | 草稿 stub 持久化 + 文档更新 | ✅ stub 完成;本文档同步更新 |
---
## 5. 测试清单
在 `/page/workspace/roundtable/planning` Step 2 下逐项勾选:
- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天)
- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}`
- [ ] 流式期间 MD 内容随 `args.content` 更新(双通道:phase 切换帧 + 兜底帧)
- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件
- [ ] `present_files` 后气泡下方出现文件卡(带扩展名徽章),点击 → HTTP 预览
- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起**
- [ ] 关闭沙箱(X)后对话区恢复全宽
- [ ] 「新建任务」后 artifacts / 沙箱 / `seatStubMessages` 全部清空
- [ ] 「保存草稿」→「加载草稿」后,点击历史 `write_file` 步骤可立即预览(stub 已还原)
- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致
**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。
---
## 6. 关键代码索引
| 用途 | 路径 |
|------|------|
| 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.tsx` |
| 主聊天 `RichFileCard`(present_files) | `src/components/workspace/messages/message-list-item.tsx` |
| 主聊天 60/40 分栏 | `src/components/workspace/chats/chat-box.tsx` |
| 虚拟 URL 内容加载 | `src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
| Artifacts 上下文 | `src/components/workspace/artifacts/context.tsx` |
| Step 2 编排 + 沙箱状态 + 草稿快照 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` |
| Step 2 沙箱布局 | `src/roundtable-planning/components/Step2SandboxLayout.tsx` |
| Step 2 文件卡 + 步骤渲染 | `src/roundtable-planning/components/Step2Panel.tsx` |
| SSE phase 推断 + `onUpstreamFrame` | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` |
| 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 草稿持久化 | `src/roundtable-planning/utils/drafts.ts`、`hooks/useDraftPersistence.ts` |
---
## 7. 下一步建议(给接续开发者)
1. **先跑通 P0 测试清单**,确认 Phase 3/4/5 改造后无回归;重点观察双通道并发写入 stub 时 args 是否被旧帧覆盖(理论上 LangGraph 完整 tool_calls 是单调累积的,应该没问题)。
2. **Phase 4 收尾**:把 Step 3 的 `listSessionArtifacts` / `HighFidelityReport` 接进沙箱选中态——Step 3 进入时复用 Step 2 的 `ArtifactsProvider`,让用户在 Step 3 也能预览 Step 2 留下的产物。
3. **Phase 2 边界**:`str_replace` 多轮编辑的 stub 模拟(追加 tool result 消息让 `applyPendingTextEdit` 能 replay edit 序列),优先级低。
4. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。
@@ -0,0 +1,580 @@
# 深蓝色 UI 规范 · 00 基础(色彩 / 字体 / 阴影 / 圆角 / 间距 / 分割线)
> 本文件是《深蓝色 UI 规范》拆分后的**基础篇(§1–§7)**,是全站设计令牌的唯一来源。
> 其余分册见同目录 `README.md`;可直接 import 的变量见 `tokens.css`。
> 跨章节 `§N` 编号全局唯一,所属文件见 README 的「§ 索引」。
**本篇目录(§1–§7)**:`1` 色彩 · `2` 字体 · `3` 阴影 · `4` 圆角 · `5` 按钮 · `6` 分割线 · `7` 间距
---
## 1. 色彩
### 1.1 标准色
色彩体系由四个色系构成,每个色系分主色与辅色两组,每组按明度从浅到深编号(01 最浅,数字越大越深)。
---
#### 1.1.1 色系总览
| 色系 | 用途定位 | 代码前缀 |
|------|----------|----------|
| 渐红系 | 警示、错误、强调操作 | ZS-001 |
| 渐蓝系 | 信息、链接、次级操作 | ZS-002 |
| 渐青系 | 成功、在线状态、数据高亮 | ZS-003 |
| 深蓝系 | 页面背景、容器、导航底色 | ZS-004 |
---
#### 1.1.2 渐红系(ZS-001)
用于错误提示、危险操作、重要告警。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-001-01 | `#FFF1F0` | 极浅红,错误背景底色 |
| ZS-001-02 | `#FFCCC7` | 浅红,输入框错误边框 |
| ZS-001-03 | `#FFA39E` | — |
| ZS-001-04 | `#FF7875` | — |
| ZS-001-05 | `#FF4D4F` | 标准红,Tag / Badge |
| ZS-001-06 | `#F5222D` | 主色红,按钮 / 图标 |
| ZS-001-07 | `#CF1322` | 深红,Hover 状态 |
| ZS-001-08 | `#A8071A` | 深红,Active / Press |
| ZS-001-09 | `#820014` | 极深红 |
| ZS-001-10 | `#5C0011` | 最深红 |
---
#### 1.1.3 渐蓝系(ZS-002)
用于信息提示、链接、次级按钮。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-002-01 | `#E6F4FF` | 极浅蓝,信息背景底色 |
| ZS-002-02 | `#BAE0FF` | 浅蓝 |
| ZS-002-03 | `#91CAFF` | — |
| ZS-002-04 | `#69B1FF` | — |
| ZS-002-05 | `#4096FF` | 标准蓝,链接 / 信息 Tag |
| ZS-002-06 | `#1677FF` | 主色蓝,主操作按钮 |
| ZS-002-07 | `#0958D9` | 深蓝,Hover |
| ZS-002-08 | `#003EB3` | 深蓝,Active |
| ZS-002-09 | `#002C8C` | 极深蓝 |
| ZS-002-10 | `#001D6C` | 最深蓝 |
---
#### 1.1.4 渐青系(ZS-003)
用于成功状态、在线标识、数据图表高亮。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-003-01 | `#E6FFFB` | 极浅青,成功背景 |
| ZS-003-02 | `#B5F5EC` | 浅青 |
| ZS-003-03 | `#87E8DE` | — |
| ZS-003-04 | `#5CDBD3` | — |
| ZS-003-05 | `#36CFC9` | 标准青,成功 Tag |
| ZS-003-06 | `#13C2C2` | 主色青,在线点 / 图标 |
| ZS-003-07 | `#08979C` | 深青,Hover |
| ZS-003-08 | `#006D75` | 深青,Active |
| ZS-003-09 | `#00474F` | 极深青 |
| ZS-003-10 | `#002329` | 最深青 |
---
#### 1.1.5 深蓝系(ZS-004)
用于页面背景、导航栏、侧边栏、卡片容器。这是深蓝主题的核心色系。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-004-01 | `#E8EDF5` | 极浅,分割线 / 禁用底色 |
| ZS-004-02 | `#C5CFE0` | 浅,边框 |
| ZS-004-03 | `#9BADC8` | — |
| ZS-004-04 | `#6E88B0` | — |
| ZS-004-05 | `#4A6491` | 中深蓝,次级文字 |
| ZS-004-06 | `#2D4473` | 深蓝,卡片容器背景 |
| ZS-004-07 | `#1A2F5A` | 深蓝,侧边栏背景 |
| ZS-004-08 | `#122347` | 深蓝,导航栏背景 |
| ZS-004-09 | `#0C1836` | 极深蓝,页面背景 |
| ZS-004-10 | `#060E24` | 最深,全局底色 |
---
#### 1.1.6 功能色(主色 / 字体色 / 辅色 / 背景色)
从标准色系中提取,用于具体 UI 角色指定。
| 角色 | 色值 | 来源 |
|------|------|------|
| 主色(Primary) | `#1677FF` | ZS-002-06 |
| 主色 Hover | `#0958D9` | ZS-002-07 |
| 主色 Active | `#003EB3` | ZS-002-08 |
| 正常字体色 | `#C5CFE0` | ZS-004-02 |
| 次级字体色 | `#6E88B0` | ZS-004-04 |
| 禁用字体色 | `#4A6491` | ZS-004-05 |
| 辅色(Accent) | `#13C2C2` | ZS-003-06 |
| 页面背景色 | `#0C1836` | ZS-004-09 |
| 容器背景色 | `#1A2F5A` | ZS-004-07 |
| 白色 | `#FFFFFF` | — |
---
#### 1.1.7 小面积装饰色
用于图标点缀、徽标、少量强调,不用于大面积背景。
| 色值 | 说明 |
|------|------|
| `#6388FF` | 紫蓝,图标装饰 |
| `#F5222D` | 红色,危险徽标 |
| `#12306C` | 深蓝,品牌点缀 |
| `#478880` | 青绿,辅助点缀 |
| `#950404` | 深红,重要告警点 |
---
#### 1.1.8 图表配色
**通用图表色序**(面积较大,如柱状图、折线图、饼图):
| 序号 | 色值 |
|------|------|
| 1 | `#1677FF` |
| 2 | `#13C2C2` |
| 3 | `#4096FF` |
| 4 | `#36CFC9` |
| 5 | `#69B1FF` |
| 6 | `#5CDBD3` |
| 7 | `#91CAFF` |
| 8 | `#87E8DE` |
**小面积图表色序**(面积较小,如散点、小徽章、迷你图):
| 序号 | 色值 |
|------|------|
| 1 | `#4096FF` |
| 2 | `#36CFC9` |
| 3 | `#FF4D4F` |
| 4 | `#FFA39E` |
| 5 | `#6388FF` |
| 6 | `#478880` |
---
#### 1.1.9 功能语义色(Functional / Status)
承载「成功 / 警告 / 错误 / 信息 / 处理中」语义。其中**错误红、信息蓝、处理中青**直接复用标准色;**成功绿、警告橙**为标准色系之外的补充色(深色背景下 ZS-003 青偏冷、且无现成橙),在此正式收录。全站语义场景一律引用本表,不再各处自定义。
| 语义 | 色值 | 来源 | 典型用途 |
|------|------|------|----------|
| 成功 / 已完成 | `#52C41A` | 补充色(绿) | 成功提示、完成状态、通过校验 |
| 警告 / 进行中 | `#FAAD14` | 补充色(橙) | 警告提示、进行中、需注意 |
| 错误 / 失败 | `#F5222D` | ZS-001-06 | 错误提示、删除、失败状态 |
| 信息 / 默认 | `#1677FF` | ZS-002-06 | 信息提示、主操作、链接 |
| 处理中 / 等待 | `#13C2C2` | ZS-003-06 | 处理中、排队、等待反馈 |
> 浅底变体(Message / Alert / Tag 底色):前景色取 16% 透明度,如成功底 `rgba(82,196,26,0.16)`、警告底 `rgba(250,173,20,0.16)`。
> CSS Token 见 `tokens.css` 的 `--func-success / --func-warning / --func-error / --func-info / --func-processing`。
---
## 2. 字体
### 2.1 全局样式 - 字体
---
#### 2.1.1 大屏页面字体规范
##### 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 24px Bold | 一级标题 |
| 24px Medium | 次要一级标题 |
| 22px Medium | 二级标题 |
| 22px Regular | 正文 |
| 20px Regular | 排版空间有限的正文 |
##### 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 32px Bold | 重要数据展示 |
| 32px Regular | 普通数据展示 |
| 26px Bold | 常规数据展示 |
| 26px Regular | 常规数据展示 |
##### 特殊数字:DS-Digital
| 样式 | 使用场景 |
|------|----------|
| 50px Bold | 大屏主数据展示 |
##### 表格字体:Arial
| 样式 | 使用场景 |
|------|----------|
| 20px Regular | 表格横纵轴数字 |
---
#### 2.1.2 后台系统字体规范
##### 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 18px Bold | 导航、一级标题 |
| 18px Medium | 次要一级标题 |
| 16px Bold | 二级标题 |
| 16px Medium | 次要二级标题 |
| 16px Regular | 正文、按钮内部文字等 |
| 16px Light | 次要正文 |
##### 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 26px Bold | 重要数据展示 |
| 26px Regular | 普通数据展示 |
| 24px Bold | 常规数据展示(小) |
| 24px Regular | 常规数据展示(小) |
##### LOGO 字体:DOUYUFont
| 样式 | 使用场景 |
|------|----------|
| 20px 文字样式 | 页面顶部系统名称 |
##### 表格数字:Arial
| 样式 | 使用场景 |
|------|----------|
| 16px Regular | 表格横纵轴数字 |
| 14px Regular | 表格横轴数字(空间有限) |
---
## 3. 阴影
### 3.1 容器与卡片配色
阴影组通过深浅层级叠加形成空间感:外层容器最深,内层卡片次之,边框与高亮文字使用主色或字体色。
#### 3.1.1 外层容器
| 角色 | 色值 | 来源 |
|------|------|------|
| 页面 / 最外层背景 | `#060E24` | ZS-004-10 |
| 分栏容器背景 | `#0C1836` | ZS-004-09 |
#### 3.1.2 卡片(标题 + 操作)
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#122347` | ZS-004-08 |
| 卡片边框 | `#1A2F5A` | ZS-004-07 |
| 卡片阴影(外发光) | `rgba(22, 119, 255, 0.12)` | 基于 ZS-002-06 主色蓝 12% 透明度 |
| 标题文字(16px Bold) | `#C5CFE0` | ZS-004-02(正常字体色) |
| 操作按钮文字 | `#4096FF` | ZS-002-05(标准蓝,链接) |
| 次级操作图标(✏️ / ⋯) | `#6E88B0` | ZS-004-04(次级字体色) |
#### 3.1.3 卡片新增(占位卡片)
虚线描边的"新增入口"卡片,用于引导用户添加内容。
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#0C1836` | ZS-004-09(与外层同色,营造凹陷感) |
| 虚线边框 | `#4096FF` | ZS-002-05 |
| 加号图标 | `#4096FF` | ZS-002-05 |
#### 3.1.4 阴影投影规则
| 层级 | 阴影值 | 使用场景 |
|------|--------|----------|
| 一级阴影(卡片悬浮) | `0 2px 8px rgba(22, 119, 255, 0.12)` | 普通卡片 |
| 二级阴影(hover / 强调) | `0 4px 16px rgba(22, 119, 255, 0.24)` | 卡片 hover、弹出层 |
| 三级阴影(弹窗 / Modal) | `0 8px 32px rgba(6, 14, 36, 0.6)` | Dialog、Drawer |
---
## 4. 圆角
### 4.1 圆角统一规范
| 元素 | 圆角值 | 说明 |
|------|--------|------|
| 按钮 | `2px` | 所有按钮(主按钮 / 次按钮 / 文本按钮)统一使用 |
| 卡片 | `4px` | 内容卡片、标题+操作卡片、卡片新增占位 |
| 页面分栏框 | `4px` | 外层容器、分栏区域 |
| 输入框 / 选择器 | `2px` | 与按钮一致,保持表单内对齐 |
| Tag / Badge | `2px` | 与按钮一致 |
| Modal / Drawer | `4px` | 与卡片一致 |
### 4.2 按钮配色(圆角 2px)
#### 4.2.1 主按钮(Primary)
| 角色 | 色值 | 来源 |
|------|------|------|
| 背景 | `#1677FF` | ZS-002-06 |
| 文字 | `#FFFFFF` | — |
| Hover 背景 | `#0958D9` | ZS-002-07 |
| Active 背景 | `#003EB3` | ZS-002-08 |
| 禁用背景 | `#4A6491` | ZS-004-05 |
| 禁用文字 | `#6E88B0` | ZS-004-04 |
#### 4.2.2 次按钮(Outline / Default)
| 角色 | 色值 | 来源 |
|------|------|------|
| 背景 | `transparent` | — |
| 边框 | `#6E88B0` | ZS-004-04 |
| 文字 | `#C5CFE0` | ZS-004-02 |
| Hover 边框 | `#4096FF` | ZS-002-05 |
| Hover 文字 | `#4096FF` | ZS-002-05 |
| Active 边框 | `#1677FF` | ZS-002-06 |
### 4.3 卡片与分栏(圆角 4px)
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#122347` | ZS-004-08 |
| 卡片边框 | `#1A2F5A` | ZS-004-07 |
| 分栏框背景 | `#0C1836` | ZS-004-09 |
| 分栏框边框 | `#1A2F5A` | ZS-004-07 |
---
## 5. 按钮
> 全部圆角 `2px`,文字字号 14px(小尺寸)/ 14px(正常尺寸),字体 Noto Sans S Chinese Regular。
### 5.1 按钮尺寸
| 规格 | 高度 | 内边距 | 使用场景 |
|------|------|--------|----------|
| 小 | `28px` | `0 10px` | 空间小的板块内部,如表格中、卡片标题等 |
| 正常 | `32px` | `0 14px` | 默认尺寸,所有常用按钮 |
### 5.2 按钮类型与配色
#### 5.2.1 主按钮(Primary)
> 一个操作区域只能有一个主按钮。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `#1677FF` (ZS-002-06) | — | `#FFFFFF` |
| Hover | `#0958D9` (ZS-002-07) | — | `#FFFFFF` |
| Active | `#003EB3` (ZS-002-08) | — | `#FFFFFF` |
| 失效 | `rgba(22, 119, 255, 0.35)` | — | `#6E88B0` (ZS-004-04) |
#### 5.2.2 默认按钮(Default / Outline)
用于"取消 / 返回"等次要操作,与主按钮成对出现时主按钮在右。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `transparent` | `#6E88B0` (ZS-004-04) | `#C5CFE0` (ZS-004-02) |
| Hover | `rgba(22, 119, 255, 0.08)` | `#4096FF` (ZS-002-05) | `#4096FF` (ZS-002-05) |
| Active | `rgba(22, 119, 255, 0.16)` | `#1677FF` (ZS-002-06) | `#1677FF` (ZS-002-06) |
| 失效 | `transparent` | `#4A6491` (ZS-004-05) | `#4A6491` (ZS-004-05) |
#### 5.2.3 文本按钮(Text)
用于表格操作、文件下载等。无背景与边框,靠文字色识别。
| 状态 | 文字 | 装饰 |
|------|------|------|
| 默认 | `#4096FF` (ZS-002-05) | 无下划线 |
| Hover | `#1677FF` (ZS-002-06) | 显示下划线(与文字同色) |
| Active | `#0958D9` (ZS-002-07) | 下划线 |
| 失效 | `#4A6491` (ZS-004-05) | 无装饰 |
#### 5.2.4 图标按钮(Icon-only)
用于增强辨识度或节省空间。仅图标,无文字。
| 变体 | 背景 | 图标颜色 |
|------|------|----------|
| 主色(常用) | `#1677FF` (ZS-002-06) | `#FFFFFF` |
| 主色 Hover | `#0958D9` (ZS-002-07) | `#FFFFFF` |
| 危险(删除等) | `#F5222D` (ZS-001-06) | `#FFFFFF` |
| 危险 Hover | `#CF1322` (ZS-001-07) | `#FFFFFF` |
| 透明(仅图标) | `transparent` | `#6E88B0` (ZS-004-04) → hover `#4096FF` (ZS-002-05) |
### 5.3 按钮组合
#### 5.3.1 文字 + 下拉按钮(含 ▾)
两种填充:
| 形态 | 配色规则 |
|------|----------|
| 默认(Outline) | 沿用 §5.2.2 默认按钮,箭头 ▾ 与文字同色 `#C5CFE0` |
| 主色(Filled) | 沿用 §5.2.1 主按钮,箭头 ▾ 同步白色 `#FFFFFF` |
#### 5.3.2 图标 + 文字按钮(如 `+ 正常`)
| 形态 | 配色规则 |
|------|----------|
| 默认(Outline) | 沿用 §5.2.2,左侧图标颜色与文字一致 `#C5CFE0` |
| 主色(Filled) | 沿用 §5.2.1,左侧图标与文字一致 `#FFFFFF` |
#### 5.3.3 文本按钮 + 图标("更多 »"、"展开 ⌄"、"下一页 »"、"« 上一页")
| 角色 | 色值 |
|------|------|
| 文字 + 图标(默认) | `#4096FF` (ZS-002-05) |
| 文字 + 图标(Hover) | `#1677FF` (ZS-002-06) |
| 失效 | `#4A6491` (ZS-004-05) |
> 图标可在文字前或后;多个文本按钮排成一行时,间距 `16px`,无分隔符。
#### 5.3.4 主按钮 + 默认按钮组合(如"确定 / 取消 / 返回")
- **排列顺序**:默认按钮在左,主按钮在右(最右侧为主操作)
- **按钮间距**:`8px`
- **示例**:`[ 返回 ] [ 取消 ] [ 确定 ]`
- **配色**:主按钮沿用 §5.2.1,其余沿用 §5.2.2
### 5.4 其他按钮
#### 5.4.1 悬浮状态(Floating)
用于 Banner / 登录 / 视觉冲击强等展示场景。配色与主按钮一致,叠加阴影抬升。
| 状态 | 阴影 |
|------|------|
| 默认 | `0 2px 8px rgba(22, 119, 255, 0.12)`(§3.1.4 一级阴影) |
| Hover | `0 4px 16px rgba(22, 119, 255, 0.24)`(§3.1.4 二级阴影) |
| Active | 阴影消失,按下感 |
#### 5.4.2 加载中(Loading)
用于异步操作等待反馈。点击瞬间将按钮锁定,文字前显示旋转图标。
| 角色 | 色值 |
|------|------|
| 背景 | `#1677FF` (ZS-002-06) |
| 文字 | `#FFFFFF` |
| 加载图标 | `#FFFFFF`,旋转动画 `1.2s linear infinite` |
| 不可点击 | 鼠标 `cursor: not-allowed` |
> 与失效态的区别:失效是"不可操作",加载是"操作进行中"——保留品牌主色,只是不可再次触发。
---
## 6. 分割线
> 统一线宽 `1px`,颜色全部从 ZS-004 深蓝系取色,与深色背景保持低对比度但仍可识别。
### 6.1 水平分割线
水平分割线常用来对不同元素内容进行分割。
| 类型 | 色值 | 来源 | 样式 | 使用场景 |
|------|------|------|------|----------|
| 实线(默认) | `#4A6491` | ZS-004-05 | `border-top: 1px solid` | 模块间、列表项间分割 |
| 实线(强调) | `#6E88B0` | ZS-004-04 | `border-top: 1px solid` | 主区块标题下方、卡片头部分割 |
| 实线(弱化) | `#2D4473` | ZS-004-06 | `border-top: 1px solid` | 卡片内部、密集列表分隔 |
| 虚线 | `#4A6491` | ZS-004-05 | `border-top: 1px dashed` | 次级 / 层级内分割,提示"非主结构" |
| 主色实线(高亮) | `#1677FF` | ZS-002-06 | `border-top: 1px solid` | 强调分隔,少量使用(如当前激活区块底边) |
### 6.2 垂直分割线
垂直分割线常用来做行内分割(如导航项之间、状态指示之间、表格列头之间)。
| 类型 | 色值 | 来源 | 样式 | 使用场景 |
|------|------|------|------|----------|
| 实线(默认) | `#4A6491` | ZS-004-05 | `border-left: 1px solid` | 行内 inline 元素分割(面包屑、操作按钮组) |
| 实线(弱化) | `#2D4473` | ZS-004-06 | `border-left: 1px solid` | 表格列分割、紧凑布局 |
| 虚线 | `#4A6491` | ZS-004-05 | `border-left: 1px dashed` | 多列对齐辅助、辅助参考线 |
### 6.3 规格与间距
| 规则 | 取值 |
|------|------|
| 线宽 | `1px` |
| 水平分割线与上下元素间距 | `≥ 8px`(紧凑布局可用 `4px`) |
| 垂直分割线与左右元素间距 | `≥ 8px`(行内紧凑 `4px`) |
| 垂直分割线高度 | 与文字行高一致,约 `12–14px`(也可全高跟随容器) |
| 透明度 | 必要时叠 `opacity: 0.6` 实现"更弱"层级 |
### 6.4 CSS 参考
```css
/* 默认水平分割线 */
.divider-h { border-top: 1px solid #4A6491; }
.divider-h-strong { border-top: 1px solid #6E88B0; }
.divider-h-weak { border-top: 1px solid #2D4473; }
.divider-h-dashed { border-top: 1px dashed #4A6491; }
.divider-h-accent { border-top: 1px solid #1677FF; }
/* 垂直分割线 */
.divider-v { border-left: 1px solid #4A6491; height: 14px; display: inline-block; vertical-align: middle; }
.divider-v-weak { border-left: 1px solid #2D4473; height: 14px; display: inline-block; vertical-align: middle; }
.divider-v-dashed { border-left: 1px dashed #4A6491; height: 14px; display: inline-block; vertical-align: middle; }
```
---
## 7. 间距
> 全站以 `8px (0.5rem)` 为对照基准单位,划分"密 / 疏"两个层次。所有间距取值均来自下列阶梯,禁止使用非阶梯值(如 5px、13px)。
### 7.1 间距阶梯
| 阶 | px | rem | 分组 | 典型场景 |
|----|------|--------|------|----------|
| 1 | `4px` | `0.25rem` | 密 | 图标与文字间距、Tag 内边距、紧凑徽标 |
| 2 | `8px` | `0.5rem` | 密 | 行内元素相邻、按钮组间距、表单 label 与控件 |
| 3 | `12px` | `0.75rem` | 密 | 表单项垂直间距、列表项内边距 |
| 4 | `16px` | `1rem` | 密 | 卡片内容内边距、模块内分隔 |
| 5 | `24px` | `1.5rem` | 疏 | 模块之间、Section 内边距 |
| 6 | `32px` | `2rem` | 疏 | 卡片之间、表单组之间 |
| 7 | `40px` | `2.5rem` | 疏 | 主区块之间、页面 Header / Footer 内边距 |
| 8 | `56px` | `3.5rem` | 疏 | 页面段落级隔断、Banner 区域 |
> "密"对应紧凑信息密度(表格、表单、卡片内部);"疏"对应视觉呼吸(模块之间、页面整体留白)。**16px 是"密"的上限,24px 是"疏"的下限**,两者之间不应再插入自定义值。
### 7.2 使用原则
- **同层级一致**:同一布局层级内的间距必须使用同一阶值;多种取值会破坏节奏。
- **就近取阶**:相邻区块的间距尽量从相邻阶梯取值(如 `16px → 24px`),跨越过多阶(如 `8px → 56px`)会让视觉断裂。
- **密疏不混用**:一个组件内部全部用"密",外部用"疏",避免内/外间距颠倒。
- **优先 8 的倍数**:除最小阶 `4px` 外,所有阶皆为 8 的倍数,方便 4/8 网格对齐。
- **响应式按比例衰减**:移动端可整体降一阶(如 `24px → 16px`),但仍在阶梯内。
### 7.3 CSS Token 建议
```css
:root {
--spacing-1: 4px; /* 0.25rem */
--spacing-2: 8px; /* 0.5rem */
--spacing-3: 12px; /* 0.75rem */
--spacing-4: 16px; /* 1rem */
--spacing-5: 24px; /* 1.5rem */
--spacing-6: 32px; /* 2rem */
--spacing-7: 40px; /* 2.5rem */
--spacing-8: 56px; /* 3.5rem */
}
```
### 7.4 与已有规范的对齐
| 已有规则 | 对应阶 |
|----------|--------|
| §4.x 卡片圆角内边距 | `--spacing-4` (16px) |
| §5.3.4 主+默认按钮组合间距 `8px` | `--spacing-2` |
| §5.3.3 文本按钮间距 `16px` | `--spacing-4` |
| §6.3 分割线与上下元素间距 `≥ 8px` | `--spacing-2` 起 |
---
@@ -0,0 +1,780 @@
# 深蓝色 UI 规范 · 01 导航(§8–§13)
> 《深蓝色 UI 规范》分册之一。基础令牌见 `00-基础-色彩字体间距.md`,可 import 的变量见 `tokens.css`,总目录与 §索引见 `README.md`。`§N` 编号全局唯一。
**本篇目录**:`8` 导航组件 · `9` 锚点与页签 · `10` 步骤条 · `11` 面包屑 · `12` 页头 · `13` 分页
---
## 8. 导航组件
> 适用于"大屏后台 / 控制台"类深蓝主题界面。整体结构 = 顶部导航 + 左侧分级菜单 + 多级下拉。所有颜色取自 ZS-002 / ZS-004 两个色系。
### 8.1 整体结构
```
┌─ 顶部导航(Header)─ 高 56px ──────────────────────────────────────────────┐
│ ≡ 二级菜单 二级菜单 二级菜单 〈一级菜单〉〈一级菜单〉 ⟪ XXXX系统 ⟫ 〈一级菜单〉 ⟳ 时间 ⌂ 返回首页 │
├──┬──────────────────────────────────────────────────────────────────────────┤
│ 三│ ┌─ 四级菜单(下拉面板)──┐ │
│ 级│ │ ▾ 四级菜单 │ │
│ 菜│ │ ▾ 五级菜单 │ │
│ 单│ │ - 六级菜单 (active)│ │
│ 三│ │ - 六级菜单 │ │
│ 级│ │ ▸ 五级菜单 │ │
│ 菜│ │ ▸ 四级菜单 │ │
│ 单│ └────────────────────────┘ │
│ …│ │
└──┴──────────────────────────────────────────────────────────────────────────┘
```
### 8.2 顶部导航(Header)
#### 8.2.1 规格
| 属性 | 取值 |
|------|------|
| 高度 | `56px` |
| 左右 padding | `--spacing-4` (16px) |
| 背景 | 渐变 `linear-gradient(180deg, #122347 0%, #0C1836 100%)`(ZS-004-08 → ZS-004-09) |
| 底边 | `border-bottom: 1px solid #1A2F5A` (ZS-004-07) |
| 阴影 | `0 2px 8px rgba(6, 14, 36, 0.6)`(§3.1.4 一级阴影变体) |
| 文字基线 | `Noto Sans S Chinese`,垂直居中 |
#### 8.2.2 二级菜单(左侧 Tabs,小号 14px)
| 状态 | 文字 | 背景 | 装饰 |
|------|------|------|------|
| 默认 | `#6E88B0` (ZS-004-04) | `transparent` | — |
| Hover | `#C5CFE0` (ZS-004-02) | `rgba(22,119,255,0.08)` | — |
| 激活 | `#4096FF` (ZS-002-05) | `transparent` | 底部 `2px solid #4096FF` 高亮线 |
- 字号 `14px Regular`,间距 `--spacing-3` (12px)
- 与最左侧的"折叠菜单 ≡"图标间距 `--spacing-4` (16px)
#### 8.2.3 一级菜单(中部主导航,16px 带切角装饰)
| 状态 | 文字 | 装饰 |
|------|------|------|
| 默认 | `#C5CFE0` (ZS-004-02) | 两端切角斜边,`border: 1px solid #1A2F5A` |
| Hover | `#4096FF` (ZS-002-05) | 切角线变 `#4096FF` |
| 激活 | `#FFFFFF` | 切角线 `#1677FF` (ZS-002-06),背景 `rgba(22,119,255,0.16)` |
- 字号 `16px Medium`
- 切角推荐 `clip-path: polygon(8px 0, calc(100% - 8px) 0, 100% 100%, 0 100%)`
- 项间距 `--spacing-2` (8px)
#### 8.2.4 系统标题(XXXX系统)
- 字号 `20px Bold`,颜色 `#FFFFFF`
- 字体 `DOUYUFont` 或 Noto Sans Medium
- 左右装饰用主色蓝渐变线条:`linear-gradient(90deg, transparent 0%, #1677FF 50%, transparent 100%)`,线宽 `1px`
- 居中相对 Header 几何中心,左右最少留 `--spacing-6` (32px) 间距给一级菜单
#### 8.2.5 右侧工具区(时间 / 图标 / 返回首页)
| 元素 | 字号/尺寸 | 颜色 |
|------|----------|------|
| 更新时间文字 | `12px Regular` | `#6E88B0` (ZS-004-04) |
| 时间数值(数字) | `12px D-DIN Regular` | `#C5CFE0` (ZS-004-02) |
| 工具图标(刷新/上/家) | `16px` | `#6E88B0`,hover → `#4096FF` |
| 返回首页文字 | `14px Regular` | `#C5CFE0`,hover → `#4096FF` |
- 元素间距 `--spacing-3` (12px)
- 整体右 padding `--spacing-4` (16px)
### 8.3 侧边栏(三级菜单)
#### 8.3.1 规格
| 属性 | 取值 |
|------|------|
| 宽度 | `64px`(仅图标 / 竖排文字) |
| 背景 | `#0C1836` (ZS-004-09) |
| 右边界 | `border-right: 1px solid #1A2F5A` (ZS-004-07) |
| 项高度 | `64px`(与宽度一致,呈正方形) |
| 项内间距 | 上下 `--spacing-3` (12px) |
| 字号 | `14px Medium`,竖排(`writing-mode: vertical-rl`)或正常排两行 |
#### 8.3.2 状态配色
| 状态 | 背景 | 文字 | 左侧指示条 |
|------|------|------|-----------|
| 默认 | `transparent` | `#6E88B0` (ZS-004-04) | 无 |
| Hover | `rgba(22,119,255,0.08)` | `#C5CFE0` (ZS-004-02) | 无 |
| 激活 | `#1A2F5A` (ZS-004-07) | `#FFFFFF` | 左侧 `2px solid #1677FF` (ZS-002-06) |
### 8.4 下拉菜单面板(四级 / 五级 / 六级)
#### 8.4.1 面板规格
| 属性 | 取值 |
|------|------|
| 背景 | `#122347` (ZS-004-08) |
| 边框 | `1px solid #1A2F5A` (ZS-004-07) |
| 阴影 | `0 4px 16px rgba(6, 14, 36, 0.6)`(§3.1.4 三级阴影变体) |
| 圆角 | `4px` |
| 内边距 | 上下 `--spacing-2` (8px) |
| 最小宽度 | `150px` |
#### 8.4.2 菜单项规格
| 层级 | 缩进(padding-left) | 字号 | 展开图标 | 前缀 |
|------|---------------------|------|----------|------|
| 四级菜单 | `--spacing-3` (12px) | `14px Medium` | `▾ / ▸` | — |
| 五级菜单 | `--spacing-5` (24px) | `14px Regular` | `▾ / ▸` | — |
| 六级菜单 | `--spacing-6` (32px) | `14px Regular` | — | `-` 短横线 |
- 行高 `32px`
- 项之间无分割线(靠缩进与图标区分层级)
#### 8.4.3 菜单项配色
| 状态 | 背景 | 文字 | 展开图标 |
|------|------|------|----------|
| 默认 | `transparent` | `#C5CFE0` (ZS-004-02) | `#6E88B0` (ZS-004-04) |
| Hover | `rgba(22,119,255,0.08)` | `#4096FF` (ZS-002-05) | `#4096FF` |
| 激活(当前选中) | `rgba(22,119,255,0.16)` | `#4096FF` (ZS-002-05) | `#1677FF` (ZS-002-06) |
| 父级(含激活子项) | `transparent` | `#FFFFFF` | `#1677FF` |
| 失效 | `transparent` | `#4A6491` (ZS-004-05) | `#4A6491` |
### 8.5 交互与动画
| 行为 | 动效 |
|------|------|
| 下拉面板展开 | `transform-origin: top; transform: scaleY(0.95) → scaleY(1); opacity 0 → 1`,`200ms ease-out` |
| 展开图标旋转 | `transform: rotate(0 → 90deg)`,`200ms ease-out` |
| Hover 背景过渡 | `background-color 120ms ease-out` |
| 激活底部高亮线(二级菜单) | `width 0% → 100%`,`240ms ease-out` |
### 8.6 间距引用
| 位置 | 取值 |
|------|------|
| Header 内部元素间距 | `--spacing-2 / --spacing-3` |
| Header 与下方内容间距 | `0`(贴合,靠底边线与阴影区分) |
| 侧边栏与下拉面板间距 | `--spacing-1` (4px) |
| 下拉面板与触发元素间距 | `--spacing-1` (4px) |
| 同级菜单项之间 | `0`(紧贴排列,靠 hover/active 背景区分) |
---
## 9. 锚点与页签
> 对应"4.2 导航组件-锚点"。三类组件共用主色蓝指示当前位置:**页签 / 锚点 / 回到顶部**。
>
> 视觉参考:标签横向 / 标签纵向 / 锚点的三联图见设计稿「选项卡.png」——三种形态在本节均已规范化(§9.1 = 标签横纵向、§9.2 = 锚点)。
### 9.1 页签(Tabs)
#### 9.1.1 横向页签(下划线-横向)
| 状态 | 文字 | 下划线 |
|------|------|--------|
| 默认 | `#C5CFE0` (ZS-004-02) | 无 |
| Hover | `#4096FF` (ZS-002-05) | 无 |
| 激活 | `#4096FF` (ZS-002-05) | `2px solid #4096FF`,宽度与文字齐 |
| 失效 | `#4A6491` (ZS-004-05) | 无 |
- 字号 `14px Medium`
- 项间距 `--spacing-5` (24px)
- 下划线与文字底部间距 `--spacing-2` (8px)
- 容器底边可叠一条 `1px solid #1A2F5A` (ZS-004-07) 作为基线,激活态的高亮线压在上面
#### 9.1.2 纵向页签(下划线-纵向)
| 状态 | 文字 | 右侧指示线 |
|------|------|-----------|
| 默认 | `#C5CFE0` (ZS-004-02) | 无 |
| Hover | `#4096FF` (ZS-002-05) | 无 |
| 激活 | `#4096FF` (ZS-002-05) | 右侧 `2px solid #4096FF`,高度与文字齐 |
| 失效 | `#4A6491` (ZS-004-05) | 无 |
- 字号 `14px Medium`,项高度 `32px`
- 项间距 `--spacing-2` (8px)
- 容器右边可叠一条 `1px solid #1A2F5A` 基线,激活指示线压在上面
### 9.2 锚点(Anchor)
用于长内容页(如详情页、文档页)的章节快速跳转。
#### 9.2.1 结构
```
│ 一级
│ 一级 (active)
│ 二级
│ 二级
│ 一级
│ 一级
```
#### 9.2.2 规格
| 属性 | 取值 |
|------|------|
| 容器背景 | `#122347` (ZS-004-08) 或 `transparent`(嵌入页面侧栏时) |
| 左侧基线 | `1px solid #1A2F5A` (ZS-004-07) 通栏 |
| 项高度 | `32px` |
| 一级项左 padding | `--spacing-3` (12px) |
| 二级项左 padding | `--spacing-5` (24px) |
| 项间距 | `0`(贴合排列) |
| 字号 | `14px Regular` |
#### 9.2.3 状态配色
| 状态 | 一级文字 | 二级文字 | 左侧指示条 |
|------|----------|----------|-----------|
| 默认 | `#C5CFE0` (ZS-004-02) | `#6E88B0` (ZS-004-04) | 无 |
| Hover | `#4096FF` (ZS-002-05) | `#4096FF` (ZS-002-05) | 无 |
| 激活 | `#4096FF` (ZS-002-05) | — | 左侧 `2px solid #4096FF`,覆盖容器基线 |
| 父级(含激活子项) | `#FFFFFF` | — | — |
- 激活态指示条与基线重合,宽 `2px`,色压主色蓝 `#4096FF`
- 二级项**不单独支持激活态**:当二级被点中时,其父级一级显示激活态,左侧指示条按父级位置渲染
#### 9.2.4 滚动同步
| 行为 | 规则 |
|------|------|
| 页面滚动 | 监听锚点对应区块进入视口的相对位置,自动切换激活态 |
| 点击锚点 | `behavior: smooth` 滚动到目标,激活指示条 `top` 过渡 `200ms ease-out` |
| 边界处理 | 滚动到页面顶部 → 第一个锚点激活;到页尾 → 最后一个锚点激活 |
### 9.3 回到顶部(BackToTop)
固定在视口右下角的悬浮按钮,向上滚动到页面顶部。
#### 9.3.1 规格
| 属性 | 取值 |
|------|------|
| 形状 | 圆形 `40px × 40px`(`border-radius: 50%`) |
| 图标 | `↑` 上箭头,`16px`,`#FFFFFF` |
| 距视口右边 | `--spacing-5` (24px) |
| 距视口下边 | `--spacing-6` (32px) |
| 出现时机 | 页面 `scrollTop > 200px` 时淡入;回到顶部后淡出 |
| 淡入/淡出 | `opacity 0 → 1` / `1 → 0`,`200ms ease-out` |
#### 9.3.2 状态配色
| 状态 | 背景 | 阴影 | 图标 |
|------|------|------|------|
| 正常(默认) | `#1677FF` (ZS-002-06) | `0 2px 8px rgba(22,119,255,0.12)` (§3.1.4 一级阴影) | `#FFFFFF` |
| 悬停(Hover) | `#0958D9` (ZS-002-07) | `0 4px 16px rgba(22,119,255,0.24)` (§3.1.4 二级阴影) | `#FFFFFF` |
| 点按(Active) | `#003EB3` (ZS-002-08) | 阴影消失,按下感 | `#FFFFFF` |
| 失效 / 不显示 | — | — | 整体 `opacity: 0`,`pointer-events: none` |
#### 9.3.3 悬停 Tooltip
Hover 状态下方显示 `回到顶部` 提示气泡:
| 属性 | 取值 |
|------|------|
| 文字 | `回到顶部`,`12px Regular` |
| 文字颜色 | `#C5CFE0` (ZS-004-02) |
| 背景 | `#1677FF` (ZS-002-06) |
| 圆角 | `2px` |
| 内边距 | 上下 `4px`,左右 `--spacing-2` (8px) |
| 与按钮间距 | `--spacing-2` (8px) |
| 出现时机 | Hover 进入 `300ms` 后显示,离开立即消失 |
### 9.4 三者的差异与选用
| 组件 | 主要用途 | 视觉特征 |
|------|----------|---------|
| 页签 | 同层级内容切换(多 Tab 互斥) | 文字 + 下划线(横/纵) |
| 锚点 | 长页面内章节跳转 | 文字 + 左侧指示条 + 层级缩进 |
| 回到顶部 | 长页面快速返回起点 | 圆形悬浮按钮 + 上箭头 |
---
## 10. 步骤条
> 对应「4.7 导航组件-步骤条」。用于多步流程的进度指引,按布局方向分**横向**与**纵向**两类,按使用场景分**弹窗步骤条**、**页面步骤条**、**纵向步骤条**三种。所有颜色取自 ZS-002 主色蓝与 ZS-004 深蓝系。
### 10.1 步骤节点(公共)
每个步骤由「指示圆 + 标题(可选描述)」组成。指示圆按状态切换三种形态:
| 状态 | 圆形外观 | 圆内符号 | 来源 |
|------|----------|----------|------|
| 已完成(Completed) | 描边圆,`2px solid #1677FF`,背景 `transparent` | `✓`(对勾,`#1677FF`) | ZS-002-06 |
| 当前(Current / Active) | 实心圆,背景 `#1677FF` | 步骤数字,`#FFFFFF` | ZS-002-06 / 白 |
| 未开始(Upcoming) | 描边圆,`1px solid #4A6491`,背景 `transparent` | 步骤数字,`#6E88B0` | ZS-004-05 / ZS-004-04 |
| 失效(Disabled) | 描边圆,`1px solid #2D4473`,背景 `transparent` | 步骤数字,`#4A6491` | ZS-004-06 / ZS-004-05 |
**指示圆规格**:
| 属性 | 取值 |
|------|------|
| 直径 | `24px` |
| 圆内字号 | `12px Bold`(D-DIN 或 Noto Sans Bold) |
| 对勾图标 | `12px`,居中 |
| 字体水平居中 | 数字 / 对勾 在圆几何中心 |
**标题与描述文字**:
| 元素 | 状态 | 字号 / 颜色 | 来源 |
|------|------|-------------|------|
| 标题 | 已完成 / 当前 | `14px Medium`,`#FFFFFF` | — |
| 标题 | 未开始 | `14px Medium`,`#6E88B0` | ZS-004-04 |
| 标题 | 失效 | `14px Medium`,`#4A6491` | ZS-004-05 |
| 描述 | 已完成 / 当前 | `12px Regular`,`#C5CFE0` | ZS-004-02 |
| 描述 | 未开始 / 失效 | `12px Regular`,`#6E88B0` → `#4A6491` | ZS-004-04 → ZS-004-05 |
**连接线(步骤之间)**:
| 类型 | 色值 | 来源 | 样式 |
|------|------|------|------|
| 已完成段(前一节点已完成) | `#1677FF` | ZS-002-06 | `1px dashed`(弹窗)/ `1px solid`(页面) |
| 未完成段 | `#4A6491` | ZS-004-05 | `1px dashed` |
> 连接线在指示圆**两侧水平居中**(横向)或**正下方垂直**(纵向);线两端与圆外缘留 `--spacing-1` (4px) 间隙,避免线穿圆。
### 10.2 弹窗步骤条(横向 · 描边圆为主)
用于 Modal / Drawer 内引导多步操作的紧凑步骤条。指示圆**完成态用描边 + 对勾**而非实心,避免在弹窗背景上过于抢眼。
#### 10.2.1 布局
```
( ✓ ) ┄┄┄┄┄ (●2) ┄┄┄┄┄ ( 3 ) ┄┄┄┄┄ ( 4 ) ┄┄┄┄┄ ( 5 )
标题 标题 标题 标题 标题
(描述) (描述) (描述) (描述) (描述)
```
#### 10.2.2 规格
| 属性 | 取值 |
|------|------|
| 容器水平 padding | `--spacing-5` (24px) |
| 指示圆 → 标题间距 | `--spacing-2` (8px) |
| 标题 → 描述间距 | `--spacing-1` (4px) |
| 节点之间水平间距 | 节点间距自适应等分;连接线占用中间空间 |
| 连接线 | `1px dashed`,已完成段 `#1677FF`,未完成段 `#4A6491` |
| 文字对齐 | 标题、描述居中对齐于指示圆下方 |
#### 10.2.3 两种变体
| 变体 | 适用 |
|------|------|
| **仅标题** | 步骤含义已经清晰(如「填写信息 / 确认 / 完成」),节省垂直空间 |
| **标题 + 描述** | 步骤名抽象需要一行小字补充时使用 |
### 10.3 页面步骤条(横向 · 实心圆 + 自定义首项标题)
用于页面级流程(如「新建集群」类向导)。和弹窗步骤条相比,**已完成态指示圆改为实心填充 + 白色对勾**,视觉权重更高;首步标题可显示具体名称(如「新建集群」)而非「标题」占位。
#### 10.3.1 布局
```
(✓) ────── (●2) ┄┄┄┄┄ ( 3 ) ┄┄┄┄┄ ( 4 ) ┄┄┄┄┄ ( 5 )
新建集群 标题 标题 标题 标题
(描述文本) (描述文本) (描述文本) (描述文本) (描述文本)
```
#### 10.3.2 与 §10.2 弹窗步骤条的差异
| 维度 | 弹窗步骤条 | 页面步骤条 |
|------|-----------|-----------|
| 完成态圆形 | 描边圆 + 蓝色对勾 | 实心蓝圆 + 白色对勾 |
| 完成段连接线样式 | `1px dashed #1677FF` | `1px solid #1677FF` |
| 标题文字号 | `14px Medium` | `14px Medium`(同) |
| 第一项 | 通用「标题」占位 | 可显示具体名称(自定义文案) |
#### 10.3.3 完成态指示圆补充
| 角色 | 色值 | 来源 |
|------|------|------|
| 已完成圆背景 | `#1677FF` | ZS-002-06 |
| 已完成圆内对勾 | `#FFFFFF` | — |
| 已完成圆边框 | 无(实心) | — |
### 10.4 纵向步骤条
用于侧栏 / 详情页内的纵向流程指引。两种变体——「仅标题」与「标题 + 描述」。
#### 10.4.1 布局
```
仅标题: 标题 + 描述:
(✓) 新建集群 (✓) 新建集群
┊ 描述文本描述
(●2) 标题 ┊
┊ (●2) 标题
( 3 ) 标题 描述文本描述
┊ ┊
( 4 ) 标题 ( 3 ) 标题
描述文本描述
┊
( 4 ) 标题
描述文本描述
```
#### 10.4.2 规格
| 属性 | 取值 |
|------|------|
| 指示圆 → 标题水平间距 | `--spacing-2` (8px) |
| 节点垂直间距(仅标题) | `--spacing-5` (24px),圆心到圆心 |
| 节点垂直间距(标题 + 描述) | `--spacing-6` (32px),圆心到圆心 |
| 标题 → 描述间距 | `--spacing-1` (4px) |
| 连接线位置 | 上下两个指示圆**正下方垂直**,居中于圆心 |
| 连接线样式 | `1px dashed`,已完成段 `#1677FF`,未完成段 `#4A6491` |
| 文字左对齐 | 标题与描述左对齐,与指示圆右侧对齐基线 |
#### 10.4.3 与横向步骤条共用规则
- 指示圆三态规格沿用 §10.1
- 已完成态默认用**描边 + 对勾**(与 §10.2 弹窗一致);若纵向步骤条用于页面级主流程,可切换为 §10.3 的实心圆变体
### 10.5 交互与动画
| 行为 | 动效 |
|------|------|
| 步骤切换(next) | 当前圆 `background: transparent → #1677FF`,`200ms ease-out`;同时上一步圆 `border-color: #4A6491 → #1677FF`,再用 `200ms` 切换为对勾图标淡入 |
| 步骤切换(prev / 回退) | 反向,未完成态恢复 `#4A6491` 边框,圆内数字淡入 |
| 连接线高亮 | 完成段 `border-color: #4A6491 → #1677FF`,`240ms ease-out` |
| Hover 节点(可点击) | 圆边框色暂时变 `#4096FF` (ZS-002-05),标题文字 `#4096FF`,`120ms ease-out`;不可点击节点(未来步骤)无 hover |
| 点击节点 | 节点必须**已完成**才能点(回看),否则视为禁用;点击合法节点触发 `step:change` 事件 |
### 10.6 状态机与可达性
| 当前步 = N | 节点 i 的状态 |
|-----------|----------------|
| `i < N` | Completed(已完成,可点回看) |
| `i === N` | Current(当前,不可点) |
| `i > N` | Upcoming(未开始,不可点) |
| `i.disabled` | Disabled(被显式禁用,例如条件未满足) |
**键盘导航**:
- `Tab` 顺序在「已完成」节点之间流转,跳过 `Current / Upcoming / Disabled`
- 焦点节点圆外加 `2px solid #4096FF` 焦点环(`outline`,`offset 2px`)
**屏幕阅读器**:
- 指示圆 `role="img"`,`aria-label="已完成第 N 步"` / `"当前第 N 步"` / `"未开始第 N 步"`
- 容器 `role="list"`,节点 `role="listitem"`
- 当前节点额外 `aria-current="step"`
### 10.7 CSS Token 参考
```css
:root {
/* 步骤条节点 */
--step-circle-size: 24px;
--step-circle-current-bg: #1677FF; /* ZS-002-06 */
--step-circle-current-text: #FFFFFF;
--step-circle-done-border: #1677FF; /* ZS-002-06 */
--step-circle-done-icon: #1677FF; /* 弹窗变体 */
--step-circle-done-bg-page: #1677FF; /* 页面变体:实心 */
--step-circle-done-icon-page: #FFFFFF;
--step-circle-upcoming-border: #4A6491; /* ZS-004-05 */
--step-circle-upcoming-text: #6E88B0; /* ZS-004-04 */
--step-circle-disabled-border: #2D4473; /* ZS-004-06 */
--step-circle-disabled-text: #4A6491; /* ZS-004-05 */
/* 连接线 */
--step-line-done: #1677FF; /* 已完成段 */
--step-line-upcoming: #4A6491; /* 未完成段:ZS-004-05 */
/* 文字 */
--step-title-active: #FFFFFF;
--step-title-inactive: #6E88B0; /* ZS-004-04 */
--step-desc-active: #C5CFE0; /* ZS-004-02 */
--step-desc-inactive: #6E88B0; /* ZS-004-04 */
}
```
### 10.8 三种步骤条的选用决策
| 场景 | 选择 |
|------|------|
| Modal / Drawer 内多步表单 | §10.2 弹窗步骤条(描边圆,紧凑) |
| 页面级向导(独占大区域) | §10.3 页面步骤条(实心圆,视觉重) |
| 侧栏 / 详情页纵向流程 | §10.4 纵向步骤条 |
| 步骤数 ≤ 3 且无描述 | 优先「仅标题」变体 |
| 步骤名抽象需补充说明 | 用「标题 + 描述」变体 |
| 步骤数 > 7 | 不推荐用步骤条,改用「锚点」(§9.2)或分页 |
---
## 11. 面包屑
> 对应「4.3 导航组件-面包屑」。用于显示当前位置在整个站点层级中的路径,配合「← 返回」按钮支持逐级回溯。颜色取自 ZS-002 主色蓝与 ZS-004 深蓝系。
### 11.1 标准形态
容器顶部带一个独立的容器 / 边框包裹:
```
🏠 | 概况 / 总概况
```
带「返回」按钮的层级形态(一级到四级):
```
一级: ← 返回 │ 现在位置
二级: ← 返回 │ 上一级位置 / 现在位置
三级: ← 返回 │ 上二级位置 / 上一级位置 / 现在位置
四级: ← 返回 │ 上三级位置 / 上二级位置 / 上一级位置 / 现在位置
```
### 11.2 规格
| 属性 | 取值 |
|------|------|
| 容器高度 | `40px` |
| 容器背景 | `#0C1836` (ZS-004-09) |
| 容器底边 | `1px solid #1A2F5A` (ZS-004-07) |
| 容器水平 padding | `--spacing-4` (16px) |
| 字号 | `14px Regular` |
| 元素间距(图标 / 文字 / 分隔符之间) | `--spacing-2` (8px) |
| 「返回」与首段路径之间分隔符 `│` | `1px solid #4A6491`,高度 `14px` |
### 11.3 配色
| 角色 | 色值 | 来源 | 备注 |
|------|------|------|------|
| Home 图标 🏠 | `#4096FF` | ZS-002-05 | 默认主色,hover → `#1677FF` (ZS-002-06) |
| 「← 返回」图标 + 文字 | `#4096FF` | ZS-002-05 | 链接样式,hover 显示下划线 |
| 历史路径文字(可点击) | `#4096FF` | ZS-002-05 | 同链接配色 |
| 当前位置文字(最后一段) | `#FFFFFF` | — | 加粗 `14px Medium`,不可点击 |
| 分隔符 `/` | `#4A6491` | ZS-004-05 | 静态、不可点 |
| 分隔符 `│`(返回按钮与路径之间) | `#4A6491` | ZS-004-05 | 同上 |
| Hover 路径项 | `#1677FF` | ZS-002-06 | 文字色 + 下划线 |
| 失效路径项(无对应页) | `#4A6491` | ZS-004-05 | `cursor: not-allowed` |
### 11.4 交互规则
- **点击「← 返回」**:逐步向上返回一级(等同浏览器 back)
- **点击文字路径**:快速跳回到对应任意上级页面
- **当前位置**(最后一段):不响应点击,仅作展示
- **层级建议**:尽可能不超过 **4 级**;超过 4 级会让面包屑过长,应改用侧栏 / 锚点辅助
### 11.5 与导航的关系
面包屑**不替代**主导航(§8):
- 主导航 = 「我能去哪些地方」(站点结构)
- 面包屑 = 「我当前在哪里」(路径回溯)
两者并存时,面包屑紧贴页面主内容区顶部,位于主导航下方、页头(§12)上方。
### 11.6 CSS 参考
```css
.breadcrumb {
display: flex;
align-items: center;
height: 40px;
padding: 0 var(--spacing-4);
background: #0C1836;
border-bottom: 1px solid #1A2F5A;
font-size: 14px;
gap: var(--spacing-2);
}
.breadcrumb-back { color: #4096FF; cursor: pointer; }
.breadcrumb-back:hover { color: #1677FF; text-decoration: underline; }
.breadcrumb-sep-bar { width: 1px; height: 14px; background: #4A6491; }
.breadcrumb-sep { color: #4A6491; }
.breadcrumb-link { color: #4096FF; cursor: pointer; }
.breadcrumb-link:hover { color: #1677FF; text-decoration: underline; }
.breadcrumb-current { color: #FFFFFF; font-weight: 500; }
```
---
## 12. 页头
> 对应「4.6 导航组件-分页」上半段示意。用于页面主内容区顶部、面包屑下方,承载页面标题与可选的说明 / Tabs。共有 4 种变体。
### 12.1 通用规格
| 属性 | 取值 |
|------|------|
| 容器高度 | `56px`(仅标题)/ `64px`(含 Tabs) |
| 背景 | `#0C1836` (ZS-004-09) |
| 底边 | `1px solid #1A2F5A` (ZS-004-07) |
| 水平 padding | `--spacing-5` (24px) |
| 文字基线 | 垂直居中 |
### 12.2 四种变体
#### 12.2.1 基础页头
只有标题:
```
页头标题
```
| 角色 | 色值 / 字号 | 来源 |
|------|-------------|------|
| 标题 | `18px Bold`,`#FFFFFF` | — |
#### 12.2.2 页头 + 说明
标题后跟一段灰色说明文字:
```
页头标题 (这是一句简要说明)
```
| 角色 | 色值 / 字号 | 来源 |
|------|-------------|------|
| 标题 | `18px Bold`,`#FFFFFF` | — |
| 说明文字 | `14px Regular`,`#6E88B0` | ZS-004-04 |
| 标题 → 说明间距 | `--spacing-3` (12px) | — |
说明用半角圆括号包裹,颜色与括号一致(不要让括号比文字更醒目)。
#### 12.2.3 页头 + 说明 icon
标题后跟一个可悬停查看说明的图标:
```
页头标题 ⓘ
```
| 角色 | 色值 / 字号 | 来源 |
|------|-------------|------|
| 标题 | `18px Bold`,`#FFFFFF` | — |
| 说明 icon `ⓘ` 或 `?` | `16px`,`#6E88B0` | ZS-004-04 |
| Icon hover | `#4096FF` | ZS-002-05 |
| 标题 → 图标间距 | `--spacing-2` (8px) | — |
Hover 弹出 Tooltip(沿用 §9.3.3 Tooltip 配色),承载完整说明文字。
#### 12.2.4 分 Tabs 页头
标题位置直接放横向页签(沿用 §9.1.1 横向页签配色),点击切换右侧主内容区:
```
标题 标题 标题
─────
```
| 复用规则 | 说明 |
|----------|------|
| Tab 配色 | 完全沿用 §9.1.1 横向页签 |
| Tab 字号 | `16px Medium`(页头位置比一般 tab 略大) |
| Tab 间距 | `--spacing-5` (24px) |
| 激活态下划线 | `2px solid #4096FF`,宽与文字齐 |
### 12.3 与右侧操作区的组合
页头右侧常承载主操作按钮(「+ 新建」、「导出」等)。规则:
- 按钮组靠右对齐,右 padding 与左 padding 一致 (`--spacing-5`)
- 多按钮排列顺序参照 §5.3.4(默认按钮在左、主按钮在右),按钮间距 `--spacing-2`
- Tabs 页头中右侧操作区与 Tabs 同高居中
---
## 13. 分页
> 对应「4.6 导航组件-分页」下半段。三种形态——**正常分页 / 精简分页 / 导航式分页**——按页面信息量与可用空间选用。
### 13.1 正常分页
承载大列表,提供页码、跳转、每页条数选择:
```
共 240 项 < ① 2 3 4 5 … 24 > 每页 [10▾] 条 前往 [ ] 页
```
#### 13.1.1 元素配色与规格
| 元素 | 字号 / 色值 | 来源 / 说明 |
|------|-------------|-------------|
| 「共 N 项」总数文字 | `14px Regular`,`#6E88B0` | ZS-004-04 |
| 页码按钮(默认) | `14px Regular`,`#C5CFE0`,背景 `transparent` | ZS-004-02 |
| 页码按钮(hover) | `#4096FF`,背景 `rgba(22,119,255,0.08)` | ZS-002-05 |
| 页码按钮(激活/当前页) | `#FFFFFF`,背景 `#1677FF`(实心圆) | ZS-002-06 |
| 页码按钮(失效) | `#4A6491`,背景 `transparent` | ZS-004-05 |
| 左右翻页箭头 `<` `>` | `14px`,`#6E88B0`,hover → `#4096FF` | ZS-004-04 → ZS-002-05 |
| 翻页箭头失效(首/末页) | `#4A6491` | ZS-004-05 |
| 省略号 `…` | `#6E88B0` | ZS-004-04,静态展示,不可点 |
| 每页条数下拉 `[10▾]` | 沿用 §14.1 输入框规格 | 高度 `28px`,宽 `60px` |
| 「前往 N 页」输入框 | 沿用 §14.1 输入框规格 | 高度 `28px`,宽 `48px` |
#### 13.1.2 页码按钮规格
| 属性 | 取值 |
|------|------|
| 形状 | 圆形 `28px × 28px`(`border-radius: 50%`) |
| 默认背景 | `transparent` |
| 当前页背景 | `#1677FF` (ZS-002-06),实心圆,白色数字 |
| 字号 | `14px Regular`(数字 D-DIN 或 Noto Sans) |
| 项间距 | `--spacing-1` (4px) |
#### 13.1.3 元素间距
| 区段 | 间距 |
|------|------|
| 「共 N 项」→ 翻页区 | `--spacing-4` (16px) |
| 翻页区内(箭头 / 页码) | `--spacing-1` (4px) |
| 翻页区 → 每页下拉 | `--spacing-5` (24px) |
| 每页下拉 → 前往输入 | `--spacing-4` (16px) |
### 13.2 精简分页
承载弹窗 / 表格紧凑场景,只暴露「当前页 / 总页数」与左右翻页:
```
共 1 项 < [ 1 ] / 10 >
```
#### 13.2.1 规格
| 元素 | 配色 / 备注 |
|------|------------|
| 「共 N 项」 | 同 §13.1.1 |
| `<` `>` 翻页箭头 | 同 §13.1.1 |
| `[ 1 ]` 当前页输入框 | 沿用 §14.1 输入框规格,高 `28px`,宽 `40px`,居中文字 |
| `/ N` 总页数 | `14px Regular`,`#6E88B0` (ZS-004-04) |
| 元素间距 | `--spacing-2` (8px) |
#### 13.2.2 与正常分页的差异
| 维度 | 正常分页 | 精简分页 |
|------|----------|----------|
| 页码 | 点击式数字列表 | 输入框 + 总页数文字 |
| 跳转方式 | 点击数字 / 前往输入 | 直接修改输入框 |
| 每页条数 | 暴露下拉 | 不暴露(默认值由调用方决定) |
| 适用 | 列表 / 表格主内容区 | 弹窗、抽屉、卡片内嵌列表 |
### 13.3 导航式分页
承载长文 / 详情页的「上一篇 / 下一篇」式翻页,只有方向,不暴露页码:
```
下一页 »
« 上一页
```
#### 13.3.1 规格
| 元素 | 配色 / 备注 |
|------|------------|
| 「下一页」/「上一页」文字 + 箭头 | `14px Regular`,`#4096FF` (ZS-002-05) |
| Hover | `#1677FF` (ZS-002-06) + 下划线 |
| 失效(首/末篇) | `#4A6491` (ZS-004-05),`cursor: not-allowed` |
| 箭头位置 | `下一页 »`(箭头在后)/ `« 上一页`(箭头在前) |
| 文字 → 箭头间距 | `--spacing-2` (8px) |
| 两个按钮纵向排列时项间距 | `--spacing-3` (12px) |
### 13.4 三种分页的选用决策
| 场景 | 选择 |
|------|------|
| 后台列表 / 表格 | §13.1 正常分页 |
| Modal / Drawer 内嵌列表 | §13.2 精简分页 |
| 详情页前后翻 | §13.3 导航式分页 |
| 列表少于 1 页(项数 ≤ pageSize) | **不显示**分页 |
| 项数明确但页码可能很多(>50 页) | 用正常分页 + 隐藏中间页码(`...` 省略) |
---
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,573 @@
# 深蓝色 UI 规范 · 04 图表(§35–§41)
> 《深蓝色 UI 规范》分册之一。基础令牌见 `00-基础-色彩字体间距.md`,可 import 的变量见 `tokens.css`,总目录与 §索引见 `README.md`。`§N` 编号全局唯一。
**本篇目录**:`35` 图表通用规范 · `36` 柱状图 · `37` 折线图 · `38` 甘特图 · `39` 环形图 · `40` 关系图/散点图 · `41` 图表色值
---
## 35. 图表通用规范(Chart 基础)
> 对应设计稿「8. 图表」系列(柱状图 / 折线图 / 甘特图)。本节先约定**所有图表共用**的容器、标题、坐标轴、网格、图例、Tooltip、配色与空状态;§36 起按图表类型补充各自细则。配色一律取自 §1.1.8 图表配色与 §1.1.5 深蓝系,不另造色。
### 35.1 图表容器
| 属性 | 取值 | 来源 |
|------|------|------|
| 背景 | `#0C1836` (ZS-004-09),嵌入卡片时随 §22 卡片 `#122347` | §3.1 |
| 圆角 | `4px` | §4.1 |
| 内边距 | `--spacing-5` (24px) | §7.1 |
| 多图并排间距 | `--spacing-6` (32px) | §7.1 |
| 边框(可选) | `1px solid #1A2F5A` (ZS-004-07) | — |
### 35.2 图表标题
设计稿中标题统一为「**主色蓝竖条 ▌ + 文字**」的小标题样式。
| 元素 | 规格 | 来源 |
|------|------|------|
| 左侧竖条 | `3px × 14px`,`#1677FF` (ZS-002-06),圆角 `1px` | §1.1.6 |
| 竖条 → 文字间距 | `--spacing-2` (8px) | §7.1 |
| 标题文字 | `16px Bold`,`#FFFFFF`(或 `#C5CFE0`) | §2.1.2 |
| 标题 → 图表区间距 | `--spacing-4` (16px) | §7.1 |
| 副标题 / 单位 | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
> 部分卡片标题不带竖条(如「标题(BW)」「数据采集XXX」),此时直接用 `16px Bold #C5CFE0`,与 §22 卡片标题一致。
### 35.3 坐标轴
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 轴线(X / Y 基线) | `1px solid #2D4473` (ZS-004-06) | §6.1 |
| 刻度文字(轴标签) | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
| 轴标题 / 类目名(如「类型」「2012」) | `12px Regular`,`#4096FF` (ZS-002-05) 或 `#6E88B0` | — |
| 刻度线(tick) | 通常隐藏,仅留网格线 | — |
| 轴名称单位 | `12px`,`#6E88B0`,置于轴末端 | — |
### 35.4 网格线(splitLine)
| 类型 | 色值 | 样式 | 使用场景 |
|------|------|------|----------|
| 主网格(默认) | `#2D4473` (ZS-004-06) | `1px dashed` | 柱状 / 折线的 Y 向参考线 |
| 弱化网格 | `#1A2F5A` (ZS-004-07) | `1px dashed` | 密集图表、次要参考 |
> 深色背景下网格一律用**虚线**且低对比,避免抢戏;纵向类目方向通常不画网格。
### 35.5 图例(Legend)
| 属性 | 取值 | 来源 |
|------|------|------|
| 位置 | 右上角(多数)或顶部居中 | — |
| 色块(marker) | `10px` 方块 / 圆点,取系列色 | §1.1.8 |
| 色块 → 文字间距 | `--spacing-1` (4px) | §7.1 |
| 图例文字 | `12px Regular`,`#C5CFE0` (ZS-004-02) | — |
| 项间距 | `--spacing-3` (12px) | §7.1 |
| 未选中态(点击隐藏系列) | 色块 + 文字置灰 `#4A6491` (ZS-004-05) | §1.1.5 |
### 35.6 Tooltip(悬浮提示)
鼠标悬停时跟随的浮层(如折线图「1月10日 异常率12.9%」、「5.32」)。
| 属性 | 取值 | 来源 |
|------|------|------|
| 背景 | `rgba(12,24,54,0.92)`(ZS-004-09 92%) | §3.1 |
| 边框 | `1px solid #1A2F5A` (ZS-004-07) | — |
| 圆角 | `4px` | §4.1 |
| 阴影 | `0 4px 16px rgba(6,14,36,0.6)` | §3.1.4 |
| 内边距 | 上下 `--spacing-2`,左右 `--spacing-3` | §7.1 |
| 标题行(日期 / 类目) | `12px Medium`,`#FFFFFF` | — |
| 数值行 | `12px Regular`,色块(系列色)+ 名称 `#6E88B0` + 数值 `#C5CFE0` | — |
| 指示线(axisPointer) | `1px dashed #4096FF` (ZS-002-05),或半透明竖向高亮带 `rgba(22,119,255,0.12)` | §6.1 |
> 设计稿中数据点 hover 常显示一个**实心小标签**(如圆点 + 数值 `5.32`):标签背景 `#122347`,文字 `#FFFFFF`,圆点取系列色,圆角 `2px`。
### 35.7 数据点与标记
| 元素 | 规格 | 来源 |
|------|------|------|
| 折线拐点(默认) | 不显示或 `2px` 实心点 | — |
| 折线拐点(hover / 强调) | `空心圆`,描边 `2px` 系列色,填充 `#0C1836`,直径 `6px` | — |
| 高亮竖带(选中区间) | `rgba(22,119,255,0.12)` 矩形,无边框 | §3.1 |
### 35.8 图表配色引用
| 用途 | 色序 | 来源 |
|------|------|------|
| 多系列(面积大:柱 / 折线 / 饼) | `#1677FF → #13C2C2 → #4096FF → #36CFC9 → #69B1FF → #5CDBD3 → #91CAFF → #87E8DE` | §1.1.8 通用图表色序 |
| 小面积(散点 / 迷你图 / 徽标) | `#4096FF → #36CFC9 → #FF4D4F → #FFA39E → #6388FF → #478880` | §1.1.8 小面积色序 |
| 语义系列(成功 / 警告 / 错误) | 绿 `--func-success` / 橙 `--func-warning` / 红 `#F5222D` | §27.4 / §1.1.2 |
> 设计稿柱状图出现的「绿(Paper) / 橙(Project) / 蓝(Book)」三系列,对应**语义化分类**:绿取 §27.4 成功绿、橙取 §27.4 警告橙、蓝取主色 `#1677FF`;若为无语义的并列分类,应改用通用图表色序避免误读。
### 35.9 空状态 / 加载
| 状态 | 表现 |
|------|------|
| 加载中 | 图表区居中显示旋转图标 `#4096FF` + 文字「加载中…」`12px #6E88B0`(沿用 §5.4.2) |
| 无数据 | 居中占位插画 / 文字「暂无数据」`14px #6E88B0` |
---
## 36. 柱状图(Column / Bar)
> 对应设计稿「8.1 图表-柱状图」。涵盖**堆叠柱 / 分组柱 / 条形图(横向)/ 排行进度条**四种形态。容器、坐标轴、网格、图例、Tooltip 全部沿用 §35。
### 36.1 通用规格
| 属性 | 取值 | 来源 |
|------|------|------|
| 柱体圆角 | 顶部 `2px`(堆叠柱仅最顶段圆角) | §4.1 |
| 柱宽 | 类目宽度的 `40%~60%` | — |
| 同组柱间距 | `--spacing-1` (4px) | §7.1 |
| 类目(组)间距 | 自适应等分 | — |
| 数值标签(可选) | `12px Regular`,`#C5CFE0`,置柱顶上方 `--spacing-1` | — |
### 36.2 堆叠柱状图(如「标题」Paper/Project/Book)
| 元素 | 配色 | 来源 |
|------|------|------|
| 系列 1(Book / 底段) | `#69B1FF` (ZS-002-04) 或主色 `#1677FF` | §1.1.3 |
| 系列 2(Project / 中段) | `--func-warning`(橙) | §27.4 |
| 系列 3(Paper / 顶段) | `--func-success`(绿) | §27.4 |
| 段间分隔 | `1px` 容器底色 `#0C1836` 描边,制造堆叠缝隙 | — |
| Y 轴类目标签「类型」 | `12px`,`#4096FF` (ZS-002-05) | §35.3 |
### 36.3 分组柱状图(如「标题(BW)」「标题」双系列)
| 元素 | 配色 | 来源 |
|------|------|------|
| 系列依次取色 | 通用图表色序 `#1677FF / #13C2C2 / #4096FF …` | §1.1.8 |
| 设计稿 BW 三色变体 | 蓝 `#69B1FF` / 绿 `--func-success` / 橙 `--func-warning` | §35.8 |
| 双系列变体 | 蓝 `#69B1FF`(ZS-002-04) + 橙 `--func-warning` | — |
### 36.4 条形图(横向,如「标题」x 轴 0–400)
| 属性 | 取值 | 来源 |
|------|------|------|
| 方向 | 水平,类目在 Y 轴、数值在 X 轴 | — |
| 条体颜色 | `#69B1FF` (ZS-002-04)(单系列)或图表色序 | §1.1.3 |
| 条体圆角 | 右端 `2px` | §4.1 |
| 类目标签「类型」 | 左对齐,`12px #6E88B0` | §35.3 |
| X 轴刻度 | `0 / 100 / 200 / 300 / 400`,`12px #6E88B0` | §35.3 |
### 36.5 排行进度条(如「数据采集XXX」TOP1–5)
横向「标签 + 渐变进度条 + 数值」的榜单形态。
```
TOP1 项目任务名称一 15,465
■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▓▓▓▓▓▓▓▓
TOP2 项目任务名称二 15,234
■■■■■■■■■■■■■■■■■■■■■■■■■▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
```
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 排名 + 名称(TOPn 项目任务名称) | `14px Regular`,`#C5CFE0` (ZS-004-02) | — |
| 数值(右对齐) | `16px D-DIN Regular`,`#FFFFFF` | §2.1.2 |
| 进度条高度 | `8px`,圆角 `4px` | §4.1 |
| 进度填充 | 渐变 `linear-gradient(90deg, #1677FF 0%, #13C2C2 100%)`(主色蓝→主色青) | §1.1.8 |
| 进度轨道(底槽) | `#1A2F5A` (ZS-004-07) | §1.1.5 |
| 行间距 | `--spacing-4` (16px) | §7.1 |
| 名称行 → 进度条间距 | `--spacing-2` (8px) | §7.1 |
### 36.6 交互
| 行为 | 动效 |
|------|------|
| 入场 | 柱体由 `0` 向目标高度 / 长度增长,`scaleY/scaleX 0→1`,`400ms ease-out`,按系列依次 `60ms` 错峰 |
| Hover 柱 | 该柱亮度 `+8%`,弹出 §35.6 Tooltip,axisPointer 高亮该类目竖带 |
| Hover 进度条 | 数值放大或显示绝对值 Tooltip |
---
## 37. 折线图(Line / Area)
> 对应设计稿「8.2 图表-折线图」与「8.3 多系列折线」。涵盖**迷你面积图 / 单线(含异常点 Tooltip)/ 长时序面积图(带缩略轴)/ 多系列折线**。容器、轴、网格、图例、Tooltip 沿用 §35。
### 37.1 线条与面积
| 元素 | 规格 | 来源 |
|------|------|------|
| 线宽 | `2px` | — |
| 线条颜色 | 取图表色序,单线默认 `#1677FF` (ZS-002-06) | §1.1.8 |
| 平滑 | `smooth`(曲线)或折线,按数据性质选用 | — |
| 面积填充(单线) | 自顶向下渐变 `rgba(22,119,255,0.32) → rgba(22,119,255,0)` | §3.1 |
| 拐点 | 默认隐藏,hover 显示空心圆(§35.7) | — |
### 37.2 迷你面积图(如左上「图表标题」+ 已选/未选 标签)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 顶部切换标签(已选 / 未选) | §29 标签:选中 `#1677FF` 实心白字 / 未选 `transparent` + `1px #6E88B0` 边、`#6E88B0` 字 | §29 |
| 线条 | `2px #4096FF` (ZS-002-05) | — |
| 面积渐变 | `rgba(64,150,255,0.32) → 0` | — |
| 当前值标签(如 `59.32`) | 实心小牌 `#122347` 背景 / `#FFFFFF` 文字,圆角 `2px`,定位于末端点 | §35.6 |
| 轴标签(日期) | `12px #6E88B0` | §35.3 |
### 37.3 单线异常点图(如中部「5-1…5-12」+「1月10日 异常率12.9%」)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 主线 | `2px #1677FF` | — |
| 面积渐变 | `rgba(22,119,255,0.32) → 0` | — |
| 选中竖带 | `rgba(22,119,255,0.12)` 矩形高亮当前 X 区间 | §35.7 |
| 异常点标记 | 空心圆 `6px`,描边 `#FAAD14`(警告橙),填充 `#0C1836` | §27.4 |
| Tooltip(日期 + 指标) | §35.6;指标行如「异常率 12.9%」用 `--func-warning` 橙强调 | §35.6 |
| Y 轴(0–2500) | `12px #6E88B0`,虚线网格 `#2D4473` | §35.3 / §35.4 |
### 37.4 长时序面积图 + 缩略轴(如底部「图表标题」1986–2024)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 主线 | `2px #36CFC9`(标准青)或 `#4096FF` | §1.1.4 |
| 面积渐变 | `rgba(54,207,201,0.24) → 0` | — |
| 数据点标签(如 `2.43`) | 实心小牌(§35.6),定位于选中点上方 | — |
| 缩略轴(dataZoom,右下迷你图) | 缩略折线 `#4A6491`;滑窗手柄 `#1677FF`,选中区 `rgba(22,119,255,0.16)` | §1.1.5 / §1.1.6 |
| X 轴年份 | `12px #6E88B0`,密集时隔项显示 | §35.3 |
### 37.5 多系列折线图(如「图表标题」XXX/XXX/都是/无反应)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 图例(顶部居中) | 方块 marker + 文字,项间距 `--spacing-3` | §35.5 |
| 系列 1(XXX) | 红 `#FF4D4F` (ZS-001-05) | §1.1.2 |
| 系列 2(XXX) | 蓝 `#1677FF` (ZS-002-06) | §1.1.3 |
| 系列 3(都是) | 橙 `--func-warning` | §27.4 |
| 系列 4(无反应) | 绿 `--func-success` 或青 `#36CFC9` | §27.4 / §1.1.4 |
| 线宽 | `2px`,多系列**不填充面积**(避免互相遮挡) | — |
| 拐点 | 全程显示 `4px` 空心圆(描边系列色,填充 `#0C1836`) | §35.7 |
| 选中竖带 + Tooltip | hover 竖带 `rgba(22,119,255,0.12)`,Tooltip 列出各系列值(如 `5.32`) | §35.6 |
| Y 轴(0–10) / X 轴(年份) | `12px #6E88B0`,横向虚线网格 `#2D4473` | §35.3 / §35.4 |
### 37.6 交互
| 行为 | 动效 |
|------|------|
| 入场 | 线条沿路径绘制 `stroke-dashoffset` 动画,`600ms ease-out`;面积同步淡入 |
| Hover | 显示 §35.6 Tooltip + 竖向 axisPointer 高亮带;对应拐点放大为空心圆 |
| 图例点击 | 切换系列显隐,隐藏态图例置灰 `#4A6491`(§35.5) |
| 缩略轴拖拽 | 主图 X 轴范围实时联动,`100ms` 节流 |
---
## 38. 甘特图(Gantt)
> 对应设计稿「甘特图」。以时间为横轴、任务行为纵轴的进度条排期图。深蓝背景上以**交替行底纹 + 渐变任务条 + 箭头收尾**呈现,配色取 §1.1.8 图表色序与语义色。
### 38.1 结构
```
1 2 3 … 15 16 17 … 31 ← 时间刻度表头
Jan ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▶ ← 任务条(带箭头收尾)
Feb ▐▓▓▓▓▓▓▓▓▓▶
Mar ▐▓▓▓▓▓▓▓▶
…
Dec ▐▓▓▓▓▓▓▓▓▓▓▓▶
● Lorem ipsum 1 ● Lorem ipsum 2 … ← 底部图例
```
### 38.2 表格框架
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 容器背景 | `#0C1836` (ZS-004-09) | §35.1 |
| 时间刻度表头(1–31 / 月) | `12px Regular`,`#C5CFE0` (ZS-004-02) | §35.3 |
| 行标签(Jan…Dec) | `14px Regular`,`#C5CFE0` | — |
| 行高 | `28px`,行间距 `--spacing-1` (4px) | §7.1 |
| 行底纹(轨道) | `#122347` (ZS-004-08),整行通栏,圆角 `2px` | §1.1.5 |
| 行底纹(隔行可选) | 奇偶交替 `#122347` / `#0F1C3D` | — |
| 列分隔(可选) | `1px dashed #1A2F5A` (ZS-004-07) | §6.2 |
### 38.3 任务条
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 条高 | `16px`,垂直居中于行内 | — |
| 圆角 | `2px`(左端);右端收**箭头**三角表示方向 | §4.1 |
| 填充 | 沿条向右渐变(系列色 `0%` → 同色 70% 透明 `100%`),叠加斜纹/点纹理增强层次 | §1.1.8 |
| 纹理 | `repeating-linear-gradient(45deg, rgba(255,255,255,0.06) 0 4px, transparent 4px 8px)` | — |
| 箭头收尾 | 与条同色实心三角,`6px`,指向时间推进方向 | — |
| 进度(可选) | 条内再叠一段高饱和系列色表示完成比例 | — |
### 38.4 任务分类配色(对应底部图例 Lorem ipsum 1–5)
| 图例 | 圆点 / 任务条主色 | 来源 |
|------|------------------|------|
| Lorem ipsum 1 | 橙 `--func-warning` | §27.4 |
| Lorem ipsum 2 | 红 `#FF4D4F` (ZS-001-05) | §1.1.2 |
| Lorem ipsum 3 | 青绿 `#36CFC9` (ZS-003-05) | §1.1.4 |
| Lorem ipsum 4 | 浅蓝 `#91CAFF` (ZS-002-03) | §1.1.3 |
| Lorem ipsum 5 | 主色蓝 `#1677FF` (ZS-002-06) | §1.1.3 |
### 38.5 图例与表头
| 元素 | 规格 | 来源 |
|------|------|------|
| 底部图例圆点 | `10px` 圆形,取 §38.4 分类色 | §35.5 |
| 图例文字 | `14px Regular`,`#C5CFE0` | — |
| 图例项间距 | `--spacing-5` (24px) | §7.1 |
| 表头 → 首行间距 | `--spacing-3` (12px) | §7.1 |
### 38.6 交互
| 行为 | 动效 |
|------|------|
| 入场 | 任务条由左端起点向右展开 `scaleX 0→1`(`transform-origin:left`),`400ms ease-out`,按行错峰 `60ms` |
| Hover 任务条 | 整条亮度 `+8%`,弹出 §35.6 Tooltip(任务名 / 起止日期 / 进度) |
| Hover 行 | 行底纹由 `#122347` → `#1A2F5A` (ZS-004-07) |
| 今日线(可选) | 一条 `1px dashed #4096FF` (ZS-002-05) 竖线标注当前日期 | §6.2 |
### 38.7 CSS Token 参考(§35–§38 共用)
```css
:root {
/* 图表容器与标题 */
--chart-bg: #0C1836; /* ZS-004-09 */
--chart-title-bar: #1677FF; /* ZS-002-06 标题竖条 */
--chart-title-text: #FFFFFF;
--chart-subtitle: #6E88B0; /* ZS-004-04 */
/* 坐标轴与网格 */
--chart-axis-line: #2D4473; /* ZS-004-06 */
--chart-axis-label: #6E88B0; /* ZS-004-04 */
--chart-axis-cat: #4096FF; /* ZS-002-05 类目名 */
--chart-grid: #2D4473; /* ZS-004-06 虚线网格 */
--chart-grid-weak: #1A2F5A; /* ZS-004-07 */
/* 图例 / Tooltip */
--chart-legend-text: #C5CFE0; /* ZS-004-02 */
--chart-legend-off: #4A6491; /* ZS-004-05 未选中 */
--chart-tooltip-bg: rgba(12,24,54,0.92);
--chart-tooltip-border:#1A2F5A; /* ZS-004-07 */
--chart-tooltip-title: #FFFFFF;
--chart-tooltip-value: #C5CFE0;
--chart-axispointer: #4096FF; /* ZS-002-05 */
--chart-highlight-band:rgba(22,119,255,0.12);
/* 通用图表色序(面积大) */
--chart-c1: #1677FF; --chart-c2: #13C2C2; --chart-c3: #4096FF; --chart-c4: #36CFC9;
--chart-c5: #69B1FF; --chart-c6: #5CDBD3; --chart-c7: #91CAFF; --chart-c8: #87E8DE;
/* 小面积图表色序 */
--chart-s1: #4096FF; --chart-s2: #36CFC9; --chart-s3: #FF4D4F;
--chart-s4: #FFA39E; --chart-s5: #6388FF; --chart-s6: #478880;
/* 排行进度条(§36.5) */
--rank-bar-fill: linear-gradient(90deg, #1677FF 0%, #13C2C2 100%);
--rank-bar-track: #1A2F5A; /* ZS-004-07 */
--rank-value: #FFFFFF;
/* 甘特图(§38) */
--gantt-track: #122347; /* ZS-004-08 行底纹 */
--gantt-row-hover: #1A2F5A; /* ZS-004-07 */
--gantt-c1: #FAAD14; /* 警告橙 */
--gantt-c2: #FF4D4F; /* ZS-001-05 */
--gantt-c3: #36CFC9; /* ZS-003-05 */
--gantt-c4: #91CAFF; /* ZS-002-03 */
--gantt-c5: #1677FF; /* ZS-002-06 */
--gantt-today: #4096FF; /* ZS-002-05 今日线 */
}
```
> 项目当前未引入图表库;若后续接入 ECharts,可将以上 token 注入主题(`backgroundColor` / `axisLine.lineStyle.color` / `splitLine.lineStyle` / `color[]` 色序 / `tooltip.backgroundColor` 等),保持与本规范一致。
>
> **关于多系列填充色**:§36–§38 表格中标注的系列色用于说明语义映射;**实际多系列图表的系列填充色一律以 §41 图表色值(设计稿 8.7)为准**——即淡色系 `#FA9196 / #FFCC80 / #97EBCD / #A2E1F9 / #80BCFF …`,按系列数量取对应色序。§1.1.8 的高彩度色序仅用于需要强对比的强调场景(如单色进度条渐变、KPI 高亮)。
---
## 39. 环形图(Donut / Ring)
> 对应设计稿「8.3 图表-环形图」。中心带总量数字的占比环图,按分类数量分**三环 / 四环 / 五环 / 六环 / 七环 / 八环**等变体。环段配色取 §41 图表色值(淡色系列),中心总量与引导标签取深蓝系字体色。
### 39.1 结构
```
三环 / 四环 …(标题)
╭──────────╮
Media│ │Social
28% │ 34801 │ 36%
│ XXX总量 │
╰──────────╯
News 28%
```
### 39.2 环体规格
| 属性 | 取值 | 来源 |
|------|------|------|
| 外径 | 容器短边的 `60%~70%` | — |
| 内径(中空比) | 外径的 `62%`(环宽约 `38%`) | — |
| 环段间隙 | `2px`(用容器底色 `#0C1836` 描边分隔) | §35.1 |
| 环段圆角 | `2px`(段端微圆角,可选) | §4.1 |
| 环段配色 | 按分类数取 §41 对应「N 色」色序 | §41 |
| 起始角 / 方向 | 顶部 `90°` 起,顺时针 | — |
### 39.3 中心与标签
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 中心主数值(如 `34801`) | `26px Bold D-DIN`,`#FFFFFF` | §2.1.2 |
| 中心说明(如 `XXX总量`) | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
| 引导标签名(如 `Social`) | `12px Regular`,`#C5CFE0` (ZS-004-02) | — |
| 引导标签占比(如 `36%`) | `12px Regular`,`#FFFFFF` 或对应环段色 | — |
| 引导线 | `1px solid #4A6491` (ZS-004-05) 折线,从环段引至外侧标签 | §6.1 |
| 变体标题(三环 / 四环…) | `14px Medium`,`#C5CFE0`,居中于图上方 | §35.2 |
### 39.4 交互
| 行为 | 动效 |
|------|------|
| 入场 | 环段沿顺时针扫出 `0°→终值`,`600ms ease-out`,按段错峰 `60ms` |
| Hover 环段 | 该段沿半径外扩 `4px`(`offset`),中心数值切换为该段值 + 占比,弹出 §35.6 Tooltip |
| 图例点击 | 切换分类显隐,其余环段占比按比例重算 |
---
## 40. 关系图 / 散点图(Network / Scatter)
> 对应设计稿「8.6 图表-散点图」「关系图」。用于展示账号、节点间关系的力导向网络图与散点分布。深蓝背景上以**发光节点 + 渐隐连线 + 平台聚类**呈现,节点配色取主色蓝 / 警告橙 / 危险红做分类,节点尺寸映射权重。
### 40.1 节点(Node)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 形状 | 圆形(普通节点)/ 头像圆(账号节点,`2px` 描边)/ 六边形(聚合节点) | — |
| 直径 | `8px ~ 40px`,按权重(连接数 / 数值)线性映射 | — |
| 分类色 A(本方 / 主) | `#1677FF` (ZS-002-06) | §1.1.3 |
| 分类色 B(异常 / 外部) | `--func-warning`(橙) | §27.4 |
| 分类色 C(高危 / 标红) | `#FF4D4F` (ZS-001-05) | §1.1.2 |
| 节点发光(重点节点) | `0 0 12px` 同色 40% 透明外发光 | §3.1 |
| 节点文字(数值 / 名称) | `12px Regular`,`#FFFFFF`(节点内)/ `#C5CFE0`(节点外) | — |
| 中心 / 焦点节点 | 加大直径 + 强发光,橙色 `--func-warning` 高亮 | — |
### 40.2 连线(Edge)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 线宽 | `1px`(普通)/ `2px`(强关系) | — |
| 颜色 | `#4A6491` (ZS-004-05) 半透明,或取**起点节点分类色** 40% 透明 | §1.1.5 |
| 渐隐 | 由中心向外渐隐(`opacity 0.6 → 0.15`),弱化边缘视觉噪声 | — |
| 方向(有向) | 末端箭头 `4px`,同线色 | — |
| 高亮态(hover 节点时) | 关联边升为节点分类色满饱和、`2px`,无关边降至 `opacity 0.08` | — |
### 40.3 散点图(Scatter,纯坐标分布)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 散点 | 实心圆 `6px ~ 16px`(尺寸可映射第三维) | — |
| 配色 | 小面积色序(§41 或 §1.1.8 小面积),分类着色 | §41 |
| 坐标轴 / 网格 | 沿用 §35.3 / §35.4 | §35 |
| 气泡变体 | 半透明填充 `35%` + `1px` 同色描边,避免重叠遮挡 | — |
### 40.4 平台聚类与图例(如 Facebook / Twitter / 微博 / TikTok)
| 元素 | 规格 | 来源 |
|------|------|------|
| 聚类标签 | 平台名 `14px Medium`,对应聚类主色(如蓝 / 橙 / 红) | — |
| 聚类辅文 | 数值 `12px Regular`,`#6E88B0` | — |
| 图例(XXX / XXX) | 方块 marker + 文字,置右下角 | §35.5 |
| 更新提示(如「近30分钟更新数据」) | `12px Regular`,`#6E88B0`,右下角 | — |
### 40.5 控制区(关系图侧栏)
设计稿左侧含「选择目标」下拉 + 分类勾选列表(主页发帖 / 群组发帖 / 私信 / 点赞 / 转发 / 帖子评论 / Pages内容 / 目标的主页帖子)+「添加账号 / 全选账号」按钮。
| 元素 | 规格 | 来源 |
|------|------|------|
| 「选择目标」下拉 | 沿用 §15 选择器 | §15 |
| 分类列表项 | `14px Regular`,前缀分类色圆点 `8px`,沿用 §16 多选 | §16 |
| 「添加账号」 | §5.2.2 默认按钮(含 `+` 图标) | §5.3.2 |
| 「全选账号」 | §5.2.3 文本按钮 | §5.2.3 |
| 刷新图标(右上 ⟳) | `16px`,`#6E88B0`,hover `#4096FF` | §8.2.5 |
### 40.6 交互
| 行为 | 动效 |
|------|------|
| 力导向布局 | 节点入场后力导向收敛,`tick` 动画 `~800ms` 后静止 |
| Hover 节点 | 高亮关联边与邻接节点(§40.2),其余降透明;弹出 §35.6 Tooltip(节点名 / 类型 / 指标) |
| 点击节点 | 居中聚焦该节点并展开其一度关系;可下钻 |
| 缩放 / 平移 | 滚轮缩放 `0.5×~3×`,拖拽平移画布 |
| 拖拽节点 | 可手动拖拽固定节点位置 |
---
## 41. 图表色值(Chart Palette · 多系列色序)
> 对应设计稿「8.7 图表-色值」。这是**全站图表多系列填充的权威色序**(淡色系,深蓝背景上柔和、区分度高)。按系列数量从「三色」到「十色」各有固定取色顺序——系列数为 N 时直接取「N 色」整行,**不要自行截取或乱序**。如需超过十色或特殊场景,由 UI 设计师按整体风格补充。
### 41.1 基础色板
| 色名 | 色值 | 色相 |
|------|------|------|
| 粉红 | `#FA9196` | 红 |
| 橙红 | `#FCA58F` | 橙红 |
| 橙 | `#FDB887` | 橙 |
| 浅橙 | `#FFCC80` | 黄橙 |
| 黄绿 | `#DCD69A` | 黄绿 |
| 浅绿 | `#BAE1B3` | 绿 |
| 青绿 | `#97EBCD` | 青绿 |
| 浅青 | `#9EE4EA` | 青 |
| 浅蓝 | `#A2E1F9` | 天蓝 |
| 蓝 | `#80BCFF` | 蓝 |
### 41.2 按系列数取色序
| 系列数 | 取色顺序 |
|--------|----------|
| 三色 | `#FA9196` · `#97EBCD` · `#80BCFF` |
| 四色 | `#FA9196` · `#FFCC80` · `#97EBCD` · `#A2E1F9` |
| 五色 | `#FA9196` · `#FFCC80` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 六色 | `#FA9196` · `#FFCC80` · `#BAE1B3` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 七色 | `#FA9196` · `#FCA58F` · `#FFCC80` · `#BAE1B3` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 八色 | `#FA9196` · `#FCA58F` · `#FDB887` · `#FFCC80` · `#BAE1B3` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 九色 | `#FA9196` · `#FCA58F` · `#FDB887` · `#FFCC80` · `#DCD69A` · `#BAE1B3` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 十色 | `#FA9196` · `#FCA58F` · `#FDB887` · `#FFCC80` · `#DCD69A` · `#BAE1B3` · `#97EBCD` · `#9EE4EA` · `#A2E1F9` · `#80BCFF` |
> 规律:色序整体沿「红 → 橙 → 黄绿 → 绿 → 青 → 蓝」色相环渐变铺开;系列数增加时在中间插入过渡色,**首色恒为 `#FA9196`、末色恒为 `#80BCFF`**,保证冷暖两端锚定不变。
### 41.3 与既有规范的关系
| 色序 | 用途 | 来源 |
|------|------|------|
| §41 淡色系列(本节) | 图表**多系列填充**(柱 / 折线 / 环形 / 散点分类)默认色序 | 设计稿 8.7 |
| §1.1.8 通用图表色序(高彩度) | 强对比强调:单色渐变进度条、KPI 高亮、需要醒目的单系列 | §1.1.8 |
| §27.4 / §1.1.2 语义色 | 带语义的系列(成功 / 警告 / 错误 / 信息) | §27.4 |
> 三者优先级:**有语义 → 用语义色;多分类并列 → 用 §41;需强调单值 → 用 §1.1.8 高彩度**。§36–§40 中标注的具体系列色为语义示例,实际并列分类应回落到本节色序。
### 41.4 CSS Token 参考
```css
:root {
/* §41 图表多系列色板(淡色系,设计稿 8.7) */
--chart-p-red: #FA9196;
--chart-p-redorange:#FCA58F;
--chart-p-orange: #FDB887;
--chart-p-amber: #FFCC80;
--chart-p-olive: #DCD69A;
--chart-p-green: #BAE1B3;
--chart-p-teal: #97EBCD;
--chart-p-cyan: #9EE4EA;
--chart-p-skyblue: #A2E1F9;
--chart-p-blue: #80BCFF;
}
```
```js
// 按系列数取色序(接入 ECharts 时用作 option.color)
export const CHART_PALETTES = {
3: ['#FA9196', '#97EBCD', '#80BCFF'],
4: ['#FA9196', '#FFCC80', '#97EBCD', '#A2E1F9'],
5: ['#FA9196', '#FFCC80', '#97EBCD', '#A2E1F9', '#80BCFF'],
6: ['#FA9196', '#FFCC80', '#BAE1B3', '#97EBCD', '#A2E1F9', '#80BCFF'],
7: ['#FA9196', '#FCA58F', '#FFCC80', '#BAE1B3', '#97EBCD', '#A2E1F9', '#80BCFF'],
8: ['#FA9196', '#FCA58F', '#FDB887', '#FFCC80', '#BAE1B3', '#97EBCD', '#A2E1F9', '#80BCFF'],
9: ['#FA9196', '#FCA58F', '#FDB887', '#FFCC80', '#DCD69A', '#BAE1B3', '#97EBCD', '#A2E1F9', '#80BCFF'],
10: ['#FA9196', '#FCA58F', '#FDB887', '#FFCC80', '#DCD69A', '#BAE1B3', '#97EBCD', '#9EE4EA', '#A2E1F9', '#80BCFF'],
}
```
---
@@ -0,0 +1,320 @@
# 深蓝色 UI 规范 · 05 页面范例(§42–§44)
> 《深蓝色 UI 规范》分册之一。基础令牌见 `00-基础-色彩字体间距.md`,可 import 的变量见 `tokens.css`,总目录与 §索引见 `README.md`。`§N` 编号全局唯一。
**本篇目录**:`42` 表单弹窗范例 · `43` 卡片列表页 · `44` 数据概览仪表盘
---
## 42. 表单弹窗范例(添加 / 编辑标题)
> 对应设计稿「添加/编辑标题」弹窗(截图 ui-25)。这是 §21 弹出式窗口在「专项 / 标题录入」场景下的落地范例:容器 / 遮罩 / 标题栏 / 底部按钮全部沿用 §21,本节补充截图中出现而 §21 未单列的两个原子——**复合输入行**(输入框 + 行内下拉 + 行内按钮)与**行内「添加项」实心按钮**(+ 类别一 / + 类别二),并给出完整表单项清单。配色从 §1.1.2 渐红系(必填星号)、§1.1.3 渐蓝系(ZS-002,主色 / 下拉 / 按钮)、§1.1.5 深蓝系(ZS-004,label / 边框 / 容器)取。
### 42.1 整体布局
```
┌──────────────────────────────────────────────────────────────┐
│ 添加/编辑标题 ✕ │ ← 标题居中 + 关闭
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ← 发光分割线
│ │
│ * 专项名称 [请输入内容 ] [一键导入历史专项模板▾] [预览] │ ← 复合输入行
│ * 专项简介 [请输入内容 ] │
│ * 国家地区 [ ▾ ] │
│ * 涉及类别 [ + 类别一 ] [ + 类别二 ] │ ← 行内添加按钮
│ * 显示指数 [请选择 ▾ ] │
│ │
│ [ 取消 ] [ 保存 ] │ ← 底部居中
└──────────────────────────────────────────────────────────────┘
```
### 42.2 容器与标题(沿用 §21)
| 元素 | 取值 | 来源 |
|------|------|------|
| 遮罩 / 容器 / 圆角 / 阴影 | 全部沿用 §21.1 | §21.1 |
| 容器宽度 | 大档 `800px`(复合输入行需要横向空间) | §21.1 |
| 标题文字 | `添加/编辑标题`,`20px Bold`,`#FFFFFF`,居中 | §21.2 |
| 标题发光分割线 | `linear-gradient(90deg, transparent, #1677FF, transparent)`,`1px` 高 | §8.2.4 / §21.2 |
| 关闭 `✕` | `16px`,`#6E88B0` (ZS-004-04),hover `#4096FF` (ZS-002-05) | §21.2 |
### 42.3 表单项(左 label + 右控件,沿用 §21.4)
| 行 | label | 控件 | 复用 |
|----|-------|------|------|
| 1 | `* 专项名称` | 复合输入行(见 §42.4) | §42.4 |
| 2 | `* 专项简介` | 单行输入框,占位「请输入内容」 | §14.1 |
| 3 | `* 国家地区` | 选择器(▾,空态占位) | §15.1 |
| 4 | `* 涉及类别` | 行内「添加项」实心按钮组(见 §42.5) | §42.5 |
| 5 | `* 显示指数` | 选择器(▾,占位「请选择」) | §15.1 |
- label `14px Regular`,`#C5CFE0` (ZS-004-02),右对齐;必填星号 `*` `#F5222D` (ZS-001-06) 置于 label 前(§21.4)
- 表单项纵向间距 `--spacing-4` (16px)(§7.1)
### 42.4 复合输入行(输入框 + 行内下拉 + 行内按钮)
「专项名称」行把主输入框、「一键导入历史专项模板」下拉、「预览」主按钮拼在同一行。
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 主输入框 | 沿用 §14.1,弹性撑满左侧剩余宽 | §14.1 |
| 行内下拉「一键导入历史专项模板 ▾」 | 沿用 §15.1 选择器触发器,固定宽(约 `200px`),占位文字 `#6E88B0` | §15.1 |
| 行内按钮「预览」 | §5.2.1 主按钮(实心 `#1677FF` + 白字) | §5.2.1 |
| 元素间水平间距 | `--spacing-2` (8px) | §7.1 |
| 三者垂直对齐 | 统一 `32px` 高,基线居中 | §5.1 |
### 42.5 行内「添加项」实心按钮(+ 类别一 / + 类别二)
「涉及类别」用一排实心主色蓝按钮,左侧 `+` 图标,点击进入对应类别的添加 / 编辑流程。
| 状态 | 背景 | 文字 / 图标 | 来源 |
|------|------|-------------|------|
| 默认 | `#1677FF` (ZS-002-06) | `#FFFFFF` | §5.2.1 / §5.3.2 |
| Hover | `#0958D9` (ZS-002-07) | `#FFFFFF` | §5.2.1 |
| Active | `#003EB3` (ZS-002-08) | `#FFFFFF` | §5.2.1 |
- 高度 `32px`,圆角 `2px`(§4.1),内边距 `0 14px`(§5.1)
- `+` 图标与文字同色 `#FFFFFF`,间距 `--spacing-1` (4px)(§5.3.2)
- 按钮之间间距 `--spacing-2` (8px)
- 与 §21.5.1 分段按钮组的区别:分段组是**互斥单选**(选中才高亮);此处每个按钮都是**独立的添加入口**,常驻主色实心。
### 42.6 底部操作(沿用 §21.6)
| 元素 | 规格 | 来源 |
|------|------|------|
| 对齐 | 居中(跟随截图) | §21.6 |
| 「取消」 | §5.2.2 默认按钮(Outline) | §5.2.2 |
| 「保存」 | §5.2.1 主按钮(实心蓝) | §5.2.1 |
| 顺序 / 间距 | 取消在左、保存在右;间距 `--spacing-2` (8px) | §5.3.4 |
### 42.7 CSS Token 参考
```css
:root {
/* 复合输入行 */
--field-row-gap: 8px; /* --spacing-2 元素间距 */
--field-addon-select-w: 200px; /* 行内下拉固定宽 */
--field-row-h: 32px; /* 统一高度 */
/* 行内添加按钮(沿用主按钮) */
--addbtn-bg: #1677FF; /* ZS-002-06 */
--addbtn-bg-hover: #0958D9; /* ZS-002-07 */
--addbtn-bg-active: #003EB3; /* ZS-002-08 */
--addbtn-text: #FFFFFF;
}
```
---
## 43. 卡片列表页(监测任务卡片)
> 对应设计稿「监测任务卡片列表」(截图 ui-26)。这是 §22 卡片在「监测任务管理」场景下的整页范例:顶部导航沿用 §8、左侧三级菜单沿用 §8.3、卡片容器沿用 §22,本节补充**页面工具条**(搜索 + 添加 + 排序)与**监测任务卡片**的专用布局(头部 + 总量 KPI + 平台统计矩阵 + 底部操作 + 空态)。配色从 §1.1.3 渐蓝系(ZS-002,主色 / KPI 数字)与 §1.1.5 深蓝系(ZS-004,容器 / 文字)取。
### 43.1 整体布局
```
┌─ Header(§8.2)──────────────────────────────────────────────┐
├──┬────────────────────────────────────────────────────────────┤
│三│ [🔍 搜索关键词/任务名称 ] [+ 添加任务] 排序: 创建时间▾ ↑升序 │ ← 工具条
│级│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐ │
│菜│ │ 莫某某 10247│ │ 冯某某 10247│ │ 钱某艺 10247│ │ 赵磊 12 │ │ ← 卡片网格
│单│ │ 社交媒体(52)│ │ 社交媒体(52)│ │ 社交媒体(52)│ │ ... │ │
│ │ │ [开始监测] │ │ [开始监测] │ │ [开始监测] │ │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ └──────────┘ │
└──┴────────────────────────────────────────────────────────────┘
```
### 43.2 页面工具条
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 搜索框 | 沿用 §14.2 基本搜索框,占位「搜索关键词 / 任务名称」 | §14.2 |
| 「+ 添加任务」 | §5.3.2 图标 + 文字主按钮(实心 `#1677FF` + 白字 + `+`) | §5.3.2 |
| 排序标签「排序」 | `14px Regular`,`#6E88B0` (ZS-004-04) | — |
| 排序字段「创建时间 ▾」 | §15.1 选择器触发器(小号) | §15.1 |
| 升 / 降序切换「↑ 升序 / ↓ 降序」 | §5.2.3 文本按钮,激活向 `#4096FF` (ZS-002-05) | §5.2.3 |
| 工具条 → 卡片区间距 | `--spacing-5` (24px) | §7.1 |
| 工具条内元素间距 | `--spacing-3` (12px) | §7.1 |
### 43.3 监测任务卡片
```
┌──────────────────────────────────────┐
│ 👤 莫某某 10247 │ ← 头部:头像+名称 / 右上总量
│ 2023-09-09 09:13:00 总数量 │
│ ──────────────────────────────────── │
│ 社交媒体 (52) │ ← 统计区标题
│ 微博 16 推特 8 facebook 16 │ ← 平台统计矩阵
│ 抖音 12 土豆 6 大牛网 9 │
│ ──────────────────────────────────── │
│ url: t.djxqbjhgf9y.cn 也方开夫某利地址│
│ [ 开始监测 ] │ ← 底部操作
└──────────────────────────────────────┘
```
#### 43.3.1 结构与配色
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 卡片容器 | 同 §22.1(背景 `#122347` / 边框 `#1A2F5A` / 圆角 `4px` / 一级阴影) | §22.1 |
| 头像占位 | 圆 `40px`,底 `#1A2F5A` (ZS-004-07),图标 `#6E88B0` | §22.3 |
| 任务名(莫某某) | `16px Medium`,`#FFFFFF` | §22.3 |
| 创建时间 | `12px Regular`(D-DIN 数字),`#6E88B0` (ZS-004-04) | §8.2.5 |
| 总量数值(10247) | `26px Bold`(D-DIN),`#4096FF` (ZS-002-05) | §2.1.2 / §1.1.3 |
| 总量标签「总数量」 | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
| 头部底分割线 | `1px solid #1A2F5A` (ZS-004-07) | §6.1 |
#### 43.3.2 平台统计矩阵
统计区为「平台名 + 数值」多列网格(如 社交媒体 (52) 下的 微博 / 推特 / facebook…)。
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 区标题「社交媒体 (52)」 | `14px Medium`,`#C5CFE0` (ZS-004-02);括号计数 `#6E88B0` | — |
| 平台名 | `14px Regular`,`#6E88B0` (ZS-004-04) | — |
| 平台数值(数字) | `16px Bold`(D-DIN),`#C5CFE0` (ZS-004-02) | §2.1.2 |
| 矩阵列数 | 3 列等分,行间距 `--spacing-2` (8px) | §7.1 |
| 名 / 值间距 | `--spacing-1` (4px) | §7.1 |
#### 43.3.3 底部操作
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 「开始监测」 | §5.2.1 主按钮(实心 `#1677FF`);运行中切 §5.4.2 加载态 | §5.2.1 / §5.4.2 |
| 次要链接(如 url / 地址) | §5.2.3 文本按钮 `#4096FF` (ZS-002-05) | §5.2.3 |
| 操作区上分割线 | `1px solid #2D4473`(弱化) | §6.1 |
#### 43.3.4 空态卡片变体
部分卡片暂无数据(总量 `0` + 占位):
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 总量数值 | `0`,`26px Bold`,`#6E88B0` (ZS-004-04,弱化非主色) | — |
| 占位文字「暂无数据」 | `14px Regular`,`#6E88B0` (ZS-004-04),居中 | §35.9 |
| 占位插画 | 灰度线性图标,`#4A6491` (ZS-004-05) | §35.9 |
### 43.4 卡片栅格(沿用 §22.4)
| 规则 | 取值 | 来源 |
|------|------|------|
| 列数 | 自适应,常用 3~4 列 | — |
| 卡片横 / 纵间距 | `--spacing-6` (32px) | §22.4 |
| 卡片内边距 | `--spacing-4` (16px) | §22.4 |
### 43.5 CSS Token 参考
```css
:root {
/* 监测任务卡片 */
--task-card-name: #FFFFFF;
--task-card-time: #6E88B0; /* ZS-004-04 */
--task-kpi-num: #4096FF; /* ZS-002-05 总量数字 */
--task-kpi-label: #6E88B0; /* ZS-004-04 */
--task-stat-name: #6E88B0; /* ZS-004-04 平台名 */
--task-stat-num: #C5CFE0; /* ZS-004-02 平台数值 */
--task-divider: #1A2F5A; /* ZS-004-07 头部分割 */
--task-divider-weak: #2D4473; /* ZS-004-06 操作区分割 */
--task-empty-text: #6E88B0; /* ZS-004-04 空态 */
}
```
---
## 44. 数据概览仪表盘(KPI 指标条 + 综合图表)
> 对应设计稿「资源使用率概览大屏」(截图 ui-27)。这是一张把 §35–§41 各图表与 §28 表格、§9.1 页签组合在一起的概览页。本节补充截图顶部出现而前文未单列的新原子——**KPI 指标卡条**(图标 + 标签 + 大数值 + 已用 + 迷你环 + 同比 / 环比箭头徽标),其余区块均引用既有章节。配色从 §1.1.3 渐蓝系(ZS-002,主色 / 数值)、§1.1.4 渐青系(ZS-003,辅色)、§1.1.5 深蓝系(ZS-004,容器 / 文字)取;涨跌语义色见 §44.2。
### 44.1 整体布局
```
┌──┬────────────────────────────────────────────────────────────┐
│三│ ▓ KPI 指标条(账号/设备/浏览器/代理IP/VPS/邮箱/手机卡/链卡服 使用率)▓│
│级│ ┌─ ▌XXX标题 ───────────────┐ ┌─ ▌XXX标题(资源列表)────────┐│
│菜│ │ [全部|账号资源|设备指标…] │ │ 🔍搜索 排序 ││
│单│ │ ◐异常率40% ╱╲折线异常点 │ │ 资源资料 关联设备 2024-… ││
│ │ │ 总量300 正常270 异常30 │ │ …(§28 表格行) ││
│ │ └───────────────────────────┘ └──────────────────────────────┘│
│ │ ┌─ ▌XXX标题 ───────────────────────┐ ┌─ ▌XXX标题 ──────────┐│
│ │ │ 异常分析 饼 + 折线(§37 / §39) │ │ ◐300/270 + 趋势饼 ││
│ │ └────────────────────────────────────┘ └─────────────────────┘│
└──┴────────────────────────────────────────────────────────────┘
```
### 44.2 涨跌语义色
| 语义 | 色值 | 来源 |
|------|------|------|
| 上升 / 正向(▲) | `#13C2C2` (ZS-003-06,辅色青) | §1.1.4 |
| 下降 / 负向(▼) | `#F5222D` (ZS-001-06,主色红) | §1.1.2 |
| 持平 | `#6E88B0` (ZS-004-04) | §1.1.5 |
> 涨跌仅用语义色,不混入图表多系列色;正向用辅色青(ZS-003)而非主色蓝,避免与 KPI 数值蓝撞色。
### 44.3 KPI 指标卡条
横向一排等宽指标卡,每张承载一个使用率指标(如「账号使用率 139 已用」+ 迷你环 + 同比箭头)。
```
┌────────────────────────┐
│ 🅰 账号使用率 │ ← 图标 + 标签
│ 139 ◐ 45% ▲ │ ← 大数值 + 迷你环 + 同比
│ 已用 同比 │
└────────────────────────┘
```
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 卡片容器 | 背景 `#122347` (ZS-004-08),边框 `1px solid #1A2F5A`,圆角 `4px`,一级阴影 | §22.1 |
| 指标图标 | `20px`,主色 `#4096FF` (ZS-002-05) 单色化 | §1.1.6 |
| 指标标签(账号使用率) | `14px Regular`,`#C5CFE0` (ZS-004-02) | — |
| 大数值(139) | `26px Bold`(D-DIN),`#FFFFFF` | §2.1.2 |
| 单位 / 说明「已用」 | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
| 迷你环(mini-donut) | 直径 `32px`,环宽 `4px`,已用段 `#1677FF` (ZS-002-06),底环 `#2D4473` (ZS-004-06) | §39.2 |
| 环内百分比 | `12px Bold`(D-DIN),`#FFFFFF` | §39.3 |
| 同比 / 环比箭头徽标 | `12px`,▲ 用 `#13C2C2`、▼ 用 `#F5222D`(见 §44.2) | §44.2 |
| 卡片宽度 | 等分横排(8 张则各占 1/8),最小宽 `160px` | — |
| 卡片间距 | `--spacing-4` (16px) | §7.1 |
| 卡片内边距 | `--spacing-4` (16px) | §7.1 |
### 44.4 区块组合引用
| 区块 | 复用 | 备注 |
|------|------|------|
| 区块标题(▌XXX标题) | §35.2 图表标题(主色蓝竖条 + 文字) | — |
| 分类页签(全部 / 账号资源 / …) | §9.1.1 横向页签 | 激活 `#4096FF` + `2px` 下划线 |
| 日期筛选(右上 `2023-02`) | §17.1 日期触发器 | — |
| 异常率环形图(40% / 总量300 / 正常270 / 异常30) | §39 环形图 | 正常段主色蓝、异常段 `#F5222D`(§44.2) |
| 折线异常点图(1月10日 异常率12.9%) | §37.3 单线异常点图 + §35.6 Tooltip | 异常点用红 `#F5222D` 强调 |
| 资源列表表格 | §28 表格 + §14.2 搜索 + §13 分页 | — |
| 异常分析饼 / 趋势饼 | §39 环形图 | 多系列回落 §41 色序 |
| 容器 / 内边距 / 多图间距 | §35.1 图表容器 | `--spacing-5` / `--spacing-6` |
### 44.5 交互
| 行为 | 规则 |
|------|------|
| KPI 卡 hover | 卡片阴影升二级(§3.1.4),迷你环高亮,弹出 §35.6 Tooltip(已用 / 总量 / 同比明细) |
| 页签切换 | 切换分类后下方图表与表格联动刷新(§9.1.1) |
| 图表 hover | 沿用各图表节交互(§37.6 / §39.4 等) |
| 日期筛选变更 | 重新拉取区块数据,图表入场动画重放 |
### 44.6 CSS Token 参考
```css
:root {
/* KPI 指标卡 */
--kpi-card-bg: #122347; /* ZS-004-08 */
--kpi-card-border: #1A2F5A; /* ZS-004-07 */
--kpi-icon: #4096FF; /* ZS-002-05 */
--kpi-label: #C5CFE0; /* ZS-004-02 */
--kpi-value: #FFFFFF;
--kpi-sub: #6E88B0; /* ZS-004-04 已用 */
--kpi-ring-used: #1677FF; /* ZS-002-06 迷你环已用段 */
--kpi-ring-track: #2D4473; /* ZS-004-06 迷你环底 */
/* 涨跌语义 */
--trend-up: #13C2C2; /* ZS-003-06 上升(辅色青) */
--trend-down: #F5222D; /* ZS-001-06 下降(红) */
--trend-flat: #6E88B0; /* ZS-004-04 持平 */
}
```
@@ -0,0 +1,82 @@
# 深蓝色 UI 规范(拆分版)
面向开发的深蓝主题设计规范。原单文件 `../ui修改-深蓝.md`(约 5200 行)已按模块拆分到本目录,便于按需查阅、减少 diff 冲突,也方便配合 AI 只加载相关分册。
> **颜色唯一来源**:所有色值以 **§1 色彩**(见 [`00-基础-色彩字体间距.md`](./00-基础-色彩字体间距.md))为准,可执行变量见 [`tokens.css`](./tokens.css)。业务代码 **import `tokens.css` 并用 `var(--token)`**,不要硬编码 HEX。
## 分册
| 文件 | 章节 | 内容 |
|------|------|------|
| [`00-基础-色彩字体间距.md`](./00-基础-色彩字体间距.md) | §1–§7 | 色彩 / 字体 / 阴影 / 圆角 / 按钮 / 分割线 / 间距 |
| [`01-导航.md`](./01-导航.md) | §8–§13 | 导航组件 / 锚点页签 / 步骤条 / 面包屑 / 页头 / 分页 |
| [`02-表单.md`](./02-表单.md) | §14–§21 | 输入框 / 选择器 / 单复选 / 日期 / 穿梭框 / 树选择 / 上传 / 弹窗 |
| [`03-数据展示.md`](./03-数据展示.md) | §22–§34 | 卡片 / 折叠 / 描述列表 / 列表 / 时间轴 / 进度条 / 表格 / 标签 / 树 / 对话框 / 提示 / 气泡确认 |
| [`04-图表.md`](./04-图表.md) | §35–§41 | 图表通用 / 柱状 / 折线 / 甘特 / 环形 / 关系散点 / 图表色值 |
| [`05-页面范例.md`](./05-页面范例.md) | §42–§44 | 表单弹窗 / 卡片列表页 / 数据概览仪表盘 |
| [`tokens.css`](./tokens.css) | — | 全量 CSS 变量(颜色 / 间距 / 圆角 / 阴影 / 字体),可直接 import |
## § 索引(编号 → 所在文件)
章节编号 `§N` 全局唯一、跨文件不变;文中 `§N` / `§N.x` 引用按下表定位。
| § | 标题 | 文件 |
|---|------|------|
| §1 | 色彩 | 00 |
| §2 | 字体 | 00 |
| §3 | 阴影 | 00 |
| §4 | 圆角 | 00 |
| §5 | 按钮 | 00 |
| §6 | 分割线 | 00 |
| §7 | 间距 | 00 |
| §8 | 导航组件 | 01 |
| §9 | 锚点与页签 | 01 |
| §10 | 步骤条 | 01 |
| §11 | 面包屑 | 01 |
| §12 | 页头 | 01 |
| §13 | 分页 | 01 |
| §14 | 输入框(搜索框) | 02 |
| §15 | 选择器 | 02 |
| §16 | 单选与多选 | 02 |
| §17 | 日期选择器 | 02 |
| §18 | 穿梭框 | 02 |
| §19 | 树选择 | 02 |
| §20 | 上传 | 02 |
| §21 | 弹出式窗口(Modal) | 02 |
| §22 | 卡片 | 03 |
| §23 | 折叠面板 | 03 |
| §24 | 描述列表 | 03 |
| §25 | 列表 | 03 |
| §26 | 时间轴(节点型) | 03 |
| §27 | 进度条 | 03 |
| §28 | 表格 | 03 |
| §29 | 标签 | 03 |
| §30 | 树形控件 | 03 |
| §31 | 时间轴(长文本) | 03 |
| §32 | 对话框(确认框) | 03 |
| §33 | 全局提示 | 03 |
| §34 | 气泡确认 | 03 |
| §35 | 图表通用规范 | 04 |
| §36 | 柱状图 | 04 |
| §37 | 折线图 | 04 |
| §38 | 甘特图 | 04 |
| §39 | 环形图 | 04 |
| §40 | 关系图 / 散点图 | 04 |
| §41 | 图表色值 | 04 |
| §42 | 表单弹窗范例 | 05 |
| §43 | 卡片列表页 | 05 |
| §44 | 数据概览仪表盘 | 05 |
## 约定
- **颜色**:以 §1 为准,代码用 `tokens.css` 的 `var(--token)`。功能语义色(成功/警告/错误/信息/处理中)见 §1.1.9 / `--func-*`。
- **间距**:仅用 §7 的 8px 阶梯(`--spacing-1..8`),禁用 5px / 13px 等非阶梯值。
- **圆角**:按钮/输入/标签 `2px`,卡片/分栏/弹窗 `4px`(`--radius-*`)。
- **跨节引用**:保留 `§N` 编号,用上方「§ 索引」定位文件。
- **名称易混点**:§26 时间轴(节点型) ↔ §31 时间轴(长文本);§21 弹出式窗口(Modal) ↔ §32 对话框(确认框)。
- **图表两套色板**:§1.1.8(高彩度,强调单值)与 §41(淡色系,多分类并列)并存,优先级见 §41.3。
## 维护
- 改色 / 改间距 → 先改 **`tokens.css` + §1/§7**,组件分册回落引用,不在分册里另立硬编码。
- 新增整页范例 → 进 `05-页面范例.md`,沿用既有组件 § 与 `tokens.css`。
@@ -0,0 +1,211 @@
/* =============================================================================
* 深蓝色 UI 规范 · 设计令牌(tokens.css)
* -----------------------------------------------------------------------------
* 这是《深蓝色 UI 规范》全站颜色 / 间距 / 圆角 / 阴影 / 字体的唯一可执行来源。
* 开发时优先 import 本文件并使用 var(--token),不要在业务代码里硬编码 HEX。
*
* 文档对应:
* §1 色彩 → 原始色阶 --zs-*、功能语义 --func-*、装饰/图表色板
* §2 字体 → --font-* / --fs-*
* §3 阴影 → --shadow-*
* §4 圆角 → --radius-*
* §7 间距 → --spacing-*
* 组件级 token(--card-* / --modal-* / --kpi-* 等)保留在各分册的「CSS Token 参考」
* 小节中,均回落到本文件的原始/语义 token。
* ========================================================================== */
:root {
/* ---------------------------------------------------------------------------
* 1. 原始色阶(§1.1.2–§1.1.5)—— 01 最浅 → 10 最深
* ------------------------------------------------------------------------ */
/* 渐红系 ZS-001 —— 警示 / 错误 / 强调 */
--zs-001-01: #FFF1F0;
--zs-001-02: #FFCCC7;
--zs-001-03: #FFA39E;
--zs-001-04: #FF7875;
--zs-001-05: #FF4D4F;
--zs-001-06: #F5222D;
--zs-001-07: #CF1322;
--zs-001-08: #A8071A;
--zs-001-09: #820014;
--zs-001-10: #5C0011;
/* 渐蓝系 ZS-002 —— 信息 / 链接 / 次级操作(含主色) */
--zs-002-01: #E6F4FF;
--zs-002-02: #BAE0FF;
--zs-002-03: #91CAFF;
--zs-002-04: #69B1FF;
--zs-002-05: #4096FF;
--zs-002-06: #1677FF;
--zs-002-07: #0958D9;
--zs-002-08: #003EB3;
--zs-002-09: #002C8C;
--zs-002-10: #001D6C;
/* 渐青系 ZS-003 —— 成功 / 在线 / 数据高亮(含辅色) */
--zs-003-01: #E6FFFB;
--zs-003-02: #B5F5EC;
--zs-003-03: #87E8DE;
--zs-003-04: #5CDBD3;
--zs-003-05: #36CFC9;
--zs-003-06: #13C2C2;
--zs-003-07: #08979C;
--zs-003-08: #006D75;
--zs-003-09: #00474F;
--zs-003-10: #002329;
/* 深蓝系 ZS-004 —— 背景 / 容器 / 导航 / 文字(主题核心) */
--zs-004-01: #E8EDF5;
--zs-004-02: #C5CFE0;
--zs-004-03: #9BADC8;
--zs-004-04: #6E88B0;
--zs-004-05: #4A6491;
--zs-004-06: #2D4473;
--zs-004-07: #1A2F5A;
--zs-004-08: #122347;
--zs-004-09: #0C1836;
--zs-004-10: #060E24;
/* ---------------------------------------------------------------------------
* 2. 功能角色色(§1.1.6)—— 从色阶提取的语义别名
* ------------------------------------------------------------------------ */
--color-primary: var(--zs-002-06); /* #1677FF 主操作 */
--color-primary-hover: var(--zs-002-07); /* #0958D9 */
--color-primary-active: var(--zs-002-08); /* #003EB3 */
--color-link: var(--zs-002-05); /* #4096FF 链接 / 文本按钮 */
--color-accent: var(--zs-003-06); /* #13C2C2 辅色 */
--color-text: var(--zs-004-02); /* #C5CFE0 正文字体 */
--color-text-secondary: var(--zs-004-04); /* #6E88B0 次级字体 */
--color-text-disabled: var(--zs-004-05); /* #4A6491 禁用字体 */
--color-text-strong: #FFFFFF; /* 标题 / 强调 */
--color-bg-page: var(--zs-004-09); /* #0C1836 页面背景 */
--color-bg-container: var(--zs-004-07); /* #1A2F5A 容器背景 */
--color-bg-card: var(--zs-004-08); /* #122347 卡片背景 */
--color-bg-deepest: var(--zs-004-10); /* #060E24 全局底色 */
--color-border: var(--zs-004-07); /* #1A2F5A 容器边框 */
--color-border-strong: var(--zs-004-04); /* #6E88B0 控件边框 */
--color-white: #FFFFFF;
/* ---------------------------------------------------------------------------
* 3. 功能语义色(§1.1.9)—— 成功/警告/错误/信息/处理中
* 成功绿、警告橙为标准色系外补充色;其余复用色阶。
* ------------------------------------------------------------------------ */
--func-success: #52C41A; /* 补充色 · 成功绿 */
--func-warning: #FAAD14; /* 补充色 · 警告橙 */
--func-error: var(--zs-001-06); /* #F5222D */
--func-info: var(--zs-002-06); /* #1677FF */
--func-processing: var(--zs-003-06); /* #13C2C2 */
/* 浅底变体(Message / Alert / Tag 背景,16% 透明度) */
--func-success-bg: rgba(82, 196, 26, 0.16);
--func-warning-bg: rgba(250, 173, 20, 0.16);
--func-error-bg: rgba(245, 34, 45, 0.16);
--func-info-bg: rgba(22, 119, 255, 0.16);
/* ---------------------------------------------------------------------------
* 4. 小面积装饰色(§1.1.7)—— 仅点缀 / 徽标,勿做大面积背景
* ------------------------------------------------------------------------ */
--deco-violet: #6388FF;
--deco-danger: #F5222D;
--deco-brand-blue: #12306C;
--deco-teal: #478880;
--deco-alert: #950404;
/* ---------------------------------------------------------------------------
* 5. 字体(§2)
* ------------------------------------------------------------------------ */
--font-zh: 'Noto Sans S Chinese', 'Noto Sans SC', system-ui, sans-serif;
--font-num: 'D-DIN', 'Noto Sans S Chinese', sans-serif; /* 数据数字 */
--font-digital:'DS-Digital', 'D-DIN', sans-serif; /* 大屏特殊数字 */
--font-logo: 'DOUYUFont', 'Noto Sans S Chinese', sans-serif;
--font-table: 'Arial', 'Noto Sans S Chinese', sans-serif; /* 表格数字 */
/* 常用字号(后台系统,§2.1.2) */
--fs-12: 12px;
--fs-14: 14px;
--fs-16: 16px;
--fs-18: 18px;
--fs-20: 20px;
--fs-24: 24px;
--fs-26: 26px;
/* ---------------------------------------------------------------------------
* 6. 间距(§7)—— 8px 基准,密(≤16) / 疏(≥24),禁用非阶梯值
* ------------------------------------------------------------------------ */
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-5: 24px;
--spacing-6: 32px;
--spacing-7: 40px;
--spacing-8: 56px;
/* ---------------------------------------------------------------------------
* 7. 圆角(§4)
* ------------------------------------------------------------------------ */
--radius-button: 2px;
--radius-input: 2px;
--radius-tag: 2px;
--radius-card: 4px;
--radius-panel: 4px;
--radius-modal: 4px;
/* ---------------------------------------------------------------------------
* 8. 阴影(§3.1.4)
* ------------------------------------------------------------------------ */
--shadow-1: 0 2px 8px rgba(22, 119, 255, 0.12); /* 卡片悬浮 */
--shadow-2: 0 4px 16px rgba(22, 119, 255, 0.24); /* hover / 弹出层 */
--shadow-3: 0 8px 32px rgba(6, 14, 36, 0.6); /* Dialog / Drawer */
--shadow-modal-mask: rgba(6, 14, 36, 0.6); /* 遮罩 */
/* 常用渐变 */
--gradient-header: linear-gradient(180deg, #122347 0%, #0C1836 100%); /* 顶栏 §8.2.1 */
--gradient-title-line: linear-gradient(90deg, transparent 0%, #1677FF 50%, transparent 100%); /* 标题发光线 §8.2.4 */
}
/* =============================================================================
* 图表多系列色板
* ========================================================================== */
:root {
/* 通用图表色序(§1.1.8,面积大:柱/折线/饼,高彩度) */
--chart-g-1: #1677FF;
--chart-g-2: #13C2C2;
--chart-g-3: #4096FF;
--chart-g-4: #36CFC9;
--chart-g-5: #69B1FF;
--chart-g-6: #5CDBD3;
--chart-g-7: #91CAFF;
--chart-g-8: #87E8DE;
/* 小面积图表色序(§1.1.8,散点/迷你图/徽标) */
--chart-s-1: #4096FF;
--chart-s-2: #36CFC9;
--chart-s-3: #FF4D4F;
--chart-s-4: #FFA39E;
--chart-s-5: #6388FF;
--chart-s-6: #478880;
/* 多系列填充色板(§41,淡色系,分类并列默认色序) */
--chart-p-red: #FA9196;
--chart-p-redorange: #FCA58F;
--chart-p-orange: #FDB887;
--chart-p-amber: #FFCC80;
--chart-p-olive: #DCD69A;
--chart-p-green: #BAE1B3;
--chart-p-teal: #97EBCD;
--chart-p-cyan: #9EE4EA;
--chart-p-skyblue: #A2E1F9;
--chart-p-blue: #80BCFF;
}
/*
* 图表配色优先级(§41.3):
* 有语义 → 用 --func-*;多分类并列 → 用 --chart-p-*(§41 色板);
* 需强调单值 → 用 --chart-g-* / --chart-s-*(§1.1.8 高彩度)。
*
* 接入 ECharts 时按系列数取 option.color,见 §41.4 的 CHART_PALETTES。
*/
@@ -0,0 +1,156 @@
# DeerFlow 上游升级对比与回合建议
> 对比对象:`bytedance/deer-flow`(上游,HEAD `9f3be2a` @ 2026-05-30)vs 本仓库 fork。
> 生成日期:2026-05-31。
> 说明:按用户要求,**多语言 (i18n)** 与 **播客 / TTS (podcast)** 不在本次升级范围内,已剔除。
---
## 0. 背景与对比结论
| 项 | 结论 |
|---|---|
| fork 切出时间 | **≈ 2026-05-12**(与目录名 `offline-backend-20260512` 吻合) |
| 上游当前 HEAD | `9f3be2a` @ 2026-05-30,完整历史 2186 提交 |
| fork 之后上游新增 | **95 个提交**(7 feat / 1 perf / 60 fix / 依赖与文档若干) |
| 后端核心框架 `packages/harness/deerflow/` | **高度同源、目录结构完全一致**,差异主要来自这 95 个提交 |
| 前端 | 技术栈已分道扬镳:上游仍是 **Next.js**,本仓库已迁到 **Vite + React Router**,文件几乎全不同 |
「升级」分两类,本文都覆盖:
- **A 类**:fork 之后上游真正新增的(95 提交)——同源、可 cherry-pick,**优先回合**。
- **B 类**:fork 时就裁掉/换了路线、上游一直保留的能力——成本高、按业务取舍。
---
## 1. 后端升级(A 类:fork 后上游新增)
### 1.1 新能力(feat / perf)
| 日期 | 升级 | 提交 | 说明 |
|---|---|---|---|
| 05-29 | ToolOutputBudgetMiddleware | #3303 | 给超大工具输出做预算保护,防止单个结果撑爆上下文 |
| 05-28 | MiMo reasoning 支持 | #3298 | 兼容小米 MiMo 模型思维链回传 |
| 05-21 | 链路追踪命名优化 | #3101 | 图名改 `lead_agent`、用自定义 agent 名作 run_name |
| 05-21 | 循环检测延迟告警 | #2752 | 警告延迟注入,减少对正常多轮对话的误打断 |
| 05-20 | Sandbox 下载文件接口 | #3038 | 可把沙箱内产物取回 |
| 05-15 | Discord 渠道增强 | #2842 | 仅@提及、thread 路由、typing 指示 |
| 05-13 | 子代理 token 用量流式上报 | #2882 | subagent 消耗实时汇总到 header,利于展示/计费 |
| 05-12 | perf:线程元数据过滤下推 SQL | #2865 | 线程列表筛选走 SQL,性能提升 |
能力性增强(fix 前缀实为增强):MCP 跨调用持久化会话、支持有状态 server(#3089 / #3203);摘要触发阈值调优(#3174)。
### 1.2 重要稳定性修复
| 主题 | 提交 | 价值 |
|---|---|---|
| 摘要压缩后保留消息 | #3280 | 数据正确性关键 |
| 重启后从持久化恢复 runs / interrupted 状态 | #2932(破坏性)、#2989、#3152、#3155 | 网关重启不丢运行 |
| JWT secret 持久化、多 worker 共享网关 token | #2933、#3184 | 重启/多进程登录态不失效 |
| memory 更新按 agent 隔离 + 包裹 JSON 解析健壮性 | #2941、#3252 | 记忆串号/解析修复 |
| MCP 配置接口脱敏 | #2667 | 防密钥泄露 |
| 阻塞 IO 移出事件循环 | #3311、#2822(+检测门禁 #3229) | 消除事件循环阻塞 |
### 1.3 依赖升级
- Python:`langsmith 0.7.36 → 0.8.0`、`idna 3.13 → 3.15`
### 1.4 怎么做(回合 A 类后端升级)
> 前提:本仓库 `packages/harness/deerflow/` 与上游高度同源,cherry-pick 冲突面小。
1. **加上游为远程并拉取历史**(在后端所在 git 仓库根执行):
```bash
git remote add upstream https://github.com/bytedance/deer-flow.git
git fetch upstream
```
2. **逐条预览某个提交的改动范围**:
```bash
git show <commit_hash> --stat
git show <commit_hash> -- backend/packages/harness/deerflow/...
```
3. **单条回合**(推荐用 `-x` 记录来源;如路径前缀不一致需先确认):
```bash
git cherry-pick -x <commit_hash>
# 冲突时:解决后
git cherry-pick --continue
```
> 注意:上游后端在 `backend/` 子目录,本仓库后端在 `offline-backend-20260512/backend/`。若 cherry-pick 因路径不匹配失败,改用按文件打补丁:
> ```bash
> git show <hash> -- backend/<path> | git apply --directory=offline-backend-20260512/backend -p2 -3
> ```
4. **回合后必跑校验**(在 `offline-backend-20260512/backend/`):
```bash
make test # PYTHONPATH=. uv run pytest tests/ -v
make lint
```
重点确认 `tests/test_harness_boundary.py`(harness→app 防火墙)与中间件链顺序未被破坏。
5. **依赖升级**:同步上游 `pyproject.toml` 对应行后执行 `uv lock && make install`;离线环境需同步更新 `requirements-offline.txt` / `wheelhouse`。
---
## 2. 前端升级(B 类:上游有、本仓库缺)
> 已剔除多语言、播客。下表为关键词核验结果(本仓库 vs 上游命中文件数)。
| 功能 | 本仓库 | 上游 | 状态 |
|---|---|---|---|
| Prose 文本润色 | 0 | 8 | 上游独有 |
| Replay 研究回放 | 2 | 29 | 上游完整,本仓库近乎无 |
| PPT / PPTX 生成 | 2 | 14 | 上游完整,本仓库近乎无 |
| Landing 落地页 | 0 | 11 | 上游独有(营销/介绍页,按需) |
| 静态 demo 模式 | 无 | #3170 | fork 后上游新增 |
前端体验类修复(可参考实现):流式渲染、消息去重、历史消息 Mermaid 预览、新建会话即时入侧栏。
前端依赖升级:`brace-expansion 1.x → 5.0.5`(安全)、`uuid 10 → 14`。
### 2.1 怎么做(前端能力移植)
> 前端技术栈已不同(Next.js → Vite + React Router),**不能直接 cherry-pick**,需按"读上游实现 → 适配到本仓库"的方式重写。
通用步骤:
1. 在上游 `frontend/src/` 定位目标功能的页面/组件/hook 与对应的后端 API 调用。
2. 后端能力是否具备:Prose / PPT / Replay 依赖后端相应接口,先确认本仓库后端有无对应路由(多为 deep_research/skills 相关),**没有则前端无法独立完成**,需连带回合后端。
3. 适配差异点:
- 路由:上游 app router (`src/app/...`) → 本仓库 `src/pages/` + `PageRoutes.tsx` / `WorkspaceRoutes.tsx`。
- 导航 API:用本仓库 `src/shims/`(`next/link`、`next/navigation` 已 polyfill)。
- 数据流:沿用本仓库 TanStack Query + `@langchain/langgraph-sdk` 流式。
4. 验证:`pnpm typecheck` + `pnpm dev` 实测。
---
## 3. 推荐升级清单(按优先级)
### P0 — 强烈建议(低风险、高价值,A 类)
- **摘要压缩后保留消息 #3280** —— 数据正确性,直接影响长对话可用性。
- **重启后恢复 runs / interrupted #2932 #2989 #3152 #3155** —— 生产稳定性核心。
- **JWT secret 持久化 / 多 worker 共享 token #2933 #3184** —— 否则重启或多进程部署会掉登录态。
- **阻塞 IO 移出事件循环 #3311 #2822** —— 并发吞吐与响应延迟改善。
### P1 — 建议(实用增强)
- **ToolOutputBudgetMiddleware #3303** —— 防超大工具输出拖垮上下文,对 agent 稳定性有实际帮助。
- **MCP 持久化会话 / 有状态 server #3089 #3203** —— 若用到有状态 MCP server 则必需。
- **MCP 配置接口脱敏 #2667** —— 安全加固。
- **子代理 token 用量流式上报 #2882** —— 计费/可观测。
- **线程元数据过滤下推 SQL #2865(perf)** —— 列表性能。
### P2 — 按需(看是否用到对应模型/渠道)
- MiMo reasoning #3298(用小米模型才需要)
- Discord 渠道增强 #2842(用 Discord 才需要)
- Sandbox 下载文件接口 #3038、追踪命名 #3101、循环检测延迟告警 #2752
### 前端(按业务价值)
- **Replay 研究回放** —— 对"深研/写作"类产品体验提升明显,建议优先(需后端配合)。
- **Prose 文本润色** —— 与写作工作台契合,建议评估接入。
- **PPT 生成** —— 若有汇报/导出诉求再做。
- **Landing 落地页** —— 仅当面向外部用户时考虑。
### 暂不做
- 多语言 i18n(本次明确排除)
- 播客 / TTS(本次明确排除)
---
## 4. 建议执行节奏
1. 先做 **P0 后端 4 项** + 跑全量测试,单独提交一个"上游稳定性回合"分支。
2. 再做 **P1**,逐条 cherry-pick、逐条验证。
3. 前端 **Replay / Prose** 立项评估(确认后端依赖),单独排期重写适配。
4. 依赖升级随回合同步,离线包 (`wheelhouse` / `requirements-offline.txt`) 一并更新。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,514 @@
# 浅红色 UI 规范 · 一、基础样式
> 返回 [规范目录](README.md) | 本篇:色彩 · 字体 · 阴影 · 圆角 · 间距 · 语义状态色
---
## 1. 色彩
### 1.1 标准色
色彩体系分为**主色**与**辅色**两大类。
- **主色**:渐红系(ZS-001)、渐蓝系(ZS-002),用于品牌表达与核心交互。
- **辅色**:渐橙系(FS-001)、渐绿系(FS-002)、渐青系(FS-003)、中性灰系(FS-004),用于状态提示、数据可视化与文字 / 背景 / 分割线。
每个色系按明度从浅到深编号(`-01` 最浅,数字越大越深),并附两种渐变色(`-14` 线性渐变、`-15` 径向渐变)。本主题为**浅色主题**:以白 / 浅灰作大面积背景,以深色作正文,红色为品牌强调主色。
---
#### 1.1.1 色系总览
| 类别 | 色系 | 用途定位 | 代码前缀 |
|------|------|----------|----------|
| 主色 | 渐红系 | 品牌主色、强调操作、警示与错误 | ZS-001 |
| 主色 | 渐蓝系 | 信息、链接、次级操作 | ZS-002 |
| 辅色 | 渐橙系 | 警告、进行中、提示标签 | FS-001 |
| 辅色 | 渐绿系 | 成功、在线、通过状态 | FS-002 |
| 辅色 | 渐青系 | 数据高亮、图表、辅助信息 | FS-003 |
| 辅色 | 中性灰系 | 背景、文字、图标、分割线 | FS-004 |
> 每个色系内:`-01 ~ -03` 为**浅色**(用于大面积底色);`-04 ~ -09` 为**中亮色**(用于标签、图表、提示等);`-10` 及以后为**深色**(用于小面积按钮、图标等);`-14 / -15` 为渐变色(用于特殊装饰性小面积底色)。
---
#### 1.1.2 主色 · 渐红系(ZS-001)
品牌主色系。用于主操作按钮、重要强调、错误与危险告警。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| ZS-001 | `#CF1322` | 深色 | 品牌代表红(默认主色基准) |
| ZS-001-01 | `#FEFAFA` | 浅色 | 极浅红,大面积底色 |
| ZS-001-02 | `#FEF6F6` | 浅色 | 浅红底色 |
| ZS-001-03 | `#FEEDED` | 浅色 | 浅红底色 / 错误背景 |
| ZS-001-04 | `#FDDADC` | 中亮色 | — |
| ZS-001-05 | `#FCB5B9` | 中亮色 | — |
| ZS-001-06 | `#FA9196` | 中亮色 | — |
| ZS-001-07 | `#F86C73` | 中亮色 | 浅红 Tag / 图表 |
| ZS-001-08 | `#F74750` | 中亮色 | — |
| ZS-001-09 | `#F5222D` | 中亮色 | **标准红**,主按钮 / 图标 |
| ZS-001-10 | `#BC0D1E` | 深色 | 深红,Hover |
| ZS-001-11 | `#A8071A` | 深色 | 深红,Active / Press |
| ZS-001-12 | `#820014` | 深色 | 极深红 |
| ZS-001-13 | `#5C0011` | 深色 | 最深红 |
| ZS-001-14 | `#F5222D → #A8071A` | 线性渐变 | 装饰底色 |
| ZS-001-15 | `#F86C73 → #F5222D` | 径向渐变 | 装饰底色 |
---
#### 1.1.3 主色 · 渐蓝系(ZS-002)
用于信息提示、链接、次级按钮与图表。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| ZS-002 | `#0062D9` | 深色 | 蓝色代表色(基准) |
| ZS-002-01 | `#F9FCFF` | 浅色 | 极浅蓝,大面积底色 |
| ZS-002-02 | `#F4F9FF` | 浅色 | 浅蓝底色 |
| ZS-002-03 | `#EAF4FF` | 浅色 | 浅蓝底色 / 信息背景 |
| ZS-002-04 | `#D4E9FF` | 中亮色 | — |
| ZS-002-05 | `#AAD2FF` | 中亮色 | — |
| ZS-002-06 | `#80BCFF` | 中亮色 | — |
| ZS-002-07 | `#55A6FE` | 中亮色 | 浅蓝 Tag / 图表 |
| ZS-002-08 | `#2B8FFE` | 中亮色 | — |
| ZS-002-09 | `#0079FE` | 中亮色 | **标准蓝**,链接 / 信息主色 |
| ZS-002-10 | `#0056C6` | 深色 | 深蓝,Hover |
| ZS-002-11 | `#004AB3` | 深色 | 深蓝,Active |
| ZS-002-12 | `#00368C` | 深色 | 极深蓝 |
| ZS-002-13 | `#002466` | 深色 | 最深蓝 |
| ZS-002-14 | `#0079FE → #004AB3` | 线性渐变 | 装饰底色 |
| ZS-002-15 | `#55A6FE → #0079FE` | 径向渐变 | 装饰底色 |
---
#### 1.1.4 辅色 · 渐橙系(FS-001)
用于警告、进行中、待处理等中性偏提醒状态。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| FS-001 | `#D97B00` | 深色 | 橙色代表色(基准) |
| FS-001-01 | `#FFFCF9` | 浅色 | 极浅橙,大面积底色 |
| FS-001-02 | `#FFFAF4` | 浅色 | 浅橙底色 |
| FS-001-03 | `#FFF6EA` | 浅色 | 浅橙底色 / 警告背景 |
| FS-001-04 | `#FFEED4` | 中亮色 | — |
| FS-001-05 | `#FFDDAA` | 中亮色 | — |
| FS-001-06 | `#FFCC80` | 中亮色 | — |
| FS-001-07 | `#FFBB55` | 中亮色 | 浅橙 Tag / 图表 |
| FS-001-08 | `#FFAA2B` | 中亮色 | — |
| FS-001-09 | `#FF9900` | 中亮色 | **标准橙**,警告主色 |
| FS-001-10 | `#C66D00` | 深色 | 深橙,Hover |
| FS-001-11 | `#B35F00` | 深色 | 深橙,Active |
| FS-001-12 | `#8C4600` | 深色 | 极深橙 |
| FS-001-13 | `#663000` | 深色 | 最深橙 |
| FS-001-14 | `#FF9900 → #B35F00` | 线性渐变 | 装饰底色 |
| FS-001-15 | `#FFBB55 → #FF9900` | 径向渐变 | 装饰底色 |
---
#### 1.1.5 辅色 · 渐绿系(FS-002)
用于成功、在线、通过、已完成等正向状态。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| FS-002 | `#1EB07F` | 深色 | 绿色代表色(基准) |
| FS-002-01 | `#FAFEFD` | 浅色 | 极浅绿,大面积底色 |
| FS-002-02 | `#F6FDFB` | 浅色 | 浅绿底色 |
| FS-002-03 | `#EEFCF7` | 浅色 | 浅绿底色 / 成功背景 |
| FS-002-04 | `#DCF8EE` | 中亮色 | — |
| FS-002-05 | `#BAF1DD` | 中亮色 | — |
| FS-002-06 | `#97EBCD` | 中亮色 | — |
| FS-002-07 | `#74E4BC` | 中亮色 | 浅绿 Tag / 图表 |
| FS-002-08 | `#52DDAB` | 中亮色 | — |
| FS-002-09 | `#2FD69A` | 中亮色 | **标准绿**,成功主色 |
| FS-002-10 | `#189D72` | 深色 | 深绿,Hover |
| FS-002-11 | `#118A65` | 深色 | 深绿,Active |
| FS-002-12 | `#07634B` | 深色 | 极深绿 |
| FS-002-13 | `#043D30` | 深色 | 最深绿 |
| FS-002-14 | `#2FD69A → #118A65` | 线性渐变 | 装饰底色 |
| FS-002-15 | `#74E4BC → #2FD69A` | 径向渐变 | 装饰底色 |
---
#### 1.1.6 辅色 · 渐青系(FS-003)
用于数据高亮、图表配色、辅助信息标识。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| FS-003 | `#2F9ACC` | 深色 | 青色代表色(基准) |
| FS-003-01 | `#FBFDFE` | 浅色 | 极浅青,大面积底色 |
| FS-003-02 | `#F7FCFE` | 浅色 | 浅青底色 |
| FS-003-03 | `#EFFAFE` | 浅色 | 浅青底色 |
| FS-003-04 | `#E0F5FD` | 中亮色 | — |
| FS-003-05 | `#C1EBFB` | 中亮色 | — |
| FS-003-06 | `#A2E1F9` | 中亮色 | — |
| FS-003-07 | `#83D7F7` | 中亮色 | 浅青 Tag / 图表 |
| FS-003-08 | `#64CDF5` | 中亮色 | — |
| FS-003-09 | `#45C3F3` | 中亮色 | **标准青**,数据高亮主色 |
| FS-003-10 | `#2788B9` | 深色 | 深青,Hover |
| FS-003-11 | `#1E76A6` | 深色 | 深青,Active |
| FS-003-12 | `#115580` | 深色 | 极深青 |
| FS-003-13 | `#0B3959` | 深色 | 最深青 |
| FS-003-14 | `#45C3F3 → #1E76A6` | 线性渐变 | 装饰底色 |
| FS-003-15 | `#83D7F7 → #45C3F3` | 径向渐变 | 装饰底色 |
---
#### 1.1.7 辅色 · 中性灰系(FS-004)
承载页面背景、正文、备注文字、图标与分割线。浅色主题的核心承载色系。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| FS-004 | `#222222` | 深色 | 中性代表色(基准) |
| FS-004-01 | `#FFFFFF` | 浅色 | 纯白,页面 / 容器背景、白色文字 |
| FS-004-02 | `#FAFAFA` | 浅色 | 浅灰背景 |
| FS-004-03 | `#F5F5F5` | 浅色 | 浅灰背景 / 分割区底 |
| FS-004-04 | `#E8E8E8` | 中亮色 | 分割线 / 边框 |
| FS-004-05 | `#DBDBDB` | 中亮色 | 边框 / 禁用底 |
| FS-004-06 | `#C1C1C1` | 中亮色 | 禁用文字 / 占位符 |
| FS-004-07 | `#A6A6A6` | 中亮色 | 备注文字 / 图标 |
| FS-004-08 | `#7F7F7F` | 中亮色 | 次级文字 |
| FS-004-09 | `#575757` | 中亮色 | 次要正文 |
| FS-004-10 | `#3D3D3D` | 深色 | 正文加强 |
> 灰系用途分层:`01 ~ 03` 背景 / 白色文字 / 分割线;`04 ~ 09` 备注文字及图标;`10` 与基准 `#222222` 用于正文。
---
#### 1.1.8 功能色(主色 / 字体色 / 辅色 / 背景色)
从标准色系中提取,用于具体 UI 角色指定。
| 角色 | 色值 | 来源 |
|------|------|------|
| 主色(Primary) | `#F5222D` | ZS-001-09 |
| 主色 Hover | `#CF1322` | ZS-001 |
| 主色 Active | `#A8071A` | ZS-001-11 |
| 信息 / 链接色 | `#0079FE` | ZS-002-09 |
| 成功色 | `#2FD69A` | FS-002-09 |
| 警告色 | `#FF9900` | FS-001-09 |
| 错误色 | `#F5222D` | ZS-001-09 |
| 正文字体色 | `#222222` | FS-004 |
| 次级字体色 | `#575757` | FS-004-09 |
| 备注 / 占位字体色 | `#A6A6A6` | FS-004-07 |
| 禁用字体色 | `#C1C1C1` | FS-004-06 |
| 页面背景色 | `#FAFAFA` | FS-004-02 |
| 容器背景色 | `#FFFFFF` | FS-004-01 |
| 分割线 | `#E8E8E8` | FS-004-04 |
---
#### 1.1.9 小面积装饰色
用于图标点缀、徽标、少量强调,不用于大面积背景。
| 色值 | 来源 | 说明 |
|------|------|------|
| `#F5222D` | ZS-001-09 | 红色,危险 / 品牌徽标 |
| `#0079FE` | ZS-002-09 | 蓝色,信息点缀 |
| `#FF9900` | FS-001-09 | 橙色,提醒点缀 |
| `#2FD69A` | FS-002-09 | 绿色,在线点 |
| `#45C3F3` | FS-003-09 | 青色,数据点缀 |
---
#### 1.1.10 图表配色
> **以 §25.1 图表通用色序为准**。本小节给出的是从主 / 辅色提取的强调型色序,适用于需要突出对比的少量序列;柱状 / 饼 / 环 / 面积等多分类图表的标准柔和色序见 [05-图表](05-图表.md)(§25)。
**通用图表色序**(面积较大,如柱状图、折线图、饼图):
| 序号 | 色值 | 来源 |
|------|------|------|
| 1 | `#F5222D` | ZS-001-09 |
| 2 | `#0079FE` | ZS-002-09 |
| 3 | `#FF9900` | FS-001-09 |
| 4 | `#2FD69A` | FS-002-09 |
| 5 | `#45C3F3` | FS-003-09 |
| 6 | `#FA9196` | ZS-001-06 |
| 7 | `#55A6FE` | ZS-002-07 |
| 8 | `#FFBB55` | FS-001-07 |
**小面积图表色序**(面积较小,如散点、小徽章、迷你图):
| 序号 | 色值 | 来源 |
|------|------|------|
| 1 | `#F86C73` | ZS-001-07 |
| 2 | `#55A6FE` | ZS-002-07 |
| 3 | `#74E4BC` | FS-002-07 |
| 4 | `#FFBB55` | FS-001-07 |
| 5 | `#83D7F7` | FS-003-07 |
| 6 | `#A6A6A6` | FS-004-07 |
---
#### 1.1.11 CSS Token 建议
```css
:root {
/* 主色 · 渐红系 ZS-001 */
--zs-001: #CF1322;
--zs-001-01: #FEFAFA; --zs-001-02: #FEF6F6; --zs-001-03: #FEEDED;
--zs-001-04: #FDDADC; --zs-001-05: #FCB5B9; --zs-001-06: #FA9196;
--zs-001-07: #F86C73; --zs-001-08: #F74750; --zs-001-09: #F5222D;
--zs-001-10: #BC0D1E; --zs-001-11: #A8071A; --zs-001-12: #820014;
--zs-001-13: #5C0011;
/* 主色 · 渐蓝系 ZS-002 */
--zs-002: #0062D9;
--zs-002-01: #F9FCFF; --zs-002-02: #F4F9FF; --zs-002-03: #EAF4FF;
--zs-002-04: #D4E9FF; --zs-002-05: #AAD2FF; --zs-002-06: #80BCFF;
--zs-002-07: #55A6FE; --zs-002-08: #2B8FFE; --zs-002-09: #0079FE;
--zs-002-10: #0056C6; --zs-002-11: #004AB3; --zs-002-12: #00368C;
--zs-002-13: #002466;
/* 辅色 · 渐橙系 FS-001 */
--fs-001: #D97B00;
--fs-001-01: #FFFCF9; --fs-001-02: #FFFAF4; --fs-001-03: #FFF6EA;
--fs-001-04: #FFEED4; --fs-001-05: #FFDDAA; --fs-001-06: #FFCC80;
--fs-001-07: #FFBB55; --fs-001-08: #FFAA2B; --fs-001-09: #FF9900;
--fs-001-10: #C66D00; --fs-001-11: #B35F00; --fs-001-12: #8C4600;
--fs-001-13: #663000;
/* 辅色 · 渐绿系 FS-002 */
--fs-002: #1EB07F;
--fs-002-01: #FAFEFD; --fs-002-02: #F6FDFB; --fs-002-03: #EEFCF7;
--fs-002-04: #DCF8EE; --fs-002-05: #BAF1DD; --fs-002-06: #97EBCD;
--fs-002-07: #74E4BC; --fs-002-08: #52DDAB; --fs-002-09: #2FD69A;
--fs-002-10: #189D72; --fs-002-11: #118A65; --fs-002-12: #07634B;
--fs-002-13: #043D30;
/* 辅色 · 渐青系 FS-003 */
--fs-003: #2F9ACC;
--fs-003-01: #FBFDFE; --fs-003-02: #F7FCFE; --fs-003-03: #EFFAFE;
--fs-003-04: #E0F5FD; --fs-003-05: #C1EBFB; --fs-003-06: #A2E1F9;
--fs-003-07: #83D7F7; --fs-003-08: #64CDF5; --fs-003-09: #45C3F3;
--fs-003-10: #2788B9; --fs-003-11: #1E76A6; --fs-003-12: #115580;
--fs-003-13: #0B3959;
/* 辅色 · 中性灰系 FS-004 */
--fs-004: #222222;
--fs-004-01: #FFFFFF; --fs-004-02: #FAFAFA; --fs-004-03: #F5F5F5;
--fs-004-04: #E8E8E8; --fs-004-05: #DBDBDB; --fs-004-06: #C1C1C1;
--fs-004-07: #A6A6A6; --fs-004-08: #7F7F7F; --fs-004-09: #575757;
--fs-004-10: #3D3D3D;
/* 功能色(语义别名) */
--color-primary: var(--zs-001-09); /* #F5222D */
--color-primary-hover: var(--zs-001); /* #CF1322 */
--color-primary-active: var(--zs-001-11); /* #A8071A */
--color-link: var(--zs-002-09); /* #0079FE */
--color-success: var(--fs-002-09); /* #2FD69A */
--color-warning: var(--fs-001-09); /* #FF9900 */
--color-error: var(--zs-001-09); /* #F5222D */
--color-text: var(--fs-004); /* #222222 */
--color-text-secondary: var(--fs-004-09); /* #575757 */
--color-text-tertiary: var(--fs-004-07); /* #A6A6A6 */
--color-text-disabled: var(--fs-004-06); /* #C1C1C1 */
--color-bg-page: var(--fs-004-02); /* #FAFAFA */
--color-bg-container: var(--fs-004-01); /* #FFFFFF */
--color-border: var(--fs-004-04); /* #E8E8E8 */
}
```
---
## 2. 字体
字体规范分**大屏页面**与**后台系统**两套,分别对应可视化大屏与管理后台两类场景。
### 2.1 大屏页面字体规范
#### 2.1.1 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 24px Bold | 一级标题 |
| 24px Medium | 次要一级标题 |
| 22px Medium | 二级标题 |
| 22px Regular | 正文 |
| 20px Regular | 排版空间有限的正文 |
#### 2.1.2 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 32px Bold | 重要数据展示 |
| 32px Regular | 普通数据展示 |
| 26px Bold | 常规数据展示 |
| 26px Regular | 常规数据展示 |
#### 2.1.3 特殊数字:DS-Digital
| 样式 | 使用场景 |
|------|----------|
| 50px Bold | 大屏主数据展示 |
#### 2.1.4 表格数字:Arial
| 样式 | 使用场景 |
|------|----------|
| 20px Regular | 表格横纵轴数字 |
---
### 2.2 后台系统字体规范
#### 2.2.1 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 18px Bold | 导航、一级标题 |
| 18px Medium | 次要一级标题 |
| 16px Bold | 二级标题 |
| 16px Medium | 次要二级标题 |
| 16px Regular | 正文、按钮内部文字等 |
| 16px Light | 次要正文 |
#### 2.2.2 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 26px Bold | 重要数据展示 |
| 26px Regular | 普通数据展示 |
| 24px Bold | 常规数据展示(小) |
| 24px Regular | 常规数据展示(小) |
#### 2.2.3 LOGO 字体:DOUYUFont
| 样式 | 使用场景 |
|------|----------|
| 20px 文字样式 | 页面顶部系统名称 |
#### 2.2.4 表格数字:Arial
| 样式 | 使用场景 |
|------|----------|
| 16px Regular | 表格横纵轴数字 |
| 14px Regular | 表格横纵轴的数字(空间有限) |
---
## 3. 阴影
> 浅色主题采用**中性投影**(基于 FS-004 `#222222`)营造层级,避免使用彩色外发光。卡片以纯白 `#FFFFFF` 浮于浅灰页面背景 `#FAFAFA` 之上,靠投影与边框区分。
### 3.1 容器与卡片配色
#### 3.1.1 卡片(标题 + 操作)
白底卡片,标题在左、操作区在右(文字操作或图标操作)。
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#FFFFFF` | FS-004-01 |
| 卡片边框 | `#E8E8E8` | FS-004-04 |
| 卡片阴影 | `0 2px 8px rgba(34, 34, 34, 0.08)` | 基于 FS-004 中性投影 |
| 标题文字(16px Bold) | `#222222` | FS-004(正文色) |
| 操作按钮文字 | `#F5222D` | ZS-001-09(主色红,链接型操作) |
| 次级操作图标(✏️ / ⋯) | `#A6A6A6` | FS-004-07(备注图标色) |
#### 3.1.2 卡片新增(占位卡片)
虚线描边的"新增入口"卡片,用于引导用户添加内容。
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#FFFFFF` | FS-004-01 |
| 虚线边框 | `#DBDBDB` | FS-004-05 |
| 加号图标 | `#A6A6A6` | FS-004-07 |
| Hover 边框 | `#F5222D` | ZS-001-09(悬停时提示可点击) |
| Hover 加号 | `#F5222D` | ZS-001-09 |
#### 3.1.3 阴影投影规则
| 层级 | 阴影值 | 使用场景 |
|------|--------|----------|
| 一级阴影(卡片浮起) | `0 2px 8px rgba(34, 34, 34, 0.08)` | 普通卡片 |
| 二级阴影(hover / 强调) | `0 4px 16px rgba(34, 34, 34, 0.12)` | 卡片 hover、悬浮按钮、弹出层 |
| 三级阴影(弹窗 / Modal) | `0 8px 32px rgba(34, 34, 34, 0.16)` | Dialog、Drawer |
---
## 4. 圆角
### 4.1 圆角统一规范
| 元素 | 圆角值 | 说明 |
|------|--------|------|
| 按钮 | `2px` | 所有按钮(主 / 次要 / 默认 / 文本)统一使用 |
| 卡片 | `4px` | 内容卡片、标题+操作卡片、卡片新增占位 |
| 页面分栏框 | `4px` | 外层容器、分栏区域 |
| 输入框 / 选择器 | `2px` | 与按钮一致,保持表单内对齐 |
| Tag / Badge | `2px` | 与按钮一致 |
| Modal / Drawer | `4px` | 与卡片一致 |
---
## 6. 间距
> 全站以 `8px (0.5rem)` 为对照基准单位,划分"密 / 疏"两个层次。所有间距取值均来自下列阶梯,禁止使用非阶梯值(如 5px、13px)。
### 6.1 间距阶梯
| 阶 | px | rem | 分组 | 典型场景 |
|----|------|--------|------|----------|
| 1 | `4px` | `0.25rem` | 密 | 图标与文字间距、Tag 内边距、紧凑徽标 |
| 2 | `8px` | `0.5rem` | 密 | 行内元素相邻、按钮组间距、表单 label 与控件 |
| 3 | `12px` | `0.75rem` | 密 | 表单项垂直间距、列表项内边距 |
| 4 | `16px` | `1rem` | 密 | 卡片内容内边距、模块内分隔 |
| 5 | `24px` | `1.5rem` | 疏 | 模块之间、Section 内边距 |
| 6 | `32px` | `2rem` | 疏 | 卡片之间、表单组之间 |
| 7 | `40px` | `2.5rem` | 疏 | 主区块之间、页面 Header / Footer 内边距 |
| 8 | `56px` | `3.5rem` | 疏 | 页面段落级隔断、Banner 区域 |
> "密"对应紧凑信息密度(表格、表单、卡片内部);"疏"对应视觉呼吸(模块之间、页面整体留白)。**16px 是"密"的上限,24px 是"疏"的下限**,两者之间不应再插入自定义值。
### 6.2 使用原则
- **同层级一致**:同一布局层级内的间距必须使用同一阶值;多种取值会破坏节奏。
- **就近取阶**:相邻区块的间距尽量从相邻阶梯取值(如 `16px → 24px`),跨越过多阶(如 `8px → 56px`)会让视觉断裂。
- **密疏不混用**:一个组件内部全部用"密",外部用"疏",避免内 / 外间距颠倒。
- **优先 8 的倍数**:除最小阶 `4px` 外,所有阶皆为 8 的倍数,方便 4/8 网格对齐。
- **响应式按比例衰减**:移动端可整体降一阶(如 `24px → 16px`),但仍在阶梯内。
### 6.3 CSS Token 建议
```css
:root {
--spacing-1: 4px; /* 0.25rem */
--spacing-2: 8px; /* 0.5rem */
--spacing-3: 12px; /* 0.75rem */
--spacing-4: 16px; /* 1rem */
--spacing-5: 24px; /* 1.5rem */
--spacing-6: 32px; /* 2rem */
--spacing-7: 40px; /* 2.5rem */
--spacing-8: 56px; /* 3.5rem */
}
```
---
## 20. 语义状态色(统一约定)
> 进度、状态标签、表格状态、时间轴等大量组件共用同一套语义色。后续章节直接引用本表。
| 语义 | 文字色 | 浅底背景 | 实填背景 | 来源 |
|------|--------|----------|----------|------|
| 成功 / 完成(绿) | `#189D72` (FS-002-10) | `#EEFCF7` (FS-002-03) | `#2FD69A` (FS-002-09) | 渐绿系 |
| 警告 / 进行中(橙) | `#FF9900` (FS-001-09) | `#FFF6EA` (FS-001-03) | `#FF9900` (FS-001-09) | 渐橙系 |
| 错误 / 失败(红) | `#F5222D` (ZS-001-09) | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) | 渐红系 |
| 信息 / 等待(蓝) | `#0079FE` (ZS-002-09) | `#EAF4FF` (ZS-002-03) | `#0079FE` (ZS-002-09) | 渐蓝系 |
| 待更新(青) | `#45C3F3` (FS-003-09) | `#EFFAFE` (FS-003-03) | `#45C3F3` (FS-003-09) | 渐青系 |
| 未开始 / 中性(灰) | `#7F7F7F` (FS-004-08) | `#F5F5F5` (FS-004-03) | `#A6A6A6` (FS-004-07) | 中性灰系 |
---
---
> 返回 [规范目录](README.md)
@@ -0,0 +1,314 @@
# 浅红色 UI 规范 · 二、按钮与表单输入
> 返回 [规范目录](README.md) | 本篇:按钮 · 表单 · 输入框 · 选择按钮 · 标签输入框 · 上传
---
## 5. 按钮
> 全部圆角 `2px`,文字字号 `14px`,字体 Noto Sans S Chinese Regular。配色取自 ZS-001 主色红与 FS-004 中性灰。
### 5.1 按钮尺寸
| 规格 | 高度 | 内边距 | 使用场景 |
|------|------|--------|----------|
| 小 | `24px` | `0 8px` | 空间小的板块内部,如表格中、卡片标题等 |
| 正常 | `32px` | `0 14px` | 默认尺寸,所有常用按钮 |
### 5.2 按钮类型与配色
#### 5.2.1 主按钮(Primary)
> 一个操作区域只能有一个主按钮。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `#F5222D` (ZS-001-09) | — | `#FFFFFF` |
| Hover | `#CF1322` (ZS-001) | — | `#FFFFFF` |
| Active | `#A8071A` (ZS-001-11) | — | `#FFFFFF` |
| 失效 | `#A6A6A6` (FS-004-07) | — | `#FFFFFF` |
#### 5.2.2 次要按钮(Secondary / Outline-Primary)
重要度低于主按钮、高于默认按钮。红色描边 + 红色文字。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `#FFFFFF` | `#F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) |
| Hover | `#FEEDED` (ZS-001-03) | `#CF1322` (ZS-001) | `#CF1322` (ZS-001) |
| Active | `#FDDADC` (ZS-001-04) | `#A8071A` (ZS-001-11) | `#A8071A` (ZS-001-11) |
| 失效 | `#FFFFFF` | `#FCB5B9` (ZS-001-05) | `#FCB5B9` (ZS-001-05) |
#### 5.2.3 默认按钮(Default)
用于"取消 / 返回"等次要操作,与主按钮成对出现时主按钮在右。中性描边。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `#FFFFFF` | `#DBDBDB` (FS-004-05) | `#222222` (FS-004) |
| Hover | `#FAFAFA` (FS-004-02) | `#F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) |
| Active | `#F5F5F5` (FS-004-03) | `#CF1322` (ZS-001) | `#CF1322` (ZS-001) |
| 禁用 | `#F5F5F5` (FS-004-03) | `#E8E8E8` (FS-004-04) | `#C1C1C1` (FS-004-06) |
#### 5.2.4 文本按钮(Text)
用于表格操作、文件下载等。无背景与边框,靠文字色识别。
| 状态 | 文字 | 装饰 |
|------|------|------|
| 默认 | `#F5222D` (ZS-001-09) | 无下划线 |
| Hover | `#CF1322` (ZS-001) | 显示下划线(与文字同色) |
| Active | `#A8071A` (ZS-001-11) | 下划线 |
| 失效 | `#C1C1C1` (FS-004-06) | 无装饰 |
#### 5.2.5 图标按钮(Icon-only)
用于增强辨识度或节省空间。仅图标,无文字。
| 变体 | 背景 | 边框 | 图标颜色 |
|------|------|------|----------|
| 描边(默认) | `#FFFFFF` | `#DBDBDB` (FS-004-05) | `#575757` (FS-004-09) |
| 描边 Hover | `#FAFAFA` | `#F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) |
| 主色(填充) | `#F5222D` (ZS-001-09) | — | `#FFFFFF` |
| 主色 Hover | `#CF1322` (ZS-001) | — | `#FFFFFF` |
| 浅色(弱填充) | `#FEEDED` (ZS-001-03) | — | `#F5222D` (ZS-001-09) |
### 5.3 按钮组合
#### 5.3.1 文字 + 下拉按钮(含 ⌄)
| 形态 | 配色规则 |
|------|----------|
| 默认(Outline) | 沿用 §5.2.3 默认按钮,箭头 ⌄ 与文字同色 `#222222` |
| 主色(Filled) | 沿用 §5.2.1 主按钮,箭头 ⌄ 同步白色 `#FFFFFF` |
#### 5.3.2 图标 + 文字按钮(如 `+ 正常`)
| 形态 | 配色规则 |
|------|----------|
| 默认(Outline) | 沿用 §5.2.3,左侧图标颜色与文字一致 `#222222` |
| 主色(Filled) | 沿用 §5.2.1,左侧图标与文字一致 `#FFFFFF` |
#### 5.3.3 文本按钮 + 图标("更多 »"、"展开 ⌄"、"下一页 »"、"« 上一页"、"收起 ^")
| 角色 | 色值 |
|------|------|
| 文字 + 图标(默认) | `#F5222D` (ZS-001-09) |
| 文字 + 图标(Hover) | `#CF1322` (ZS-001) |
| 失效 | `#C1C1C1` (FS-004-06) |
> 图标可在文字前或后;多个文本按钮排成一行时,间距 `16px`,无分隔符。
#### 5.3.4 主按钮 + 默认按钮组合(如"确定 / 取消 / 返回")
- **排列顺序**:默认按钮在左,主按钮在右(最右侧为主操作)
- **按钮间距**:`8px`
- **示例**:`[ 返回 ] [ 取消 ] [ 确定 ]`
- **配色**:主按钮沿用 §5.2.1,其余沿用 §5.2.3
### 5.4 其他按钮
#### 5.4.1 悬浮状态(Floating)
用于 Banner / 登录 / 视觉冲击强等展示场景。配色与主按钮一致,叠加阴影抬升。
| 状态 | 阴影 |
|------|------|
| 默认 | `0 2px 8px rgba(34, 34, 34, 0.08)`(§3.1.3 一级阴影) |
| Hover | `0 4px 16px rgba(34, 34, 34, 0.12)`(§3.1.3 二级阴影) |
| Active | 阴影消失,按下感 |
#### 5.4.2 加载中(Loading)
用于异步操作等待反馈。点击瞬间将按钮锁定,文字前显示旋转图标。
| 角色 | 色值 |
|------|------|
| 背景 | `#F5222D` (ZS-001-09) |
| 文字 | `#FFFFFF` |
| 加载图标 | `#FFFFFF`,旋转动画 `1.2s linear infinite` |
| 不可点击 | 鼠标 `cursor: not-allowed` |
> 与失效态的区别:失效是"不可操作",加载是"操作进行中"——保留品牌主色,只是不可再次触发。
---
## 12. 表单(Form)
> 页面级表单可参照弹窗表单,**操作按钮置于左下角**。容器为白底卡片,标题下叠 `1px solid #E8E8E8` 分割线。
### 12.1 基础表单结构
| 元素 | 规格 / 配色 |
|------|-------------|
| 容器背景 | `#FFFFFF` (FS-004-01),圆角 `4px` |
| 表单标题 | `16px Bold`,`#222222` (FS-004),下叠 `1px solid #E8E8E8` |
| 字段标签(label) | `14px Regular`,`#575757` (FS-004-09),左对齐 |
| 字段垂直间距 | `--spacing-3` (12px) |
| 输入控件 | 见 §13 输入框 |
| 操作按钮区 | 置于左下角,主按钮 + 默认按钮(§5.3.4),间距 `--spacing-2` (8px) |
### 12.2 开关(Switch)
| 状态 | 轨道背景 | 滑块 | 文字 |
|------|----------|------|------|
| 开(ON) | `#F5222D` (ZS-001-09) | `#FFFFFF` 圆点 | "开" `#FFFFFF` |
| 关(OFF) | `#DBDBDB` (FS-004-05) | `#FFFFFF` 圆点 | "关" `#A6A6A6` (FS-004-07) |
| 禁用 | `#E8E8E8` (FS-004-04) | `#FFFFFF` | `#C1C1C1` |
- 轨道高 `20px`,圆角全圆;切换过渡 `transform 160ms ease-out`
### 12.3 表格内报错(Table 报错)
行内可编辑表格的校验错误态。
| 元素 | 配色 |
|------|------|
| 表头背景 | `#FEEDED` (ZS-001-03) |
| 表头文字 | `#222222` (FS-004),`14px Medium` |
| 行(默认) | 背景 `#FFFFFF`,底边 `1px solid #F5F5F5` (FS-004-03) |
| 行(hover / 选中) | 背景 `#FEF6F6` (ZS-001-02) |
| 正文文字 | `#222222` (FS-004) |
| 错误输入框边框 | `1px solid #F5222D` (ZS-001-09) |
| 单位后缀(如 `M`) | addon 背景 `#FAFAFA`,边框 `#DBDBDB`,文字 `#575757` |
| 错误提示气泡 | 背景 `#F5222D`,文字 `#FFFFFF` `12px`,圆角 `2px`,带向上箭头,定位于出错输入框下方 |
| 复选框(选中) | 勾选填充 `#F5222D`,对勾 `#FFFFFF` |
---
## 13. 输入框(Input)
> 字号 `16px`,高度 `32px`,圆角 `2px`,内边距 `0 12px`,背景 `#FFFFFF`。
### 13.1 基础状态
| 状态 | 边框 | 背景 | 文字 / 占位符 |
|------|------|------|--------------|
| 未填(默认) | `1px solid #DBDBDB` (FS-004-05) | `#FFFFFF` | 占位符 `#A6A6A6` (FS-004-07) |
| 聚焦(Focus) | `1px solid #F5222D` (ZS-001-09) | `#FFFFFF` | 文字 `#222222` |
| 已填 | `1px solid #DBDBDB` | `#FFFFFF` | 文字 `#222222` (FS-004) |
| 未填 · 禁用 | `1px solid #E8E8E8` (FS-004-04) | `#F5F5F5` (FS-004-03) | 占位符 `#C1C1C1` (FS-004-06),`cursor: not-allowed` |
| 已填 · 禁用 | `1px solid #E8E8E8` | `#F5F5F5` | 文字 `#A6A6A6` |
| 错误(Error) | `1px solid #F5222D` | `#FFFFFF` | 文字 `#222222`,下方红色提示文字 `12px #F5222D` |
### 13.2 带操作 / 特殊输入框
| 类型 | 说明 | 配色 |
|------|------|------|
| 未填 / 已填 · 操作 | 尾部带操作图标(如 ⚙) | 图标 `#A6A6A6`,hover `#F5222D` |
| 密码 · 不可见 | 尾部带可见性切换 👁 | 图标 `#A6A6A6`,点击切换明 / 密文 |
| 前置标签 | 头部下拉(如 `http:// ▾`) | addon 背景 `#FAFAFA`,边框 `#DBDBDB`,文字 `#575757` |
| 后置标签 | 尾部下拉(如 `.com ▾`) | 同前置标签 |
| 前后置标签 | 头尾各一段 addon | 同上;输入区与 addon 共用一条外边框 |
---
## 14. 选择按钮(单选 / 多选式)
> 以按钮形态替代传统单选 / 复选,提升点击区域与辨识度。每组提供三种"已选"填充强度,可按对比需要选用;布局分**相连**(segmented)与**分离**两种。
### 14.1 选择按钮(基础)
| 状态 / 变体 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 未选 | `#FFFFFF` | `1px solid #DBDBDB` (FS-004-05) | `#222222` (FS-004) |
| 已选 · 描边 | `#FFFFFF` | `1px solid #F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) |
| 已选 · 浅填 | `#FEEDED` (ZS-001-03) | `1px solid #FCB5B9` (ZS-001-05) | `#F5222D` (ZS-001-09) |
| 已选 · 实填 | `#F5222D` (ZS-001-09) | — | `#FFFFFF` |
| 禁用 | `#F5F5F5` (FS-004-03) | `1px solid #E8E8E8` | `#C1C1C1` (FS-004-06) |
- 高度 `32px`,内边距 `0 14px`,圆角 `2px`,字号 `14px`
- **相连布局**:按钮紧贴,仅最外两端圆角,相邻共用边框
- **分离布局**:按钮间距 `--spacing-2` (8px),各自独立圆角
### 14.2 选择按钮(带统计)
在文案后附计数,如 `已选(230)`。配色完全沿用 §14.1,计数文字与主文字同色(实填态为 `#FFFFFF`)。
### 14.3 按钮式页签(带图标)
文案前带图标(如 `田`),用于视图 / 模式切换。
| 状态 / 变体 | 背景 | 边框 | 图标 + 文字 |
|------|------|------|-------------|
| 正常(未激活) | `#FFFFFF` | `1px solid #DBDBDB` | `#222222` |
| 激活 · 描边 | `#FFFFFF` | `1px solid #F5222D` | `#F5222D` |
| 激活 · 实填 | `#F5222D` (ZS-001-09) | — | `#FFFFFF` |
- 图标与文字间距 `--spacing-1` (4px),其余规格同 §14.1
---
## 15. 标签输入框(Tag Input)
> 在输入框内以"标签 + 自由输入"混合录入,支持单行 / 多行,含字数统计。
### 15.1 规格
| 属性 | 取值 |
|------|------|
| 容器边框 | `1px solid #DBDBDB` (FS-004-05),圆角 `2px` |
| 聚焦边框 | `1px solid #F5222D` (ZS-001-09) |
| 容器背景 | `#FFFFFF` |
| 内边距 | `--spacing-2` (8px) |
| 标签与标签间距 | `--spacing-1` (4px) |
| 右下角支持拖拽缩放(多行) | `resize: vertical` |
### 15.2 标签(Tag)配色
| 元素 | 色值 | 来源 |
|------|------|------|
| 标签背景 | `#F5F5F5` (FS-004-03) | — |
| 标签文字 | `#575757` (FS-004-09) | — |
| 关闭图标 `×` | `#A6A6A6` (FS-004-07) | hover `#F5222D` |
| 占位符(请输入) | `#A6A6A6` (FS-004-07) | — |
| 字数统计(如 `2/100`) | `#A6A6A6` (FS-004-07) | 超限时变 `#F5222D` |
> 折叠多标签时可用 `+N` 形式聚合(如 `+1 ×`),样式同普通标签。
---
## 16. 上传(Upload)
> 含单文件、批量上传、拖拽上传三种形态。操作按钮中"开始上传 / 上传"为主按钮,"取消"为默认按钮。
### 16.1 上传触发器
| 形态 | 配色 |
|------|------|
| 上传按钮(⬆ 上传文件) | 默认按钮(§5.2.3),含上传图标 |
| 选择文件输入 | 只读输入框(§13)+ 右侧"选择文件"默认按钮 |
| 提示文字 | `12px Regular`,`#A6A6A6` (FS-004-07),如"支持 DOCX/ZIP/PDF,文件大小不超过 2MB" |
| 示例文件链接(下载 / 查看示例文件) | 文本按钮 `#F5222D` (ZS-001-09),hover `#CF1322` |
### 16.2 拖拽区(Drop Zone)
| 属性 | 取值 |
|------|------|
| 背景 | `#FAFAFA` (FS-004-02) |
| 边框 | `1px dashed #DBDBDB` (FS-004-05) |
| 拖入高亮 | 边框 `1px dashed #F5222D` (ZS-001-09),背景 `#FEF6F6` (ZS-001-02) |
| 提示文字(释放鼠标) | `#A6A6A6` (FS-004-07) |
### 16.3 文件列表与状态
列表表头:文件名 / 大小 / 状态 / 操作。各状态配色:
| 状态 | 图标 | 文字色 |
|------|------|--------|
| 上传成功 | ✓ 圆形,`#2FD69A` (FS-002-09) | `#189D72` (FS-002-10) |
| 文件格式有误 / 失败 | ❗ 圆形,`#F5222D` (ZS-001-09) | `#F5222D` |
| 上传中(如 上传 40%) | ⟳ 旋转,`#F5222D` (ZS-001-09) | `#575757` (FS-004-09) |
| 待上传 | 🕐 时钟,`#A6A6A6` (FS-004-07) | `#A6A6A6` |
| 操作列「删除」 | — | 文本按钮 `#A6A6A6`,hover `#F5222D` |
### 16.4 底部操作
`[ 取消 ](默认按钮) [ 开始上传 ](主按钮 #F5222D)`,间距 `--spacing-2` (8px),右对齐。
---
---
> 返回 [规范目录](README.md)
@@ -0,0 +1,495 @@
# 浅红色 UI 规范 · 三、导航
> 返回 [规范目录](README.md) | 本篇:导航组件 · 面包屑 · 页头与分页 · 步骤条 · 页签与锚点
---
## 7. 导航组件
> 适用于"后台 / 控制台"类浅红主题界面。整体结构 = 顶部导航(红底)+ 二级导航(白底)+ 左侧分级菜单 + 多级下拉。颜色取自 ZS-001 主色红与 FS-004 中性灰。
### 7.1 顶部导航(Header · 红底)
#### 7.1.1 规格
| 属性 | 取值 |
|------|------|
| 高度 | `56px` |
| 左右 padding | `--spacing-4` (16px) |
| 背景 | `linear-gradient(90deg, #A8071A 0%, #CF1322 100%)`(ZS-001-11 → ZS-001) |
| LOGO 区切角装饰 | 叠深红斜切块 `#A8071A` (ZS-001-11),`clip-path` 斜边 |
| 底边 | 无(与二级导航白底直接相接) |
| 文字基线 | `Noto Sans S Chinese`,垂直居中 |
#### 7.1.2 系统标题(LOGO + ******系统)
- 字号 `20px Bold`,颜色 `#FFFFFF`,字体 `DOUYUFont` 或 Noto Sans Medium
- LOGO 图标 `#FFFFFF`,置于左侧切角块上
#### 7.1.3 一级导航(中部主导航,红底白字)
| 状态 | 文字 | 背景 / 装饰 |
|------|------|------------|
| 默认 | `#FFFFFF` | `transparent` |
| Hover | `#FFFFFF` | 背景 `rgba(255, 255, 255, 0.12)` |
| 激活 | `#FFFFFF` | 底部 `2px solid #FFFFFF` 高亮线,背景 `rgba(255, 255, 255, 0.16)` |
- 字号 `16px Medium`,项间距 `--spacing-5` (24px)
#### 7.1.4 右侧工具区(图标 / 用户 / 退出)
| 元素 | 字号/尺寸 | 颜色 |
|------|----------|------|
| 工具图标(消息 / 通知等) | `16px` | `#FFFFFF`,hover `rgba(255,255,255,0.7)` |
| 用户名(如"李向前") | `14px Regular` | `#FFFFFF` |
| 用户头像 | `28px` 圆形 | — |
| 退出图标 | `16px` | `#FFFFFF` |
- 元素间距 `--spacing-3` (12px),整体右 padding `--spacing-4` (16px)
#### 7.1.5 用户下拉菜单(右上角)
白底面板,规格同 §7.4 下拉面板。
| 状态 | 背景 | 文字 / 图标 |
|------|------|-------------|
| 默认 | `transparent` | `#575757` (FS-004-09) |
| Hover / 激活 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) |
> 项示例:用户中心 / 修改密码 / 退出;每项左侧带 16px 图标,图标色与文字同步。
### 7.2 二级导航(白底 Tab 行)
紧贴顶部导航下方的白底标签行。
#### 7.2.1 规格
| 属性 | 取值 |
|------|------|
| 高度 | `48px` |
| 背景 | `#FFFFFF` (FS-004-01) |
| 底边 | `border-bottom: 1px solid #E8E8E8` (FS-004-04) |
| 项间距 | `--spacing-6` (32px) |
| 字号 | `16px Medium` |
#### 7.2.2 状态配色
| 状态 | 文字 | 下划线 / 装饰 |
|------|------|--------------|
| 默认 | `#222222` (FS-004) | 无 |
| Hover | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | 底部 `2px solid #F5222D`,含下拉箭头 `▾` 同色 |
| 失效 | `#C1C1C1` (FS-004-06) | 无 |
### 7.3 侧边栏(分级菜单)
#### 7.3.1 规格
| 属性 | 取值 |
|------|------|
| 宽度 | `200px`(展开)/ `64px`(折叠仅图标) |
| 背景 | `#FFFFFF` (FS-004-01) |
| 右边界 | `border-right: 1px solid #E8E8E8` (FS-004-04) |
| 项高度 | `40px` |
| 项左 padding | 二级 `--spacing-4` (16px);三级 `--spacing-6` (32px) |
| 字号 | `16px Regular`(二级)/ `14px Regular`(三级) |
#### 7.3.2 二级菜单项配色
| 状态 | 背景 | 文字 | 图标 | 左侧指示条 |
|------|------|------|------|-----------|
| 默认 | `transparent` | `#222222` (FS-004) | `#A6A6A6` (FS-004-07) | 无 |
| Hover | `#FAFAFA` (FS-004-02) | `#F5222D` (ZS-001-09) | `#F5222D` | 无 |
| 激活 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) | `#F5222D` | 左侧 `2px solid #F5222D` |
#### 7.3.3 三级菜单项配色(前缀 `-` 短横线)
| 状态 | 背景 | 文字 | 左侧指示条 |
|------|------|------|-----------|
| 默认 | `transparent` | `#575757` (FS-004-09) | 无 |
| Hover | `#FAFAFA` (FS-004-02) | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) | 左侧 `2px solid #F5222D` |
### 7.4 下拉菜单面板
#### 7.4.1 列表型面板(如二级导航的"浏览器容器 / 手机板卡容器…")
| 属性 | 取值 |
|------|------|
| 背景 | `#FFFFFF` (FS-004-01) |
| 边框 | `1px solid #E8E8E8` (FS-004-04) |
| 阴影 | `0 4px 16px rgba(34, 34, 34, 0.12)`(§3.1.3 二级阴影) |
| 圆角 | `4px` |
| 内边距 | 上下 `--spacing-2` (8px) |
| 最小宽度 | `150px` |
| 行高 | `36px` |
| 状态 | 背景 | 文字 |
|------|------|------|
| 默认 | `transparent` | `#575757` (FS-004-09) |
| Hover / 激活 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) |
| 失效 | `transparent` | `#C1C1C1` (FS-004-06) |
#### 7.4.2 卡片宫格型面板(一级导航的图标宫格下拉)
白底大面板内以网格陈列"图标 + 二级导航名"卡片。
| 角色 | 背景 | 图标 / 文字 |
|------|------|-------------|
| 卡片(默认) | `#FEF6F6` (ZS-001-02) | 图标 `#F5222D`,文字 `#575757` |
| 卡片(Hover) | `#FEEDED` (ZS-001-03) | 图标 / 文字 `#F5222D` |
| 卡片(激活 / 当前) | `#F5222D` (ZS-001-09) | 图标 / 文字 `#FFFFFF` |
| 属性 | 取值 |
|------|------|
| 卡片尺寸 | `80px × 72px`(自适应等分) |
| 卡片圆角 | `4px` |
| 卡片间距 | `--spacing-2` (8px) |
| 面板内边距 | `--spacing-4` (16px) |
| 面板阴影 | `0 8px 32px rgba(34, 34, 34, 0.16)`(§3.1.3 三级阴影) |
### 7.5 交互与动画
| 行为 | 动效 |
|------|------|
| 下拉面板展开 | `transform-origin: top; scaleY(0.95) → scaleY(1); opacity 0 → 1`,`200ms ease-out` |
| 展开箭头旋转 | `rotate(0 → 180deg)`,`200ms ease-out` |
| Hover 背景过渡 | `background-color 120ms ease-out` |
| 激活底部高亮线 | `width 0% → 100%`,`240ms ease-out` |
### 7.6 侧边栏布局变体
> 侧边栏宽度通常固定,按"是否分级 / 是否展开"分为四种形态。关键区分:**叶子节点选中**用实心红填充 + 白字 + 左侧红指示条;**父级(含子项的可展开项)选中**仅用红字 + 红图标,不填充。
#### 7.6.1 上下分级 · 收起(仅图标)
| 属性 | 取值 |
|------|------|
| 宽度 | `64px`,仅图标 |
| 图标项(默认) | 图标 `#A6A6A6`,背景 `transparent` |
| 图标项(激活) | 图标 `#FFFFFF`,背景 `#F5222D` (ZS-001-09) 圆角方块 `4px` |
| Hover 弹出浮层 | **深色浮层**:背景 `#3D3D3D` (FS-004-10),圆角 `4px`,阴影 §3.1.3 三级 |
| 浮层内二级标题 | `14px Medium`,`#FFFFFF` |
| 浮层内三级项(前缀 `-`) | `14px Regular`,`#FFFFFF`;hover 背景 `rgba(255,255,255,0.1)` |
#### 7.6.2 上下分级 · 展开
完整宽度列表,二级为可展开组(`^ / ▾`),其下挂三级项。
| 层级 / 状态 | 背景 | 文字 / 图标 | 左侧指示条 |
|------|------|-------------|-----------|
| 二级(默认) | `transparent` | 文字 `#222222`、图标 `#A6A6A6` | 无 |
| 二级(展开 / 当前父级) | `transparent` | 文字 `#F5222D`、图标 `#F5222D` | 无 |
| 三级(默认,前缀 `-`) | `transparent` | `#575757` (FS-004-09) | 无 |
| 三级(选中 · 叶子) | `#F5222D` (ZS-001-09) | `#FFFFFF` | 左侧 `2px solid #F5222D` |
#### 7.6.3 只有二级标题 · 不分级
扁平列表,二级即叶子节点,无三级。
| 状态 | 背景 | 文字 / 图标 | 左侧指示条 |
|------|------|-------------|-----------|
| 默认 | `transparent` | 文字 `#222222`、图标 `#A6A6A6` | 无 |
| Hover | `#FAFAFA` (FS-004-02) | 文字 `#F5222D`、图标 `#F5222D` | 无 |
| 选中 | `#F5222D` (ZS-001-09) | `#FFFFFF` | 左侧 `2px solid #F5222D` |
#### 7.6.4 二级选中 · 分级(含四级浮层)
二级展开后选中三级,三级再向右弹出**白底四级浮层**。
| 角色 | 配色 |
|------|------|
| 三级选中(叶子) | 背景 `#F5222D`,文字 `#FFFFFF`,左侧 `2px solid #F5222D` |
| 四级浮层背景 | `#FFFFFF`,边框 `1px solid #E8E8E8`,阴影 §3.1.3 二级 |
| 四级项(默认) | `#575757` (FS-004-09) |
| 四级项(Hover) | 背景 `#FEEDED` (ZS-001-03),文字 `#F5222D` |
| 四级项(选中) | 文字 `#F5222D`,左侧 `2px solid #F5222D` |
---
## 8. 面包屑(Breadcrumb)
> 用于标识当前页面在层级结构中的位置,并提供逐级返回与跳转。建议层级**不超过四级**。
### 8.1 顶部路径式(🏠 | 概况 / 总概况)
页面顶部的简版面包屑,靠 `/` 分隔。
| 元素 | 色值 | 来源 | 说明 |
|------|------|------|------|
| 首页图标 🏠 | `#A6A6A6` (FS-004-07) | — | hover `#F5222D` |
| 竖分隔 `\|` | `#E8E8E8` | FS-004-04 | 首页图标与路径间 |
| 父级路径文字 | `#575757` (FS-004-09) | — | 可点击,hover `#F5222D` |
| 路径分隔 `/` | `#C1C1C1` | FS-004-06 | — |
| 当前页文字 | `#222222` (FS-004) | — | `Medium`,不可点击 |
### 8.2 返回 + 路径式(含 ← 返回)
左侧"← 返回"按钮 + 竖分隔 + 文字路径。按层级展开:
```
一级: ← 返回 │ 现在位置
二级: ← 返回 │ 上一级位置 / 现在位置
三级: ← 返回 │ 上二级位置 / 上一级位置 / 现在位置
四级: ← 返回 │ 上三级位置 / 上二级位置 / 上一级位置 / 现在位置
```
| 元素 | 色值 | 来源 | 说明 |
|------|------|------|------|
| 返回箭头 ← + "返回" | `#F5222D` (ZS-001-09) | — | hover `#CF1322`,文本按钮态 |
| 竖分隔 `\|` | `#E8E8E8` | FS-004-04 | 返回与路径间 |
| 历史层级文字 | `#575757` (FS-004-09) | — | 可点击,hover `#F5222D` |
| 路径分隔 `/` | `#C1C1C1` | FS-004-06 | — |
| 当前位置文字 | `#222222` (FS-004) | — | `Medium`,不可点击 |
### 8.3 行为规则
- 点击「返回」按钮逐步向上返回一级。
- 点击文字路径,快速返回到该层级页面。
- 层级尽可能不超过四级;超出时中间层折叠为 `…`。
- 字号 `14px Regular`,元素间距 `--spacing-2` (8px),竖分隔高度约 `12–14px`。
---
## 9. 页头与分页
### 9.1 页头(Page Header)
内容区顶部的标题区,底部叠一条 `1px solid #E8E8E8` (FS-004-04) 分割线。
| 变体 | 构成 | 配色 |
|------|------|------|
| 基础页头 | 页头标题 | 标题 `16px Bold #222222` (FS-004) |
| 页头 + 说明 | 标题 +(简要说明) | 说明 `14px Regular #A6A6A6` (FS-004-07) |
| 页头 + 说明 icon | 标题 + `?` 帮助图标 | 图标 `#A6A6A6`,hover `#F5222D` |
| 分 Tabs 页头 | 多个标题 Tab | 见 §9.2 |
### 9.2 分 Tabs 页头
横向页签形态,激活态红色下划线。
| 状态 | 文字 | 下划线 |
|------|------|--------|
| 默认 | `#222222` (FS-004) | 无 |
| Hover | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | `2px solid #F5222D`,宽度与文字齐 |
| 失效 | `#C1C1C1` (FS-004-06) | 无 |
- 字号 `14px Medium`,项间距 `--spacing-5` (24px),下划线与文字底部间距 `--spacing-2` (8px)
### 9.3 分页(Pagination)
#### 9.3.1 正常分页
`共 240 项 [1] 2 3 4 5 … 24 > 每页 [10 ▾] 条 前往 [ ] 页`
| 元素 | 默认 | Hover | 激活 | 失效 |
|------|------|-------|------|------|
| 总数 / 辅助文字(共N项 / 每页 / 条 / 前往 / 页) | `#575757` (FS-004-09) | — | — | — |
| 页码数字 | `#222222` (FS-004) | `#F5222D` (ZS-001-09) | 背景 `#F5222D` + 文字 `#FFFFFF` | `#C1C1C1` (FS-004-06) |
| 翻页箭头 `< >` | `#575757` (FS-004-09) | `#F5222D` | — | `#C1C1C1` |
| 省略号 `…` | `#A6A6A6` (FS-004-07) | — | — | — |
| 每页选择器 / 跳转输入框 | 边框 `#DBDBDB` (FS-004-05) | 边框 `#F5222D` | — | 边框 `#E8E8E8` |
- 页码项尺寸 `28px × 28px`,圆角 `2px`,激活态为实心红方块;项间距 `--spacing-1` (4px)
#### 9.3.2 精简分页
`共 1 项 < [1] / 10 >`
| 元素 | 色值 |
|------|------|
| 总数文字 | `#575757` (FS-004-09) |
| 当前页输入框 | 边框 `#DBDBDB`,文字 `#222222` |
| 总页数 `/ N` | `#A6A6A6` (FS-004-07) |
| 翻页箭头 `< >` | `#575757`,hover `#F5222D`,失效 `#C1C1C1` |
#### 9.3.3 导航式分页
仅"下一页 » / « 上一页"文本按钮,无页码。配色沿用 §5.3.3 文本按钮 + 图标:
| 状态 | 色值 |
|------|------|
| 默认 | `#F5222D` (ZS-001-09) |
| Hover | `#CF1322` (ZS-001) |
| 失效 | `#C1C1C1` (FS-004-06) |
---
## 10. 步骤条(Steps)
> 用于多步流程的进度指引。按布局分**横向**(弹窗 / 页面)与**纵向**两类。颜色取自 ZS-001 主色红与 FS-004 中性灰。
### 10.1 步骤节点(公共)
每个步骤由「指示圆 + 标题(可选描述)」组成。指示圆按状态切换形态:
| 状态 | 圆形外观 | 圆内符号 | 来源 |
|------|----------|----------|------|
| 已完成(Completed) | 描边圆 `2px solid #F5222D`,背景 `transparent` | `✓`(对勾,`#F5222D`) | ZS-001-09 |
| 当前(Current) | 实心圆,背景 `#F5222D` | 步骤数字,`#FFFFFF` | ZS-001-09 / 白 |
| 未开始(Upcoming) | 描边圆 `1px solid #C1C1C1`,背景 `transparent` | 步骤数字,`#A6A6A6` | FS-004-06 / FS-004-07 |
| 失效(Disabled) | 描边圆 `1px solid #E8E8E8`,背景 `transparent` | 步骤数字,`#C1C1C1` | FS-004-04 / FS-004-06 |
**指示圆规格**:
| 属性 | 取值 |
|------|------|
| 直径 | `24px` |
| 圆内字号 | `12px Bold`(D-DIN 或 Noto Sans Bold) |
| 对勾图标 | `12px`,居中 |
**标题与描述文字**:
| 元素 | 状态 | 字号 / 颜色 | 来源 |
|------|------|-------------|------|
| 标题 | 已完成 / 当前 | `14px Medium`,`#222222` | FS-004 |
| 标题 | 未开始 / 失效 | `14px Medium`,`#A6A6A6` → `#C1C1C1` | FS-004-07 → FS-004-06 |
| 描述 | 已完成 / 当前 | `12px Regular`,`#575757` | FS-004-09 |
| 描述 | 未开始 / 失效 | `12px Regular`,`#A6A6A6` → `#C1C1C1` | FS-004-07 → FS-004-06 |
**连接线(步骤之间)**:
| 类型 | 色值 | 来源 | 样式 |
|------|------|------|------|
| 已完成段 | `#F5222D` | ZS-001-09 | `1px dashed`(弹窗)/ `1px solid`(页面) |
| 未完成段 | `#C1C1C1` | FS-004-06 | `1px dashed` |
> 连接线在指示圆两侧水平居中(横向)或正下方垂直(纵向);线两端与圆外缘留 `--spacing-1` (4px) 间隙。
### 10.2 弹窗步骤条(横向 · 描边圆为主)
用于 Modal / Drawer 内的紧凑步骤条。完成态用**描边 + 红色对勾**,避免在弹窗内过于抢眼。
```
( ✓ ) ┄┄┄ (●2) ┄┄┄ ( 3 ) ┄┄┄ ( 4 ) ┄┄┄ ( 5 )
标题 标题 标题 标题 标题
(描述) (描述) (描述) (描述) (描述)
```
| 属性 | 取值 |
|------|------|
| 容器水平 padding | `--spacing-5` (24px) |
| 指示圆 → 标题间距 | `--spacing-2` (8px) |
| 标题 → 描述间距 | `--spacing-1` (4px) |
| 连接线 | `1px dashed`,完成段 `#F5222D`,未完成段 `#C1C1C1` |
| 文字对齐 | 标题、描述居中于指示圆下方 |
| 两种变体 | 仅标题 / 标题 + 描述 |
### 10.3 页面步骤条(横向 · 实心圆 + 自定义首项标题)
用于页面级向导(如「新建集群」)。完成态连接线改为**实线**,首步标题可显示具体名称。
```
(✓) ──── (●2) ┄┄┄ ( 3 ) ┄┄┄ ( 4 ) ┄┄┄ ( 5 )
新建集群 标题 标题 标题 标题
(描述文本) (描述文本) (描述文本) (描述文本) (描述文本)
```
| 维度 | 弹窗步骤条 | 页面步骤条 |
|------|-----------|-----------|
| 完成态圆形 | 描边圆 + 红色对勾 | 描边圆 + 红色对勾(同) |
| 完成段连接线 | `1px dashed #F5222D` | `1px solid #F5222D` |
| 第一项 | 通用「标题」占位 | 可显示具体名称(自定义文案) |
### 10.4 纵向步骤条
用于侧栏 / 详情页内的纵向流程指引。两种变体——「仅标题」与「标题 + 描述」。
```
仅标题: 标题 + 描述:
(✓) 新建集群 (✓) 新建集群
┊ 描述文本描述
(●2) 标题 ┊
┊ (●2) 标题
( 3 ) 标题 描述文本描述
┊ ┊
( 4 ) 标题 ( 3 ) 标题
描述文本描述
```
| 属性 | 取值 |
|------|------|
| 指示圆 → 标题水平间距 | `--spacing-2` (8px) |
| 节点垂直间距(仅标题) | `--spacing-5` (24px),圆心到圆心 |
| 节点垂直间距(标题 + 描述) | `--spacing-6` (32px),圆心到圆心 |
| 标题 → 描述间距 | `--spacing-1` (4px) |
| 连接线位置 | 上下指示圆正下方垂直,居中于圆心 |
| 连接线样式 | `1px dashed`,完成段 `#F5222D`,未完成段 `#C1C1C1` |
| 文字对齐 | 标题与描述左对齐 |
### 10.5 交互与动画
| 行为 | 动效 |
|------|------|
| 步骤切换(next) | 当前圆 `background: transparent → #F5222D`,`200ms ease-out`;上一步圆 `border-color: #C1C1C1 → #F5222D`,再 `200ms` 切换为对勾图标淡入 |
| 连接线进度 | 完成段 `width 0% → 100%`,`240ms ease-out` |
---
## 11. 页签与锚点
> 共用主色红指示当前位置:页签用于同层级内容切换,锚点用于长页面内章节跳转。
### 11.1 页签(Tabs)
#### 11.1.1 横向页签(下划线 · 横向)
| 状态 | 文字 | 下划线 |
|------|------|--------|
| 默认 | `#222222` (FS-004) | 无 |
| Hover | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | `2px solid #F5222D`,宽度与文字齐 |
| 失效 | `#C1C1C1` (FS-004-06) | 无 |
- 字号 `14px Medium`,项间距 `--spacing-5` (24px),下划线与文字底部间距 `--spacing-2` (8px)
- 容器底边叠一条 `1px solid #E8E8E8` (FS-004-04) 基线,激活高亮线压在其上
#### 11.1.2 纵向页签(下划线 · 纵向)
| 状态 | 文字 | 右侧指示线 |
|------|------|-----------|
| 默认 | `#222222` (FS-004) | 无 |
| Hover | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | 右侧 `2px solid #F5222D`,高度与文字齐 |
| 失效 | `#C1C1C1` (FS-004-06) | 无 |
- 字号 `14px Medium`,项高度 `32px`,项间距 `--spacing-2` (8px)
- 容器右边叠一条 `1px solid #E8E8E8` 基线,激活指示线压在其上
### 11.2 锚点(Anchor)
用于长内容页的章节快速跳转,支持一 / 二级缩进。
```
│ 一级
│ 一级 (active)
│ 二级
│ 二级
│ 一级
│ 一级
```
| 属性 | 取值 |
|------|------|
| 左侧基线 | `1px solid #E8E8E8` (FS-004-04) 通栏 |
| 项高度 | `32px` |
| 一级项左 padding | `--spacing-3` (12px) |
| 二级项左 padding | `--spacing-5` (24px) |
| 字号 | `14px Regular` |
| 状态 | 一级文字 | 二级文字 | 左侧指示条 |
|------|----------|----------|-----------|
| 默认 | `#222222` (FS-004) | `#A6A6A6` (FS-004-07) | 无 |
| Hover | `#F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | — | 左侧 `2px solid #F5222D`,覆盖容器基线 |
- 二级项不单独支持激活态:选中二级时其父级一级显示激活态,指示条按父级位置渲染
---
---
> 返回 [规范目录](README.md)
@@ -0,0 +1,308 @@
# 浅红色 UI 规范 · 四、数据展示
> 返回 [规范目录](README.md) | 本篇:弹出式窗口 · 卡片 · 折叠面板 · 进度条 · 表格 · 标签 · 时间轴
---
## 17. 弹出式窗口(Modal / Dialog)
> 用于聚焦的多步表单 / 确认操作。内部可承载页签、步骤条、表单控件。
### 17.1 容器规格
| 属性 | 取值 |
|------|------|
| 背景 | `#FFFFFF` (FS-004-01),圆角 `4px` |
| 阴影 | `0 8px 32px rgba(34, 34, 34, 0.16)`(§3.1.3 三级阴影) |
| 遮罩层 | `rgba(34, 34, 34, 0.45)`(基于 FS-004) |
| 标题区 | 左侧 `3px` 主色红竖条 `#F5222D` + 标题 `16px Bold #222222` |
| 关闭按钮 `×` | `#A6A6A6` (FS-004-07),hover `#F5222D` |
| 内边距 | `--spacing-5` (24px) |
| 底部操作区 | 右对齐,主按钮在最右(§5.3.4) |
### 17.2 内部页签与步骤条
- 顶部页签沿用 §11.1 横向页签(Tab 激活红字 + 红下划线,基线 `#E8E8E8`)。
- 步骤条沿用 §10.2 弹窗步骤条(完成态红描边对勾、当前态红实心、未开始灰描边,连接线 `1px dashed`)。
### 17.3 表单控件补充(Modal 内常用)
| 控件 | 配色 |
|------|------|
| 必填星号 `*` | `#F5222D` (ZS-001-09),置于 label 前 |
| 下拉选择(Select,`请选择 ▾`) | 同输入框(§13),箭头 `#A6A6A6`,聚焦边框 `#F5222D` |
| 日期 / 时间选择器(📅) | 输入框 + 日历图标 `#A6A6A6`;区间用"起 至 止"两段 |
| 文本区域(Textarea) | 同输入框,`resize: vertical`,最小高度约 `80px` |
| 数值 + 单位(如 `100 天`) | 输入框 + 尾部 addon(背景 `#FAFAFA`,边框 `#DBDBDB`,文字 `#575757`) |
| 复选框(Checkbox) | 未选边框 `#DBDBDB`;选中背景 `#F5222D` + 对勾 `#FFFFFF` |
| 错误态 | 控件边框 `1px solid #F5222D`,下方红色提示文字 `12px #F5222D`(如"输入表单内容报错") |
---
## 18. 数据展示 · 卡片(Card)
> 用于陈列结构化信息(人物 / 媒体 / 群组等档案)。在 §3 卡片基础上扩展"分级信息卡片"。
### 18.1 卡片头部
| 元素 | 配色 |
|------|------|
| 类目竖条(标题前) | `3px` 主色红 `#F5222D` (ZS-001-09) |
| 标题(如「个体」「媒体」) | `16px Bold`,`#222222` (FS-004) |
| 关闭 `×` | `#A6A6A6` (FS-004-07),hover `#F5222D` |
| 右上操作(查看档案) | 小尺寸主按钮 `#F5222D` + 白字(§5.1 小,含图标) |
### 18.2 标签 / 属性 Tag
| 类型 | 背景 | 文字 | 来源 |
|------|------|------|------|
| 人物属性 / 红色标签 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) | 主色红 |
| 立场 / 成功类(绿) | `#EEFCF7` (FS-002-03) | `#189D72` (FS-002-10) | 渐绿系 |
| 状态签(右上角强调) | `transparent` | `#F5222D` (ZS-001-09) | 主色红 |
| 话题标签(#话题) | `transparent` | `#F5222D` (ZS-001-09) | 主色红,`#` 前缀 |
- Tag 圆角 `2px`,内边距 `0 8px`,字号 `12px`
### 18.3 卡片内页签与信息行
| 元素 | 配色 |
|------|------|
| 页签(基本信息 / 线下活动 / 线上发声) | 激活 `#F5222D` + 红下划线;默认 `#222222`(§11.1.1) |
| 页签红点提示(•) | `#F5222D` (ZS-001-09),标识有更新 |
| 信息项标签(国家/地区、职务…) | `#A6A6A6` (FS-004-07) |
| 信息项取值 | `#222222` (FS-004) |
| 来源平台图标(如 Facebook) | 平台原色(蓝 `#0079FE` 近似),不纳入主题强调色 |
---
## 19. 数据展示 · 折叠面板(Collapse)
> 用于长内容的收起 / 展开。含列表概览折叠、带图标折叠、手风琴折叠、信息流折叠四类。
### 19.1 列表概览折叠(含 + 图标变体)
| 状态 | 构成 | 配色 |
|------|------|------|
| 收起 | 标题 + 描述 + 右侧 `▾` | 标题 `#222222` (FS-004)、描述 `#A6A6A6` (FS-004-07)、箭头 `#A6A6A6` |
| 展开 | 上述 + 下方内容区 | 内容区背景 `#FAFAFA` (FS-004-02),箭头旋转为 `^` |
| 属性 | 取值 |
|------|------|
| 容器边框 | `1px solid #DBDBDB` (FS-004-05),圆角 `4px` |
| 头部内边距 | `--spacing-4` (16px) |
| 左侧图标(带图标变体) | 占位 `40px`,图标 `#A6A6A6` |
### 19.2 手风琴折叠(Accordion)
多面板同列堆叠,同一时刻通常仅展开一项。
| 元素 | 配色 |
|------|------|
| 项分隔 | `1px solid #E8E8E8` (FS-004-04) |
| 标题 | `#222222` (FS-004),前置 `▾ / >` 箭头 `#A6A6A6` |
| 收起 / 展开 链接 | 文本按钮 `#F5222D` (ZS-001-09) |
| 展开正文 | `#575757` (FS-004-09),背景 `#FFFFFF` |
### 19.3 信息流折叠(Feed)
用于资讯流条目的展开详情。
| 元素 | 配色 |
|------|------|
| 前置「标签」Tag | 背景 `#FEEDED` (ZS-001-03),文字 `#F5222D` (ZS-001-09) |
| 条目标题 | `#222222` (FS-004),`14px Medium` |
| 热度计数(🔥 6405) | 火苗图标 `#FF9900` (FS-001-09),数字 `#575757` (FS-004-09) |
| 收起 / 展开 链接 + 箭头 | `#F5222D` (ZS-001-09) |
| 分节标题(【热点首发】/【应对建议】) | `#F5222D` (ZS-001-09),`Medium` |
| 来源 / 作者行(社交媒体 \| 平台 \| @用户) | `#A6A6A6` (FS-004-07) |
| 外链 URL | `#0079FE` (ZS-002-09),hover 下划线 |
| 日期 | `#A6A6A6` (FS-004-07) |
| 正文 | `#575757` (FS-004-09) |
| 缩略图 | 圆角 `2px`,间距 `--spacing-1` (4px) |
---
## 21. 数据展示 · 进度条(Progress)
### 21.1 标准进度条
| 属性 | 取值 |
|------|------|
| 轨道背景 | `#F5F5F5` (FS-004-03) |
| 轨道高度 | `8px`,圆角全圆 |
| 已完成段 | 按语义取色(§20):成功绿 / 失败红 / 警告橙 |
| 百分比文字 | `#222222` (FS-004),置于条右侧 |
| 末端状态图标 | 成功 ✓ `#2FD69A`;失败 ✗ `#F5222D`(橙色进行态可不带图标) |
### 21.2 小型进度条
放在较窄区域内(如表格单元格)。规格同 §21.1,仅尺寸缩小:
| 属性 | 取值 |
|------|------|
| 轨道高度 | `4px` |
| 百分比 / 图标 | 字号缩至 `12px`,图标 `12px` |
### 21.3 环形进度条
| 属性 | 取值 |
|------|------|
| 轨道环 | `#F5F5F5` (FS-004-03) |
| 进度环 | 按语义取色(§20),线宽 `6px`,圆头端点 |
| 中心主数字(如 `82.0%`) | `#222222` (FS-004),D-DIN |
| 中心名称 | `12px Regular`,`#A6A6A6` (FS-004-07) |
> 进度环颜色按阈值映射(如低→绿、中→橙、高→红)由业务自定义,但必须取自 §20 语义色。
---
## 22. 数据展示 · 表格(Table)
### 22.1 结构规格
| 属性 | 取值 |
|------|------|
| 表头背景 | `#FFFFFF`(或浅红 `#FEF6F6` ZS-001-02 强调首行) |
| 表头文字 | `14px Medium`,`#222222` (FS-004) |
| 单元格文字 | `14px Regular`,`#222222` |
| 行高 | `48px` |
| 行分隔 | `1px solid #F5F5F5` (FS-004-03) |
| 行 Hover | 背景 `#FAFAFA` (FS-004-02) |
| 斑马纹(可选) | 偶数行 `#FAFAFA` (FS-004-02) |
| 复选框(选中) | 填充 `#F5222D` + 对勾 `#FFFFFF` |
### 22.2 状态列(圆点 + 文字)
| 文字 | 圆点色 | 来源 |
|------|--------|------|
| 普通 | `#2FD69A`(成功绿) | FS-002-09 |
| 异常 | `#F5222D`(错误红) | ZS-001-09 |
| 特殊 | `#FF9900`(警告橙) | FS-001-09 |
- 文字统一 `#222222` (FS-004),圆点直径 `6px`,与文字间距 `--spacing-1` (4px)
### 22.3 整体状态列(状态标签)
使用 §23 浅底状态标签:进行中(橙)/ 已完成(绿)/ 已失败(红)。
### 22.4 操作列
文本按钮 + 图标,沿用 §5.3.3:查看 / 配置 等均为 `#F5222D` (ZS-001-09),hover `#CF1322`,按钮间距 `--spacing-3` (12px)。
### 22.5 单元格内编辑报错
可编辑单元格(如 `100 [M]`)校验失败:输入框边框 `1px solid #F5222D`,红色气泡提示"Table 内输入报错"(背景 `#F5222D`、白字 `12px`,§12.3 同款)。
---
## 23. 数据展示 · 标签(Tag / Badge)
### 23.1 标签形式
| 形式 | 用途 | 配色 |
|------|------|------|
| 文字按钮标签 | 重点突出标记属性 | 三种强度:灰底 `#F5F5F5`/`#575757`;浅红底 `#FEEDED`/`#F5222D`;实红 `#F5222D`/`#FFFFFF` |
| 图标 + 文字按钮标签 | 增强辨识度 | 灰底 + 平台原色图标(如 Twitter 蓝);或实红 `#F5222D` + 白字 + ✓ |
| 圆点 + 文字标签 | 属性多 / 复杂、无需突出 | 圆点按语义色(普通绿 / 特殊橙 / 异常红),文字 `#222222` |
| 动态标签 | 可增删(筛选列表、关键词) | 已加:灰底 `#F5F5F5` + `×`;新增入口:虚线框 `#DBDBDB` + "+ 标签" |
- 标签圆角 `2px`,高度 `24px`,内边距 `0 8px`,字号 `12px`
### 23.2 状态标签(Status Badge)
四种呈现风格,颜色全部取自 §20 语义状态色:
| 风格 | 说明 |
|------|------|
| ① 浅底彩字 | 浅底背景 + 语义文字色(默认推荐,弱干扰) |
| ② 实填白字 | 语义实填背景 + 白字(强调态,用于重点状态) |
| ③ 图标 + 浅底彩字 | ① 基础上前置语义图标 |
| ④ 图标 + 彩字(无底) | 仅图标 + 文字,最轻量 |
常见状态与语义映射:
| 状态 | 语义 / 取色 |
|------|-------------|
| 未开始 / 就绪 | 中性灰 |
| 等待中 / 等待 | 信息蓝 |
| 进行中 / 进行 | 警告橙 |
| 已完成 / 成功 | 成功绿 |
| 已失败 / 失败 | 错误红 |
| 待更新 / 待更 | 青 |
- 图标(②④ 风格):就绪 ⊖、等待 ⊗、进行 🕐、成功 ✓、失败 ✗、待更 🕐,颜色与文字同步
---
## 24. 数据展示 · 时间轴(Timeline)
> 用于按时间顺序陈列事件。分纵向(基础 / 卡片 / 长文本)与横向两种主轴方向,另含全局缩略时间轴。
### 24.1 纵向时间轴点(基础)
| 元素 | 配色 |
|------|------|
| 轴线 | `1px solid #E8E8E8` (FS-004-04) 或虚线 |
| 最新 / 当前节点 | 空心红圆环 `2px solid #F5222D` (ZS-001-09),背景 `#FFFFFF` |
| 历史节点 | 实心红点 `#F5222D` (ZS-001-09),直径 `8px` |
| 事件标题 | `14px Medium`,`#222222` (FS-004) |
| 时间文字 | `12px Regular`,`#A6A6A6` (FS-004-07) |
### 24.2 卡片式时间轴点
节点内容置于浅灰卡片中。
| 元素 | 配色 |
|------|------|
| 卡片背景 | `#FAFAFA` (FS-004-02),圆角 `4px` |
| 卡片内分隔 | `1px solid #E8E8E8` |
| 正文标签(如"安全状况:") | `#575757` (FS-004-09) |
| 正向取值(如"今日无安全风险") | `#189D72`(成功绿,FS-002-10) |
### 24.3 横向长文本时间轴
横轴双向箭头,节点上下交错分布。
| 元素 | 配色 |
|------|------|
| 主轴线 + 双向箭头 | `#222222` (FS-004) |
| 上方节点标记 + 日期 | 红色 `#F5222D` (ZS-001-09),`▼` 标记 |
| 下方节点标记 + 日期 | 橙色 `#FF9900` (FS-001-09),`▲` 标记 |
| 节点正文 | `#575757` (FS-004-09),`14px Regular` |
> 上 / 下用红 / 橙双色区分两类事件或交替排布,避免拥挤。
### 24.4 纵向长文本时间轴
带头像、状态标签、富文本与附件的纵向时间流。
| 元素 | 配色 |
|------|------|
| 轴线 | `1px dashed #DBDBDB` (FS-004-05) |
| 日期分组吸顶标签 | 背景 `#A6A6A6`(FS-004-07) 或 `#F5F5F5`,文字 `#FFFFFF` / `#575757`,圆角 `2px` |
| 节点圆点 | 红 `#F5222D`(重点)/ 浅红 `#FCB5B9`(次要) |
| 状态标签 | 浅底状态标签:计划(橙 `#FFF6EA`/`#FF9900`)、已成行(红 `#FEEDED`/`#F5222D`)(§23.2 ①) |
| 人物名 | `14px Regular`,`#222222` |
| 时间 | `12px`,`#A6A6A6` (FS-004-07) |
| 正文 | `#575757` (FS-004-09) |
| 地址(📍 地址) | 图标 + 文字 `#A6A6A6` |
| 缩略图 | 圆角 `2px`,间距 `--spacing-1` (4px) |
### 24.5 全局时间轴(缩略 / 范围选择)
页面顶部用于框选时间范围的缩略轴。
| 元素 | 配色 |
|------|------|
| 刻度轴背景 | 灰阶渐变 `linear-gradient(90deg, #7F7F7F, #DBDBDB)`(FS-004-08 → FS-004-05) |
| 刻度文字(如 04-03) | `#FFFFFF`,`12px` |
| 拖拽游标(handle) | `#FFFFFF` 竖条,带轻微阴影 |
| 选区刷选框(brush) | 边框 `1px solid #F5222D`,填充 `rgba(245, 34, 45, 0.08)`(浅红 ZS-001-09 8%) |
| 选区内迷你面积图 | 描边 `#F5222D`,填充浅红渐变 |
---
---
> 返回 [规范目录](README.md)
@@ -0,0 +1,90 @@
# 浅红色 UI 规范 · 五、图表
> 返回 [规范目录](README.md) | 本篇:图表通用色序 · 公共元素 · 柱状/折线/甘特/饼环
---
## 25. 图表(Charts)
> 图表配色以**柔和色系**为主(保证大面积色块不刺眼),强调线 / 排行榜可叠加主色红渐变。多分类图表的色序按"用几色取几色",从下表对应行从左到右取用。
### 25.1 图表通用色序(权威)
> 如需更多色值,请 UI 设计师按系统整体风格在同色系内扩展。
| 分类数 | 色序(从左到右) |
|--------|------------------|
| 三色 | `#FA9196` `#97EBCD` `#80BCFF` |
| 四色 | `#FA9196` `#FFCC80` `#97EBCD` `#A2E1F9` |
| 五色 | `#FA9196` `#FFCC80` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 六色 | `#FA9196` `#FFCC80` `#BAE1B3` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 七色 | `#FA9196` `#FCA58F` `#FFCC80` `#BAE1B3` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 八色 | `#FA9196` `#FCA58F` `#FDB887` `#FFCC80` `#BAE1B3` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 九色 | `#FA9196` `#FCA58F` `#FDB887` `#FFCC80` `#DCD69A` `#BAE1B3` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 十色 | `#FA9196` `#FCA58F` `#FDB887` `#FFCC80` `#DCD69A` `#BAE1B3` `#97EBCD` `#9EE4EA` `#A2E1F9` `#80BCFF` |
- 色值映射:`#FA9196`(ZS-001-06 浅红) / `#FCA58F`·`#FDB887`(橙红过渡) / `#FFCC80`(FS-001-06 浅橙) / `#DCD69A`(黄绿过渡) / `#BAE1B3`·`#97EBCD`(FS-002-06 浅绿) / `#9EE4EA`·`#A2E1F9`(FS-003-06 浅青) / `#80BCFF`(ZS-002-06 浅蓝)
- 取色原则:序列从暖(红)到冷(蓝)渐变排布,相邻类别对比清晰
### 25.2 图表公共元素
| 元素 | 配色 |
|------|------|
| 坐标轴线 | `1px solid #E8E8E8` (FS-004-04) |
| 网格线 | `1px dashed #F5F5F5` (FS-004-03) |
| 轴刻度文字 | `12px`,`#A6A6A6` (FS-004-07) |
| 图例文字 | `12px`,`#575757` (FS-004-09),前置色块圆角 `2px` |
| Tooltip 浮层 | 背景 `rgba(34,34,34,0.85)`(FS-004),文字 `#FFFFFF` `12px`,圆角 `4px` |
| 数值标注气泡 | 背景 `#7F7F7F` (FS-004-08),白字(如悬停值 `59.32`) |
| 高亮选区(band) | 填充 `rgba(245,34,45,0.08)`,中线 `1px dashed #F5222D` (ZS-001-09) |
### 25.3 柱状图(Bar)
| 形态 | 配色 |
|------|------|
| 堆叠柱 / 分组柱 | 各序列按 §25.1 取色(如 Paper 绿 `#97EBCD`、Project 橙 `#FFCC80`、Book 红 `#FA9196`) |
| 单序列柱 / 条形 | 主序列 `#FA9196`(浅红) |
| 排行榜(TOP 渐变条) | 进度条填充 `linear-gradient(90deg, #F5222D 0%, #FF9900 100%)`(红→橙),轨道 `#F5F5F5`,名称 `#222222`、数值 `#222222` 右对齐 |
- 柱体圆角 `2px`,柱间距按类目自适应
### 25.4 折线图(Line / Area)
| 元素 | 配色 |
|------|------|
| 主折线 | `#F5222D` (ZS-001-09),线宽 `2px` |
| 面积填充 | `linear-gradient(180deg, rgba(245,34,45,0.15), rgba(245,34,45,0))` |
| 数据点 | 描边 `#F5222D` + 白色填充,悬停放大并实心 |
| 多序列线 | 用饱和主色保证细线可辨:红 `#F5222D` / 蓝 `#0079FE` / 橙 `#FF9900` / 绿 `#2FD69A` |
| 涨跌双色线 | 上行 `#F5222D`(红)/ 下行 `#2FD69A`(绿) |
| 堆叠面积 | 各层按 §25.1 柔和色 + 各自浅色渐变填充 |
| 悬停辅助线 + tooltip | 竖虚线 `#F5222D`,tooltip 见 §25.2(如"异常率 12.3%") |
### 25.5 甘特图(Gantt)
| 元素 | 配色 |
|------|------|
| 行背景斑马纹 | 浅红 `#FEF6F6` (ZS-001-02) 与 `#FFFFFF` 交替 |
| 任务条 | 按 §25.1 取色(每类任务一色),圆角 `2px`,末端可带箭头 |
| 网格线 | `1px solid #F5F5F5` (FS-004-03) |
| 时间轴 / 月份标签 | `12px`,`#A6A6A6` (FS-004-07) |
| 图例 | 圆点 + 文字,圆点取对应任务色,文字 `#575757` |
### 25.6 饼图 / 环形图(Pie / Donut)
支持三环至八环(即 3~8 个分类),扇区按 §25.1 对应行取色。
| 元素 | 配色 |
|------|------|
| 扇区 | §25.1 通用色序 |
| 中心主数字(如 `34801`) | `#222222` (FS-004),D-DIN |
| 中心说明(XXX总数) | `12px`,`#A6A6A6` (FS-004-07) |
| 引导线 + 标签 | 引导线同扇区色,标签文字 `#575757` (FS-004-09),百分比同行 |
| 扇区间隙 | `2px` 白色描边分隔,环宽适中(甜甜圈留中心空 ≥ 50%) |
---
---
> 返回 [规范目录](README.md)
@@ -0,0 +1,121 @@
# 浅红色 UI 规范 · 六、页面与布局
> 返回 [规范目录](README.md) | 本篇:统计与汇总卡 · 实体卡片 · 综合页面布局示例
---
## 26. 统计与汇总卡(Dashboard)
> 仪表盘顶部的指标卡与汇总卡,用于一屏概览关键数据。
### 26.1 指标卡(已用 / 可用 + 迷你环)
横向排列的使用率卡,左侧数值、右侧迷你环形进度。
| 元素 | 配色 |
|------|------|
| 卡标题(如"账号使用率") | `14px Regular`,`#575757` (FS-004-09) |
| 主数值(已用,如 `139`) | D-DIN `26px Bold`,`#222222` (FS-004) |
| 次数值(可用,如 `2`) | D-DIN,`#FF9900`(警告橙,FS-001-09) |
| 数值下标签(已用 / 可用) | `12px`,`#A6A6A6` (FS-004-07) |
| 迷你环 + 中心百分比 | 按使用率阈值取语义色(§20):低→绿、中→橙、高→红;轨道 `#F5F5F5` |
| 卡间竖分隔 | `1px solid #E8E8E8` (FS-004-04) |
### 26.2 汇总卡(总量 + 正常 / 异常 + 环形)
| 元素 | 配色 |
|------|------|
| 总量数值(如 `300`) | D-DIN,`#222222` (FS-004) |
| 正常数值(如 `270`) | `#FF9900`(橙)或成功绿,按业务定 |
| 异常数值(如 `30`) | `#F5222D`(错误红,ZS-001-09) |
| 标签(总量 / 正常 / 异常) | `#575757` (FS-004-09) |
| 环形图 | 正常段绿 `#97EBCD`、异常段红 `#FA9196`(§25.1 色序);中心主数(如 `40%`)`#222222`、说明(异常率)`#A6A6A6` |
### 26.3 时间范围切换(今日 / 近7日)
分段选择,沿用 §14.3 按钮式页签:
| 状态 | 配色 |
|------|------|
| 选中(今日) | 背景 `#F5222D` (ZS-001-09),文字 `#FFFFFF` |
| 未选(近7日) | 背景 `#FFFFFF`,边框 `#DBDBDB`,文字 `#222222` |
### 26.4 资源检索条
`[资源类型 ▾] [请输入关键词] [搜索]`:下拉 + 输入框沿用 §13;搜索为主按钮(§5.2.1 红)。配合右侧表格(§22),表头含排序箭头(默认 `#A6A6A6`,激活 `#F5222D`)。
---
## 27. 实体卡片(列表卡 / 任务集卡)
> 卡片网格陈列实体(任务集 / 人物 / 项目等),含主数据、属性表、负责人操作按钮与空状态。
### 27.1 卡片结构
| 元素 | 配色 |
|------|------|
| 卡片背景 / 边框 / 阴影 | `#FFFFFF` / `1px solid #E8E8E8` / §3.1.3 一级阴影,圆角 `4px` |
| 主标题(如人名) | `16px Bold`,`#222222` (FS-004) |
| 副标题(项目名) | `14px Regular`,`#575757` (FS-004-09) |
| 创建时间 | `12px`,`#A6A6A6` (FS-004-07) |
| 主指标(目标数量,如 `10247`) | D-DIN `26px Bold`,`#F5222D` (ZS-001-09);标签"目标数量" `#A6A6A6` |
| 属性面板(社交媒体(52)) | 背景 `#FEF6F6` (ZS-001-02);条目名 `#575757`、数值 `#F5222D` (ZS-001-09) |
| 联系/备注行 | `#575757` (FS-004-09),时间后缀 `#A6A6A6` |
| 底部负责人按钮 | 通栏主按钮:背景 `#F5222D`、文字 `#FFFFFF`(§5.2.1) |
### 27.2 卡片 Hover 操作菜单
卡片右上 `⋯` 触发**深色下拉**:
| 元素 | 配色 |
|------|------|
| 菜单背景 | `#3D3D3D` (FS-004-10),圆角 `4px`,阴影 §3.1.3 二级 |
| 菜单项(修改 / 删除) | 文字 + 图标 `#FFFFFF`;hover 背景 `rgba(255,255,255,0.1)` |
### 27.3 空状态(暂无任务)
| 元素 | 配色 |
|------|------|
| 空状态插图 / 图标 | 中性灰 `#C1C1C1` (FS-004-06) |
| 空状态文字(暂无任务) | `14px`,`#A6A6A6` (FS-004-07) |
| 主指标归零 | 数值显示 `0`,颜色同 §27.1(红) |
---
## 28. 综合页面布局示例
> 标准后台页面 = 顶部红底导航(§7.1)+ 左侧分级侧边栏(§7.3 / §7.6)+ 右侧内容区。
### 28.1 仪表盘页
```
┌ Header(红底,§7.1)────────────────────────────────────────────┐
├──────┬───────────────────────────────────────────────────────────┤
│ 侧栏 │ 面包屑(§8.1) │
│(§7.3)│ ┌ 指标卡 × N(§26.1,横向等分,竖线分隔)───────────────┐ │
│ │ ├ 汇总卡(§26.2) │ 折线图(§25.4) │ 资源表(§22 + 检索§26.4)│ │
│ │ └ 异常分布图 … │ │
└──────┴───────────────────────────────────────────────────────────┘
```
- 内容区背景 `#FAFAFA` (FS-004-02),卡片白底浮起;模块间距 `--spacing-5` (24px),卡内边距 `--spacing-4` (16px)
### 28.2 列表卡片页
```
┌ Header(红底)─────────────────────────────────────────────────┐
├──────┬───────────────────────────────────────────────────────────┤
│ 侧栏 │ [搜索框] [搜索] [+ 添加任务集] 排序 [创建时间 ▾]│
│ │ ┌实体卡┐ ┌实体卡┐ ┌实体卡┐ ┌实体卡┐ (§27,等宽网格) │
│ │ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │ ┌实体卡┐ ┌空状态┐ … │
└──────┴───────────────────────────────────────────────────────────┘
```
- 卡片网格列间距 / 行间距 `--spacing-5` (24px)
- 顶部工具条:搜索输入(§13)+ 搜索主按钮(§5.2.1)+ "添加"主按钮;右侧排序下拉(§13)
- "+ 添加任务集"为主按钮(红 `#F5222D` + 白字 + 加号图标,§5.3.2)
---
> 返回 [规范目录](README.md)
@@ -0,0 +1,85 @@
# 浅红色 UI 规范
浅色主题设计规范:以白 / 浅灰作大面积背景、深色作正文、**红色(`#F5222D`)为品牌强调主色**。
> 全文按主题拆分为 6 篇,章节号(§1 ~ §28)全局唯一,跨篇引用(如"沿用 §5.2.1")按下表定位即可。
## 篇目导航
| 篇 | 文件 | 内容 |
|----|------|------|
| 一、基础样式 | [01-基础样式.md](01-基础样式.md) | §1 色彩 · §2 字体 · §3 阴影 · §4 圆角 · §6 间距 · §20 语义状态色 |
| 二、按钮与表单 | [02-按钮与表单.md](02-按钮与表单.md) | §5 按钮 · §12 表单 · §13 输入框 · §14 选择按钮 · §15 标签输入框 · §16 上传 |
| 三、导航 | [03-导航.md](03-导航.md) | §7 导航组件 · §8 面包屑 · §9 页头与分页 · §10 步骤条 · §11 页签与锚点 |
| 四、数据展示 | [04-数据展示.md](04-数据展示.md) | §17 弹窗 · §18 卡片 · §19 折叠面板 · §21 进度条 · §22 表格 · §23 标签 · §24 时间轴 |
| 五、图表 | [05-图表.md](05-图表.md) | §25 图表(通用色序 / 柱 / 折线 / 甘特 / 饼环) |
| 六、页面与布局 | [06-页面与布局.md](06-页面与布局.md) | §26 统计与汇总卡 · §27 实体卡片 · §28 综合页面布局示例 |
## 章节速查(§ → 篇)
| § | 章节 | 所在篇 |
|---|------|--------|
| 1 | 色彩 | [一](01-基础样式.md) |
| 2 | 字体 | [一](01-基础样式.md) |
| 3 | 阴影 | [一](01-基础样式.md) |
| 4 | 圆角 | [一](01-基础样式.md) |
| 5 | 按钮 | [二](02-按钮与表单.md) |
| 6 | 间距 | [一](01-基础样式.md) |
| 7 | 导航组件 | [三](03-导航.md) |
| 8 | 面包屑 | [三](03-导航.md) |
| 9 | 页头与分页 | [三](03-导航.md) |
| 10 | 步骤条 | [三](03-导航.md) |
| 11 | 页签与锚点 | [三](03-导航.md) |
| 12 | 表单 | [二](02-按钮与表单.md) |
| 13 | 输入框 | [二](02-按钮与表单.md) |
| 14 | 选择按钮 | [二](02-按钮与表单.md) |
| 15 | 标签输入框 | [二](02-按钮与表单.md) |
| 16 | 上传 | [二](02-按钮与表单.md) |
| 17 | 弹出式窗口 | [四](04-数据展示.md) |
| 18 | 卡片 | [四](04-数据展示.md) |
| 19 | 折叠面板 | [四](04-数据展示.md) |
| 20 | 语义状态色 | [一](01-基础样式.md) |
| 21 | 进度条 | [四](04-数据展示.md) |
| 22 | 表格 | [四](04-数据展示.md) |
| 23 | 标签 | [四](04-数据展示.md) |
| 24 | 时间轴 | [四](04-数据展示.md) |
| 25 | 图表 | [五](05-图表.md) |
| 26 | 统计与汇总卡 | [六](06-页面与布局.md) |
| 27 | 实体卡片 | [六](06-页面与布局.md) |
| 28 | 综合页面布局示例 | [六](06-页面与布局.md) |
## 颜色速查
完整色板与编号见 [§1 色彩](01-基础样式.md)。最常用的功能色:
| 角色 | 色值 | 编号 |
|------|------|------|
| 主色 Primary | `#F5222D` | ZS-001-09 |
| 主色 Hover | `#CF1322` | ZS-001 |
| 主色 Active | `#A8071A` | ZS-001-11 |
| 链接 / 信息 | `#0079FE` | ZS-002-09 |
| 正文 | `#222222` | FS-004 |
| 次级文字 | `#575757` | FS-004-09 |
| 备注 / 占位 | `#A6A6A6` | FS-004-07 |
| 禁用文字 | `#C1C1C1` | FS-004-06 |
| 页面背景 | `#FAFAFA` | FS-004-02 |
| 容器背景 | `#FFFFFF` | FS-004-01 |
| 分割线 | `#E8E8E8` | FS-004-04 |
**语义状态色**(成功 / 警告 / 错误 / 信息 / 待更新 / 中性,详见 [§20](01-基础样式.md)):
| 语义 | 文字 | 浅底 | 实填 |
|------|------|------|------|
| 成功(绿) | `#189D72` | `#EEFCF7` | `#2FD69A` |
| 警告(橙) | `#FF9900` | `#FFF6EA` | `#FF9900` |
| 错误(红) | `#F5222D` | `#FEEDED` | `#F5222D` |
| 信息(蓝) | `#0079FE` | `#EAF4FF` | `#0079FE` |
| 待更新(青) | `#45C3F3` | `#EFFAFE` | `#45C3F3` |
| 中性(灰) | `#7F7F7F` | `#F5F5F5` | `#A6A6A6` |
## 通用约定速记
- **圆角**:按钮 / 输入框 / Tag `2px`;卡片 / 分栏 / Modal `4px`(§4)
- **间距阶梯**:4 / 8 / 12 / 16(密)· 24 / 32 / 40 / 56(疏),16 是密的上限、24 是疏的下限(§6)
- **按钮尺寸**:小 `24px` / 正常 `32px`(§5.1)
- **字体**:中文 Noto Sans S Chinese,数字 D-DIN(§2)
+14
View File
@@ -0,0 +1,14 @@
# 浅红色 UI 规范(已拆分)
本规范已按主题拆分到文件夹 **[`浅红色UI规范/`](浅红色UI规范/README.md)**,请从目录进入:
👉 **[浅红色UI规范 / README.md](浅红色UI规范/README.md)**
| 篇 | 文件 |
|----|------|
| 一、基础样式(色彩 / 字体 / 阴影 / 圆角 / 间距 / 语义色) | [01-基础样式.md](浅红色UI规范/01-基础样式.md) |
| 二、按钮与表单 | [02-按钮与表单.md](浅红色UI规范/02-按钮与表单.md) |
| 三、导航 | [03-导航.md](浅红色UI规范/03-导航.md) |
| 四、数据展示 | [04-数据展示.md](浅红色UI规范/04-数据展示.md) |
| 五、图表 | [05-图表.md](浅红色UI规范/05-图表.md) |
| 六、页面与布局 | [06-页面与布局.md](浅红色UI规范/06-页面与布局.md) |
+109
View File
@@ -0,0 +1,109 @@
# AI 智能写作 — 手工测试清单
> 测试环境:后端 `http://localhost:8001`,前端 `http://localhost:5174`
> 每项测试前确认后端已启动(`make dev`)、前端已启动(`pnpm dev`)
---
## T1 — 启动写作任务
| # | 步骤 | 预期结果 |
|---|------|----------|
| 1.1 | 打开 Canvas 页面,点击右上角「AI 写作」按钮 | AIWritingPanel 在右侧展开,显示写作配置表单 |
| 1.2 | 填写:意图「新能源汽车市场趋势分析」,类型「分析报告」,目标读者「行业研究人员」,字数「1200」 | 表单验证通过,提交按钮可点击 |
| 1.3 | 点击「开始写作」 | 按钮变为 loading 状态;时间轴出现第一条「素材收集专家 · 开始检索素材」进度条目 |
| 1.4 | 意图字段留空后点击提交 | 表单校验阻止提交,提示「请填写写作意图」 |
---
## T2 — SSE 进度流(时间轴)
| # | 步骤 | 预期结果 |
|---|------|----------|
| 2.1 | 任务启动后观察时间轴 | 每隔几秒新增进度条目,agent_name 标签分别出现「素材收集专家」「作家」「编辑」 |
| 2.2 | 打开浏览器 DevTools → Network,过滤 `stream` | 看到一个 `text/event-stream` 请求长连接,持续收到 `data:` 帧 |
| 2.3 | 断网 5 秒后恢复 | 面板显示「连接中断」提示(或 error 事件),不崩溃 |
---
## T3 — PAUSE-1 素材确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 3.1 | 等待素材收集专家完成检索,出现 PAUSE-1 干预卡 | 卡片标题「素材确认」,列出 ≥1 条素材,每条有复选框、标题、来源、相关度分数色条 |
| 3.2 | 全选所有素材,点击「确认素材,继续写作」 | 干预卡禁用,时间轴新增「作家 · 开始规划大纲」 |
| 3.3 | 重新发起任务,PAUSE-1 出现后取消勾选全部素材,点击「重新检索」 | 向后端发送 `action: re_search`;时间轴回到素材收集专家节点重新检索 |
| 3.4 | PAUSE-1 出现后填写「追加关键词」输入框(如「锂电池」)并按 Enter,再点「追加搜索」 | 标签出现「锂电池」;后端收到 `action: append_search, extraKeywords: ["锂电池"]` |
| 3.5 | 验证相关度色条颜色:分数 ≥0.8 绿色 / 0.5–0.8 橙色 / <0.5 红色 | 三种颜色均可在不同素材中观察到 |
---
## T4 — PAUSE-2 大纲确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 4.1 | PAUSE-2 出现后检查大纲卡 | 显示文章标题 + ≥2 个章节,每节有节标题与要点列表 |
| 4.2 | 点击文章标题,内联编辑改为「2025 新能源趋势报告」,按 Enter | 标题实时变更,不刷新页面 |
| 4.3 | 展开某章节,点击一条要点,修改文字,按 Blur 失焦 | 要点文字更新 |
| 4.4 | 点击「+ 新增章节」,填写章节标题 | 大纲末尾追加新章节 |
| 4.5 | 点击「确认大纲,开始写作」 | 编辑后的大纲随 `action: confirm_outline` 提交;时间轴出现「作家 · 开始撰写草稿」 |
| 4.6 | PAUSE-2 出现后点击「重新规划大纲」 | 显示意见输入框;填写「请增加政策分析章节」后提交;后端收到 `action: re_outline, outlineFeedback: ...` |
---
## T5 — PAUSE-3 草稿确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 5.1 | 作家完成草稿,PAUSE-3 出现 | 干预卡显示修改次数(如「第 1 稿」);右侧 Artifact 面板同步显示 Markdown 草稿内容 |
| 5.2 | 点击「定稿,提交编辑审核」 | 进入编辑节点;时间轴出现「编辑 · 开始审核」 |
| 5.3 | 点击「提出修改意见」,填写意见后提交 | 作家节点重新生成草稿;修改次数 +1 |
| 5.4 | 连续打回 3 次,观察第 4 次 PAUSE-3 | revisionCount 正确递增显示 |
---
## T6 — PAUSE-4 审核结果确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 6.1 | 编辑审核完成,PAUSE-4 出现 | ReviewCard 显示:综合得分、事实/逻辑/语言三项分数、及格线(默认 75)、verdict 徽章(通过=绿 / 打回=红)|
| 6.2 | verdict=pass 时,点击「接受审核,定稿」 | 流程结束;时间轴出现「完成」;右侧面板保留最终草稿 |
| 6.3 | verdict=reject 时,查看问题列表 | major 问题排在 minor 前;每条显示类别徽章、位置、问题描述、建议 |
| 6.4 | verdict=reject 时,点击「强制定稿」 | 向后端发送 `action: force_finalize`;流程结束 |
| 6.5 | 添加「额外说明」文字后点击「接受审核,定稿」 | extra_notes 包含在提交的 intervention payload 中(DevTools → Network 验证) |
---
## T7 — 错误与边界场景
| # | 步骤 | 预期结果 |
|---|------|----------|
| 7.1 | 后端未启动时点击「开始写作」 | 面板显示错误提示「无法连接到服务器」,不白屏 |
| 7.2 | 写作进行中点击「停止/重置」 | 时间轴清空,表单恢复可编辑,后端流被中断(Network 连接关闭)|
| 7.3 | 素材列表为空(后端返回 0 条)时 PAUSE-1 | 干预卡显示「暂无素材」提示,「确认」按钮仍可点击 |
| 7.4 | 大纲只有 1 个章节时,点击删除该章节 | 删除按钮禁用或弹出「至少保留一个章节」提示 |
| 7.5 | 弱模型返回格式错误的 JSON(手动 mock 后端) | 前端收到 fallback 结构,不崩溃;时间轴正常继续 |
---
## T8 — 跨阶段状态一致性
| # | 步骤 | 预期结果 |
|---|------|----------|
| 8.1 | PAUSE-2 时展开大纲卡,刷新页面(F5) | 页面恢复;如写作任务 ID 持久化,状态可恢复(否则回到初始状态,均可接受)|
| 8.2 | 同时打开两个浏览器标签,各自启动独立写作任务 | 两个任务互不干扰,各自有独立 task_id |
| 8.3 | 完整跑通一次全流程(T1→T2→T3→T4→T5→T6 pass) | 右侧 Artifact 面板显示完整定稿文章,时间轴所有节点状态为「完成」 |
---
## 后端单元测试(补充确认)
```bash
# 在 offline-backend-20260512/backend/ 下运行
PYTHONPATH=. uv run pytest tests/test_ai_writing_json_parse.py -v # 16 tests
PYTHONPATH=. uv run pytest tests/test_ai_writing_graph.py -v # 19 tests
```
两个测试文件均应全部通过(0 failed)。
npm install @tiptap/core @tiptap/starter-kit @tiptap/extension-underline @tiptap/extension-placeholder
+15 -3
View File
@@ -1,9 +1,21 @@
<!doctype html>
<html lang="en-US">
<html lang="en-US" suppressHydrationWarning>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeerFlow Vite</title>
<script>
(function () {
try {
var t = localStorage.getItem("strategy-theme");
var root = document.documentElement;
root.classList.remove("dark", "light");
if (t === "light") return;
root.classList.add("dark");
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CM 助手</title>
</head>
<body>
<div id="root"></div>
-8920
View File
File diff suppressed because it is too large. Load diff
+119 -95
View File
@@ -1,95 +1,119 @@
{
"name": "deer-flow-frontend-vite",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2",
"@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tanstack/react-query": "^5.90.17",
"@types/hast": "^3.0.4",
"@uiw/codemirror-theme-basic": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4",
"@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.33",
"best-effort-json-parser": "^1.2.1",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"gsap": "^3.13.0",
"hast": "^1.0.0",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"ogl": "^1.0.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^4.4.1",
"react-router-dom": "^7.9.4",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "3.15.0",
"sonner": "^2.0.7",
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
"uuid": "^14.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.15",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.2",
"vite": "^6.0.0"
},
"packageManager": "pnpm@10.26.2"
}
{
"name": "deer-flow-frontend-vite",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/noto-sans-sc": "^5.2.9",
"@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tanstack/react-query": "^5.90.17",
"@tiptap/core": "^3.23.4",
"@tiptap/extension-bubble-menu": "^3.23.4",
"@tiptap/extension-placeholder": "^3.23.4",
"@tiptap/extension-underline": "^3.23.4",
"@tiptap/pm": "^3.23.4",
"@tiptap/react": "^3.23.4",
"@tiptap/starter-kit": "^3.23.4",
"@types/hast": "^3.0.4",
"@uiw/codemirror-theme-basic": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4",
"@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.33",
"axios": "1.16.1",
"best-effort-json-parser": "^1.2.1",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"dayjs": "^1.11.21",
"dnd-kit": "^0.0.2",
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"gsap": "^3.13.0",
"hast": "^1.0.0",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"ogl": "^1.0.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.4.1",
"react-router-dom": "^7.9.4",
"react-syntax-highlighter": "^16.1.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "3.15.0",
"sonner": "^2.0.7",
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
"tdesign-react": "^1.12.3",
"tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
"uuid": "^14.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.15",
"@types/file-saver": "^2.0.7",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.2",
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2"
},
"packageManager": "pnpm@10.26.2"
}
+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="智能助手中心">
<defs>
<!-- 品牌玫红渐变填充的圆角磁贴 -->
<linearGradient id="tile" x1="6" y1="4" x2="58" y2="60" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB7185"/>
<stop offset="1" stop-color="#E11D48"/>
</linearGradient>
<!-- 气泡内 AI 闪光星:玫红渐变,与白色气泡形成对比 -->
<linearGradient id="spark" x1="24" y1="18" x2="40" y2="36" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB7185"/>
<stop offset="1" stop-color="#E11D48"/>
</linearGradient>
</defs>
<!-- 磁贴背景 -->
<rect width="64" height="64" rx="15" fill="url(#tile)"/>
<!-- 辅助小闪光,点出「多种智能工具」 -->
<path fill="#FFFFFF" fill-opacity="0.85"
d="M47 43 Q47.6 46.4 50.5 47 Q47.6 47.6 47 51 Q46.4 47.6 43.5 47 Q46.4 46.4 47 43 Z"/>
<!-- 助手对话气泡(白色)+ 尾巴 -->
<rect x="12" y="13" width="40" height="28" rx="10" fill="#FFFFFF"/>
<path fill="#FFFFFF" d="M21 40 H31 L19 49 Z"/>
<!-- 气泡内的 AI 闪光星 -->
<path fill="url(#spark)"
d="M32 17.5 Q33.2 25.8 41.5 27 Q33.2 28.2 32 36.5 Q30.8 28.2 22.5 27 Q30.8 25.8 32 17.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+96 -155
View File
@@ -1,155 +1,96 @@
import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { QueryClientProvider } from "@/components/query-client-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import ChatsPage from "./pages/ChatsPage";
import ChatPage from "./pages/ChatPage";
import AgentsPage from "./pages/AgentsPage";
import AgentChatPage from "./pages/AgentChatPage";
import NewAgentPage from "./pages/NewAgentPage";
import LoginPage from "./pages/LoginPage";
import ScheduledTaskRunDetailPage from "./pages/ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./pages/ScheduledTasksPage";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
return (
<NextThemesProvider
attribute="class"
enableSystem
disableTransitionOnChange
forcedTheme={forcedTheme}
>
<I18nProvider initialLocale={locale}>
<QueryClientProvider>{children}</QueryClientProvider>
</I18nProvider>
</NextThemesProvider>
);
}
function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider className="h-screen" defaultOpen>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
<CommandPalette />
<Toaster position="top-center" />
</SidebarProvider>
);
}
function ChatRuntime({ children }: { children: React.ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
export function App() {
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
<Route
path="/workspace"
element={<Navigate to="/workspace/chats/new" replace />}
/>
<Route
path="/workspace/chats"
element={
<WorkspaceLayout>
<ChatsPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents"
element={
<WorkspaceLayout>
<AgentsPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/scheduled"
element={<Navigate to="/workspace/scheduled/tasks" replace />}
/>
<Route
path="/workspace/scheduled/tasks"
element={
<WorkspaceLayout>
<ScheduledTasksPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/scheduled/runs/:run_id"
element={
<WorkspaceLayout>
<ScheduledTaskRunDetailPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents/new"
element={
<WorkspaceLayout>
<NewAgentPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents/:agent_id/chats"
element={<Navigate to="/workspace/agents" replace />}
/>
<Route
path="/workspace/agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "light" : undefined;
return (
<NextThemesProvider
attribute="class"
storageKey="strategy-theme"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
forcedTheme={forcedTheme}
>
{/* QueryClientProvider must wrap ThemeProvider because the custom
ThemeProvider reads the admin-configured default via useQuery. */}
<QueryClientProvider>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
<ChatProvider>
<AuthSyncWatcher />
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</NextThemesProvider>
);
}
export function App() {
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only roundtable share — landing + full result report. */}
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage report />}
/>
{/* Public read-only scheduled-task runs viewer — no authentication. */}
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><g transform="translate(-128.5 -117)"><rect class="a" width="16" height="16" transform="translate(128.5 117)"/><path class="b" d="M4,0,8,5H0Z" transform="translate(140.5 128) rotate(180)"/></g></svg>

After

Width:  |  Height:  |  Size: 589 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="84.499" height="70" viewBox="0 0 84.499 70"><defs><style>.a{fill:url(#a);}</style><linearGradient id="a" x1="0.043" x2="0.978" y2="1.016" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#0062d9" stop-opacity="0.6"/><stop offset="1" stop-color="#0062d9" stop-opacity="0.302"/></linearGradient></defs><path class="a" d="M8855.277-1940.39l-.054-70h78v31.183l6.5,5.257-6.5,5.259v28.3Z" transform="translate(-8855.224 2010.389)"/></svg>

After

Width:  |  Height:  |  Size: 536 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="77" height="45" viewBox="0 0 77 45"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 340 45"><image width="340" height="45" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAVQAAAAtCAYAAADxyJMIAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7tnd2O5MZ1x5vdPTO7WkmGhMiQDQS5NODbvEBeIgbygcRwjOQur+D1K+QiQC4CBMld8hJ+CgO+DpAIcixblvZjZnqG+f/PqcM6Vawii+zuda+0XLSaXawvFos//s85xVHX9/3mXFvXdeeq+htSb/9mB4iX+vlmfZu/PKLs1BX7/ET1fndznsn8w5X1Pkc5jvfPV5ZfPcu784zD6v683QWXMLJbknnpsHy7gXpmWP5sIYRaYHgqsC2dKG97/haQL4Xy2SH8Drqt024JI98BtXVUi/nOBM0pWLaAkX09JRx/sBDe+Vj99sjyR10jV/ijI5Xir44s78+jBcLMPwXis0H3HWz9pXoH1FPdgFLPCaF5DCjXAvLrALM/rQzKuWH36QnH7xTX9bMzw2IO2muhPAfgNwbeM4/fKa7xiet4B9RVAzq4ndb7GL3nquSrnFKXrcBcqhbngHlO4P3+Qp3oH54xcDAH7Dng5nO3FcBTwC3Blv5d29bPeNfbby5o3wF1EVBH/vtl0+tn0ti4TA2ex4JzDpD5uc8B81TQe3FhSnTRHEDmZycCwhys54Cb97sG4GNBW1O0R7sRTjSOS6/fGfO/A2pxcJsDnykcFZjlLYdmGZZj2JZUZisof4iufJHBaykUW+D38QJ1+fIth6ld3fcWwOCLTOUSyPm4zkHa4MvrVwNxK4BL4F0L3bO4DxaM7RnhuKbqd0BdEzuo+TdLSnNOZY6B2W3mgFlSki2grMFxCoinAOBtBbg3ATS14zajP3FT+/WJgPzE3bS/XnPruDJ2HsdUUwL0/6DC78tDsfyEb4Vw3q858K4B7tiN0CNI5rd4Dj+fHajC+S4zBmdbOFOGbxlQm5VnHO4SPH85HNar/Pno6kwrzTZgah05KKlO8rQSKEuQXArHGugIuBawEVot+fzw3U5E+W/w9Js67utZkrd0c7H8kq31XJmvBvClYC5BuATfHLylOWSq92P0L87v8gh44I6V7XjcctiWVG0K2caxvzzIfguA2nht8qkzDVLNXVef3eYHSYVUnbaNZ0GuOBWY5dmSw/M18lLF+C0Hp4GxdMPWoPnBCZRgK/xmwPXRswUuhSUQDHl/+2JF4KkE7KUQLvX1q4q5O3Xt8mM5aKl0nxTcDrWxyl0KU4o2wlVvtCnXQTtYV9y0lwHXbyhQF16PHJ7+Ca3qs361vMk+pTw9NKPCTOtNYanHcqU5pTJPBccZELYC7vFEQF3ByDdSZBsAaudpv+canwX4UjAvgbB1Lodui7K1sh64Ebbjm25ayVptWu67btRyBbs4+PWHg+s3AKiL4ZnOd+/3HJvuqao01VkDZ6o0O5jmaXn75cGpwNSMY1imM6MEzDklWYNaTfUh/3fQFcLh5EC8v5BF+3PEaz1+tdAt0Fjvl1NAranpOQiXoDvnYvDQ9cBNXQjpDViGLc885jPQ/koGpH4De0Wb+mM3y1/RfTOQfQuBugCgpah7C0BT1elvA70qn2brUBWcZbX5x6H4i2C2KjTHV/e22yb3WwRleSaUQFmA5HeOUYmNAOyfnsA90Aibty7bl7jY141R6yMAXYRwCb5LwVuDrsH2Wf8o1+S/8RkHyfRGmXch9JuP3JWtuQ2mAEswzwe7XCPnAexbBNRGkFq25xi7sXO9KwSQNhP+TsJzPPI+KGRqk+A0aD5DJ14EyHrV6RXmJw6qMXAT24og1LQMlgZKb2bOKsoaIClJC1t/dxpQ9nfjB0h3rcqkdEyeTuH4VJ5TwbWlran++vLH9KkKXkC5uM0AuDQ3RuDNoVsCbm1FxE2AKTsXAYslYWEaP4PflqDV+Rtv4AjYmOb9tPkqA1WyMhWGPe8isES6Cp6HH828bM7YdGkvHKiNEOWp5mrUYDq1bGnKhDcVOl6O1GENoQ1uBx+n7kdwlsx0TUvN81Z4Sr4mpTmjKE+pJGsgbJp1LlN/OG/AaWl/avm7/YrAVaGyU8GXVXevZpRvg+IdAZaK9Ius4zVV+1Wn6pRbVLLpTWugVZeBHist9zLIlgJg0UUw5R7Qfoz9rwumwPFwvVCgNoLUQ3QcSJI5N4xmhOd4gE2FlnyeCk8LEOV+Tk3PfZs1c11VZ644c2DmVzX+rgDzGFC2gHE19J4UFO5XGIEPFszxS83K87DNzud1o2mfnVMLrI8BcRW8a4DrFe3NJgJVz0lv3NRf2zvY2pn3ULS2vnYtZLW9qF7jqNYCXM0ugfVgvSCgNtw5BtDclC8Fk8wPGpcraQPe/xkBqsfSYBFTOihP65jBU9O5jddjhjwO5GqqT0EzB+bwO4By8dWdguQiOJaA2HCZzpWlb/TptrbfNQClta6j8xHQV21AngPwWviOwDs9Pn2ibhW0erNERRuVUVx/G/Ikil8By/JfqCdI6nkW0mxwP3THPstuizTQlV6O8XItPX4GwF42UOtm/Hj++nWfPgofAcpF8ikczXS3pUlT8PzAKVWrxfyc0b85D84IBZ0tGtCpq1J3rAbKWUieAYwnhdvNhUf/b08XzT8LxGeUcQ3AS8BbgG1ZnWIyB9BGmBpsU9fBWM3mboMcsgrYuNVWFKQBLl9C2zQFO145MOZKkjKvbS4XqK0wTU35eMbRB5oO0liFjpcrmQk/XpLU4U0dbonZnvk3/ah3m1RVdc48T/OlvewyeEbV2uJzPAFAVwHz0sE4c7uc/PAKEJ8EuA2uh2MhC8CmQL1KzP/EZwfAat7oLoAbYPSQ6gdXQe1FhXTpluiR4Zqpet1sch+swTVforUaqvH2L82XywNqbtaXzXk9F2/Opwvn47nWVaiOzG1gVWk9p19yFFRogOdYUdbBmV8BVaYa+U6g6pRmWbEeCcpmSP4BwDirsk9Ou7YK58zrtloW5KKZ/XVb/qPhu0LZOlWbBzrUhNNAWTxWAe2gYvX+rLsKzB9bh6yW9+o1Va5xMCNcx0GaHLAr3QGXA9Tn4bxjdD6dVbWgEpXo+N12vmGk5cevYarvkzB77ca1Dk/rh1a4AJwllRnAYcCM4FRYjtO19aqtMQvJM8BxNfyusyCV+Qzvj1ue1T9Muwy63elM9mRS3rX5PHM8ngXSM2p4NXwr0MU5jEz+AmxjACIFbb9J/bOSb/DJpko2AteWb/FvIUwBtuR7bfW5rlKu8fa8HKD+WT7t8JsQrQeVIkjjMiYFafSFxgi8+kB1K8NzDC7n74Spnh8f4Adw+mOqQKNpHiEZFWbN3E+A6mA5BuqJQNkExxyEhUuVJ81BrqGKtzrLIog3gPlkEJ4A70Lo4g/fDA+UCM4UtHrT6Jpirwptv098swrZJF8Bsj7oFVcU3AzFbAVBBLGpV3ML8IgCNrZF9Tq1YsD7WyfU65LFdef9f0oZUPn9v+FeSk16TfSRefpDoxKNNyDNeKrQX0O5GEjp+/yYMO0677MeFsN75akL3eHHTCE6Y6ZzOVAEYqq6cuU5AmqAZ0x/H3VlAaylhKnC8pmrqUEdrobjIVOOT9HuHpM4T6+dWK48qTRn1OjG1OhcvrzNNSr2VVYJz23F1gRfvwLgRbmRo6FbcDdMQLYESK5UMOUKn+jQz971LcLUvbxBuA0vNnyZuAwkv3tJoRcs2HrZG2mD/le757lMKw6QD2ylbgHNk75QoGnfw+cX2RinS7H0YAGslwPUf3AnYCAtReipRhWiCh9To+YLZZpF5DWApBsGs7A4ntCSrRIsGvk6R8ozNdVjvzL/qLRxv/Gvl6aQVcU5p1yzq5wo4dGxTYOyXA1L35oHJKGZb0vhFsr3rt4uwMqnFVratObLy1q5Up3FtDkAe9iuBK1vtwm6E0p3NWzrirZ3sC2qT7uM8q1qNsI0fVECMI3H3HlHBVtzEUTrlIEu3fw6WINr+ncJbEkWX4uVW1PKmVugtkqgAaqXA9QfuWFUv2g6jw2kClA9GANKMa9F4fk08kC1MgGgUoea8VLfhxzV7BXJzN/JP4FWU5oJCAumuh5PoVmC51i5qutgbPLPwPJoUJYgOQHGOdClF3PBr+s39CbV3ZJbob3/k6A2KJ9I6cpEngN9BborgMu/TTsAMoyI3pyp6V6HbR2yg6vg91qx1uHfDIuAjfVz5YAB1q8ioHqV+89dY32xIILV2iBgvb81XSWg9UwEsJbMovOa/P84AVGeRFSi0Yy3eU1wKkBT8JThKdec//nA+T4BzyHdgTOmlRWnh11s+2ZCiWaBp2qQ6rovqVmdXUsV35yJfT0G9ipAtsDv0BiAemIX9w1/v25sb98QkFoB6SKA72ZcCQsV8CR024FbB6WCdgTbgqItAtmrWe8q+OrawVUvUw/I6je3q+w4FSsDXKZcrQwBG5WrpipcU5eNh2tJtRYCWP3zxvlDCC2JYLVXqzm7H4cScZlTHlyK78Kb8jSfiFei5nd06pM1B3hKUwGesg94WleL5nbm2/RKU8uH7le+hzxoMwWkgqXbvKen78arC8Csmf/joa0B81hQ1gC5EIr9Q6PKfFgIW6t3h5uG+/y2jWC8Cmk+H4+XoLnDzZS3z7SGrbN2j4XxAgAvAu8pgPsyDER8kKiKVNO9DMZxOouYsk3KzKpap2a9mgZk07YNsApX3Uyt5urVg5X54uuwXrmqWyBLine truncated

After

Width:  |  Height:  |  Size: 12 KiB

@@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-[1.35rem] border border-[#ece7dd] bg-white shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
"flex flex-col overflow-hidden rounded-[1.35rem] border border-border bg-background shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
className,
)}
{...props}
@@ -31,7 +31,7 @@ export const ArtifactHeader = ({
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b border-[#efe9de] bg-white px-4 py-3",
"flex items-center justify-between border-b border-border bg-background px-4 py-3",
className,
)}
{...props}
@@ -147,7 +147,7 @@ export const ArtifactContent = forwardRef<HTMLDivElement, ArtifactContentProps>(
<div
ref={ref}
className={cn(
"min-h-0 flex-1 overflow-auto bg-white p-4",
"min-h-0 flex-1 overflow-auto bg-background p-4",
className,
)}
{...props}
@@ -674,12 +674,22 @@ export const PromptInput = ({
useEffect(() => {
if (!globalDrop) return;
// Suppress the global chat-upload handler when the drop happens inside an open
// modal dialog or popover — those owners (e.g. the skill-install dialog) handle
// their own file uploads and should not leak into the active chat thread.
const isInsideModal = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
return target.closest('[role="dialog"], [role="alertdialog"]') !== null;
};
const onDragOver = (e: DragEvent) => {
if (isInsideModal(e.target)) return;
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
};
const onDrop = (e: DragEvent) => {
if (isInsideModal(e.target)) return;
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
}
@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import { useLocalSettings } from "@/core/settings";
/**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* `fontScale === 1` 即默认 16px。
*/
export const BASE_FONT_PX = 16;
export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[] =
[
{ value: 0.875, label: "小", px: 14 },
{ value: 1, label: "标准", px: 16 },
{ value: 1.125, label: "大", px: 18 },
{ value: 1.25, label: "特大", px: 20 },
];
/**
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
*/
export function FontScaleApplier() {
const [settings] = useLocalSettings();
const { fontScale } = 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]);
return null;
}
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
/**
* Sandboxed preview of a generated HTML document.
*
* Renders via a `blob:` URL rather than `srcDoc`. A srcDoc document's base URL
* 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 `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
* Chinese Windows), garbling CJK text when the HTML lacks a `<meta charset>`.
*/
export function HtmlPreviewFrame({
html,
className = "h-[70vh] w-full rounded-md border bg-white",
}: {
html: string;
className?: string;
}) {
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [html]);
return (
<iframe
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
className={className}
/>
);
}
@@ -12,7 +12,7 @@ export function Footer() {
</div>
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
<p>Licensed under MIT License</p>
<p>&copy; {year} CM业务中心-问答管理</p>
<p>&copy; {year} CM 助手</p>
</div>
</footer>
);
+2 -77
View File
@@ -1,14 +1,9 @@
"use client";
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import type { Locale } from "@/core/i18n/locale";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export type HeaderProps = {
@@ -30,11 +25,11 @@ export function Header({ className, homeURL }: HeaderProps) {
>
<div className="flex items-center gap-6">
<a
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
href={homeURL ?? "/"}
target={isExternalHome ? "_blank" : "_self"}
rel={isExternalHome ? "noopener noreferrer" : undefined}
>
<h1 className="font-serif text-xl">CM业务中心-问答管理</h1>
<h1 className="font-serif text-xl">CM 助手</h1>
</a>
</div>
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
@@ -51,77 +46,7 @@ export function Header({ className, homeURL }: HeaderProps) {
{t.home.blog}
</Link>
</nav>
<div className="relative">
<div
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
style={{
background: "linear-gradient(90deg, #ff80b5 0%, #9089fc 100%)",
filter: "blur(16px)",
}}
/>
<Button
variant="outline"
size="sm"
asChild
className="group relative z-10"
>
<a
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
>
<GitHubLogoIcon className="size-4" />
Star on GitHub
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
</a>
</Button>
</div>
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
</header>
);
}
function StarCounter() {
const [stars, setStars] = useState(10000);
useEffect(() => {
let cancelled = false;
async function loadStars() {
try {
const response = await fetch(
"https://api.github.com/repos/bytedance/deer-flow",
{
headers: env.GITHUB_OAUTH_TOKEN
? {
Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
"Content-Type": "application/json",
}
: {},
},
);
if (!response.ok) return;
const data = await response.json();
if (!cancelled && typeof data?.stargazers_count === "number") {
setStars(data.stargazers_count);
}
} catch (error) {
console.error("Error fetching GitHub stars:", error);
}
}
void loadStars();
return () => {
cancelled = true;
};
}, []);
return (
<>
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
<NumberTicker className="font-mono tabular-nums" value={stars} />
</>
);
}
+1 -1
View File
@@ -55,7 +55,7 @@ export function Hero({ className }: { className?: string }) {
"Learn Anything",
]}
/>{" "}
<div>CM业务中心-问答管理</div>
<div>CM 助手</div>
</h1>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
<a
@@ -461,7 +461,7 @@ export default function ProgressiveSkillsAnimation() {
<div className="border-b border-zinc-800 p-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-green-500" />
<span className="text-sm text-zinc-400">CM业务中心-问答管理 Agent</span>
<span className="text-sm text-zinc-400">CM 助手 Agent</span>
</div>
</div>
@@ -691,7 +691,7 @@ export default function ProgressiveSkillsAnimation() {
{/* Chat Input (decorative) */}
<div className="border-t border-zinc-800 p-4">
<div className="rounded-xl bg-zinc-800 px-4 py-3 text-sm text-zinc-500">
Ask CM业务中心-问答管理 anything...
Ask CM 助手 anything...
</div>
</div>
</div>
@@ -49,7 +49,7 @@ export function CaseStudySection({ className }: { className?: string }) {
<Section
className={className}
title="Case Studies"
subtitle="CM业务中心-问答管理"
subtitle="CM 助手"
>
<div className="container-md mt-8 grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-20 lg:grid-cols-3">
{caseStudies.map((caseStudy) => (
@@ -1,10 +1,6 @@
"use client";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { AuroraText } from "@/components/ui/aurora-text";
import { Button } from "@/components/ui/button";
import { Section } from "../section";
@@ -16,20 +12,9 @@ export function CommunitySection() {
Join the Community
</AuroraText>
}
subtitle="Contribute brilliant ideas to shape the future of DeerFlow. Collaborate, innovate, and make impacts."
subtitle="Contribute brilliant ideas to shape the future of CM Assistant. Collaborate, innovate, and make impacts."
>
<div className="flex justify-center">
<Button className="text-xl" size="lg" asChild>
<Link
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
>
<GitHubLogoIcon />
Contribute Now
</Link>
</Button>
</div>
<div />
</Section>
);
}
@@ -15,7 +15,7 @@ export function SandboxSection({ className }: { className?: string }) {
title="Agent Runtime Environment"
subtitle={
<p>
We give DeerFlow a &quot;computer&quot;, which can execute commands,
We give CM Assistant a &quot;computer&quot;, which can execute commands,
manage files, and run long tasks — all in a secure Docker-based
sandbox
</p>
@@ -15,7 +15,7 @@ export function SkillsSection({ className }: { className?: string }) {
Agent Skills are loaded progressively — only what&apos;s needed, when
it&apos;s needed.
<br />
Extend DeerFlow with your own skill files, or use our built-in
Extend CM Assistant with your own skill files, or use our built-in
library.
</div>
}
@@ -52,8 +52,8 @@ export function WhatsNewSection({ className }: { className?: string }) {
return (
<Section
className={cn("", className)}
title="CM业务中心-问答管理"
subtitle="CM业务中心-问答管理"
title="CM 助手"
subtitle="CM 助手"
>
<div className="flex w-full items-center justify-center">
<MagicBento data={features} />
+322
View File
@@ -0,0 +1,322 @@
.sidebar-contnet {
position: absolute;
left: 0;
top: 4rem;
z-index: 40;
width: 98px;
height: calc(100vh - 4rem);
flex-shrink: 0;
}
.sidebar-contents {
width: 98px;
min-height: calc(100% - 20px);
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 10px;
position: relative;
height: calc(100% - 20px);
box-sizing: border-box;
transform: translate(10px, 10px);
}
.item-nav-group {
width: 100%;
}
.item-nav-wrap {
position: relative;
width: 100%;
}
.item-nav-group:first-child .item-nav {
margin-top: 0;
}
.item-nav {
width: 100%;
height: 75px;
margin-top: 13px;
font-family: Noto Sans S Chinese;
font-weight: 500;
color: #fff;
font-size: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
background: #000c1c26;
border: 1px solid;
border-color: #55a6fe33;
box-shadow: 0 0 20px #00b9ff26 inset;
position: relative;
}
.item-nav:hover,
.item-nav-active,
.item-nav-hovered {
color: #ffffff;
background: linear-gradient(134.25deg,
rgba(0, 98, 217, 0.59) 2.28%,
rgba(0, 98, 217, 0.3) 99.59%) !important;
background-size: cover;
border: 1px solid;
border-color: rgba(255, 255, 255, 0.2);
position: relative;
}
.item-nav:hover::before,
.item-nav-active::before,
.item-nav-hovered::before {
content: '';
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 10px solid rgba(0, 98, 217, 0.45);
pointer-events: none;
}
.item-nav:hover::after,
.item-nav-active::after,
.item-nav-hovered::after {
content: '';
position: absolute;
left: 0;
top: 29px;
width: 77px;
height: 45px;
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
background-size: cover;
}
.item-nav__label {
display: block;
width: 3em;
margin: 0 auto;
padding-left: 6px;
box-sizing: border-box;
letter-spacing: 6px;
word-break: break-all;
}
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
min-height: calc(100vh - 4rem - 20px);
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(2px);
position: absolute;
left: 108px;
top: 0;
z-index: 1000;
padding: 14px 13px 18px 16px;
overflow-y: auto;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.item-nav-content::before {
content: '';
position: absolute;
left: -30px;
top: 0;
width: 30px;
height: 100%;
}
.item-nav-hover-bridge {
position: absolute;
right: -30px;
top: 0;
width: 30px;
height: 100%;
z-index: 999;
}
.item-nav-content--visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.sidebar-flyout-list {
display: flex;
flex-direction: column;
gap: 2px;
position: relative;
}
.sidebar-flyout-list--level-1,
.sidebar-flyout-list--level-2,
.sidebar-flyout-list--level-3 {
margin-left: 19px;
padding-left: 14px;
}
.sidebar-flyout-node {
position: relative;
}
.sidebar-flyout-node--has-children.sidebar-flyout-node--expanded>.sidebar-flyout-list::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 100%;
background: rgba(43, 143, 254, 0.22);
}
.sidebar-flyout-item {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
gap: 9px;
border: 0;
background: transparent;
padding: 3px 0;
font-family: Noto Sans S Chinese;
font-size: 18px;
line-height: 16px;
font-weight: 500;
letter-spacing: 0;
color: rgba(255, 255, 255, 0.8);
text-align: left;
cursor: pointer;
transition: color 0.2s ease, text-shadow 0.2s ease;
}
.sidebar-flyout-item:hover,
.sidebar-flyout-item--active {
color: #48ddff;
text-shadow: 0 0 8px rgba(72, 221, 255, 0.25);
}
.sidebar-flyout-item--leaf {
justify-content: center;
padding-left: 0;
}
.sidebar-flyout-list--level-1>.sidebar-flyout-node>.sidebar-flyout-item--leaf,
.sidebar-flyout-list--level-2>.sidebar-flyout-node>.sidebar-flyout-item--leaf,
.sidebar-flyout-list--level-3>.sidebar-flyout-node>.sidebar-flyout-item--leaf {
padding-left: 0;
}
.sidebar-flyout-arrow {
width: 9px;
height: 9px;
flex: 0 0 9px;
background: url('../assets/icon/down-logo.svg') center / contain no-repeat;
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
.sidebar-flyout-node--expanded>.sidebar-flyout-item>.sidebar-flyout-arrow {
transform: rotate(0deg);
}
.sidebar-flyout-label {
flex: 1;
white-space: nowrap;
}
.sidebar-collapse-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
cursor: pointer;
}
.sidebar-collapse-btn__icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
color: #80e9ff;
}
.sidebar-collapse-btn__label {
font-size: 14px;
line-height: 1;
white-space: nowrap;
}
.sidebar-collapse-btn--inline {
width: calc(100% - 6px);
min-height: 58px;
margin-top: auto;
margin-bottom: 2px;
flex-direction: column;
gap: 6px;
border: 1px solid #55a6fe33;
background: #000c1c26;
color: rgba(255, 255, 255, 0.9);
box-shadow: inset 0 0 16px #00b9ff1f;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.sidebar-collapse-btn--inline .sidebar-collapse-btn__label {
font-size: 16px;
line-height: 1.1;
}
.sidebar-collapse-btn--inline:hover {
background: rgba(0, 98, 217, 0.3);
border-color: rgba(255, 255, 255, 0.25);
}
.sidebar-collapse-btn--floating {
position: fixed;
left: 10px;
bottom: 10px;
z-index: 60;
width: 36px;
height: 36px;
min-height: 36px;
padding: 0 10px;
border: 1px solid rgba(128, 233, 255, 0.35);
background: rgba(3, 38, 80, 0.82);
color: rgba(205, 245, 255, 0.95);
backdrop-filter: blur(2px);
transition: width 0.22s ease, opacity 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
overflow: hidden;
}
.sidebar-collapse-btn--floating:hover {
width: 104px;
background: rgba(7, 52, 110, 0.9);
border-color: rgba(128, 233, 255, 0.65);
}
.sidebar-collapse-btn--floating .sidebar-collapse-btn__label {
max-width: 0;
opacity: 0;
transform: translateX(-4px);
transition: max-width 0.22s ease, opacity 0.16s ease, transform 0.22s ease;
}
.sidebar-collapse-btn--floating:hover .sidebar-collapse-btn__label {
max-width: 64px;
opacity: 1;
transform: translateX(0);
}
@@ -0,0 +1,228 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { PanelLeftOpen, PanelLeftClose } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import {
defaultExpandedIds,
filterMenuItemsByRole,
sidebarMenuItems,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import './page-sdebar.css'
type PageSidebarDarkProps = {
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
collapsed: boolean;
onToggleCollapsed: () => void;
};
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useMemo(
() => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin),
[isAdmin],
);
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
const navigate = useNavigate();
const { pathname } = useLocation();
// pathname 变化时取消任何未触发的 hover 关闭计时(cleanup 在卸载时也跑)
useEffect(() => {
return () => {
if (hoverCloseTimerRef.current !== null) {
window.clearTimeout(hoverCloseTimerRef.current);
}
};
}, []);
const handleNavHoverEnter = (id: string) => {
if (hoverCloseTimerRef.current !== null) {
window.clearTimeout(hoverCloseTimerRef.current);
hoverCloseTimerRef.current = null;
}
setHoveredTopId(id);
};
const scheduleNavPanelClose = () => {
if (hoverCloseTimerRef.current !== null) {
window.clearTimeout(hoverCloseTimerRef.current);
}
hoverCloseTimerRef.current = window.setTimeout(() => {
setHoveredTopId(null);
hoverCloseTimerRef.current = null;
}, 180);
};
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
return true;
}
return item.children?.some(hasActiveChild) ?? false;
};
// lazy init:仅在首次 render 计算一次 active id;之后由下面的 useEffect 跟随 pathname 同步
const [activeTopId, setActiveTopId] = useState<string>(
() => visibleMenuItems.find(hasActiveChild)?.id ?? visibleMenuItems[0]?.id ?? "qa-center",
);
const [hoveredTopId, setHoveredTopId] = useState<string | null>(null);
const hoverCloseTimerRef = useRef<number | null>(null);
useEffect(() => {
const matchedTopItem = visibleMenuItems.find(hasActiveChild);
if (matchedTopItem) {
setActiveTopId(matchedTopItem.id);
}
// 不在导航时强制关闭 flyout:flyout 绝对定位在 group 盒子之外,强制关闭后鼠标
// 仍停在 flyout 区域却收不到 group 的 mouseenter,导致连续点击第二个任务时面板
// 消失且不回来。开合只交给 hover(mouseleave 有 180ms 宽限),即可连续点选。
}, [pathname, visibleMenuItems]);
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) {
return null;
}
for (const child of items) {
if (child.path) {
return child.path;
}
const nestedPath = findFirstRoutePath(child.children);
if (nestedPath) {
return nestedPath;
}
}
return null;
};
const handleTopItemClick = (item: MenuItem) => {
setActiveTopId(item.id);
if (item.children?.length) {
const firstPath = findFirstRoutePath(item.children);
if (firstPath) {
navigate(firstPath);
}
return;
}
if (item.path) {
navigate(item.path);
}
};
const handleMenuItemClick = (item: MenuItem) => {
if (item.children?.length) {
toggleExpanded(item.id);
return;
}
if (item.path) {
navigate(item.path);
}
};
const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
{items.map((item) => {
const hasChildren = !!item.children?.length;
const isExpanded = expandedIds.has(item.id);
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
return (
<div
key={item.id}
className={`sidebar-flyout-node sidebar-flyout-node--level-${level} ${hasChildren ? "sidebar-flyout-node--has-children" : ""} ${isExpanded ? "sidebar-flyout-node--expanded" : ""}`}
>
<button
type="button"
className={`sidebar-flyout-item ${isSelected ? "sidebar-flyout-item--active" : ""} ${hasChildren ? "sidebar-flyout-item--parent" : "sidebar-flyout-item--leaf"}`}
onClick={() => handleMenuItemClick(item)}
>
{hasChildren ? <span className="sidebar-flyout-arrow" aria-hidden="true" /> : null}
<span className="sidebar-flyout-label">{item.label}</span>
</button>
{hasChildren && isExpanded ? renderFlyoutMenu(item.children ?? [], level + 1) : null}
</div>
);
})}
</div>
);
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
if (collapsed) {
return (
<button
type="button"
title="展开侧边栏"
aria-label="展开侧边栏"
className="sidebar-collapse-btn sidebar-collapse-btn--floating"
onClick={onToggleCollapsed}
>
<PanelLeftOpen className="sidebar-collapse-btn__icon" aria-hidden="true" />
<span className="sidebar-collapse-btn__label">收起面板</span>
</button>
);
}
return (
<aside className="sidebar-contnet">
<div className="sidebar-contents">
{visibleMenuItems.map((item) => (
<div
key={item.id}
className="item-nav-group"
onMouseEnter={() => handleNavHoverEnter(item.id)}
onMouseLeave={scheduleNavPanelClose}
>
{item.children?.length ? (
<div
className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`}
>
{renderFlyoutMenu(item.children)}
</div>
) : null}
<div className="item-nav-wrap">
<button
type="button"
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
onClick={() => handleTopItemClick(item)}
>
<span className="item-nav__label">{item.label}</span>
</button>
{item.children?.length && hoveredTopId === item.id ? (
<span className="item-nav-hover-bridge" aria-hidden="true" />
) : null}
</div>
</div>
))}
{/* 底部"收起面板"按钮:独立样式,mt-auto 推到底 */}
<button
type="button"
title="收起侧边栏"
aria-label="收起侧边栏"
className="sidebar-collapse-btn sidebar-collapse-btn--inline"
onClick={onToggleCollapsed}
>
<PanelLeftClose className="sidebar-collapse-btn__icon" aria-hidden="true" />
<span className="sidebar-collapse-btn__label">收起面板</span>
</button>
</div>
</aside>
);
}
@@ -0,0 +1,297 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
Folder,
FolderOpen,
Settings,
User,
Wrench,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
defaultExpandedIds,
filterMenuItemsByRole,
sidebarMenuItems,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
type PageSidebarProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
export function PageSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(sidebarMenuItems, !!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
const iconButtonClass = isDark
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = isDark
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
: "ml-4 space-y-1 border-l border-red-100 pl-4";
const labelClass = (compact: boolean, small = false) =>
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
const menuButtonClass = (isActive: boolean, depth = 0) =>
`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
} ${
isActive
? isDark
? "bg-blue-500/15 text-cyan-300 font-semibold"
: "bg-red-50 text-red-600 font-semibold"
: isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
}`;
const footerButtonClass = menuButtonClass(false, 0);
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const renderLeaf = (item: MenuItem, depth = 0) => {
const Icon = item.icon;
const isActive = !!item.path && pathname === item.path;
return (
<button
key={item.id}
type="button"
title={collapsed && depth === 0 ? item.label : undefined}
onClick={() => item.path && navigate(item.path)}
className={menuButtonClass(isActive, depth)}
>
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
{(!collapsed || depth > 0) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
</button>
);
};
const renderBranch = (item: MenuItem, depth = 0) => {
const expanded = expandedIds.has(item.id);
const isTopLevel = depth === 0;
const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon;
return (
<div key={item.id} className="space-y-1">
<button
type="button"
onClick={() => {
if (collapsed && isTopLevel) {
onToggleCollapsed();
return;
}
toggleExpanded(item.id);
}}
title={collapsed && isTopLevel ? item.label : undefined}
className={menuButtonClass(false, depth)}
>
<Icon
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
expanded && depth > 0 ? "scale-105" : ""
}`}
/>
{(!collapsed || !isTopLevel) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
{(!collapsed || !isTopLevel) && (
<ChevronRight
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
/>
)}
</button>
{(!collapsed || !isTopLevel) && item.children && (
<div
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
}`}
>
{item.children.map((child) =>
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
)}
</div>
)}
</div>
);
};
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
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 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
} ${collapsed ? "w-16" : "w-64"}`}
>
<button
type="button"
onClick={onToggleCollapsed}
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
isDark
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
}`}
>
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
</button>
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{visibleMenuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
</nav>
<div
className={`mt-auto shrink-0 theme-transition ${
isDark ? "border-t border-blue-800/30" : "border-t border-red-100"
} ${collapsed ? "p-2" : "p-4"}`}
>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={collapsed ? t.workspace.settingsAndMore : undefined}
className={footerButtonClass}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
<ChevronsUpDown className="h-4 w-4 flex-shrink-0" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={collapsed ? "start" : "end"}
side={collapsed ? "right" : "top"}
sideOffset={4}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span
className={`ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
isDark ? "bg-cyan-500" : "bg-red-500"
}`}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className={`${footerButtonClass} pointer-events-none`}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
)}
</button>
)}
</div>
<div
className={`pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full blur-3xl theme-transition ${
isDark ? "bg-blue-500/10" : "bg-red-500/5"
}`}
/>
<div
className={`pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full blur-3xl theme-transition ${
isDark ? "bg-cyan-400/5" : "bg-red-400/5"
}`}
/>
</aside>
</>
);
}
+19 -17
View File
@@ -31,23 +31,25 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
+12 -12
View File
@@ -14,7 +14,7 @@ const buttonVariants = cva(
destructive:
"cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"cursor-pointer border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"cursor-pointer border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-[#213D5E] dark:bg-[#022248] dark:text-[#CCD2DA] dark:hover:border-[#213D5E] dark:hover:bg-[#0a2f58] dark:hover:text-[#CCD2DA] dark:[&_svg]:text-[#CCD2DA]",
secondary:
"cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
@@ -37,20 +37,18 @@ const buttonVariants = cva(
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="button"
{...(variant !== undefined && { "data-variant": variant })}
{...(size !== undefined && { "data-size": size })}
@@ -58,6 +56,8 @@ function Button({
{...props}
/>
);
}
});
Button.displayName = "Button";
export { Button, buttonVariants };
+3 -3
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-[4px] border py-6 shadow-sm dark:border-[#0049A5] dark:bg-[#02244C]",
className,
)}
{...props}
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-muted-foreground text-sm dark:text-[#F2F3F5]", className)}
{...props}
/>
);
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
className={cn("px-6 ", className)}
{...props}
/>
);
+36 -26
View File
@@ -1,6 +1,7 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
"use client";
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { cn } from "@/lib/utils";
@@ -10,28 +11,37 @@ function Collapsible({
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
className,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
const CollapsibleTrigger = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleTrigger>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleTrigger>
>(({ className, ...props }, ref) => {
return (
<CollapsiblePrimitive.CollapsibleTrigger
ref={ref}
data-slot="collapsible-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
);
});
CollapsibleTrigger.displayName =
CollapsiblePrimitive.CollapsibleTrigger.displayName;
const CollapsibleContent = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ ...props }, ref) => {
return (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
data-slot="collapsible-content"
{...props}
/>
);
});
CollapsibleContent.displayName =
CollapsiblePrimitive.CollapsibleContent.displayName;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+74 -58
View File
@@ -12,11 +12,20 @@ function Dialog({
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
const DialogTrigger = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DialogPrimitive.Trigger
ref={ref}
data-slot="dialog-trigger"
{...props}
/>
);
});
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
function DialogPortal({
...props
@@ -24,61 +33,68 @@ function DialogPortal({
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
const DialogClose = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
>(({ ...props }, ref) => {
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
});
DialogClose.displayName = DialogPrimitive.Close.displayName;
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
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",
className,
)}
{...props}
/>
);
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => {
return (
<DialogPrimitive.Overlay
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",
className,
)}
{...props}
/>
);
});
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
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}
>
{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>
</DialogPortal>
);
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<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}
>
{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>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
+102 -89
View File
@@ -20,36 +20,42 @@ function DropdownMenuPortal({
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
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;
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
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>
);
}
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;
function DropdownMenuGroup({
...props
@@ -59,28 +65,29 @@ function DropdownMenuGroup({
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
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}
/>
);
}
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,
@@ -198,45 +205,51 @@ function DropdownMenuSub({
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
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 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;
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
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}
/>
);
}
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;
export {
DropdownMenu,
+3 -3
View File
@@ -34,7 +34,7 @@ const emptyMediaVariants = cva(
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6 dark:text-[#55A6FE] dark:bg-[#022248]",
},
},
defaultVariants: {
@@ -62,7 +62,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
className={cn("text-lg font-medium tracking-tight dark:text-[#AACEFF]", className)}
{...props}
/>
);
@@ -73,7 +73,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4 dark:text-[#55A6FE]",
className,
)}
{...props}
+37 -29
View File
@@ -11,34 +11,42 @@ function HoverCard({
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
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 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
const HoverCardTrigger = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<HoverCardPrimitive.Trigger
ref={ref}
data-slot="hover-card-trigger"
{...props}
/>
);
});
HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
ref={ref}
data-slot="hover-card-content"
align={align}
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 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
});
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input/50 dark:bg-background/80 relative flex w-full items-center rounded-md border bg-white/80 shadow-xs transition-[color,box-shadow] outline-none",
"group/input-group relative flex w-full items-center rounded-md border border-input/50 bg-white/80 shadow-xs transition-[color,box-shadow] outline-none dark:border-[#1B477E] dark:bg-[#022248]",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
+15 -3
View File
@@ -3,14 +3,26 @@ import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
const usesDarkNativePicker =
type === "number" ||
type === "date" ||
type === "datetime-local" ||
type === "time" ||
type === "month" ||
type === "week";
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] md:text-sm",
"border-input placeholder:text-muted-foreground",
"dark:border-[#1B477E] dark:bg-[#0D2D54] dark:text-[#AACEFF] dark:placeholder:text-[#566B7F] dark:rounded-[4px] dark:text-[16px] md:dark:text-[16px]",
usesDarkNativePicker && "dark:[color-scheme:dark]",
"file:text-foreground selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
@@ -0,0 +1,96 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/** Compute the page-number window to render, inserting -1 sentinels for gaps.
* Always shows first/last and a window around the current page. */
function buildPageItems(current: number, totalPages: number): number[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const items = new Set<number>([1, totalPages, current]);
for (const d of [1, 2]) {
if (current - d >= 1) items.add(current - d);
if (current + d <= totalPages) items.add(current + d);
}
const sorted = [...items].sort((a, b) => a - b);
const out: number[] = [];
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) out.push(-1); // gap sentinel
out.push(p);
prev = p;
}
return out;
}
export interface PaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
className?: string;
}
/** Controlled, compact pagination control. Renders nothing when a single page. */
export function Pagination({
page,
pageSize,
total,
onPageChange,
className,
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / Math.max(pageSize, 1)));
if (totalPages <= 1) return null;
const items = buildPageItems(page, totalPages);
return (
<nav
className={cn("flex items-center justify-center gap-1", className)}
aria-label="分页"
>
<Button
variant="outline"
size="icon-sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
{items.map((p, idx) =>
p === -1 ? (
<span
key={`gap-${idx}`}
className="text-muted-foreground px-1 text-sm select-none"
>
…
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="icon-sm"
onClick={() => onPageChange(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="icon-sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
</nav>
);
}
+30 -4
View File
@@ -22,10 +22,31 @@ function ResizablePanelGroup({
);
}
// react-resizable-panels v4 changed size semantics: a bare *number* is now
// interpreted as PIXELS, while a unit-less *string* is a percentage. Every call
// site in this app passes sizes as percentages (the v2/v3 convention), so a
// value like `maxSize={60}` would otherwise pin a panel to 60px and freeze the
// layout/drag. Normalize numbers to "<n>%" to preserve the intended percentage.
const asPercent = (v: number | string | undefined) =>
typeof v === "number" ? `${v}%` : v;
function ResizablePanel({
defaultSize,
minSize,
maxSize,
collapsedSize,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
return (
<ResizablePrimitive.Panel
data-slot="resizable-panel"
defaultSize={asPercent(defaultSize)}
minSize={asPercent(minSize)}
maxSize={asPercent(maxSize)}
collapsedSize={asPercent(collapsedSize)}
{...props}
/>
);
}
function ResizableHandle({
@@ -39,14 +60,19 @@ function ResizableHandle({
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
// Thin visible divider, but a generous invisible hit area on both sides
// (after:w-4 → 16px) so the handle is easy to grab and drag. Hovering /
// dragging tints the divider so it's obvious it's interactive.
"bg-border hover:bg-primary/60 active:bg-primary relative flex w-px items-center justify-center transition-colors",
"after:absolute after:inset-y-0 after:left-1/2 after:w-4 after:-translate-x-1/2 after:cursor-col-resize",
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
<div className="bg-border z-10 flex h-8 w-4 items-center justify-center rounded-sm border shadow-sm">
<GripVerticalIcon className="size-3.5" />
</div>
)}
</ResizablePrimitive.Separator>
+36 -28
View File
@@ -24,20 +24,20 @@ function SelectValue({
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, size = "default", children, ...props }, ref) => {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEE2] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#022248] dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
className,
)}
{...props}
@@ -48,21 +48,21 @@ function SelectTrigger({
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
});
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
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",
"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,
@@ -85,7 +85,9 @@ function SelectContent({
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectLabel({
className,
@@ -94,22 +96,26 @@ function SelectLabel({
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs dark:text-[#566B7F]",
className,
)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"dark:text-[#AACEFF] dark:focus:bg-[#0062D9] dark:focus:text-white dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus:[&_svg:not([class*='text-'])]:text-white dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-white",
className,
)}
{...props}
@@ -125,7 +131,9 @@ function SelectItem({
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
function SelectSeparator({
className,
@@ -148,7 +156,7 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
@@ -166,7 +174,7 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
+71 -61
View File
@@ -10,17 +10,23 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
const SheetTrigger = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger>
>(({ ...props }, ref) => {
return <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />;
});
SheetTrigger.displayName = SheetPrimitive.Trigger.displayName;
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
const SheetClose = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Close>
>(({ ...props }, ref) => {
return <SheetPrimitive.Close ref={ref} data-slot="sheet-close" {...props} />;
});
SheetClose.displayName = SheetPrimitive.Close.displayName;
function SheetPortal({
...props
@@ -28,58 +34,62 @@ function SheetPortal({
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-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",
className,
)}
{...props}
/>
);
}
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => {
return (
<SheetPrimitive.Overlay
ref={ref}
data-slot="sheet-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",
className,
)}
{...props}
/>
);
});
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}
>(({ className, children, side = "right", ...props }, ref) => {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
});
SheetContent.displayName = SheetPrimitive.Content.displayName;
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
+105 -84
View File
@@ -53,6 +53,10 @@ function useSidebar() {
return context;
}
function useSidebarSafe() {
return React.useContext(SidebarContext);
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
@@ -139,7 +143,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-full w-full",
className,
)}
{...props}
@@ -229,7 +233,7 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed bottom-0 z-10 hidden w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex [top:var(--header-height,4rem)]",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -309,7 +313,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-[#fffdfa] relative flex w-full flex-1 flex-col",
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
@@ -393,15 +397,15 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
@@ -412,17 +416,19 @@ function SidebarGroupLabel({
{...props}
/>
);
}
});
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
@@ -435,7 +441,9 @@ function SidebarGroupAction({
{...props}
/>
);
}
});
SidebarGroupAction.displayName = "SidebarGroupAction";
function SidebarGroupContent({
className,
@@ -495,69 +503,79 @@ const sidebarMenuButtonVariants = cva(
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
const button = (
<Comp
ref={ref}
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
</Tooltip>
);
}
);
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
@@ -575,7 +593,9 @@ function SidebarMenuAction({
{...props}
/>
);
}
});
SidebarMenuAction.displayName = "SidebarMenuAction";
function SidebarMenuBadge({
className,
@@ -666,21 +686,19 @@ function SidebarMenuSubItem({
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
@@ -696,7 +714,9 @@ function SidebarMenuSubButton({
{...props}
/>
);
}
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
@@ -723,4 +743,5 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
useSidebarSafe,
};
+1 -1
View File
@@ -13,7 +13,7 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-[#55A6FE] dark:data-[state=checked]:bg-[#0062D9] inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+41 -23
View File
@@ -30,8 +30,10 @@ const tabsListVariants = cva(
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
default:
"bg-muted dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-hidden dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
line:
"gap-1 bg-transparent dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-visible dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
},
},
defaultVariants: {
@@ -40,52 +42,68 @@ const tabsListVariants = cva(
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>
>(({ className, variant = "default", ...props }, ref) => {
return (
<TabsPrimitive.List
ref={ref}
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
});
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Trigger
ref={ref}
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"dark:text-muted-foreground dark:hover:text-foreground",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground",
"dark:group-data-[variant=line]/tabs-list:relative dark:group-data-[variant=line]/tabs-list:!h-full dark:group-data-[variant=line]/tabs-list:self-stretch dark:group-data-[variant=line]/tabs-list:flex-none dark:group-data-[variant=line]/tabs-list:rounded-none dark:group-data-[variant=line]/tabs-list:border-0 dark:group-data-[variant=line]/tabs-list:bg-transparent dark:group-data-[variant=line]/tabs-list:px-4 dark:group-data-[variant=line]/tabs-list:!py-0 dark:group-data-[variant=line]/tabs-list:!leading-none dark:group-data-[variant=line]/tabs-list:text-[#CCD2DA] dark:group-data-[variant=line]/tabs-list:shadow-none dark:group-data-[variant=line]/tabs-list:hover:text-[#CCD2DA] dark:group-data-[variant=line]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=line]/tabs-list:[&_svg]:text-[#CCD2DA]",
"dark:group-data-[variant=line]/tabs-list:not(:first-child):border-l dark:group-data-[variant=line]/tabs-list:not(:first-child):border-[#1d3961] dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-l-0",
"dark:group-data-[variant=line]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=line]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=line]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF]",
"dark:group-data-[variant=default]/tabs-list:flex dark:group-data-[variant=default]/tabs-list:min-h-0 dark:group-data-[variant=default]/tabs-list:!h-full dark:group-data-[variant=default]/tabs-list:flex-1 dark:group-data-[variant=default]/tabs-list:self-stretch dark:group-data-[variant=default]/tabs-list:rounded-none dark:group-data-[variant=default]/tabs-list:border-0 dark:group-data-[variant=default]/tabs-list:bg-transparent dark:group-data-[variant=default]/tabs-list:px-4 dark:group-data-[variant=default]/tabs-list:!py-0 dark:group-data-[variant=default]/tabs-list:!leading-none dark:group-data-[variant=default]/tabs-list:text-[#CCD2DA] dark:group-data-[variant=default]/tabs-list:shadow-none dark:group-data-[variant=default]/tabs-list:hover:text-[#CCD2DA] dark:group-data-[variant=default]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=default]/tabs-list:[&_svg]:text-[#CCD2DA]",
"dark:group-data-[variant=default]/tabs-list:not(:first-child):border-l dark:group-data-[variant=default]/tabs-list:not(:first-child):border-[#1d3961] dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-l-0",
"dark:group-data-[variant=default]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=default]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=default]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:hover:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF]",
"group-data-[variant=line]/tabs-list:after:pointer-events-none group-data-[variant=line]/tabs-list:after:absolute group-data-[variant=line]/tabs-list:after:inset-x-2 group-data-[variant=line]/tabs-list:after:bottom-[-5px] group-data-[variant=line]/tabs-list:after:h-0.5 group-data-[variant=line]/tabs-list:after:bg-foreground group-data-[variant=line]/tabs-list:after:opacity-0 group-data-[variant=line]/tabs-list:after:transition-opacity group-data-[variant=line]/tabs-list:after:content-[''] group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
"dark:group-data-[variant=line]/tabs-list:after:inset-x-0 dark:group-data-[variant=line]/tabs-list:after:bottom-0 dark:group-data-[variant=line]/tabs-list:after:z-[2] dark:group-data-[variant=line]/tabs-list:after:h-[2px] dark:group-data-[variant=line]/tabs-list:after:bg-[#FFFFFF]",
className,
)}
{...props}
/>
);
}
});
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Content
ref={ref}
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
});
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+3 -1
View File
@@ -7,7 +7,9 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input placeholder:text-muted-foreground/60 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] ",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
+38 -28
View File
@@ -28,33 +28,43 @@ function Tooltip({
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset ?? 4}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
className,
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
const TooltipTrigger = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<TooltipPrimitive.Trigger
ref={ref}
data-slot="tooltip-trigger"
{...props}
/>
);
});
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset, children, ...props }, ref) => {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
data-slot="tooltip-content"
sideOffset={sideOffset ?? 4}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
className,
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
});
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,723 @@
"use client";
import { useState } from "react";
import { Check, Loader2, Pencil, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
useAdminAddMemoryEntry,
useAdminAddV1Fact,
useAdminDeleteMemoryEntry,
useAdminDeleteV1Fact,
useAdminReplaceMemoryEntry,
useAdminUpdateV1Fact,
useAdminUpdateV1Section,
useUserMemory,
} from "@/core/admin/hooks";
import { useAllUsers } from "@/core/auth/hooks";
export function getEntries(data: Record<string, unknown>): string[] {
const raw = (data as { entries?: unknown }).entries;
if (!Array.isArray(raw)) return [];
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
}
// ── V1 只读视图 ────────────────────────────────────────────────────────────
const CTX_LABELS: Record<string, string> = {
workContext: "工作背景",
personalContext: "个人信息",
topOfMind: "当前重点",
};
const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期",
earlierContext: "早期背景",
longTermBackground: "长期背景",
};
const CATEGORY_COLORS: Record<string, string> = {
preference: "bg-blue-100 text-blue-700",
knowledge: "bg-green-100 text-green-700",
context: "bg-yellow-100 text-yellow-700",
behavior: "bg-purple-100 text-purple-700",
goal: "bg-orange-100 text-orange-700",
correction: "bg-red-100 text-red-700",
};
const V1_CATEGORIES = ["preference", "knowledge", "context", "behavior", "goal", "correction"];
function extractSummary(val: unknown): string {
if (typeof val === "string") return val;
if (val && typeof val === "object" && "summary" in val)
return String((val as Record<string, unknown>).summary ?? "");
return "";
}
// 单个摘要字段的行内编辑
function V1SectionField({
label,
value,
onSave,
}: {
label: string;
value: string;
onSave: (v: string) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [saving, setSaving] = useState(false);
async function handleSave() {
if (draft.trim() === value.trim()) { setEditing(false); return; }
setSaving(true);
try { await onSave(draft.trim()); setEditing(false); }
catch (err) { toast.error(err instanceof Error ? err.message : "保存失败"); }
finally { setSaving(false); }
}
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground dark:text-[#AACEE2]">{label}</span>
{!editing && (
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
<Pencil className="size-3" />
</Button>
)}
</div>
{editing ? (
<div className="space-y-1.5">
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[72px] text-sm" autoFocus />
<div className="flex gap-1.5">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
</div>
</div>
) : (
<p className="text-sm whitespace-pre-wrap text-foreground/80 min-h-[1.5rem]">{value || <span className="text-muted-foreground italic">(空)</span>}</p>
)}
</div>
);
}
// 单条事实的行内编辑
function V1FactItem({
fact,
agent,
userId,
}: {
fact: Record<string, unknown>;
agent: string | null;
userId: string;
}) {
const [editing, setEditing] = useState(false);
const [draftContent, setDraftContent] = useState(String(fact.content ?? ""));
const [draftCategory, setDraftCategory] = useState(String(fact.category ?? "context"));
const [draftConfidence, setDraftConfidence] = useState(
typeof fact.confidence === "number" ? Math.round(fact.confidence * 100) : 80,
);
const updateFact = useAdminUpdateV1Fact(userId);
const deleteFact = useAdminDeleteV1Fact(userId);
const factId = String(fact.id ?? "");
const confidence = typeof fact.confidence === "number" ? fact.confidence : 0;
const category = String(fact.category ?? "context");
async function handleSave() {
try {
await updateFact.mutateAsync({
factId,
updates: { content: draftContent.trim(), category: draftCategory, confidence: draftConfidence / 100 },
agent,
});
setEditing(false);
} catch (err) { toast.error(err instanceof Error ? err.message : "更新失败"); }
}
async function handleDelete() {
try { await deleteFact.mutateAsync({ factId, agent }); }
catch (err) { toast.error(err instanceof Error ? err.message : "删除失败"); }
}
return (
<div className="rounded border p-2 space-y-1 group ">
{editing ? (
<div className="space-y-2">
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
<div className="flex items-center gap-2 flex-wrap">
<Select value={draftCategory} onValueChange={setDraftCategory}>
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">置信度</span>
<input
type="number" min={0} max={100} value={draftConfidence}
onChange={(e) => setDraftConfidence(Number(e.target.value))}
className="w-16 h-7 rounded border px-2 text-xs"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={handleSave} disabled={updateFact.isPending}>
{updateFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category] ?? "bg-gray-100 text-gray-700"}`}>{category}</span>
<span className="text-xs text-muted-foreground">{Math.round(confidence * 100)}%</span>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraftContent(String(fact.content ?? "")); setDraftCategory(category); setDraftConfidence(Math.round(confidence * 100)); setEditing(true); }}>
<Pencil className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6 hover:text-destructive" onClick={handleDelete} disabled={deleteFact.isPending}>
{deleteFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
</div>
</div>
<p className="text-sm whitespace-pre-wrap dark:text-[#AACEE2]">{String(fact.content ?? "")}</p>
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
</>
)}
</div>
);
}
// 新增事实表单
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
const { theme } = useTheme();
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [category, setCategory] = useState("context");
const [confidence, setConfidence] = useState(80);
const addFact = useAdminAddV1Fact(userId);
async function handleAdd() {
if (!content.trim()) return;
try {
await addFact.mutateAsync({ content: content.trim(), category, confidence: confidence / 100, agent });
setContent(""); setCategory("context"); setConfidence(80); setOpen(false);
} catch (err) { toast.error(err instanceof Error ? err.message : "新增失败"); }
}
if (!open) {
return (
<Button
variant={theme === "dark" ? "default" : "outline"}
size="sm"
className="mt-1 w-full"
onClick={() => setOpen(true)}
>
<Plus className="size-3" />新增事实
</Button>
);
}
return (
<div className="rounded-md border p-3 space-y-2">
<Textarea placeholder="事实内容…" value={content} onChange={(e) => setContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
<div className="flex items-center gap-2 flex-wrap">
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">置信度</span>
<input type="number" min={0} max={100} value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} className="w-16 h-7 rounded border px-2 text-xs" />
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={handleAdd} disabled={addFact.isPending || !content.trim()}>
{addFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}确认
</Button>
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}><X className="size-3" />取消</Button>
</div>
</div>
);
}
export function V1BucketView({
data,
agent,
userId,
}: {
data: Record<string, unknown>;
agent: string | null;
userId: string;
}) {
const updateSection = useAdminUpdateV1Section(userId);
const ctxRaw = (data.user ?? data.userContext ?? {}) as Record<string, unknown>;
const historyRaw = (data.history ?? {}) as Record<string, unknown>;
const factsRaw = Array.isArray(data.facts) ? (data.facts as Record<string, unknown>[]) : [];
const allCtxFields = ["workContext", "personalContext", "topOfMind"];
const allHistoryFields = ["recentMonths", "earlierContext", "longTermBackground"];
if (!Object.keys(ctxRaw).length && !Object.keys(historyRaw).length && !factsRaw.length) {
return <p className="text-muted-foreground py-6 text-center text-sm">暂无 V1 记忆</p>;
}
return (
<div className="space-y-4">
{/* 用户画像字段 */}
<div className="rounded-md border p-3 space-y-3 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<h4 className="text-xs font-semibold text-muted-foreground dark:text-white">用户画像</h4>
{allCtxFields.map((field) => (
<V1SectionField
key={field}
label={CTX_LABELS[field] ?? field}
value={extractSummary(ctxRaw[field])}
onSave={(summary) =>
updateSection.mutateAsync({ sectionType: "user", field, summary, agent }).then(() => undefined)
}
/>
))}
</div>
{/* 历史摘要字段 */}
<div className="rounded-md border p-3 space-y-3 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<h4 className="text-xs font-semibold text-muted-foreground dark:text-white">历史摘要</h4>
{allHistoryFields.map((field) => (
<V1SectionField
key={field}
label={HISTORY_LABELS[field] ?? field}
value={extractSummary(historyRaw[field])}
onSave={(summary) =>
updateSection.mutateAsync({ sectionType: "history", field, summary, agent }).then(() => undefined)
}
/>
))}
</div>
{/* 事实记录 */}
<div className="rounded-md border p-3 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h4 className="text-xs font-semibold text-muted-foreground dark:text-white">事实记录({factsRaw.length} 条)</h4>
{factsRaw.map((fact, i) => (
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
))}
<V1AddFactForm agent={agent} userId={userId} />
</div>
</div>
);
}
// ── Memory entry list with inline editing ──────────────────────────────────
export function MemoryEntryList({
entries,
userId,
agent,
}: {
entries: string[];
userId: string;
agent: string | null;
}) {
const [editIndex, setEditIndex] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState("");
const [addDraft, setAddDraft] = useState("");
const [isAdding, setIsAdding] = useState(false);
const addMutation = useAdminAddMemoryEntry(userId);
const replaceMutation = useAdminReplaceMemoryEntry(userId);
const deleteMutation = useAdminDeleteMemoryEntry(userId);
const isPending =
addMutation.isPending || replaceMutation.isPending || deleteMutation.isPending;
function startEdit(index: number) {
setEditIndex(index);
setEditDraft(entries[index] ?? "");
}
function cancelEdit() {
setEditIndex(null);
setEditDraft("");
}
async function confirmEdit(index: number) {
const oldText = entries[index];
if (!oldText || editDraft.trim() === oldText.trim()) {
cancelEdit();
return;
}
try {
await replaceMutation.mutateAsync({ oldText, content: editDraft.trim(), agent });
cancelEdit();
} catch (err) {
toast.error(err instanceof Error ? err.message : "修改失败");
}
}
async function handleDelete(entry: string) {
try {
await deleteMutation.mutateAsync({ oldText: entry, agent });
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除失败");
}
}
async function handleAdd() {
const content = addDraft.trim();
if (!content) return;
try {
await addMutation.mutateAsync({ content, agent });
setAddDraft("");
setIsAdding(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "新增失败");
}
}
return (
<div className="flex flex-col gap-2">
{entries.length === 0 && !isAdding && (
<p className="text-muted-foreground py-6 text-center text-sm">暂无记忆条目</p>
)}
{entries.map((entry, i) => (
<div key={i} className="group rounded-md border p-3">
{editIndex === i ? (
<div className="flex flex-col gap-2">
<Textarea
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
className="min-h-[80px] text-sm"
autoFocus
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => confirmEdit(i)}
disabled={replaceMutation.isPending}
>
{replaceMutation.isPending ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Check className="size-3" />
)}
保存
</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>
<X className="size-3" />
取消
</Button>
</div>
</div>
) : (
<div className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0 text-sm">·</span>
<span className="flex-1 whitespace-pre-wrap text-sm">{entry}</span>
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={() => startEdit(i)}
disabled={isPending}
>
<Pencil className="size-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="size-7 hover:text-destructive"
onClick={() => handleDelete(entry)}
disabled={isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</Button>
</div>
</div>
)}
</div>
))}
{isAdding ? (
<div className="flex flex-col gap-2 rounded-md border p-3">
<Textarea
placeholder="输入新记忆条目内容…"
value={addDraft}
onChange={(e) => setAddDraft(e.target.value)}
className="min-h-[80px] text-sm"
autoFocus
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending || !addDraft.trim()}>
{addMutation.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
确认
</Button>
<Button size="sm" variant="ghost" onClick={() => { setIsAdding(false); setAddDraft(""); }}>
<X className="size-3" />
取消
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="mt-1 w-full"
onClick={() => setIsAdding(true)}
disabled={isPending}
>
<Plus className="size-3" />
新增条目
</Button>
)}
</div>
);
}
// ── Right panel: memory tabs for a selected user ───────────────────────────
export function UserMemoryPanel({ userId }: { userId: string }) {
const { data: memory, isLoading } = useUserMemory(userId);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
if (isLoading) {
return (
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
加载中…
</div>
);
}
const isV1 = memory?.version === "v1";
const globalBucket = memory?.buckets.find((b) => b.name === "global");
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
// ── V1 只读视图 ──────────────────────────────────────────────────────────
if (isV1) {
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
const currentBucket = currentAgent
? agentBuckets.find((b) => b.name === currentAgent)
: globalBucket;
return (
<Tabs defaultValue="global" className="flex flex-1 flex-col overflow-hidden">
<div className="border-b px-4 pt-4">
<div className="mb-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-1.5 text-xs text-amber-700">
V1 模式:记忆由系统自动提取,不支持手动管理
</div>
<TabsList variant="line">
<TabsTrigger value="global">用户记忆</TabsTrigger>
{agentBuckets.length > 0 && (
<TabsTrigger value="agent">
Agent 记忆
<Badge variant="secondary" className="ml-1.5 text-xs">
{agentBuckets.length}
</Badge>
</TabsTrigger>
)}
</TabsList>
</div>
<TabsContent value="global" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
{globalBucket ? <V1BucketView data={globalBucket.data} agent={null} userId={userId} /> : (
<p className="text-muted-foreground py-6 text-center text-sm">暂无数据</p>
)}
</div>
</ScrollArea>
</TabsContent>
{agentBuckets.length > 0 && (
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-muted-foreground text-xs">选择 Agent:</span>
<Select value={currentAgent ?? ""} onValueChange={(v) => setSelectedAgent(v)}>
<SelectTrigger className="h-7 w-48 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{agentBuckets.map((b) => (
<SelectItem key={b.name} value={b.name} className="text-xs">
{b.display_name || b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentBucket && <V1BucketView data={currentBucket.data} agent={currentAgent} userId={userId} />}
</div>
</ScrollArea>
</TabsContent>
)}
</Tabs>
);
}
// ── V2 可编辑视图 ────────────────────────────────────────────────────────
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
return (
<Tabs defaultValue="user" className="flex flex-1 flex-col overflow-hidden">
<div className="border-b px-4 pt-4">
<TabsList variant="line">
<TabsTrigger value="user">
用户画像
{userEntries.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs">
{userEntries.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="agent">
Agent 记忆
{agentBuckets.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs">
{agentBuckets.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="user" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
<p className="text-muted-foreground mb-3 text-xs">
USER.md — 跨 Agent 共享的用户画像
</p>
<MemoryEntryList entries={userEntries} userId={userId} agent={null} />
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
{agentBuckets.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">该用户暂无 Agent 记忆</p>
) : (
<>
<div className="mb-3 flex items-center gap-2">
<span className="text-muted-foreground text-xs">选择 Agent:</span>
<Select
value={currentAgent ?? ""}
onValueChange={(v) => setSelectedAgent(v)}
>
<SelectTrigger className="h-7 w-48 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{agentBuckets.map((b) => (
<SelectItem key={b.name} value={b.name} className="text-xs">
{b.display_name || b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">— MEMORY.md</span>
</div>
{currentAgent && (
<MemoryEntryList entries={agentEntries} userId={userId} agent={currentAgent} />
)}
</>
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
);
}
// ── Main page ──────────────────────────────────────────────────────────────
export default function AdminMemoryPage() {
const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const filteredUsers = (users ?? []).filter((u) =>
u.email.toLowerCase().includes(userQuery.toLowerCase()),
);
return (
<div className="flex h-full">
{/* Left: user list */}
<div className="flex w-64 shrink-0 flex-col border-r">
<div className="border-b p-3">
<Input
placeholder="搜索用户…"
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
className="h-8 text-sm"
/>
</div>
<ScrollArea className="flex-1">
{usersLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
<Loader2 className="size-4 animate-spin" />
加载中…
</div>
)}
{!usersLoading && filteredUsers.length === 0 && (
<p className="text-muted-foreground py-8 text-center text-sm">暂无用户</p>
)}
{filteredUsers.map((user) => (
<button
key={user.id}
className={`flex w-full flex-col items-start gap-0.5 px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent ${
selectedUserId === user.id ? "bg-accent font-medium" : ""
}`}
onClick={() => setSelectedUserId(user.id)}
>
<span className="max-w-full truncate">{user.email}</span>
{user.system_role === "admin" && (
<Badge variant="default" className="h-4 px-1 text-[10px]">
Admin
</Badge>
)}
</button>
))}
</ScrollArea>
</div>
{/* Right: memory tabs */}
<div className="flex flex-1 flex-col overflow-hidden">
{selectedUserId ? (
<UserMemoryPanel key={selectedUserId} userId={selectedUserId} />
) : (
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
请在左侧选择用户
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,449 @@
"use client";
import { ArrowDownWideNarrow, Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { AdminMessage } from "@/core/admin/api";
import {
useUserMemory,
useUserThreadMessages,
useUserThreads,
} from "@/core/admin/hooks";
import type { UserListItem } from "@/core/auth/api";
import { useAllUsers } from "@/core/auth/hooks";
import { cn } from "@/lib/utils";
import {
getEntries,
MemoryEntryList,
V1BucketView,
} from "./admin-memory-page";
// ── Helpers (shared with AdminUserActivityPage) ─────────────────────────────
function extractText(content: unknown): string {
if (content == null) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block === "object") {
const text = (block as Record<string, unknown>).text;
if (typeof text === "string") return text;
}
return "";
})
.join("");
}
if (typeof content === "object") {
const inner = (content as Record<string, unknown>).content;
if (inner !== undefined) return extractText(inner);
}
return "";
}
function roleOf(message: AdminMessage): "user" | "assistant" | null {
const type = (message.type ?? "").toLowerCase();
if (type === "human") return "user";
if (type === "ai" || type === "aimessagechunk") return "assistant";
return null;
}
function fmtTime(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
// ── UserInfoPanel ───────────────────────────────────────────────────────────
function UserInfoPanel({ user }: { user: UserListItem }) {
return (
<div className="p-6">
<dl className="grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 text-sm">
<dt className="text-muted-foreground font-medium">邮箱</dt>
<dd className="break-all">{user.email}</dd>
<dt className="text-muted-foreground font-medium">角色</dt>
<dd>
{user.system_role === "admin" ? (
<Badge variant="default">管理员</Badge>
) : (
<Badge variant="secondary">普通用户</Badge>
)}
</dd>
<dt className="text-muted-foreground font-medium">问答次数</dt>
<dd className="tabular-nums">{user.qa_count}</dd>
<dt className="text-muted-foreground font-medium">Skills</dt>
<dd className="tabular-nums">{user.skill_count}</dd>
<dt className="text-muted-foreground font-medium">Agents</dt>
<dd className="tabular-nums">{user.agent_count}</dd>
<dt className="text-muted-foreground font-medium">User ID</dt>
<dd className="font-mono text-xs text-muted-foreground break-all">{user.id}</dd>
</dl>
</div>
);
}
// ── MessageRow / MemoryBucketView (audit sub-components) ────────────────────
function MessageRow({ message }: { message: AdminMessage }) {
const role = roleOf(message);
if (!role) return null;
const text = extractText(message.content).trim();
if (!text) return null;
return (
<div
className={cn(
"flex flex-col gap-1",
role === "user" ? "items-start" : "items-end",
)}
>
<span className="text-muted-foreground text-xs">
{role === "user" ? "用户提问" : "AI 回答"}
</span>
<div
className={cn(
"max-w-[85%] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm",
role === "user" ? "bg-muted" : "bg-primary/5 border-primary/20",
)}
>
{text}
</div>
</div>
);
}
// ── UserAuditPanel ──────────────────────────────────────────────────────────
function UserAuditPanel({ userId }: { userId: string }) {
const [threadId, setThreadId] = useState<string | null>(null);
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
userId,
threadId,
);
return (
<div className="flex min-h-0 flex-1">
{/* Thread list */}
<div className="flex w-60 shrink-0 flex-col border-r">
<div className="text-muted-foreground border-b px-3 py-2 text-xs font-medium">
会话列表{threads ? `(${threads.length})` : ""}
</div>
<ScrollArea className="min-h-0 flex-1">
{threadsLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{!threadsLoading && (threads?.length ?? 0) === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
该用户暂无会话
</div>
)}
<div className="flex flex-col p-1">
{(threads ?? []).map((thread) => (
<button
key={thread.thread_id}
type="button"
onClick={() => setThreadId(thread.thread_id)}
className={cn(
"flex flex-col gap-0.5 rounded-md px-2 py-2 text-left",
thread.thread_id === threadId ? "bg-accent" : "hover:bg-muted",
)}
>
<span className="truncate text-sm">
{thread.display_name || "未命名会话"}
</span>
<span className="text-muted-foreground text-xs">
{fmtTime(thread.updated_at)}
</span>
</button>
))}
</div>
</ScrollArea>
</div>
{/* Messages */}
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{!threadId && (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm dark:text-[#AACEF8]">
<MessageSquareIcon className="size-6" />
请从左侧选择一个会话
</div>
)}
{threadId && messagesLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{threadId && !messagesLoading && (
<div className="flex flex-col gap-3">
{(messages ?? []).map((m, i) => (
<MessageRow key={m.id ?? i} message={m} />
))}
{(messages ?? []).every((m) => !roleOf(m)) && (
<div className="text-muted-foreground py-12 text-center text-sm">
该会话暂无问答记录
</div>
)}
</div>
)}
</div>
</ScrollArea>
</div>
);
}
// ── AgentMemoryPanel ─────────────────────────────────────────────────────────
function bucketLabel(name: string, displayName?: string): string {
if (name === "default") return "默认智能体";
return displayName || name;
}
function AgentMemoryPanel({ userId }: { userId: string }) {
const { data: memory, isLoading } = useUserMemory(userId);
// 默认选中"默认助手"("default" 对应全局记忆文件)
const [selectedAgent, setSelectedAgent] = useState<string>("default");
if (isLoading) {
return (
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中…
</div>
);
}
const isV1 = memory?.version === "v1";
// 非 global、非 default 的其他智能体 bucket
const otherBuckets = (memory?.buckets ?? []).filter(
(b) => b.name !== "global" && b.name !== "default",
);
// 当前选中的 bucket
const currentBucket = (memory?.buckets ?? []).find(
(b) => b.name === selectedAgent,
);
// "default" 对应 agent=null(全局记忆文件),与 _resolve_agent_name 的哨兵逻辑一致
const bucketAgentParam = selectedAgent === "default" ? null : selectedAgent;
return (
<div className="flex min-h-0 flex-1 flex-col">
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 p-4">
{/* 智能体下拉选择器 */}
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm shrink-0">智能体:</span>
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="h-8 w-48" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
{/* 默认助手始终显示,对应全局记忆文件 */}
<SelectItem value="default">默认助手</SelectItem>
{otherBuckets.map((b) => (
<SelectItem key={b.name} value={b.name}>
{bucketLabel(b.name, b.display_name)}
</SelectItem>
))}
</SelectContent>
</Select>
{!isV1 && currentBucket && (
<span className="text-muted-foreground text-xs">
{getEntries(currentBucket.data).length} 条
</span>
)}
</div>
{/* 当前智能体记忆内容 */}
{!currentBucket ? (
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
暂无记忆数据
</div>
) : isV1 ? (
<V1BucketView
data={currentBucket.data}
agent={bucketAgentParam}
userId={userId}
/>
) : (
<MemoryEntryList
entries={getEntries(currentBucket.data)}
userId={userId}
agent={bucketAgentParam}
/>
)}
</div>
</ScrollArea>
</div>
);
}
// ── AdminUserHub (main) ─────────────────────────────────────────────────────
export function AdminUserHub() {
const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
// 活动审计 放到前面 → 默认进入审计标签页。
const [activeTab, setActiveTab] = useState<"audit" | "info" | "memory">("audit");
// 是否按问答次数(降序)排序用户列表。
const [sortByQa, setSortByQa] = useState(false);
const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase();
let list = users ?? [];
if (needle) {
list = list.filter((u) => u.email.toLowerCase().includes(needle));
}
if (sortByQa) {
// Copy before sorting so we never mutate the query cache array.
list = [...list].sort((a, b) => (b.qa_count ?? 0) - (a.qa_count ?? 0));
}
return list;
}, [users, userQuery, sortByQa]);
const selectedUser = users?.find((u) => u.id === selectedUserId) ?? null;
function selectUser(id: string) {
setSelectedUserId(id);
setActiveTab("audit");
}
return (
<div className="flex size-full flex-col">
<div className="border-b px-6 py-4">
<h1 className="text-xl font-semibold">Admin 用户中心</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
管理用户信息、审计历史问答、编辑用户记忆。
</p>
</div>
<div className="flex min-h-0 flex-1">
{/* Left: user list */}
<div className="flex w-64 shrink-0 flex-col border-r">
<div className="relative p-2">
<SearchIcon className="text-muted-foreground absolute left-4 top-1/2 size-4 -translate-y-1/2" />
<Input
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
placeholder="搜索用户邮箱"
className="pl-8"
/>
</div>
<button
type="button"
onClick={() => setSortByQa((v) => !v)}
className={cn(
"mx-2 mb-1 flex items-center justify-center gap-1 rounded-md border px-2 py-1 text-xs",
sortByQa
? "border-primary/40 bg-primary/5 text-primary"
: "text-muted-foreground hover:bg-muted",
)}
title="按问答次数从高到低排序"
>
<ArrowDownWideNarrow className="size-3.5" />
{sortByQa ? "按问答次数排序中" : "按问答次数排序"}
</button>
<ScrollArea className="min-h-0 flex-1">
{usersLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{!usersLoading && filteredUsers.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
无用户
</div>
)}
<div className="flex flex-col p-1">
{filteredUsers.map((u) => (
<button
key={u.id}
type="button"
onClick={() => selectUser(u.id)}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm",
u.id === selectedUserId
? "bg-accent font-medium"
: "hover:bg-muted",
)}
>
<UserIcon className="text-muted-foreground size-4 shrink-0" />
<span className="flex-1 truncate">{u.email}</span>
{u.system_role === "admin" && (
<Badge variant="outline" className="shrink-0 text-xs">
管理员
</Badge>
)}
<span
className="text-muted-foreground shrink-0 tabular-nums text-xs"
title="问答次数"
>
{u.qa_count ?? 0}
</span>
</button>
))}
</div>
</ScrollArea>
</div>
{/* Right: tabs */}
{!selectedUserId ? (
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
从左侧选择一个用户
</div>
) : (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
className="flex min-w-0 flex-1 flex-col overflow-hidden"
>
<div className="border-b px-4 pt-3 shrink-0">
<TabsList variant="line">
<TabsTrigger value="audit">活动审计</TabsTrigger>
<TabsTrigger value="info">用户信息</TabsTrigger>
<TabsTrigger value="memory">记忆管理</TabsTrigger>
</TabsList>
</div>
<TabsContent value="info" className="flex-1 overflow-auto p-0">
{selectedUser && <UserInfoPanel user={selectedUser} />}
</TabsContent>
<TabsContent
value="audit"
className="flex min-h-0 flex-1 flex-col p-0"
>
<UserAuditPanel key={selectedUserId} userId={selectedUserId} />
</TabsContent>
<TabsContent
value="memory"
className="flex min-h-0 flex-1 flex-col p-0"
>
<AgentMemoryPanel key={selectedUserId} userId={selectedUserId} />
</TabsContent>
</Tabs>
)}
</div>
</div>
);
}
@@ -0,0 +1,293 @@
"use client";
import { Loader2, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { TagChip } from "@/components/workspace/tags/tag-chip";
import { TAG_COLORS } from "@/components/workspace/tags/tag-colors";
import { useI18n } from "@/core/i18n/hooks";
import {
useCreateTag,
useDeleteTag,
useTags,
useUpdateTag,
type Tag,
type TagType,
} from "@/core/tags";
import { cn } from "@/lib/utils";
function TagFormDialog({
open,
onOpenChange,
tagType,
editing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
tagType: TagType;
editing: Tag | null;
}) {
const { t } = useI18n();
const createTag = useCreateTag();
const updateTag = useUpdateTag();
const [name, setName] = useState("");
const [color, setColor] = useState("gray");
const [description, setDescription] = useState("");
// Sync form state whenever the dialog (re)opens.
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${editing?.id ?? "new"}`;
if (open && syncedFor !== syncKey) {
setName(editing?.name ?? "");
setColor(editing?.color ?? "gray");
setDescription(editing?.description ?? "");
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
const pending = createTag.isPending || updateTag.isPending;
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
try {
if (editing) {
await updateTag.mutateAsync({
id: editing.id,
input: { name: trimmed, color, description },
});
} else {
await createTag.mutateAsync({
name: trimmed,
tag_type: tagType,
color,
description,
});
}
onOpenChange(false);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing ? t.adminTags.editTitle : t.adminTags.createTitle}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.name}</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.adminTags.namePlaceholder}
maxLength={256}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.color}</label>
<div className="flex flex-wrap gap-2">
{TAG_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setColor(c.name)}
aria-label={c.name}
className={cn(
"size-6 rounded-full border-2",
c.swatch,
color === c.name
? "border-foreground"
: "border-transparent",
)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{t.adminTags.tagDescription}
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.adminTags.descriptionPlaceholder}
maxLength={1024}
rows={2}
/>
</div>
{name.trim() && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Preview:</span>
<TagChip tag={{ name: name.trim(), color }} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t.adminTags.cancel}
</Button>
<Button onClick={handleSave} disabled={pending || !name.trim()}>
{pending && <Loader2 className="size-4 animate-spin" />}
{t.adminTags.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function TagManagementPage() {
const { t } = useI18n();
const [activeType, setActiveType] = useState<TagType>("agent");
const { data: tags, isLoading, error } = useTags(activeType);
const deleteTag = useDeleteTag();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Tag | null>(null);
const openCreate = () => {
setEditing(null);
setDialogOpen(true);
};
const openEdit = (tag: Tag) => {
setEditing(tag);
setDialogOpen(true);
};
const handleDelete = async (tag: Tag) => {
if (!window.confirm(t.adminTags.deleteConfirm)) return;
try {
await deleteTag.mutateAsync(tag.id);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminTags.title}
description={t.adminTags.description}
>
<div className="flex items-center justify-between gap-4">
<Tabs
value={activeType}
onValueChange={(v) => setActiveType(v as TagType)}
>
<TabsList variant="line">
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
<TabsTrigger value="scheduled_task">
{t.adminTags.typeScheduledTask}
</TabsTrigger>
</TabsList>
</Tabs>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />
{t.adminTags.createButton}
</Button>
</div>
<div className="mt-4">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive py-4 text-sm">{String(error)}</div>
)}
{!isLoading && !error && tags && tags.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
{t.adminTags.empty}
</div>
)}
{!isLoading && !error && tags && tags.length > 0 && (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-muted-foreground px-4 py-3 text-left font-medium dark:text-white">
{t.adminTags.name}
</th>
<th className="text-muted-foreground px-4 py-3 text-left font-medium dark:text-white">
{t.adminTags.tagDescription}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium dark:text-white">
{t.adminTags.usageCount}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium dark:text-white">
{t.adminTags.actions}
</th>
</tr>
</thead>
<tbody>
{tags.map((tag, i) => (
<tr
key={tag.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">
<TagChip tag={tag} />
</td>
<td className="text-muted-foreground px-4 py-3">
{tag.description || "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{tag.usage_count}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(tag)}
aria-label={t.adminTags.edit}
>
<PencilIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(tag)}
aria-label={t.adminTags.delete}
>
<Trash2Icon className="text-destructive size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</SettingsSection>
<TagFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
tagType={activeType}
editing={editing}
/>
</div>
);
}
@@ -0,0 +1,631 @@
"use client";
import { AlertCircle,CheckCircle2, Download, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
downloadToolMetricsExcel,
TOOL_ERROR_CATEGORIES,
TOOL_ERROR_CATEGORY_LABELS,
type ToolMetric,
type ToolMetricsFilters,
} from "@/core/tool-metrics/api";
import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
const PAGE_SIZE = 50;
type StatusFilter = "all" | "success" | "error";
type KindFilter = "skill" | "all";
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
if (row.user_name && row.user_name.trim()) return row.user_name.trim();
const id = (row.user_id ?? "").trim();
return id || "—";
}
function formatDate(value?: string | null): string {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function formatDuration(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
if (value === 0) return "0 ms";
if (value < 1000) return `${value} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
const minutes = Math.floor(value / 60_000);
const seconds = Math.round((value % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}
function categoryLabel(category?: string | null): string {
if (!category) return "—";
return TOOL_ERROR_CATEGORY_LABELS[category] ?? category;
}
export function ToolMetricsPanel() {
const { theme } = useTheme();
const [page, setPage] = useState(0);
const [tool, setTool] = useState("");
const [user, setUser] = useState("");
const [skill, setSkill] = useState("");
// Default to skill-only — the table is otherwise flooded with low-level
// bash / file-op calls. "全部" reveals every tool invocation.
const [kind, setKind] = useState<KindFilter>("skill");
const [status, setStatus] = useState<StatusFilter>("all");
const [category, setCategory] = useState<string>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false);
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null);
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const filters = useMemo<ToolMetricsFilters>(() => {
const out: ToolMetricsFilters = {};
if (tool.trim()) out.tool_name = tool.trim();
if (user.trim()) out.user_name = user.trim();
if (skill.trim()) out.skill_name = skill.trim();
if (kind !== "all") out.kind = kind;
if (status !== "all") out.status = status;
if (category !== "all") out.error_category = category;
if (since) out.since = new Date(since).toISOString();
if (until) out.until = new Date(until).toISOString();
return out;
}, [tool, user, skill, kind, status, category, since, until]);
const query = useMemo(
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
[filters, page],
);
const { data, isLoading, error, isFetching } = useToolMetrics(query);
// The summary ignores the status/category filter so the breakdown always
// reflects the full failure picture for the chosen tool/user/time window.
const summaryFilters = useMemo<ToolMetricsFilters>(() => {
const out: ToolMetricsFilters = {};
if (tool.trim()) out.tool_name = tool.trim();
if (user.trim()) out.user_name = user.trim();
if (skill.trim()) out.skill_name = skill.trim();
if (kind !== "all") out.kind = kind;
if (since) out.since = new Date(since).toISOString();
if (until) out.until = new Date(until).toISOString();
return out;
}, [tool, user, skill, kind, since, until]);
const { data: summary } = useToolMetricsSummary(summaryFilters);
const handleExport = async () => {
setExporting(true);
try {
await downloadToolMetricsExcel(filters);
toast.success("已导出为 Excel",darkToastOpts.success);
} catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败",darkToastOpts.error);
} finally {
setExporting(false);
}
};
const resetFilters = () => {
setTool("");
setUser("");
setSkill("");
setKind("skill");
setStatus("all");
setCategory("all");
setSince("");
setUntil("");
setPage(0);
};
const items = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const errorRate =
summary && summary.total > 0
? ((summary.error_total / summary.total) * 100).toFixed(1)
: "0.0";
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">工具调用统计</h2>
<p className="text-muted-foreground mt-1 text-sm">
记录每一次工具 / 技能 / MCP 调用,并对失败进行归类:检索为空、IP
被封、限流、超时、鉴权失败等。后台无条件记录。
</p>
</div>
<Button onClick={handleExport} disabled={exporting}>
{exporting ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Download className="mr-1 size-4" />
)}
导出 Excel
</Button>
</div>
{/* Summary strip */}
<div className="flex flex-wrap items-center gap-2 rounded-xl border bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="mr-4 flex flex-col">
<span className="text-muted-foreground text-xs">总调用</span>
<span className="text-lg font-semibold tabular-nums">
{summary?.total ?? "—"}
</span>
</div>
<div className="mr-4 flex flex-col">
<span className="text-muted-foreground text-xs">失败数</span>
<span className="text-destructive text-lg font-semibold tabular-nums">
{summary?.error_total ?? "—"}
</span>
</div>
<div className="mr-4 flex flex-col">
<span className="text-muted-foreground text-xs">失败率</span>
<span className="text-lg font-semibold tabular-nums">{errorRate}%</span>
</div>
<div className="flex flex-1 flex-wrap items-center gap-1.5">
{summary && Object.keys(summary.by_category).length === 0 && (
<span className="text-muted-foreground text-sm">暂无失败记录 🎉</span>
)}
{summary &&
Object.entries(summary.by_category)
.sort((a, b) => b[1] - a[1])
.map(([cat, count]) => (
<button
key={cat}
type="button"
onClick={() => {
setCategory(cat);
setStatus("error");
setPage(0);
}}
className="hover:bg-muted inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs"
>
<span>{categoryLabel(cat)}</span>
<span className="text-destructive font-semibold tabular-nums">
{count}
</span>
</button>
))}
</div>
</div>
{/* Per-skill breakdown — answers "was skill X invoked, and did it succeed". */}
{summary && summary.by_skill.length > 0 && (
<div className="rounded-xl border bg-white p-4 ">
<h3 className="mb-2 text-sm font-medium">各技能调用情况</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground">
<tr>
<th className="px-2 py-1 text-left text-xs font-medium">技能名称</th>
<th className="px-2 py-1 text-right text-xs font-medium">调用次数</th>
<th className="px-2 py-1 text-right text-xs font-medium">成功</th>
<th className="px-2 py-1 text-right text-xs font-medium">失败</th>
<th className="px-2 py-1 text-right text-xs font-medium">成功率</th>
</tr>
</thead>
<tbody>
{summary.by_skill.map((s) => {
const rate =
s.total > 0 ? ((s.success / s.total) * 100).toFixed(0) : "0";
return (
<tr
key={s.skill_name}
className="hover:bg-muted cursor-pointer border-t"
title="点击只看该技能"
onClick={() => {
setSkill(s.skill_name);
setPage(0);
}}
>
<td className="px-2 py-1.5 font-medium">{s.skill_name}</td>
<td className="px-2 py-1.5 text-right tabular-nums">{s.total}</td>
<td className="px-2 py-1.5 text-right tabular-nums text-green-600">
{s.success}
</td>
<td className="text-destructive px-2 py-1.5 text-right tabular-nums">
{s.error}
</td>
<td className="px-2 py-1.5 text-right tabular-nums">{rate}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{summary && kind === "skill" && summary.by_skill.length === 0 && (
<div className="text-muted-foreground rounded-xl border bg-white p-4 text-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
当前筛选范围内没有检测到任何技能调用记录。技能只有在被 Agent 通过
<code className="bg-muted mx-1 rounded px-1">skill_view</code>
读取(即真正"调用")时才会被记录;仅在系统提示里被列出、但未被读取的技能不会计入。
</div>
)}
{/* Filters */}
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-3 lg:grid-cols-6 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">调用类型</label>
<Select
value={kind}
onValueChange={(value) => {
setKind(value as KindFilter);
setPage(0);
}}
>
<SelectTrigger className="dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="skill">技能(Agent Skill)</SelectItem>
<SelectItem value="all">全部工具调用</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">技能名称</label>
<Input
value={skill}
placeholder="按技能名筛选"
onChange={(e) => {
setSkill(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">工具名</label>
<Input
value={tool}
placeholder="按工具名筛选"
onChange={(e) => {
setTool(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">用户名</label>
<Input
value={user}
placeholder="@ 前部分"
onChange={(e) => {
setUser(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">状态</label>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as StatusFilter);
setPage(0);
}}
>
<SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">错误类型</label>
<Select
value={category}
onValueChange={(value) => {
setCategory(value);
setPage(0);
}}
>
<SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部</SelectItem>
{TOOL_ERROR_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{categoryLabel(cat)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">开始时间</label>
<Input
type="datetime-local"
value={since}
onChange={(e) => {
setSince(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">结束时间</label>
<Input
type="datetime-local"
value={until}
onChange={(e) => {
setUntil(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex justify-end lg:col-span-6">
<Button variant="outline" size="sm" onClick={resetFilters}>
清空筛选
</Button>
</div>
</div>
{/* Table */}
<div className="rounded-xl border bg-white dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] text-sm">
<thead className="bg-muted/60 text-muted-foreground dark:text-white">
<tr>
<Th>时间</Th>
<Th>用户</Th>
<Th>Agent</Th>
<Th>技能</Th>
<Th>工具</Th>
<Th className="text-right">耗时</Th>
<Th>状态</Th>
<Th>错误类型</Th>
<Th>错误详情</Th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={9} className="py-10 text-center">
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
</td>
</tr>
)}
{!isLoading && error && (
<tr>
<td colSpan={9} className="text-destructive py-10 text-center text-sm">
加载失败:{error.message}
</td>
</tr>
)}
{!isLoading && !error && items.length === 0 && (
<tr>
<td colSpan={9} className="text-muted-foreground py-10 text-center text-sm">
暂无符合条件的调用记录。
</td>
</tr>
)}
{items.map((row) => (
<tr key={row.id} className="border-t">
<Td>{formatDate(row.created_at)}</Td>
<Td title={row.user_id ?? undefined}>{displayUser(row)}</Td>
<Td>{row.agent_name ?? "—"}</Td>
<Td className="font-medium">
{row.skill_name ? (
<button
type="button"
onClick={() => {
setSkill(row.skill_name as string);
setPage(0);
}}
className="hover:text-foreground underline-offset-2 hover:underline"
title="点击只看该技能"
>
{row.skill_name}
</button>
) : (
<span className="text-muted-foreground">—</span>
)}
</Td>
<Td className="font-mono text-xs">{row.tool_name ?? "—"}</Td>
<Td className="text-right tabular-nums" title={`${row.duration_ms} ms`}>
{formatDuration(row.duration_ms)}
</Td>
<Td>
<Badge variant={row.status === "success" ? "default" : "destructive"}>
{row.status === "success" ? "成功" : "失败"}
</Badge>
</Td>
<Td>
{row.error_category ? (
<Badge variant="outline">{categoryLabel(row.error_category)}</Badge>
) : (
"—"
)}
</Td>
<Td className="max-w-[300px]">
{row.error_message ? (
<button
type="button"
onClick={() => setDetailRow(row)}
className="text-muted-foreground hover:text-foreground block max-w-full truncate text-left underline-offset-2 hover:underline"
title="点击查看完整报错"
>
{row.error_message}
</button>
) : (
<span className="text-muted-foreground">—</span>
)}
</Td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
<span className="text-muted-foreground">
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 0 || isLoading}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
上一页
</Button>
<span className="text-muted-foreground">
第 {page + 1} / {totalPages} 页
</span>
<Button
size="sm"
variant="outline"
disabled={page + 1 >= totalPages || isLoading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
{/* Full error detail popup */}
<Dialog
open={detailRow !== null}
onOpenChange={(open) => {
if (!open) setDetailRow(null);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>工具调用报错详情</DialogTitle>
</DialogHeader>
{detailRow && (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<DetailField label="时间" value={formatDate(detailRow.created_at)} />
<DetailField label="用户" value={displayUser(detailRow)} />
<DetailField label="工具" value={detailRow.tool_name ?? "—"} mono />
<DetailField label="技能" value={detailRow.skill_name ?? "—"} />
<DetailField label="Agent" value={detailRow.agent_name ?? "—"} />
<DetailField
label="状态"
value={detailRow.status === "success" ? "成功" : "失败"}
/>
<DetailField
label="错误类型"
value={categoryLabel(detailRow.error_category)}
/>
<DetailField label="耗时" value={formatDuration(detailRow.duration_ms)} />
<DetailField label="Thread" value={detailRow.thread_id ?? "—"} mono />
</div>
<div>
<span className="text-muted-foreground text-xs">完整报错信息</span>
<pre className="bg-muted mt-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md p-3 text-xs">
{detailRow.error_message ?? "(无)"}
</pre>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
function DetailField({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex flex-col">
<span className="text-muted-foreground text-xs">{label}</span>
<span className={cn("break-all", mono && "font-mono text-xs")}>{value}</span>
</div>
);
}
function Th({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<th className={`px-3 py-2 text-left text-xs font-medium ${className ?? ""}`}>
{children}
</th>
);
}
function Td({
children,
className,
title,
}: {
children: React.ReactNode;
className?: string;
title?: string;
}) {
return (
<td className={cn("px-3 py-2 align-top text-sm", className)} title={title}>
{children}
</td>
);
}
@@ -0,0 +1,79 @@
"use client";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
export function UserManagementPage() {
const { t } = useI18n();
const { data: users, isLoading, error } = useAllUsers(true);
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminUsers.title}
description={t.adminUsers.description}
>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive text-sm py-4">
{String(error)}
</div>
)}
{!isLoading && !error && users && users.length === 0 && (
<div className="text-muted-foreground text-sm py-8 text-center">
{t.adminUsers.empty}
</div>
)}
{!isLoading && !error && users && users.length > 0 && (
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.email}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.role}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.skillCount}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.agentCount}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.userId}</th>
</tr>
</thead>
<tbody>
{users.map((user, i) => (
<tr
key={user.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">{user.email}</td>
<td className="px-4 py-3">
{user.system_role === "admin" ? (
<Badge variant="default">{t.adminUsers.roleAdmin}</Badge>
) : (
<Badge variant="secondary">{t.adminUsers.roleUser}</Badge>
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">{user.skill_count}</td>
<td className="px-4 py-3 text-right tabular-nums">{user.agent_count}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[160px]">
{user.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SettingsSection>
</div>
);
}
@@ -3,6 +3,7 @@
import { BotIcon } from "lucide-react";
import { type Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
export function AgentWelcome({
@@ -14,8 +15,10 @@ export function AgentWelcome({
agent: Agent | null | undefined;
agentName: string;
}) {
const displayName = agent?.name ?? agentName;
const description = agent?.description;
const displayName = desensitize(agent?.name ?? agentName);
const description = agent?.description
? desensitize(agent.description)
: undefined;
return (
<div
@@ -2,14 +2,28 @@
import {
BotIcon,
EyeIcon,
EyeOffIcon,
CopyIcon,
GlobeIcon,
InfoIcon,
LockIcon,
MessageSquareIcon,
PinIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
StarIcon,
TagsIcon,
Trash2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -33,37 +47,193 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
useAgent,
useCreateAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useToggleAgentPin,
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
interface AgentCardProps {
agent: Agent;
/** "card" (default) renders the full gallery card; "list" renders a compact
* single-row layout for the list view. All dialogs/actions are shared. */
layout?: "card" | "list";
}
export function AgentCard({ agent }: AgentCardProps) {
interface BadgeItem {
key: string;
node: ReactNode;
}
// Minimum number of skill/tool badges to always render before collapsing the
// remainder into the "+N" chip.
const MIN_VISIBLE_BADGES = 3;
/** Render skill/tool-group badges clamped to at most two rows. Overflow is
* collapsed into a clickable "+N" chip that opens a dialog listing the full
* badge set (the visible rows stay as-is — only the chip is interactive).
*
* Badge widths are variable, so the cutoff is measured rather than assumed: an
* off-screen mirror of the full badge set is laid out at the real container
* width, and we keep only the badges that land within the first two rows
* (reserving one slot for the "+N" chip). A ResizeObserver recomputes when the
* responsive grid changes the card width.
*/
function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
const measureRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(items.length);
const [dialogOpen, setDialogOpen] = useState(false);
const sig = items.map((i) => i.key).join("|");
useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const compute = () => {
const kids = Array.from(el.children) as HTMLElement[];
if (kids.length === 0) {
setVisible(0);
return;
}
const top0 = kids[0].offsetTop;
const rowH = kids[0].offsetHeight;
// +2px fudge so the second row's badges (which may sit a hair lower due
// to line-box rounding) are not falsely treated as a third row.
const maxBottom = top0 + rowH * 2 + 2;
let count = 0;
for (const k of kids) {
if (k.offsetTop + k.offsetHeight <= maxBottom) count += 1;
else break;
}
// Reserve one slot for the "+N" chip when anything overflows so the chip
// itself never spills onto a third row.
if (count < kids.length && count > 0) count -= 1;
// Always surface a few badges before collapsing into "+N", even when the
// card is narrow enough that two rows barely fit anything (otherwise a
// skinny column could show just "+N" with no skills at all). Keeps at
// least one badge hidden so the chip still makes sense.
if (count < kids.length) {
count = Math.max(count, Math.min(MIN_VISIBLE_BADGES, kids.length - 1));
}
setVisible(count);
};
compute();
const ro = new ResizeObserver(compute);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
const hidden = items.length - visible;
const showChip = hidden > 0;
const shown = items.slice(0, visible);
return (
<div className="relative">
{/* Off-screen full-width mirror used only for measuring row breaks. */}
<div
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute inset-x-0 top-0 flex flex-wrap gap-1"
>
{items.map((i) => i.node)}
</div>
<div className="flex flex-wrap gap-1">
{shown.map((i) => i.node)}
{showChip && (
<Badge
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
title="查看全部"
onClick={(e) => {
e.stopPropagation();
setDialogOpen(true);
}}
>
+{hidden}
</Badge>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>全部技能与工具</DialogTitle>
<DialogDescription>共 {items.length} 项</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-wrap gap-1.5 overflow-y-auto py-1">
{items.map((i) => i.node)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
const isList = layout === "list";
const { t } = useI18n();
const router = useRouter();
const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent();
const createAgent = useCreateAgent();
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const currentUserId = useMemo(() => getStoredAuth()?.user_id ?? null, []);
// The /api/agents list response intentionally omits `soul` (perf optimization
// — SOUL.md is only read on demand). Fetch the detail row when the edit
// dialog opens so the SOUL textarea can be hydrated.
const { agent: agentDetail, isLoading: isAgentDetailLoading } = useAgent(
editOpen ? agent.id : null,
);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
const isBuiltin = agent.builtin;
const isOwned =
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
const isFromOthers = !isBuiltin && !isOwned;
// System admins are allowed to edit and delete any agent (built-in or owned
// by another user) so the admin console can manage the full catalog.
// Publish-toggle stays owner-only — built-ins are inherently visible to
// everyone so toggling published is moot.
const canEdit = isOwned || isAdmin;
const canDelete = isOwned || isAdmin;
// Favoriting collects a square agent into "我的"; pointless for one you
// already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
const isPinned = !!agent.is_pinned;
useEffect(() => {
if (!editOpen) {
@@ -71,20 +241,34 @@ export function AgentCard({ agent }: AgentCardProps) {
}
setEditName(agent.name);
setEditDescription(agent.description ?? "");
setEditSoul(agent.soul ?? "");
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []);
}, [
agent.description,
agent.is_private,
agent.name,
agent.published,
agent.skills,
agent.soul,
editOpen,
]);
useEffect(() => {
if (!editOpen) {
return;
}
// SOUL.md only ships on the detail endpoint; fall back to whatever the list
// happened to carry so older deployments (or cached objects that still
// include `soul`) keep working.
if (agentDetail) {
setEditSoul(agentDetail.soul ?? "");
} else {
setEditSoul(agent.soul ?? "");
}
}, [agent.soul, agentDetail, editOpen]);
function handleChat() {
router.push(`/workspace/agents/${agent.id}/chats/new`);
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
async function handleDelete() {
@@ -97,12 +281,51 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleDuplicate() {
if (createAgent.isPending) {
return;
}
try {
// SOUL.md is omitted from the list payload (perf), so fetch the detail
// row to carry the source agent's personality into the copy. Fall back
// to whatever the list happened to include if the detail fetch fails.
let soul = agent.soul ?? "";
try {
const detail = await getAgent(agent.id);
soul = detail.soul ?? soul;
} catch {
// keep the list fallback
}
const created = await createAgent.mutateAsync({
name: `${agent.name} 副本`,
description: agent.description ?? "",
soul,
skills: agent.skills ?? [],
model: agent.model ?? null,
tool_groups: agent.tool_groups ?? null,
// A copy starts as a private, unpublished draft the owner can refine
// before sharing — never inherit the source's published/private state.
published: false,
is_private: false,
});
toast.success(
`已复制为「${desensitize(created.name)}」,可在「我的」中编辑`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleSaveEdit() {
const trimmedName = editName.trim();
if (!trimmedName) {
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
return;
}
if (editIsPrivate && editPublished) {
toast.warning("私密广场与公开发布互斥,请只勾选其一");
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
@@ -110,7 +333,10 @@ export function AgentCard({ agent }: AgentCardProps) {
name: trimmedName,
description: editDescription,
soul: editSoul,
published: editPublished,
// Private agents cannot also be published — force the value the
// backend will see, regardless of any stale state in editPublished.
published: editIsPrivate ? false : editPublished,
is_private: editIsPrivate,
skills: selectedSkills,
},
});
@@ -122,6 +348,13 @@ export function AgentCard({ agent }: AgentCardProps) {
}
async function handleTogglePublished() {
// Private and public squares are mutually exclusive — a private agent
// can't simultaneously be published to everyone. Block the toggle
// instead of silently moving the agent between squares.
if (!agent.published && agent.is_private) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
@@ -133,6 +366,67 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleTogglePrivate() {
const turningOn = !agent.is_private;
try {
if (isOwned) {
// Owners can re-categorize their own agents through the normal update
// endpoint — keeps publish/private edits in one transaction.
await updateAgent.mutateAsync({
id: agent.id,
request: { is_private: turningOn },
});
} else {
// Admins can move any agent (built-in or other users') via the
// dedicated admin endpoint.
await setPrivate.mutateAsync({ id: agent.id, isPrivate: turningOn });
}
toast.success(turningOn ? "已加入私密广场" : "已移出私密广场");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFavorite() {
const next = !isFavorite;
try {
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
toast.success(next ? "已收藏到「我的」" : "已取消收藏");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePin() {
const next = !isPinned;
try {
await togglePin.mutateAsync({ id: agent.id, pinned: next });
toast.success(next ? "已置顶" : "已取消置顶");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
// dialog. Featured agents get appended to the end on add (Date.now() is a
// safe seed; the manage dialog can rewrite to 0..N-1 later).
const turningOn = agent.featured_order == null;
if (turningOn && !agent.published) {
toast.error("只有已发布的智能体可以加入首页选择器");
return;
}
try {
await setFeatured.mutateAsync({
id: agent.id,
order: turningOn ? Date.now() : null,
});
toast.success(turningOn ? "已加入首页选择器" : "已从首页选择器移除");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
function toggleSkill(skillName: string) {
setSelectedSkills((prev) => {
if (prev.includes(skillName)) {
@@ -144,140 +438,331 @@ export function AgentCard({ agent }: AgentCardProps) {
const enabledSkills = skills.filter((skill) => skill.enabled);
return (
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row.
const statusBadges = (
<>
<Card className="group flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">
{agent.name}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{agent.model}
</Badge>
)}
</div>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
我的
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
>
来自他人
</Badge>
)}
{isOwned && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title="该智能体已对所有用户可见"
>
<GlobeIcon className="size-3" />
已发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title="该智能体位于私密广场,需要密码才能查看"
>
<ShieldIcon className="size-3" />
私密
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
title="已加入首页选择器"
>
<SparklesIcon className="size-3" />
首页推荐
</Badge>
)}
</>
);
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
node: (
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
{desensitize(group)}
</Badge>
),
})),
...(agent.skills ?? []).map((skill) => ({
key: `sk:${skill}`,
node: (
<Badge
key={`sk:${skill}`}
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
setDetailSkillName(skill);
}}
>
{desensitize(skill)}
</Badge>
),
})),
];
const hasSkillsOrTools =
(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0;
const showActionRow =
canEdit || canDelete || isOwned || isAdmin || canFavorite;
// The icon action buttons, shared by both layouts.
const actionButtons = (
<>
{canFavorite && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
!isList && "mr-auto",
isFavorite && "text-amber-500 hover:text-amber-600",
)}
onClick={() => void handleToggleFavorite()}
disabled={toggleFavorite.isPending}
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
>
<StarIcon
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
/>
</Button>
)}
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
isPinned && "text-emerald-600 hover:text-emerald-700",
!isList && !canFavorite && "mr-auto",
)}
onClick={() => void handleTogglePin()}
disabled={togglePin.isPending}
title={isPinned ? "取消置顶" : "置顶"}
>
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
</Button>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.featured_order != null &&
"text-amber-600 hover:text-amber-700",
)}
onClick={() => void handleToggleFeatured()}
disabled={setFeatured.isPending}
title={
agent.featured_order != null
? "从首页选择器移除"
: "加入首页选择器"
}
>
<SparklesIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
{isOwned && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
title={agent.published ? "取消发布" : "发布"}
>
{agent.published ? (
<LockIcon className="h-3.5 w-3.5" />
) : (
<GlobeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
{(isOwned || isAdmin) && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.is_private && "text-purple-600 hover:text-purple-700",
)}
onClick={() => void handleTogglePrivate()}
disabled={updateAgent.isPending || setPrivate.isPending}
title={agent.is_private ? "移出私密广场" : "放入私密广场"}
>
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleDuplicate()}
disabled={createAgent.isPending}
title="复制为新智能体"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canEdit && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑智能体"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
)}
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
</>
);
const chatButton = (
<Button
size="sm"
className={cn(isList ? "shrink-0" : "w-full")}
onClick={handleChat}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
);
const cardLayout = (
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
<CardHeader className="min-w-0 pb-2">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="flex shrink-0 items-center gap-1">
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
我的
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
>
来自他人
</Badge>
)}
{isOwned && agent.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title="该智能体已对所有用户可见"
>
已发布
<div className="min-w-0 flex-1 overflow-hidden">
<CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{desensitize(agent.model)}
</Badge>
)}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{agent.description}
</CardDescription>
)}
</CardHeader>
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1">
{agent.tool_groups?.map((group) => (
<Badge
key={`tg:${group}`}
variant="outline"
className="text-xs"
>
{group}
</Badge>
))}
{agent.skills?.map((skill) => (
<Badge
key={`sk:${skill}`}
variant="secondary"
className="text-xs"
>
{skill}
</Badge>
))}
</div>
</CardContent>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{statusBadges}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
<Button size="sm" className="flex-1" onClick={handleChat}>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{isOwned && (
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
title={agent.published ? "取消发布" : "发布"}
>
{agent.published ? (
<EyeOffIcon className="h-3.5 w-3.5" />
) : (
<EyeIcon className="h-3.5 w-3.5" />
)}
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑智能体"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
)}
</CardFooter>
</Card>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{hasSkillsOrTools && (
<CardContent className="pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} />
</CardContent>
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
{showActionRow && (
<div className="flex flex-wrap items-center justify-end gap-1">
{actionButtons}
</div>
)}
{chatButton}
</CardFooter>
</Card>
);
const listLayout = (
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2">
<span
className="min-w-0 truncate text-sm font-medium"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</span>
<div className="flex shrink-0 items-center gap-1">{statusBadges}</div>
</div>
{agent.description && (
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{desensitize(agent.description)}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons}
{chatButton}
</div>
</div>
);
return (
<>
{isList ? listLayout : cardLayout}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
<DialogDescription>
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
</DialogDescription>
@@ -307,7 +792,7 @@ export function AgentCard({ agent }: AgentCardProps) {
已选 {selectedSkills.length} 个
</span>
</div>
<div className="rounded-md border p-2">
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
@@ -315,21 +800,35 @@ export function AgentCard({ agent }: AgentCardProps) {
当前没有可用技能
</p>
) : (
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<Button
<div
key={skill.name}
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={skill.description}
className="h-8"
className="flex w-full min-w-0 items-center"
>
{skill.name}
</Button>
<Button
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={desensitize(skill.description ?? "")}
className="h-8 min-w-0 flex-1 rounded-r-none"
>
<span className="truncate">{desensitize(skill.name)}</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
);
})}
</div>
@@ -344,7 +843,11 @@ export function AgentCard({ agent }: AgentCardProps) {
<Textarea
value={editSoul ?? ""}
onChange={(e) => setEditSoul(e.target.value)}
placeholder="输入 SOUL.md 内容"
placeholder={
isAgentDetailLoading && !agentDetail
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="min-h-40 font-mono text-xs"
/>
</div>
@@ -352,12 +855,41 @@ export function AgentCard({ agent }: AgentCardProps) {
<div>
<p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs">
打开后,该智能体对所有用户可见。
{editIsPrivate
? "已加入私密广场,不可发布;请先关闭私密广场。"
: "打开后,该智能体对所有用户可见。"}
</p>
</div>
<Switch
checked={editPublished}
onCheckedChange={setEditPublished}
checked={editPublished && !editIsPrivate}
disabled={editIsPrivate}
onCheckedChange={(v) => {
if (editIsPrivate) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
setEditPublished(v);
}}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">加入私密广场</p>
<p className="text-muted-foreground text-xs">
打开后,该智能体仅在私密广场显示,需输入密码才能查看;与「公开发布」互斥。
</p>
</div>
<Switch
checked={editIsPrivate}
onCheckedChange={(v) => {
setEditIsPrivate(v);
if (v) {
// Mutual-exclusion: switching the agent into the private
// square automatically clears the public-publish flag so
// the user can't end up with a half-and-half state.
setEditPublished(false);
}
}}
/>
</div>
</div>
@@ -401,6 +933,23 @@ export function AgentCard({ agent }: AgentCardProps) {
</DialogFooter>
</DialogContent>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
</>
);
}
@@ -1,69 +1,393 @@
"use client";
import { BotIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useAgents } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const handleNewAgent = () => {
router.push("/workspace/agents/new");
};
return (
<div className="flex size-full flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : agents.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-muted-foreground h-7 w-7" />
</div>
<div>
<p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">
{t.agents.emptyDescription}
</p>
</div>
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
)}
</div>
</div>
);
}
"use client";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
import { AgentCard } from "./agent-card";
const PAGE_SIZE = 12;
// Within a single tab the verified password is kept in sessionStorage so a
// refresh / route navigation does not re-prompt. Cleared on tab close, so the
// password never persists beyond the browsing session.
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
"mine",
);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc");
// Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 },
);
// Search + tag filtering are both server-side now. The backend mirrors the
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
// filters change, every tab returns to its first page.
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey, sort]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
const privateFeatureUsable =
privateFeatureAvailable && !!privateStatus?.password_set;
const [privatePassword, setPrivatePassword] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
return window.sessionStorage.getItem(PRIVATE_SQUARE_PASSWORD_KEY);
});
const [passwordInput, setPasswordInput] = useState("");
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
const mainScope = activeTab === "mine" ? "mine" : "square";
const mainPage = activeTab === "mine" ? pages.mine : pages.all;
const {
agents: mainAgents,
total: mainTotal,
isLoading: mainLoading,
} = useAgentsPaginated({
scope: mainScope,
search,
tagIds,
sort,
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
});
const {
agents: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
error: privateError,
} = usePrivateAgentsPaginated(privatePassword, {
search,
tagIds,
sort,
page: pages.private,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
});
// If the server reports the stored password no longer works (likely the
// admin rotated it), drop it from session storage and re-prompt.
useEffect(() => {
if (privatePassword && privateError) {
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
setPrivatePassword(null);
toast.error("私密广场密码已失效,请重新输入");
}
}, [privateError, privatePassword]);
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
const handleNewAgent = () => {
router.push("/page/workspace/agents/new");
};
const handleSubmitPassword = () => {
const trimmed = passwordInput.trim();
if (!trimmed) {
toast.error("请输入密码");
return;
}
window.sessionStorage.setItem(PRIVATE_SQUARE_PASSWORD_KEY, trimmed);
setPrivatePassword(trimmed);
setPasswordInput("");
};
const handleLockPrivate = () => {
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
setPrivatePassword(null);
};
const renderGrid = (list: Agent[]) =>
viewMode === "list" ? (
<div className="flex flex-col gap-2">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} layout="list" />
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
);
/** Loading / empty / (grid + pagination) for one paginated tab. */
const renderListBody = (opts: {
list: Agent[];
total: number;
isLoading: boolean;
tab: "all" | "mine" | "private";
emptyDescription: string;
showNewButton?: boolean;
}) => {
const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts;
// keepPreviousData keeps the old page visible while a new page loads, so
// only show the spinner on the very first load (no rows yet).
if (isLoading && list.length === 0) {
return (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
);
}
if (list.length === 0) {
return renderEmpty(emptyDescription, showNewButton);
}
return (
<div className="flex flex-col gap-4">
{renderGrid(list)}
<Pagination
page={pages[tab]}
pageSize={PAGE_SIZE}
total={total}
onPageChange={(p) => setTabPage(tab, p)}
className="pt-2"
/>
</div>
);
};
const renderEmpty = (description: string, showNewButton = false) => (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
<BotIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
</div>
<div>
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
{description}
</p>
</div>
{showNewButton && (
<Button
variant="outline"
className="mt-2 dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
onClick={handleNewAgent}
>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
)}
</div>
);
const renderPrivateTab = () => {
if (!privateFeatureAvailable) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<ShieldIcon className="text-muted-foreground h-7 w-7" />
</div>
<p className="font-medium">私密广场尚未开启</p>
<p className="text-muted-foreground max-w-sm text-sm">
请管理员前往「综合管理 → 私密广场配置」开启该功能并设置密码。
</p>
</div>
);
}
if (!privateFeatureUsable) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<ShieldIcon className="text-muted-foreground h-7 w-7" />
</div>
<p className="font-medium">私密广场未配置密码</p>
<p className="text-muted-foreground max-w-sm text-sm">
请管理员在私密广场配置页设置一个访问密码。
</p>
</div>
);
}
if (!privatePassword) {
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-4 pt-12 text-center">
<div className="bg-muted mx-auto flex h-14 w-14 items-center justify-center rounded-full">
<LockIcon className="text-muted-foreground h-7 w-7" />
</div>
<div>
<p className="font-medium">私密广场</p>
<p className="text-muted-foreground mt-1 text-sm">
请输入访问密码,验证通过后才能查看其中的智能体。
</p>
</div>
<div className="flex flex-col gap-2">
<Input
type="password"
placeholder="访问密码"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitPassword();
}}
// This is a one-off access code, not a login credential. Tell
// browsers / password managers not to save or autofill it (which
// would also stop them from spilling a saved account into the
// name search box on this page).
name="private-square-access-code"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
</div>
</div>
);
}
return renderListBody({
list: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
tab: "private",
emptyDescription: "私密广场暂时还没有智能体。",
});
};
return (
<div className="flex size-full flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={(v) =>
setActiveTab(v as "all" | "mine" | "private")
}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="mx-6 mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<TabsList className="w-auto self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
{privateFeatureAvailable && (
<TabsTrigger value="private">
<LockIcon className="mr-1 h-3.5 w-3.5" />
私密广场
</TabsTrigger>
)}
</TabsList>
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm whitespace-nowrap">
共 {activeTotal} 个
</span>
<ViewModeToggle value={viewMode} onChange={setViewMode} />
{activeTab === "private" && privatePassword && (
<Button
variant="outline"
size="sm"
onClick={handleLockPrivate}
title="锁定私密广场,下次需要重新输入密码"
>
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
锁定
</Button>
)}
</div>
</div>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
tab: "all",
emptyDescription: t.agents.emptyDescription,
})}
</TabsContent>
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
tab: "mine",
emptyDescription: t.agents.emptyDescription,
showNewButton: true,
})}
</TabsContent>
{privateFeatureAvailable && (
<TabsContent
value="private"
className="flex-1 overflow-y-auto p-6 mt-0"
>
{renderPrivateTab()}
</TabsContent>
)}
</Tabs>
</div>
);
}
@@ -0,0 +1,246 @@
"use client";
import {
ArrowDownIcon,
ArrowUpIcon,
BotIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import {
useAgentSelector,
useAgents,
useReorderFeaturedAgents,
useSetAgentFeatured,
} from "@/core/agents";
import type { Agent } from "@/core/agents";
import { cn } from "@/lib/utils";
interface AgentManageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Admin-only homepage agent picker management.
*
* Single list (no tabs, no per-user buckets): admin curates exactly what every
* user sees in the new-chat selector. Up/down to reorder, ✕ to remove, + to
* add a published agent. All actions hit the server immediately and react to
* TanStack Query invalidation, so the selector dropdown stays in lockstep.
*/
export function AgentManageDialog({
open,
onOpenChange,
}: AgentManageDialogProps) {
const { agents, isLoading } = useAgentSelector();
const { agents: allAgents } = useAgents();
const setFeaturedMutation = useSetAgentFeatured();
const reorderMutation = useReorderFeaturedAgents();
const [pickerOpen, setPickerOpen] = useState(false);
const candidates = useMemo(() => {
const existing = new Set(agents.map((a) => a.id));
// Private-square agents are hidden from the homepage selector even if
// they're published — the backend ``list_featured`` filter would drop
// them again on the way out, so offering them here is just confusing.
return allAgents.filter(
(a) => a.published && !a.is_private && !existing.has(a.id),
);
}, [agents, allAgents]);
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
const move = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= agents.length) return;
const next = agents.map((a) => a.id);
[next[index], next[target]] = [next[target]!, next[index]!];
try {
await reorderMutation.mutateAsync(next);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const remove = async (agentId: string) => {
try {
await setFeaturedMutation.mutateAsync({ id: agentId, order: null });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const add = async (agent: Agent) => {
if (!agent.published) {
toast.error("只有已发布的智能体可以加入选择器");
return;
}
const maxOrder = agents.reduce(
(acc, a) => Math.max(acc, a.featured_order ?? 0),
-1,
);
try {
await setFeaturedMutation.mutateAsync({
id: agent.id,
order: maxOrder + 1,
});
setPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>管理 Agent 列表</DialogTitle>
<DialogDescription>
配置首页对话框里展示的 agent。所有人会看到同一份列表,按下方顺序展示。
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{isLoading ? (
<div className="rounded-md border p-6 text-center text-sm text-muted-foreground">
加载中…
</div>
) : agents.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
还没有配置任何 agent。点击下方“添加 Agent”开始。
</div>
) : (
<div className="max-h-[50vh] divide-y overflow-y-auto rounded-md border">
{agents.map((agent, index) => (
<div
key={agent.id}
className="flex min-w-0 items-start gap-2 px-3 py-2 hover:bg-accent/30"
>
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs tabular-nums">
{index + 1}
</span>
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="line-clamp-2 whitespace-normal break-words text-[11px] leading-snug text-muted-foreground">
{agent.description}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip content="上移">
<Button
size="icon"
variant="ghost"
disabled={busy || index === 0}
onClick={() => void move(index, -1)}
className="size-7"
>
<ArrowUpIcon className="size-3.5" />
</Button>
</Tooltip>
<Tooltip content="下移">
<Button
size="icon"
variant="ghost"
disabled={busy || index === agents.length - 1}
onClick={() => void move(index, 1)}
className="size-7"
>
<ArrowDownIcon className="size-3.5" />
</Button>
</Tooltip>
<Tooltip content="从选择器移除">
<Button
size="icon"
variant="ghost"
disabled={busy}
onClick={() => void remove(agent.id)}
className="text-destructive hover:text-destructive size-7"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
</div>
</div>
))}
</div>
)}
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setPickerOpen(true)}
className="w-full"
>
<PlusIcon className="mr-1 size-3.5" />
添加 Agent
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
<DialogTitle className="sr-only">添加 Agent</DialogTitle>
<Command className={cn("**:data-[slot=command-input-wrapper]:h-auto")}>
<CommandInput placeholder="搜索可添加的 agent…" className="h-auto py-3.5" />
<CommandList>
<CommandEmpty>
{candidates.length === 0
? "没有更多已发布的 agent 可添加。"
: "没有找到匹配的 agent。"}
</CommandEmpty>
{candidates.length > 0 && (
<CommandGroup>
{candidates.map((agent) => (
<CommandItem
key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => void add(agent)}
className="flex w-full min-w-0 items-start overflow-hidden"
>
<BotIcon className="text-muted-foreground mr-2 mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
{agent.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,173 @@
"use client";
import { BotIcon, SettingsIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
import { useAgentSelector } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { cn } from "@/lib/utils";
interface AgentSelectorProps {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
disabled?: boolean;
}
/**
* Homepage agent picker — shows the admin-curated selector list.
*
* - Non-admins see a read-only dropdown.
* - Admins also see a "管理 Agent 列表" entry that opens the management dialog.
*
* Selecting an agent flips this chip to a coloured pill with a ✕ to clear.
* The actual handoff to ``AgentChatPage`` happens in ``ChatPage`` on submit.
*/
export function AgentSelector({
selectedAgentId,
onAgentChange,
disabled,
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const { agents, isLoading } = useAgentSelector();
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null;
return agents.find((a) => a.id === selectedAgentId) ?? null;
}, [agents, selectedAgentId]);
const handleSelect = (agent: Agent) => {
onAgentChange(agent.id);
setOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onAgentChange(null);
};
const triggerLabel = selectedAgent?.name ?? "选择 Agent";
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<BotIcon className="size-3" />
<span className="max-w-[140px] truncate text-xs font-normal">
{triggerLabel}
</span>
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-3" />
</span>
)}
</PromptInputButton>
</DialogTrigger>
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
<CommandInput placeholder="搜索 agent…" className="h-auto py-3.5" />
<CommandList>
<CommandEmpty>
{isLoading ? "加载中…" : "没有找到匹配的 agent"}
</CommandEmpty>
{agents.length > 0 && (
<CommandGroup>
{agents.map((agent) => (
<CommandItem
key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => handleSelect(agent)}
className={cn(
"flex w-full min-w-0 items-start gap-2 overflow-hidden",
agent.id === selectedAgentId && "bg-accent",
)}
>
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
{agent.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{!isLoading && agents.length === 0 && (
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
{isAdmin
? "还没有配置任何 agent。点击下方 “管理” 添加几个。"
: "管理员还没有配置可选的 agent。"}
</div>
)}
</CommandList>
{isAdmin && (
<div className="border-t px-2 py-1.5">
<Button
type="button"
size="sm"
variant="ghost"
className="w-full justify-start text-xs"
onClick={() => {
setOpen(false);
setManageOpen(true);
}}
>
<SettingsIcon className="mr-2 size-3.5" />
管理 Agent 列表 …
</Button>
</div>
)}
</Command>
</DialogContent>
</Dialog>
{isAdmin && (
<AgentManageDialog open={manageOpen} onOpenChange={setManageOpen} />
)}
</>
);
}
@@ -2,17 +2,19 @@
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -34,20 +36,39 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import {
checkCodeFile,
getFileName,
isImageFile,
} from "@/core/utils/files";
import {
downloadMermaidSvgFile,
isMermaidFile,
openMermaidSvgInNewTab,
renderMermaidToSvg,
} from "@/core/utils/mermaid";
import {
createMarkdownAnchorResolver,
injectInPageAnchorHandler,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { getBackendBaseURL } from "@/core/config";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { ArtifactLink } from "../citations/artifact-link";
@@ -58,6 +79,12 @@ import { useArtifacts } from "./context";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) {
return null;
@@ -82,29 +109,36 @@ export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
threadId,
onClose,
}: {
className?: string;
filepath: string;
threadId: string;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread } = useThread();
const isWriteFile = useMemo(() => {
const { isMock, thread, publicShareCode } = useThread();
const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
const filepath = useMemo(() => {
if (isWriteFile) {
if (isWriteFileVirtual) {
const url = new URL(filepathFromProps);
return decodeURIComponent(url.pathname);
}
return filepathFromProps;
}, [filepathFromProps, isWriteFile]);
}, [filepathFromProps, isWriteFileVirtual]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath);
}, [filepath, isWriteFileVirtual]);
const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill");
}, [filepath]);
const { isCodeFile, language } = useMemo(() => {
if (isWriteFile) {
if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language;
nextLanguage ??= "text";
return { isCodeFile: true, language: nextLanguage };
@@ -113,31 +147,40 @@ export function ArtifactFileDetail({
return { isCodeFile: true, language: "markdown" };
}
return checkCodeFile(filepath);
}, [filepath, isWriteFile, isSkillFile]);
}, [filepath, useWriteFileStreaming, isSkillFile]);
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
return (
language === "html" ||
language === "markdown" ||
language === "mermaid"
);
}, [language]);
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const canEditArtifact =
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !useWriteFileStreaming,
});
const canEditArtifact =
language === "markdown" &&
!useWriteFileStreaming &&
!isSkillFile &&
!isMock;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
if (language === "html") return "HTML";
if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL";
if (language) return language.toUpperCase();
return "FILE";
}, [isSkillFile, language]);
}, [filepath, isSkillFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
@@ -148,20 +191,20 @@ export function ArtifactFileDetail({
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setDraftContent(content ?? "");
setHasLocalEdits(false);
}, [filepathFromProps]);
useEffect(() => {
if (!canEditArtifact || hasLocalEdits) {
return;
}
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setDraftContent(content ?? "");
setHasLocalEdits(false);
}, [filepathFromProps]);
useEffect(() => {
if (!canEditArtifact || hasLocalEdits) {
return;
}
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
useEffect(() => {
setAllowAutoViewSwitch(true);
@@ -279,7 +322,7 @@ export function ArtifactFileDetail({
(async () => {
try {
const res = await apiFetch(
urlOfArtifact({ filepath, threadId, isMock }),
urlOfArtifact({ filepath, threadId, isMock, publicShareCode }),
);
if (cancelled || !res.ok) return;
const blob = await res.blob();
@@ -304,7 +347,7 @@ export function ArtifactFileDetail({
return null;
});
};
}, [filepath, threadId, isMock, isCodeFile]);
}, [filepath, threadId, isMock, isCodeFile, publicShareCode]);
const handlePauseAutoScroll = useCallback(() => {
if (!thread.isLoading || !autoScrollEnabled) {
@@ -329,7 +372,7 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleInstallSkill = useCallback(async () => {
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
setIsInstalling(true);
@@ -349,33 +392,108 @@ export function ArtifactFileDetail({
} finally {
setIsInstalling(false);
}
}, [threadId, filepath, isInstalling]);
const handleSaveArtifact = useCallback(async () => {
if (!canEditArtifact || updateArtifact.isPending) {
return;
}
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
toast.success("Markdown 已保存");
} catch (error) {
console.error("Failed to save artifact:", error);
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
}
}, [
canEditArtifact,
draftContent,
filepath,
isMock,
threadId,
updateArtifact,
]);
}, [threadId, filepath, isInstalling]);
const handleSaveArtifact = useCallback(async () => {
if (!canEditArtifact || updateArtifact.isPending) {
return;
}
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
toast.success("Markdown 已保存");
} catch (error) {
console.error("Failed to save artifact:", error);
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
}
}, [
canEditArtifact,
draftContent,
filepath,
isMock,
threadId,
updateArtifact,
]);
const [isExportingWord, setIsExportingWord] = useState(false);
const handleDownloadAsWord = useCallback(async () => {
const source = (displayContent ?? "").trim();
if (!source) {
toast.error("Markdown 内容为空,无法另存为 Word");
return;
}
setIsExportingWord(true);
try {
const baseName = getFileName(filepath).replace(/\.(md|markdown)$/i, "");
await downloadMarkdownAsDocx(source, baseName || "document", baseName);
toast.success("已另存为 Word");
} catch (error) {
console.error("Failed to export markdown as docx:", error);
toast.error(error instanceof Error ? error.message : "另存为 Word 失败");
} finally {
setIsExportingWord(false);
}
}, [displayContent, filepath]);
const handleOpenWriteFileInNewTab = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法打开");
return;
}
if (language === "mermaid") {
void openMermaidSvgInNewTab(source).catch((error) => {
console.error("Failed to open mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Failed to open artifact");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [displayContent, language]);
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法下载");
return;
}
if (language === "mermaid") {
void downloadMermaidSvgFile(source, filepath).catch((error) => {
console.error("Failed to download mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Download failed");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = getFileName(filepath);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [displayContent, filepath, language]);
return (
<Artifact className={cn(className)}>
@@ -386,7 +504,7 @@ export function ArtifactFileDetail({
{fileTypeLabel}
</span>
<ArtifactTitle className="min-w-0">
{isWriteFile ? (
{useWriteFileStreaming ? (
<div className="truncate px-3">{getFileName(filepath)}</div>
) : hasMultipleArtifacts ? (
<Select value={filepath} onValueChange={select}>
@@ -452,19 +570,19 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
label="保存 Markdown"
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
disabled={!hasLocalEdits || updateArtifact.isPending}
onClick={handleSaveArtifact}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
label="保存 Markdown"
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
disabled={!hasLocalEdits || updateArtifact.isPending}
onClick={handleSaveArtifact}
/>
)}
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
@@ -477,18 +595,18 @@ export function ArtifactFileDetail({
/>
</Tooltip>
)}
{!isWriteFile && (
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab();
return;
}
if (language === "mermaid") {
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
});
await openMermaidSvgInNewTab(displayContent ?? "");
} catch (error) {
console.error(error);
toast.error(
@@ -497,9 +615,25 @@ export function ArtifactFileDetail({
: "Failed to open artifact",
);
}
}}
/>
)}
return;
}
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
}}
/>
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
@@ -517,18 +651,27 @@ export function ArtifactFileDetail({
tooltip={t.clipboard.copyToClipboard}
/>
)}
{!isWriteFile && (
{!useWriteFileStreaming && language === "markdown" && (
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
onClick={async () => {
icon={isExportingWord ? LoaderIcon : FileType2Icon}
label="另存为 Word"
tooltip="另存为 Word (.docx)"
disabled={isExportingWord || !displayContent}
onClick={handleDownloadAsWord}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
onClick={async () => {
if (useWriteFileStreaming) {
handleDownloadWriteFile();
return;
}
if (language === "mermaid") {
try {
await downloadArtifactFile({
filepath,
threadId,
isMock,
});
await downloadMermaidSvgFile(displayContent ?? "", filepath);
} catch (error) {
console.error(error);
toast.error(
@@ -537,32 +680,48 @@ export function ArtifactFileDetail({
: "Download failed",
);
}
}}
/>
)}
return;
}
try {
await downloadArtifactFile({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Download failed",
);
}
}}
/>
<ArtifactAction
className="hover:bg-background"
icon={XIcon}
label={t.common.close}
onClick={() => setOpen(false)}
onClick={() => (onClose ? onClose() : setOpen(false))}
tooltip={t.common.close}
/>
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{isApplyingEdit && (
<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">
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
正在改写 Markdown
</div>
)}
{showResumeAutoScroll && (
<button
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{isApplyingEdit && (
<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">
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
正在改写 Markdown
</div>
)}
{showResumeAutoScroll && (
<button
type="button"
onClick={handleResumeAutoScroll}
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
@@ -573,32 +732,40 @@ export function ArtifactFileDetail({
)}
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
(language === "markdown" ||
language === "html" ||
language === "mermaid") && (
<ArtifactFilePreview
content={desensitize(displayContent ?? "")}
language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
{isCodeFile && viewMode === "code" && (
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly={!canEditArtifact}
onChange={
canEditArtifact
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
)}
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={
canEditArtifact
? (displayContent ?? "")
: desensitize(displayContent ?? "")
}
readonly={!canEditArtifact}
onChange={
canEditArtifact
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
)}
{!isCodeFile &&
(binaryPreviewUrl ? (
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
@@ -613,18 +780,121 @@ export function ArtifactFileDetail({
);
}
export function ArtifactFilePreview({
content,
language,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
function SandboxPreviewImage({
src,
alt,
threadId,
publicShareCode,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
publicShareCode?: string;
}) {
if (!src || typeof src !== "string") {
return null;
}
let url = src;
if (src.startsWith("/mnt/")) {
if (publicShareCode) {
const normalized = src.startsWith("/") ? src : `/${src}`;
url = `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
} else {
url = resolveArtifactURL(src, threadId);
}
}
return (
<img
{...props}
className={cn(
"my-3 max-w-full overflow-hidden rounded-lg",
props.className,
)}
src={url}
alt={alt}
/>
);
}
export function ArtifactFilePreview({
content,
language,
threadId,
publicShareCode,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
threadId: string;
publicShareCode?: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(null);
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children}
</Tag>
);
};
return {
h1: heading(1),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
return <ArtifactLink {...props} />;
}
const { className, ...rest } = props;
const resolvedHref = anchorResolver.resolveHref(
props.href,
extractTextFromChildren(props.children),
);
if (resolvedHref === null) {
return (
<span
className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className,
)}
title="未在当前 Markdown 中找到对应章节"
>
{props.children}
</span>
);
}
const finalHref = resolvedHref ?? props.href;
return (
<ArtifactLink
{...rest}
className={className}
href={finalHref}
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
};
}, [content]);
useEffect(() => {
if (language !== "html") {
@@ -632,7 +902,14 @@ export function ArtifactFilePreview({
return;
}
const blob = new Blob([content ?? ""], { type: "text/html" });
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
// <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([injectInPageAnchorHandler(content ?? "")], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
setHtmlPreviewUrl(url);
@@ -641,10 +918,42 @@ export function ArtifactFilePreview({
};
}, [content, language]);
useEffect(() => {
if (language !== "mermaid") {
setMermaidSvg(null);
setMermaidError(null);
return;
}
let cancelled = false;
setMermaidSvg(null);
setMermaidError(null);
void renderMermaidToSvg(content ?? "")
.then((svg) => {
if (!cancelled) {
setMermaidSvg(svg);
}
})
.catch((error) => {
console.error("Failed to render mermaid preview:", error);
if (!cancelled) {
setMermaidError(
error instanceof Error ? error.message : "Mermaid 渲染失败",
);
}
});
return () => {
cancelled = true;
};
}, [content, language]);
if (language === "markdown") {
return (
<div
data-artifact-preview-scroll
data-markdown-anchor-root
className={cn(
"size-full overflow-auto px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
@@ -653,16 +962,25 @@ export function ArtifactFilePreview({
<Streamdown
className="size-full"
{...streamdownPlugins}
components={{ a: ArtifactLink }}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
</div>
);
}
components={{
...markdownComponents,
img: (props) => (
<SandboxPreviewImage
{...props}
threadId={threadId}
publicShareCode={publicShareCode}
/>
),
}}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
</div>
);
}
if (language === "html") {
return (
@@ -678,5 +996,38 @@ export function ArtifactFilePreview({
);
}
if (language === "mermaid") {
return (
<div
data-artifact-preview-scroll
className={cn(
"size-full overflow-auto bg-[#fcfdfc] px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
{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 }}
/>
) : 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">
{mermaidError}
</div>
<pre className="whitespace-pre-wrap break-words rounded-xl bg-slate-100 p-4 text-xs text-slate-700">
{content ?? ""}
</pre>
</div>
) : (
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
<LoaderIcon className="size-5 animate-spin" />
正在生成图表预览
</div>
)}
</div>
);
}
return null;
}
@@ -33,7 +33,7 @@ export function ArtifactFileList({
threadId: string;
}) {
const { t } = useI18n();
const { isMock } = useThread();
const { isMock, publicShareCode } = useThread();
const { select: selectArtifact, setOpen } = useArtifacts();
const [installingFile, setInstallingFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
@@ -81,7 +81,7 @@ export function ArtifactFileList({
if (downloadingFile) return;
setDownloadingFile(filepath);
try {
await downloadArtifactFile({ filepath, threadId, isMock });
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
} catch (error) {
console.error(error);
toast.error(
@@ -91,7 +91,7 @@ export function ArtifactFileList({
setDownloadingFile(null);
}
},
[threadId, isMock, downloadingFile],
[threadId, isMock, publicShareCode, downloadingFile],
);
return (
Loaded 100 of 1513 files, more files were not shown because too many files have changed in this diff. Show more