Compare commits

..
109 Commits
Author SHA1 Message Date
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
吉鑫升 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
吉鑫升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 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
董宏凯 050b1ad78e 代码提交 2026-05-15 14:08:01 +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
630 changed files with 62530 additions and 91557 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
remote connectivity test - 2026-05-13
+27 -6
View File
@@ -1,13 +1,17 @@
# 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/
# Frontend local/runtime artifacts
frontend-web/.env
frontend-web/node_modules/
frontend-web/.pnpm-store/
frontend-web/tsconfig.tsbuildinfo
frontend-web/vite-dev.log
frontend-web/vite-dev.err.log
@@ -54,6 +58,23 @@ 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/*
+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
+6
View File
@@ -0,0 +1,6 @@
# 复制为 .env 后按需修改(Vite 仅暴露以 VITE_ 开头的变量)
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
@@ -0,0 +1,726 @@
# AI 写作助手后端接口与 Prompt 协议文档
## 1. 文档目标
本文档用于指导 AI 写作助手第二阶段升级中的后端开发、接口设计、提示词设计和联调约束。
本轮后端目标不是重做 DeerFlow 的整体架构,而是在保留现有 Python + LangGraph + 线程体系的基础上,新增一套稳定的“选区改写型能力”,服务前端的 Tiptap 写作编辑器。
---
## 2. 本轮后端职责
后端负责:
- 接收当前线程信息
- 接收用户选区文本
- 接收改写动作
- 选取必要上下文
- 调用模型生成候选改写结果
- 返回纯净候选文本
后端本轮不负责:
- 直接编辑 Tiptap JSON
- 直接操作前端编辑器选区
- 直接替换前端文档内容
- 做复杂富文本 patch
- 做 tracked changes
---
## 3. 设计原则
### 3.1 纯文本协议优先
首版只让后端处理:
- 选区文本
- 前后文文本
- 动作类型
- 风格参数
不让后端直接理解完整富文本 JSON。
### 3.2 前端负责应用,后端负责生成
- 前端负责选区获取
- 前端负责候选结果展示
- 前端负责替换 / 插入
- 后端只负责“给出候选文本”
### 3.3 结果必须可直接落文档
后端输出必须尽量是可直接放回编辑器的正文,不应带解释性前缀,例如:
- “当然可以,以下是润色版本:”
- “我帮你改写如下:”
- “下面是优化后的内容:”
这类内容必须禁止。
---
## 4. 推荐接口设计
## 4.1 主接口
推荐新增统一接口:
```text
POST /api/writing/rewrite
```
由 `action` 决定具体重写类型。
## 4.2 为什么推荐统一接口
因为前端交互是一套统一浮层和统一预览面板,拆成多个接口会导致:
- 前端判断分支增多
- 埋点与异常处理分散
- 后续新增动作不方便扩展
统一接口更适合本轮需求。
---
## 5. 请求体定义
建议请求体如下:
```json
{
"threadId": "string",
"documentId": "string",
"selection": {
"from": 120,
"to": 260,
"text": "待改写文本"
},
"action": "polish",
"tone": "professional",
"surroundingContext": {
"before": "选区前文",
"after": "选区后文"
}
}
```
---
## 6. 字段说明
### 6.1 `threadId`
用途:
- 关联当前 DeerFlow 线程
- 必要时取线程上下文或会话元数据
### 6.2 `documentId`
用途:
- 标识当前右侧文档
- 首版可选
- 如果当前系统还没有独立文档 ID,可先允许为空
### 6.3 `selection.from` / `selection.to`
用途:
- 帮助前后端联调排查
- 首版后端通常不需要真正使用偏移量
- 建议保留,便于日志定位
### 6.4 `selection.text`
用途:
- 这是本轮最核心输入
- 后端实际重写的主文本
### 6.5 `action`
允许值:
- `polish`
- `expand`
- `shorten`
- `tone`
### 6.6 `tone`
仅当 `action = tone` 时使用。
建议允许值:
- `professional`
- `casual`
- `readable`
- `academic`
- `expressive`
### 6.7 `surroundingContext.before`
用途:
- 提供选区前文
- 帮助模型理解语气、主语、叙事连续性
建议:
- 控制长度
- 不要塞太多无关上下文
### 6.8 `surroundingContext.after`
用途:
- 提供选区后文
- 帮助模型在扩写、改语气时与后续内容衔接
---
## 7. 返回体定义
建议返回体如下:
```json
{
"output": "AI 改写后的候选文本",
"originalSelection": {
"from": 120,
"to": 260,
"text": "待改写文本"
},
"metadata": {
"action": "polish",
"tone": null,
"model": "your-model-name"
}
}
```
---
## 8. 返回字段要求
## 8.1 `output`
必须:
- 只包含候选正文
- 尽量不夹带解释语
- 能直接被前端替换或插入
## 8.2 `originalSelection`
作用:
- 前端可以做校验
- 联调时方便确认是针对哪段文本返回的
## 8.3 `metadata`
作用:
- 便于埋点
- 便于日志记录
- 便于问题追踪
---
## 9. 错误响应规范
建议统一错误结构:
```json
{
"error": {
"code": "WRITING_REWRITE_FAILED",
"message": "rewrite failed"
}
}
```
建议错误码:
- `WRITING_REWRITE_FAILED`
- `WRITING_INVALID_ACTION`
- `WRITING_INVALID_TONE`
- `WRITING_EMPTY_SELECTION`
- `WRITING_MODEL_UNAVAILABLE`
- `WRITING_CONTEXT_TOO_LARGE`
---
## 10. 动作语义定义
## 10.1 `polish`
目标:
- 保持原意
- 改善表达
- 提升通顺度、自然度和可读性
不应:
- 引入新事实
- 改写成完全不同内容
## 10.2 `expand`
目标:
- 保持主旨
- 扩充细节
- 增强画面感、解释性或完整度
不应:
- 无限制发散
- 添加明显脱离主题的新内容
## 10.3 `shorten`
目标:
- 压缩篇幅
- 保留关键信息
- 减少重复表达
不应:
- 丢失关键论点
- 压缩到语义残缺
## 10.4 `tone`
目标:
- 不改变原意
- 调整语言风格
不应:
- 改写事实
- 改写逻辑结构到完全不同
---
## 11. Prompt 设计原则
## 11.1 通用系统约束
所有改写类 prompt 都应包含以下约束:
- 直接输出最终可用正文
- 不输出标题,除非原文本身就是标题改写
- 不输出解释
- 不输出分析过程
- 不输出“以下是结果”之类前缀
- 保持与上下文语气一致
## 11.2 上下文使用原则
前后文只用于理解,不要求模型重写前后文。
明确告诉模型:
- 只改写选中文本
- 前后文仅用于参考衔接
## 11.3 长度控制原则
- `polish`:长度与原文接近
- `expand`:长度可明显增加,但不要失控
- `shorten`:长度明显缩短,但要保留关键信息
- `tone`:长度尽量与原文接近
---
## 12. Prompt 模板建议
## 12.1 润色模板
```text
你是一名中文写作编辑助手。
请在不改变原意的前提下,对“选中文本”进行润色,使表达更自然、更流畅、更清晰。
要求:
1. 保留原文事实和核心意思。
2. 不要添加原文没有的新事实。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出润色后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.2 扩写模板
```text
你是一名中文写作编辑助手。
请在不偏离主题的前提下,对“选中文本”进行扩写,增加必要细节、画面感或补充说明,使内容更充实。
要求:
1. 保持原文主旨不变。
2. 增加内容时要自然,不要跑题。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出扩写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.3 缩写模板
```text
你是一名中文写作编辑助手。
请在保留关键信息和原意的前提下,对“选中文本”进行缩写,使其更精炼。
要求:
1. 不要丢掉核心信息。
2. 去掉重复、冗余和拖沓表达。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出缩写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.4 改语气模板
```text
你是一名中文写作编辑助手。
请在不改变原意和事实的前提下,把“选中文本”改写成更{tone_label}的表达方式。
要求:
1. 保持原文信息不变。
2. 只调整表达风格,不改变事实和逻辑。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出改写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
---
## 13. Tone 映射建议
建议前端传标准值,后端映射中文标签:
- `professional` -> 专业、正式
- `casual` -> 口语化、轻松自然
- `readable` -> 更易读、更清晰
- `academic` -> 学术、严谨
- `expressive` -> 更有文采、更有表现力
---
## 14. 上下文截取策略
## 14.1 推荐策略
前端传:
- 选区前固定长度文本
- 选区后固定长度文本
例如:
- 前文 200 到 500 字
- 后文 200 到 500 字
### 原因
- 足够帮助模型理解文气和衔接
- 不会让请求太重
## 14.2 不建议
- 把整篇长文全量塞进去
- 把整个线程消息都塞进改写接口
如果后端确实要参考线程上下文,也建议只取最近和写作相关的少量消息摘要。
---
## 15. LangGraph 接入建议
## 15.1 推荐接入方式
在现有 LangGraph / gateway 架构中新增一个写作改写专用入口。
建议二选一:
### 方案 A
新增专用 router:
- `POST /api/writing/rewrite`
内部调用现有模型层 / agent 层。
### 方案 B
新增专用 graph node 或 tool:
- `rewrite_selection`
再由 router 调用该能力。
## 15.2 更推荐方案
推荐:
- 对外暴露统一 router
- 内部走已有模型封装或 LangGraph 节点
这样前端接口更稳定,后续后端内部重构不会影响前端。
---
## 16. 后端模块拆解
## 16.1 模块 A:接口层
负责:
- 请求校验
- 错误响应
- 参数标准化
需要完成:
- 统一 rewrite API
- action / tone 校验
- 空选区校验
## 16.2 模块 B:上下文拼装层
负责:
- 拼装 before / selection / after
- 必要时提取线程相关信息
需要完成:
- 上下文长度控制
- 清洗空白和异常字符
## 16.3 模块 C:Prompt 层
负责:
- 选择模板
- 填充 action 和 tone
需要完成:
- 4 类动作模板
- tone 映射
## 16.4 模块 D:模型调用层
负责:
- 走现有模型配置体系
- 执行大模型请求
需要完成:
- 统一调用入口
- 模型不可用错误处理
## 16.5 模块 E:结果清洗层
负责:
- 去掉解释性前缀
- 去掉多余包裹
- 标准化返回
需要完成:
- 输出清洗
- metadata 填充
---
## 17. 返回结果清洗建议
即使 prompt 已经约束,模型仍可能输出:
- “以下是润色后的内容:”
- “当然,这里是改写结果:”
- “优化如下:”
建议后端加一层轻量清洗:
- 去掉常见说明性前缀
- 去掉包裹性 markdown 代码块
- 去掉多余首尾空白
注意:
- 清洗必须保守
- 不要误删正文
---
## 18. 日志与观测建议
建议记录:
- `threadId`
- `documentId`
- `action`
- `tone`
- `selection_length`
- `before_length`
- `after_length`
- `model_name`
- `latency_ms`
- `success / fail`
不要直接把整段用户正文原文打到普通日志里,避免敏感信息风险。
---
## 19. 后端测试清单
## 19.1 单元测试
- action 校验
- tone 校验
- 空选区校验
- prompt 选择逻辑
- 输出清洗逻辑
## 19.2 集成测试
- `polish`
- `expand`
- `shorten`
- `tone-professional`
- `tone-casual`
- `tone-readable`
- `tone-academic`
- `tone-expressive`
## 19.3 边界测试
- 选区过短
- 选区过长
- 前后文为空
- 多段文本带换行
- 模型超时
- 模型不可用
---
## 20. 联调约束
## 20.1 前后端必须统一
- `action` 枚举值
- `tone` 枚举值
- 错误码
- 返回体字段名
## 20.2 联调重点
- 多段落文本中换行保留是否正确
- AI 返回内容是否适合直接落文档
- 超时和失败时前端是否能优雅重试
## 20.3 联调建议顺序
1. 先 mock 返回固定文本
2. 再接真实模型
3. 先验证单段落
4. 再验证多段落
5. 最后验证大段文本
---
## 21. 优先级建议
### P0
- 统一 rewrite API
- 4 类动作支持
- 输出纯净正文
- 错误结构标准化
### P1
- 输出清洗增强
- 埋点与日志
- 上下文截断策略优化
### P2
- 更细粒度风格控制
- 结果质量二次校验
---
## 22. 完成定义
后端满足以下条件即可视为本轮完成:
- 前端能通过统一接口发起改写
- 4 类动作均可稳定返回候选文本
- 返回文本基本可直接用于替换或插入
- 多段落文本能稳定处理
- 错误结构规范统一
---
## 23. 结论
本轮后端最重要的,不是做复杂文档结构编辑,而是把“选区改写”做成稳定、可预测、可直接接入前端编辑器的能力。
最稳妥的路线是:
- 统一接口
- 统一动作枚举
- 统一 prompt 模板
- 统一错误结构
- 统一输出约束
只要这层做稳,前端的 Tiptap 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。
@@ -0,0 +1,703 @@
# AI 写作助手前端开发任务清单
## 1. 文档目标
本文档用于把《AI 写作助手右侧编辑器升级需求文档》进一步拆成前端可直接执行的任务清单。
目标是让前端团队按模块并行推进,最终完成:
- 保留左侧聊天区
- 右侧升级为 `Tiptap` 写作编辑器
- 支持选区浮层
- 支持润色 / 扩写 / 缩写 / 改语气
- 支持替换 / 插入
- 支持标题 / 列表 / 引用 / 代码块工具条
- UI 参考豆包 + Novel + open-canvas
---
## 2. 本轮前端改造范围
### 2.1 保留不动
- 左侧聊天主链路
- 现有线程消息流
- 现有模型选择器
- 现有输入框的 `writing_mode` 总入口
### 2.2 重点改造
- 右侧 Artifact 面板承载方式
- 新增独立写作工作区
- 富文本编辑器内核
- 选区浮层
- AI 改写结果预览
- 文档级工具条
---
## 3. 推荐目录结构
建议新增独立模块目录:
```text
frontend-web/src/open-canvas-writing/
routes/
WritingRoutes.tsx
pages/
WritingWorkspacePage.tsx
components/
WritingShell.tsx
WritingHeader.tsx
WritingToolbar.tsx
WritingEditorPane.tsx
WritingBubbleMenu.tsx
WritingAiMenu.tsx
WritingRewritePreview.tsx
WritingStatusBar.tsx
WritingEmptyState.tsx
editor/
TiptapEditor.tsx
TiptapExtensions.ts
commands.ts
selection.ts
context/
WritingEditorContext.tsx
hooks/
useWritingEditor.ts
useWritingSelection.ts
useWritingRewrite.ts
useWritingToolbar.ts
api/
rewrite.ts
documents.ts
types/
document.ts
rewrite.ts
utils/
apply-rewrite.ts
markdown.ts
editor-style.ts
```
---
## 4. 现有代码落点
## 4.1 需要接入的现有文件
- `frontend-web/src/pages/PageRoutes.tsx`
- 增加新写作路由入口
- `frontend-web/src/components/workspace/chats/chat-box.tsx`
- 当前双栏布局参考来源
- 后续决定是否复用布局容器,或在新页面中重建双栏
- `frontend-web/src/components/workspace/input-box.tsx`
- 保留 `writing_mode`
- 后续可在切换写作模式时跳转到新写作页面
- `frontend-web/src/components/workspace/artifacts/context.tsx`
- 仅保留给旧 Artifact 逻辑
- 不要继续承载写作编辑器状态
## 4.2 不建议继续沿用的方式
- 不要把编辑器主状态存成 `string[] artifacts`
- 不要把右侧主体验继续建在 `artifact-file-detail.tsx` 上
- 不要让 AI 改写结果先回聊天,再由用户手动复制
---
## 5. 任务分阶段拆解
## 阶段 A:路由与页面壳
### 目标
先把新的写作工作区页面立起来,让后续编辑器与交互都有落点。
### 任务
- 新增 `WritingRoutes.tsx`
- 新增 `WritingWorkspacePage.tsx`
- 在 `PageRoutes.tsx` 中挂入 `page/writing/*`
- 确认新页面进入方式
- 确定是:
- 通过左侧聊天上下文进入
- 通过 `writing_mode` 入口进入
- 或二者都支持
### 交付物
- 新写作页面能被路由访问
- 页面有基础左右布局和空态
### 验收
- 打开 `page/writing` 能进入工作区
- 页面不报错
---
## 阶段 B:编辑器底座
### 目标
把右侧正式替换成富文本编辑器。
### 任务
- 安装 Tiptap 依赖
- 建立 `TiptapEditor.tsx`
- 建立 `TiptapExtensions.ts`
- 配置节点:
- paragraph
- heading
- bulletList
- orderedList
- listItem
- blockquote
- codeBlock
- hardBreak
- 配置 marks:
- bold
- italic
- underline
- strike
- code
- 建立编辑器初始化逻辑
- 建立编辑器销毁逻辑
- 定义最小文档状态
### 交付物
- 可编辑正文区域
- 初始文档可加载
- 内容可读可写
### 验收
- 可以直接输入正文
- 可以换行、加标题、切换列表
- 不出现内容错位和明显闪烁
---
## 阶段 C:主工具栏
### 目标
右侧具备正式写作工具栏。
### 任务
- 新增 `WritingToolbar.tsx`
- 新增 `useWritingToolbar.ts`
- 实现按钮:
- 正文
- H1
- H2
- H3
- 有序列表
- 无序列表
- 引用
- 代码块
- 加粗
- 斜体
- 下划线
- 删除线
- 处理 active 状态
- 处理不可用状态
- 处理光标块状态联动
### 交付物
- 主工具栏可用
### 验收
- 工具条按钮能正确反映当前块状态
- 操作后编辑器内容正确变化
---
## 阶段 D:选区状态与浮层
### 目标
实现豆包式选区浮层基础能力。
### 任务
- 新增 `useWritingSelection.ts`
- 新增 `selection.ts`
- 新增 `WritingBubbleMenu.tsx`
- 基于 Tiptap 监听 selection 变化
- 过滤掉空选区
- 处理多段连续选区
- 浮层按钮:
- AI 改写
- 加粗
- 斜体
- 标题
- 复制
- 处理滚动定位
- 处理窗口尺寸变化
### 交付物
- 选中内容后出现浮层
### 验收
- 单段可触发
- 多段可触发
- 浮层位置稳定
---
## 阶段 E:AI 改写菜单
### 目标
把选区浮层接到 AI 改写工作流。
### 任务
- 新增 `WritingAiMenu.tsx`
- 新增 `useWritingRewrite.ts`
- 新增 `api/rewrite.ts`
- 实现一级动作:
- 润色
- 扩写
- 缩写
- 改语气
- 实现改语气二级菜单:
- 更专业
- 更口语化
- 更易读
- 更学术
- 更有文采
- 封装请求入参
- 增加 loading 态
- 增加 error 态
- 增加 retry 入口
### 交付物
- AI 改写菜单和请求链路
### 验收
- 4 个动作都能发请求
- 改语气的二级菜单可正常展开
- 请求失败时有反馈
---
## 阶段 F:候选结果预览
### 目标
把 AI 结果变成“可预览、可决策、可回退”的候选内容。
### 任务
- 新增 `WritingRewritePreview.tsx`
- 新增 `apply-rewrite.ts`
- 显示原文与候选内容
- 提供按钮:
- 替换
- 插入
- 重试
- 取消
- 用 transaction 应用结果
- 处理应用成功后的焦点与滚动
### 交付物
- 改写结果预览卡片或面板
### 验收
- 替换准确
- 插入准确
- 取消后文档保持原样
---
## 阶段 G:文档状态与容器体验
### 目标
补齐正式产品的壳层体验。
### 任务
- 新增 `WritingShell.tsx`
- 新增 `WritingHeader.tsx`
- 新增 `WritingStatusBar.tsx`
- 新增 `WritingEmptyState.tsx`
- 补充文档标题区域
- 补充文档状态提示
- 补充空态和加载态
### 交付物
- 页面完整壳层
### 验收
- 页面不是裸编辑器
- 有完整的文档工作区感
---
## 阶段 H:视觉打磨
### 目标
让整体观感接近成熟产品。
### 任务
- 调整右侧阅读宽度
- 调整字体大小和行高
- 调整段距和标题层级
- 调整工具栏阴影、边框、圆角
- 调整浮层动效
- 调整 AI 预览卡片质感
- 优化深浅色模式兼容
### 交付物
- 完整视觉优化
### 验收
- 明显优于当前版本
- 有豆包 / Novel 风格的编辑器质感
---
## 6. 组件级开发清单
## 6.1 `WritingWorkspacePage.tsx`
职责:
- 页面级容器
- 获取线程 / 文档上下文
- 组织左右布局
必须完成:
- 页面初始化
- 与左侧聊天协同
- 右侧编辑区挂载
## 6.2 `WritingShell.tsx`
职责:
- 写作工作区总壳层
- 布局标题区、工具栏、正文区、状态区
必须完成:
- 自适应布局
- 桌面端优先
- 窄屏降级
## 6.3 `TiptapEditor.tsx`
职责:
- 包装 Tiptap EditorContent
- 负责 editor 实例初始化
必须完成:
- `onUpdate`
- `onSelectionUpdate`
- 内容同步
## 6.4 `WritingToolbar.tsx`
职责:
- 顶部主工具栏
必须完成:
- 所有基础格式化命令
- 当前状态高亮
## 6.5 `WritingBubbleMenu.tsx`
职责:
- 选区浮层
必须完成:
- 跟随选区
- 基础快捷操作
- AI 改写入口
## 6.6 `WritingAiMenu.tsx`
职责:
- AI 改写动作菜单
必须完成:
- 一级菜单
- 改语气二级菜单
- 动作触发
## 6.7 `WritingRewritePreview.tsx`
职责:
- 展示候选内容
必须完成:
- 替换
- 插入
- 重试
- 取消
---
## 7. Hook 级开发清单
## 7.1 `useWritingEditor.ts`
负责:
- editor 生命周期
- 初始内容装载
- 内容变更同步
## 7.2 `useWritingSelection.ts`
负责:
- 当前选区抽取
- 选区文本缓存
- 多段落纯文本拼接
## 7.3 `useWritingRewrite.ts`
负责:
- 改写请求
- loading / success / error
- 结果缓存
- 重试
## 7.4 `useWritingToolbar.ts`
负责:
- toolbar active 状态
- editor command 映射
---
## 8. API 对接清单
前端需向后端约定以下接口能力:
- `POST /api/writing/rewrite`
前端请求内容至少包括:
- `threadId`
- `documentId`
- `selection.from`
- `selection.to`
- `selection.text`
- `action`
- `tone`
- `surroundingContext.before`
- `surroundingContext.after`
前端需处理的返回字段:
- `output`
- `originalSelection`
- `metadata`
---
## 9. 状态管理建议
建议新增独立 Context:
- `WritingEditorContext.tsx`
建议持有:
- 当前文档内容
- 当前 editor 实例
- 当前选区
- 当前改写请求状态
- 当前候选结果
- 当前 UI 面板状态
不建议继续复用旧 Artifact Context 的原因:
- 旧 Context 关注文件列表和文件路径
- 新编辑器关注选区、结构、候选结果、应用动作
- 关注点完全不同
---
## 10. 前端联调检查点
## 10.1 与后端联调前
- 先用 mock 数据打通浮层和预览
- 先验证替换 / 插入逻辑
## 10.2 与后端联调时
- 检查多段落选区拼接结果
- 检查 AI 返回内容是否带说明性前缀
- 检查替换后位置是否准确
## 10.3 联调后
- 验证大段文本性能
- 验证撤销 / 重做
- 验证快速连续触发是否会状态错乱
---
## 11. 前端测试清单
## 11.1 功能测试
- 输入正文
- 插入标题
- 切换列表
- 插入引用
- 插入代码块
- 加粗 / 斜体 / 下划线 / 删除线
- 选区浮层显示
- 选区浮层隐藏
- AI 改写菜单打开
- 改语气二级菜单打开
- 改写结果替换
- 改写结果插入
- 改写结果取消
## 11.2 场景测试
- 单段短文本润色
- 多段文本润色
- 多段文本扩写
- 多段文本缩写
- 多段文本改语气
- 连续两次改写同一段
- 快速切换线程后重新进入
## 11.3 回归测试
- 左侧聊天是否正常
- 模型切换是否正常
- `writing_mode` 是否正常
- 原有 artifact 模块是否仍可使用
---
## 12. 任务优先级
### P0
- 路由与页面壳
- Tiptap 接入
- 主工具栏
- 选区浮层
- 改写菜单
- 替换 / 插入
### P1
- 页面头部与状态
- 优雅 loading / error / retry
- 多段落稳定性
- UI 打磨
### P2
- 空态优化
- 动效优化
- 快捷键支持
---
## 13. 建议分工
### 前端 1 号
负责:
- 路由与页面壳
- Tiptap 接入
- 主工具栏
### 前端 2 号
负责:
- 选区状态
- BubbleMenu
- AI 菜单
### 前端 3 号
负责:
- 候选结果预览
- 替换 / 插入
- 状态栏与页面体验
### 联调负责人
负责:
- 前后端请求协议
- 错误态规范
- 多段落测试和回归
---
## 14. 前端完成定义
满足以下条件可视为前端完成:
- 右侧能作为正式编辑器独立使用
- 选区后可直接发起 AI 改写
- 改写后可替换或插入
- 视觉上明显优于当前版本
- 左侧聊天和现有系统不被破坏
---
## 15. 下一步建议
前端团队可以按以下顺序开工:
1. 先起页面和路由
2. 再接 Tiptap 与主工具栏
3. 再做选区浮层
4. 再接 AI 菜单和结果预览
5. 最后做视觉打磨
这个顺序风险最小,也最容易在中途验收。
@@ -0,0 +1,457 @@
# AI 写作助手源码与实现映射表
## 1. 文档目标
本文档用于把以下几类信息对应起来:
- 豆包 AI 写作助手里要借鉴的交互
- Novel / open-canvas / Tiptap 中可参考的实现思路
- DeerFlow 当前项目里已经存在的落点
- 本次新建模块应该落到哪里
这份文档不是源码复制文档,而是开发时的“对照表”和“导航图”。
---
## 2. 使用方式
开发时建议按以下顺序看:
1. 先看“目标能力”
2. 再看“参考来源”
3. 再看“DeerFlow 当前落点”
4. 最后看“本次目标文件”
这样可以避免出现两种常见问题:
- 直接照搬外部项目结构,结果和你们现有架构冲突
- 只看自己项目,不知道成熟产品交互应该怎么落
---
## 3. 总体映射
| 目标能力 | 产品参考 | 外部参考实现 | DeerFlow 当前落点 | 本次目标文件 |
| --- | --- | --- | --- | --- |
| 写作工作区路由 | open-canvas 独立页面 | open-canvas 路由组织思路 | `frontend-web/src/pages/PageRoutes.tsx` | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` |
| 左右双栏工作区 | 豆包写作页 / open-canvas | open-canvas 页面壳思路 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`、`WritingShell.tsx` |
| 富文本正文编辑 | Novel | Tiptap 官方编辑器模式 | 当前无正式富文本正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` |
| 文档工具栏 | 豆包 / Novel | Tiptap Toolbar 组合思路 | 当前右侧无正式工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` |
| 选区浮层 | 豆包 | Tiptap BubbleMenu | 当前右侧无选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` |
| AI 改写菜单 | 豆包 | 自定义菜单 + BubbleMenu 挂接 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` |
| 候选结果预览 | 豆包 / open-canvas | open-canvas 候选应用思路 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` |
| 替换 / 插入 | 豆包 | Tiptap transaction 应用 | 当前无 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` |
| 写作模式总入口 | 当前 DeerFlow | 现有 `writing_mode` 开关 | `frontend-web/src/components/workspace/input-box.tsx` | 保留现有入口并接新页面 |
| 线程与模型协同 | 当前 DeerFlow | 现有线程 / 模型体系 | 现有 `core/threads`、`core/models` | 继续复用,新增 `api/rewrite.ts` |
---
## 4. 豆包功能到 DeerFlow 的映射
## 4.1 滑词 / 选区浮层
### 豆包里的体验
- 选中文本后出现轻量浮层
- 浮层里有:
- AI 改写
- 文本样式
- 复制
### DeerFlow 当前状态
- 右侧还没有正式的选区级工具层
### 本次实现方式
- 使用 Tiptap 的 `BubbleMenu`
- 再叠加自定义菜单逻辑
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
- `frontend-web/src/open-canvas-writing/editor/selection.ts`
---
## 4.2 多段落改写
### 豆包里的体验
- 不是只改单句
- 能对连续几段一起润色、扩写、缩写、改语气
### DeerFlow 当前状态
- 现有右侧编辑区没有正式的多段落选区改写模型
### 本次实现方式
- 前端提取连续选区纯文本
- 前端附带前后文
- 后端返回候选纯文本
- 前端决定替换或插入
### 本次落点
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
- `frontend-web/src/open-canvas-writing/hooks/useWritingRewrite.ts`
- `frontend-web/src/open-canvas-writing/api/rewrite.ts`
---
## 4.3 替换 / 插入
### 豆包里的体验
- 改写结果不是直接覆盖原文
- 用户可选择:
- 替换
- 插入
### DeerFlow 当前状态
- 当前没有候选文本应用层
### 本次实现方式
- 建立候选结果预览区
- 基于 Tiptap transaction 应用文本
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
- `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts`
---
## 4.4 标题 / 列表 / 引用 / 代码块工具条
### 豆包里的体验
- 右侧本身就是正式文档编辑器
- 可以快速切换文档结构
### DeerFlow 当前状态
- 当前右侧偏预览,不是正式写作编辑器
### 本次实现方式
- 使用 Tiptap 节点和命令
- 在主工具栏中集中承载
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx`
- `frontend-web/src/open-canvas-writing/hooks/useWritingToolbar.ts`
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
---
## 5. Novel 参考映射
## 5.1 最值得借鉴的不是“代码整搬”,而是这三件事
- 编辑器主容器的产品感
- 工具栏和空态的组织方式
- 正文阅读宽度、排版、留白
## 5.2 对应到 DeerFlow 的实现位置
- 页面壳:`WritingShell.tsx`
- 顶部文档区:`WritingHeader.tsx`
- 编辑容器:`TiptapEditor.tsx`
- 工具栏:`WritingToolbar.tsx`
- 空态:`WritingEmptyState.tsx`
## 5.3 不建议直接照搬的部分
- Next.js 页面结构
- 依赖其服务端 AI 调用方式
- 与 Vercel AI SDK 深绑定的部分
---
## 6. open-canvas 参考映射
## 6.1 主要借鉴对象
open-canvas 这次最值得借鉴的不是页面外观,而是:
- AI 工作流组织方式
- 改写结果应用节奏
- 编辑器和聊天协作的模式
## 6.2 借鉴重点
### 借鉴 1:独立工作区思想
不要把写作体验塞进一个零散的弹窗或文件详情页,要有独立工作区。
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`
### 借鉴 2:画布状态独立维护
不要让写作状态完全依附聊天消息渲染结果,要独立维护文档和候选状态。
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx`
### 借鉴 3:AI 输出先预览,再应用
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
---
## 7. Tiptap 参考映射
## 7.1 Tiptap 在本项目中的职责
- 作为右侧正式富文本编辑器内核
- 提供结构节点与 marks
- 提供 BubbleMenu 能力
- 提供 transaction 应用能力
## 7.2 建议直接参考的实现点
- `EditorContent` 的封装模式
- `BubbleMenu` 的接入方式
- heading / list / blockquote / codeBlock 扩展配置
- `editor.chain().focus()` 命令调用模式
- selection 变化监听
## 7.3 DeerFlow 本次落点
- `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx`
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
---
## 8. DeerFlow 现有文件到新模块的映射
## 8.1 `frontend-web/src/pages/PageRoutes.tsx`
### 当前职责
- 汇总页面路由
### 本次作用
- 挂入新的写作助手路由
### 后续动作
- 引入 `WritingRoutes`
- 增加 `page/writing/*`
---
## 8.2 `frontend-web/src/components/workspace/chats/chat-box.tsx`
### 当前职责
- 左聊天 + 右 Artifact 双栏布局
### 本次作用
- 提供双栏工作区组织思路
- 可复用布局比例和 panel 交互经验
### 不建议直接复用的部分
- 不要继续让右侧核心能力挂在 ArtifactFileDetail 上
### 本次目标替代
- `WritingWorkspacePage.tsx`
- `WritingShell.tsx`
---
## 8.3 `frontend-web/src/components/workspace/artifacts/context.tsx`
### 当前职责
- 管理文件型 artifact 列表、选中项和开关状态
### 本次作用
- 仅作为旧 Artifact 模块保留
### 本次目标替代
- `WritingEditorContext.tsx`
### 原因
- 写作编辑器需要的不是文件路径状态
- 而是文档状态、选区状态、候选结果状态、工具层状态
---
## 8.4 `frontend-web/src/components/workspace/input-box.tsx`
### 当前职责
- 聊天输入区
- 已有 `writing_mode` 入口
### 本次作用
- 保留为用户进入写作模式的现有入口
### 本次改造建议
- 写作模式打开时,跳转或唤起新的写作工作区
- 不再只停留在“生成 Markdown 文件”这个层面
---
## 9. 新模块间的依赖关系
## 9.1 页面层
- `WritingRoutes.tsx`
- `WritingWorkspacePage.tsx`
- `WritingShell.tsx`
职责:
- 页面路由
- 双栏或工作区布局
- 上下文装配
## 9.2 编辑器层
- `TiptapEditor.tsx`
- `TiptapExtensions.ts`
- `commands.ts`
- `selection.ts`
职责:
- 编辑器内核
- 节点和 marks
- 基础命令
- 选区抽取
## 9.3 交互层
- `WritingToolbar.tsx`
- `WritingBubbleMenu.tsx`
- `WritingAiMenu.tsx`
- `WritingRewritePreview.tsx`
职责:
- 工具栏
- 浮层
- AI 菜单
- 候选内容应用
## 9.4 状态层
- `WritingEditorContext.tsx`
- `useWritingEditor.ts`
- `useWritingSelection.ts`
- `useWritingRewrite.ts`
- `useWritingToolbar.ts`
职责:
- 主状态
- 选区状态
- 改写状态
- 工具栏状态
## 9.5 API 层
- `api/rewrite.ts`
- `api/documents.ts`
职责:
- 对接后端写作改写接口
- 对接文档加载与保存接口
---
## 10. 能力到文件的精确映射
| 能力 | 新文件 | 说明 |
| --- | --- | --- |
| 新写作页面 | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` | 承载整个新模式 |
| 写作路由 | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` | 统一路由入口 |
| 页面外壳 | `frontend-web/src/open-canvas-writing/components/WritingShell.tsx` | 页面布局与容器 |
| 文档头部 | `frontend-web/src/open-canvas-writing/components/WritingHeader.tsx` | 标题、状态 |
| 主工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` | 文档结构与格式工具 |
| 正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` | Tiptap 核心封装 |
| 扩展配置 | `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts` | 节点和 marks |
| 选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` | 选区出现的悬浮条 |
| AI 菜单 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` | 润色、扩写等动作 |
| 改写预览 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` | 候选内容展示与应用 |
| 替换 / 插入 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` | 应用候选结果 |
| 编辑器状态 | `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx` | 独立上下文 |
| 改写请求 | `frontend-web/src/open-canvas-writing/api/rewrite.ts` | 调后端接口 |
---
## 11. 开发时的参考顺序
建议开发顺序如下:
1. 先看 `PageRoutes.tsx`,确定路由接法
2. 再看 `chat-box.tsx`,理解当前左右布局
3. 然后起 `WritingWorkspacePage.tsx` 和 `WritingShell.tsx`
4. 再接 `TiptapEditor.tsx`
5. 再做 `WritingToolbar.tsx`
6. 再做 `WritingBubbleMenu.tsx`
7. 再做 `WritingAiMenu.tsx`
8. 最后做 `WritingRewritePreview.tsx`
这样可以让每一层都有稳定承载,不会出现“先做了 AI 菜单,但页面还没落点”的混乱情况。
---
## 12. 明确哪些是“参考”,哪些是“目标”
### 12.1 参考
- 豆包:交互体验参考
- Novel:页面和编辑器质感参考
- open-canvas:AI 工作流参考
- Tiptap:编辑器技术实现参考
### 12.2 目标
目标不是复刻任何一个外部项目,而是:
- 在 DeerFlow 现有架构中
- 用 React + Vite + 现有线程体系
- 做出一个更成熟的 AI 写作编辑器模式
---
## 13. 结论
这份映射表的核心作用,是避免团队在开发时迷路。
真正推荐的姿势是:
- 用豆包定义目标体验
- 用 Novel 定义页面质感
- 用 open-canvas 定义 AI 工作流
- 用 Tiptap 作为编辑器内核
- 用 DeerFlow 现有路由、线程、模型体系做宿主
这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
+140
View File
@@ -0,0 +1,140 @@
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
## 一、需求背景
当前 `frontend-web/src/App.tsx` 同时承载了:
1. 登录/首页相关路由(如 `/login`、`/landing`)
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
---
## 二、目标(你提出的核心诉求)
你希望:
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
---
## 三、重构范围
### 1)保留在 `App.tsx` 的内容
- 全局 Provider:`HashRouter`、`AppProviders`
- 登录/首页路由:
- `/`
- `/login`
- `/login/:username`
- `/landing`
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
### 2)迁移出 `App.tsx` 的内容
将以下与工作台有关的路由从 `App.tsx` 抽离:
- `/workspace`(重定向)
- `/workspace/chats`
- `/workspace/chats/:thread_id`
- `/workspace/agents`
- `/workspace/agents/new`
- `/workspace/agents/:agent_id/chats`(重定向)
- `/workspace/agents/:agent_id/chats/:thread_id`
- `/workspace/scheduled`
- `/workspace/scheduled/tasks`
- `/workspace/scheduled/runs/:run_id`
以及配套的包装组件职责:
- `WorkspaceLayout`
- `ChatRuntime`
---
## 四、建议的新结构(方案)
## 方案 A(推荐):新增“业务路由容器组件”
在 `pages` 下新增一个路由容器组件,例如:
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
职责:
- 专门维护 `/workspace/*` 相关路由
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
- 输出一个可被 `App.tsx` 挂载的路由节点
同时在 `App.tsx` 里只保留:
- 登录/首页/404
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
---
## 五、“再建立一个页面放置导航”的落地解释
你说的“建立一个页面放置这些导航”,可以有两种理解:
1. **路由容器页(推荐)**:
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
- 即上面的 `WorkspaceRoutes.tsx`。
2. **可见的导航页(可选)**:
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
---
## 六、实施步骤(开发任务拆解)
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
5. 校验以下访问路径行为一致:
- `/workspace` 重定向
- chats/agents/scheduled 各页面可正常进入
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
- 404 不受影响
6. 运行前端并做手工回归。
---
## 七、验收标准
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
2. `/workspace/*` 全部由新建组件统一管理。
3. 功能行为与改造前一致(或符合新定义)。
4. 代码可读性提升:登录入口与业务入口职责分离。
---
## 八、注意事项
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
---
## 九、建议的命名(可直接采用)
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
---
## 十、后续可选优化
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
+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/` |
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+260
View File
@@ -0,0 +1,260 @@
# Open Canvas 集成进度文档
> 更新时间:2026-05-16
> 当前分支:dhk-1
---
## 一、整体进度概览
| 阶段 | 名称 | 状态 |
|------|------|------|
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 |
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 |
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) |
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 |
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
| 前端阶段四 | API 适配层 | ✅ 已完成 |
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
**当前状态**:全部 9 个阶段已完成 🎉
---
## 二、已完成工作详情
### 2.1 前端路由接入
修改了 `frontend-web/src/pages/PageRoutes.tsx`:
```tsx
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
// 在 PageLayout 内部添加:
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
```
子路由:
- `/page/canvas` → `CanvasHomePage`(欢迎页 + 快速开始)
- `/page/canvas/:thread_id` → `CanvasThreadPage`(主工作区)
### 2.2 前端目录结构(已建立,26 个文件)
```
frontend-web/src/open-canvas/
api/
client.ts # stub,Phase 5 实现
models.ts # stub,Phase 5 实现
threads.ts # stub,Phase 5 实现
runs.ts # stub,Phase 5 实现
components/
layout/
CanvasShell.tsx # 顶层布局壳,flex-col
CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示
CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels)
chat/
CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框
artifact/
CanvasArtifactPanel.tsx # 右栏:空状态或渲染器
CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航
CanvasArtifactVersionNav.tsx # 版本 prev/next 导航
CanvasEmptyState.tsx # 空状态:5 种快速开始模板
TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown)
CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror)
contexts/
CanvasThreadContext.tsx # threadId、modelName
CanvasGraphContext.tsx # messages、artifact、isStreaming
CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态
hooks/
useCanvasStream.ts # stub,Phase 5 实现
useCanvasArtifact.ts # stub,Phase 6 实现
useCanvasSelection.ts # stub,Phase 7 实现
useCanvasQuickActions.ts # stub,Phase 8 实现
pages/
CanvasHomePage.tsx # 欢迎页 + 快速开始入口
CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell
routes/
OpenCanvasRoutes.tsx # React Router 子路由
types/
artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata
model.ts # CanvasModel(含 supports_canvas_* 能力字段)
selection.ts # CanvasTextSelection、CanvasCodeSelection
quick-action.ts # QuickAction、QuickActionId
utils/
artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具
selection.ts # stub
thread.ts # stub
stream.ts # stub
quick-actions.ts # stub
constants/
index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_*
```
### 2.3 核心类型定义
```ts
// types/artifact.ts
export type CanvasArtifact = {
currentIndex: number; // 从 1 开始
contents: CanvasArtifactContent[];
};
export type CanvasArtifactText = {
index: number; type: "text"; title: string; fullMarkdown: string;
};
export type CanvasArtifactCode = {
index: number; type: "code"; title: string;
language: ProgrammingLanguageOptions; code: string;
};
```
### 2.4 Context 结构
```tsx
// CanvasThreadPage.tsx 的装配顺序
<CanvasThreadProvider initialThreadId={thread_id}>
<CanvasGraphProvider>
<CanvasUIProvider>
<CanvasShell />
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
```
---
## 三、已解决的技术难题
### 3.1 react-resizable-panels v4.6.2 API 差异
此版本与旧版 API 不同,必须使用:
```tsx
// ✅ 正确写法
import type { GroupImperativeHandle } from "react-resizable-panels";
const layoutRef = useRef<GroupImperativeHandle>(null);
<ResizablePanelGroup
orientation="horizontal" // ← 不是 direction
groupRef={layoutRef} // ← 不是 ref
>
<ResizablePanel id="chat" ... /> // ← 必须有 id
<ResizablePanel id="artifact" ... /> // ← 必须有 id
</ResizablePanelGroup>
// 切换折叠
layoutRef.current?.setLayout({ chat: 0, artifact: 100 }); // ← 对象形式,非数组
```
### 3.2 Flex 高度链(防止底部内容被裁剪)
```
PageLayout(h-screen)
└── main(flex-1)
└── CanvasShell div(h-full flex flex-col)
└── CanvasResizableLayout wrapper(flex-1 min-h-0 overflow-hidden)← 关键
└── ResizablePanelGroup(h-full)
└── 面板内容(h-full)
```
**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。
### 3.3 折叠/展开按钮
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`,
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
```tsx
useEffect(() => {
layoutRef.current?.setLayout(
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
);
}, [isChatCollapsed]);
```
---
## 四、下一步:后端 ThreadState 扩展
**目标文件**:`offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py`
**任务**:在现有 `ThreadState` 中新增以下 Canvas 专用字段,不影响 `artifacts: list[str]`(文件产物):
```python
# 新增 TypedDict(建议单独定义)
class CanvasArtifactText(TypedDict):
index: int
type: Literal["text"]
title: str
fullMarkdown: str
class CanvasArtifactCode(TypedDict):
index: int
type: Literal["code"]
title: str
language: str
code: str
class CanvasArtifact(TypedDict):
currentIndex: int
contents: list[CanvasArtifactText | CanvasArtifactCode]
# 在 ThreadState 中新增字段
artifact: Optional[CanvasArtifact] # Canvas 主文档(区别于 artifacts 文件列表)
highlighted_text: Optional[str] # 用户选中的文本片段
highlighted_code: Optional[str] # 用户选中的代码片段
canvas_mode: Optional[Literal["text", "code"]]
quick_action_id: Optional[str] # 快捷动作 id
web_search_enabled: bool # 默认 False
web_search_results: Optional[list[dict]]
```
**验收标准**:
- 旧线程无 `artifact` 字段时不报错
- `pnpm typecheck` 无新增错误(前端字段已对齐)
- `make test` 全量回归通过
**之后紧接**:`canvas_agent` 注册 + `factory.py` 修改 + 最小节点闭环
---
## 五、关键文件位置
| 文件 | 用途 |
|------|------|
| `frontend-web/docs/openCanvas.md` | 主需求文档 |
| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 |
| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 |
| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 |
| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 |
---
## 六、架构决策记录
| 决策 | 内容 |
|------|------|
| canvas_agent 方案 | 新增独立 canvas_agent,不改 lead_agent(方案 A)|
| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 |
| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 |
| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models |
| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) |
| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 |
---
## 七、开始新对话的推荐提示词
```
我们在集成 open-canvas 到 DeerFlow 项目。
参考进度文档:frontend-web/docs/openCanvas-progress.md
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
请从第四节"下一步:后端 ThreadState 扩展"开始继续。
```
+950
View File
@@ -0,0 +1,950 @@
# Open Canvas 模式需求拆解文档
## 1. 文档目标
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
- 前端:基于 `React 19 + Vite + react-router-dom`
- 后端:基于 `Python + FastAPI + LangGraph`
- 模型层:支持云模型与本地模型自由配置
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
---
## 2. 最终目标
### 2.1 用户视角目标
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
- 左侧是聊天区,右侧是 Canvas 编辑区
- 可以从空白文本或代码模板快速开始
- 可以通过聊天让 AI 生成一份结构化 artifact
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
- artifact 支持版本历史切换
- artifact 支持文本和代码两种主要类型
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
- 支持启用 Web 搜索增强
- 支持切换模型
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
### 2.2 工程视角目标
新增的 Canvas 模式需要满足以下工程原则:
- 不破坏现有 DeerFlow 工作区
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
- 可逐阶段上线
- 前后端状态协议清晰
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
---
## 3. 范围定义
### 3.1 本次需求包含
- 前端新增 `open-canvas` 专用目录
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
- 前端新增 Canvas 页面、上下文、组件、状态管理
- 后端新增 Canvas 专用线程状态字段
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
- 模型配置统一为 DeerFlow 后端配置驱动
- 支持本地大模型配置与模型列表动态下发
- 支持结构化 artifact 的保存、读取、更新、版本切换
### 3.2 本次需求不包含
- 一次性复刻 open-canvas 全部 UI 细节
- 完整照搬 Supabase 鉴权体系
- 直接复用 open-canvas 的 Next.js API Route
- 直接运行 open-canvas 的 LangGraph.js 后端
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
### 3.3 迁移原则
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
- 前端尽量复用其交互设计和数据结构
- 后端以 Python 实现同等能力
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
---
## 4. 总体设计思路
### 4.1 前端设计思路
在现有 `frontend-web/src` 下新增一个专用目录,例如:
```text
src/
open-canvas/
pages/
routes/
components/
contexts/
hooks/
types/
api/
utils/
constants/
```
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
建议路由形式:
- `/canvas`
- `/canvas/:threadId`
也可以挂在现有页面布局下,例如:
- `/workspace/canvas`
- `/workspace/canvas/:threadId`
推荐优先采用独立一级功能路由:
- 逻辑更清晰
- 状态隔离更容易
- 不容易污染现有 `workspace` 功能
### 4.2 后端设计思路
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
#### 方案 A:新增独立 Canvas Assistant
- 新增 `canvas_agent`
- 使用单独的 LangGraph 状态定义
- 前端调用时通过 `assistant_id=canvas_agent`
优点:
- 与现有 `lead_agent` 隔离
- 风险更低
- 更容易对齐 open-canvas 的图结构
缺点:
- 会产生一套新的 Agent 图维护成本
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
- Agent 根据模式进入 Canvas 路径
优点:
- 统一入口
缺点:
- 会显著增加现有 Agent 复杂度
- 容易影响现有聊天和文件产物模式
推荐先做方案 A。
### 4.3 模型配置设计思路
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
- `name`
- `display_name`
- `provider`
- `supports_thinking`
- `supports_reasoning_effort`
- `supports_vision`
- `supports_tool_calling`
- `supports_artifact_editing`
- `supports_code_editing`
- `temperature_range`
- `max_tokens_range`
- `base_url_source` 或 provider 标识
- `is_local`
---
## 5. 与当前项目的关键差异
### 5.1 当前 DeerFlow 现状
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
- `artifacts: string[]`
这更接近“生成文件产物”的工作模式。
前端当前 artifact 面板也主要是:
- 文件列表
- 文件详情
- 文件下载/预览
### 5.2 open-canvas 的关键能力
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
- 一个 conversation 对应一个主 artifact
- artifact 具有类型、标题、内容、版本
- 用户可以选中部分内容让 AI 改写
- 每次改写都会生成新版本
### 5.3 需要新增的核心能力
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
- 当前 artifact
- 当前版本索引
- 历史版本列表
- 选区信息
- 当前编辑模式
- 快速动作状态
- Web 搜索结果状态
---
## 6. 分阶段实施方案
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
---
## 7. 阶段一:前端路由与目录骨架
### 7.1 目标
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
### 7.2 需求
- 新增 `src/open-canvas/` 目录
- 新增 Canvas 页面路由
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
- 提供 Canvas 首页和 Canvas Thread 页
- 支持空状态展示
### 7.3 建议目录
```text
src/open-canvas/
api/
threads.ts
runs.ts
models.ts
components/
layout/
chat/
canvas/
artifacts/
quick-actions/
selectors/
contexts/
CanvasThreadContext.tsx
CanvasGraphContext.tsx
CanvasUIContext.tsx
hooks/
useCanvasThread.ts
useCanvasStream.ts
useCanvasArtifact.ts
pages/
CanvasHomePage.tsx
CanvasThreadPage.tsx
routes/
OpenCanvasRoutes.tsx
types/
artifact.ts
thread.ts
model.ts
utils/
artifact.ts
selection.ts
stream.ts
constants/
index.ts
```
### 7.4 路由建议
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
建议形式:
```text
/canvas
/canvas/:threadId
```
### 7.5 验收标准
- 能访问 Canvas 首页
- 能访问指定 thread 的 Canvas 页面
- 不影响现有 `workspace/*` 与 `strategy/*`
- 页面有独立布局骨架
---
## 8. 阶段二:前端 Canvas 布局与交互容器
### 8.1 目标
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
### 8.2 需求
- 左侧聊天区
- 右侧画布区
- 支持面板拖拽或折叠
- 初始空状态
- 支持“快速开始”:
- 新建空白文本 artifact
- 新建代码 artifact
### 8.3 组件拆分建议
- `CanvasShell`
- `CanvasSidebar`
- `CanvasChatPanel`
- `CanvasArtifactPanel`
- `CanvasEmptyState`
- `QuickStartActions`
### 8.4 快速开始能力
用户进入空线程时可以选择:
- 开始写文章
- 开始写代码
- 从模板开始
首版模板可只做最小集:
- 空白文档
- HTML 模板
- React 组件模板
- Python 脚本模板
### 8.5 验收标准
- 页面可稳定展示双栏布局
- 快速开始能创建前端本地 artifact 状态
- 代码和文本可分别进入不同渲染器
---
## 9. 阶段三:结构化 Artifact 数据模型
### 9.1 目标
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
### 9.2 核心要求
需要支持以下能力:
- 文本 artifact
- 代码 artifact
- 标题
- 当前版本索引
- 版本历史
- 当前选中版本切换
- 后续支持局部高亮编辑
### 9.3 建议数据结构
```ts
type CanvasArtifact = {
currentIndex: number;
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
};
type CanvasArtifactText = {
index: number;
type: "text";
title: string;
fullMarkdown: string;
};
type CanvasArtifactCode = {
index: number;
type: "code";
title: string;
language: string;
code: string;
};
```
### 9.4 线程状态建议新增字段
前端与后端需共同支持:
- `artifact`
- `highlighted_text`
- `highlighted_code`
- `canvas_mode`
- `web_search_enabled`
- `web_search_results`
- `quick_action_id`
### 9.5 与现有 `artifacts: string[]` 的关系
建议保留现有 `artifacts: string[]` 不动。
新增字段:
- `artifact` 用于 Canvas 主文档
- `artifacts` 继续用于文件产物
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
### 9.6 验收标准
- 前端类型定义完成
- 后端状态字段设计完成
- 确认与现有线程状态兼容
---
## 10. 阶段四:后端线程状态与 API 扩展
### 10.1 目标
让后端线程状态可以安全存储和返回 Canvas Artifact。
### 10.2 需求
- 扩展 Python `ThreadState`
- 扩展状态序列化逻辑
- 确保 `/api/threads/*` 返回结构化 `artifact`
- 确保 LangGraph 流中可带上 `artifact` 更新
### 10.3 后端改造点
建议关注以下模块:
- `packages/harness/deerflow/agents/thread_state.py`
- 线程 state serializer
- `app/gateway/routers/threads.py`
- 流事件适配层
### 10.4 注意事项
- 旧线程没有 `artifact` 字段时必须兼容
- 状态更新不能破坏现有消息结构
- 必须确保 JSON 可序列化
### 10.5 验收标准
- 新建线程可写入 `artifact`
- 读取线程时可返回 `artifact`
- 不影响现有线程读取
- 前端能从线程 state 中恢复 Canvas 内容
---
## 11. 阶段五:Canvas 专用 Agent 图
### 11.1 目标
实现与 open-canvas 类似的 AI 路由能力。
### 11.2 推荐方案
新增 `canvas_agent`,单独维护图逻辑。
### 11.3 节点拆分建议
首版建议至少拆成这些节点:
- `route_user_intent`
- `reply_general_input`
- `generate_artifact`
- `rewrite_artifact`
- `update_highlighted_text`
- `update_highlighted_code`
- `generate_followup`
二期可加:
- `rewrite_artifact_theme`
- `rewrite_code_theme`
- `change_length`
- `change_reading_level`
- `translate_artifact`
- `add_comments`
- `add_logs`
- `fix_bugs`
- `port_language`
- `web_search_enrich`
### 11.4 路由逻辑要求
系统需要能判断:
- 用户是在闲聊
- 用户要新生成 artifact
- 用户要整体重写当前 artifact
- 用户要局部修改当前 artifact
- 用户要执行某个快捷动作
### 11.5 状态输入要求
节点需要读取:
- 当前消息历史
- 当前 artifact
- 当前选区
- 当前模型配置
- Web 搜索开关
### 11.6 输出要求
节点输出必须能更新:
- `messages`
- `artifact`
- `web_search_results`
- `followup`
### 11.7 验收标准
- 能根据输入创建新 artifact
- 能根据输入整体改写 artifact
- 能保留版本历史
- 能在改写后返回自然语言 followup
---
## 12. 阶段六:前端流式同步与版本管理
### 12.1 目标
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
### 12.2 需求
- 复用现有 LangGraph stream client
- 在 Canvas 模式中解析 `artifact` 更新
- 支持流式文本更新
- 支持版本切换
- 支持断线后恢复线程状态
### 12.3 前端状态层建议
- `CanvasThreadContext`:线程基础信息
- `CanvasGraphContext`:消息、artifact、streaming 状态
- `CanvasUIContext`:面板、编辑态、选中态
### 12.4 关键能力
- 当前版本切换
- 新版本创建后自动定位到最新版本
- 手动编辑内容后保存
- 流结束后线程状态持久化
### 12.5 验收标准
- 发送消息时聊天和画布可同步更新
- 版本数会增加
- 切换历史版本稳定
- 刷新页面后线程状态能恢复
---
## 13. 阶段七:局部选区改写
### 13.1 目标
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
### 13.2 文本选区需求
- 支持在 Markdown 文本中选择一段文本
- 记录选区位置与原始文本
- 用户发出修改请求后,仅重写该选区
- 将选区结果合并为新版本 artifact
### 13.3 代码选区需求
- 支持在代码编辑器中选择一段代码
- 记录 start/end index 或行列信息
- AI 只返回替换片段
- 前端或后端负责将新片段拼接回完整代码
### 13.4 实现建议
首版建议:
- 文本:前端记录纯文本字符位置
- 代码:使用 CodeMirror selection 信息
不建议首版就做复杂块级富文本局部编辑。
### 13.5 风险
- Markdown 渲染态与源码态位置映射复杂
- 富文本块级位置难维护
- 流式更新中选区同步容易错位
### 13.6 首版折中方案
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
### 13.7 验收标准
- 文本局部改写成功
- 代码局部改写成功
- 原内容未改动部分保持不变
- 新版本正确入历史
---
## 14. 阶段八:快捷动作系统
### 14.1 目标
实现 open-canvas 风格的快捷动作体系。
### 14.2 首批推荐动作
文本类:
- 扩写
- 精简
- 改正式语气
- 改口语语气
- 改阅读难度
- 翻译
代码类:
- 加注释
- 加日志
- 修复 bug
- 优化代码
- 转换语言
### 14.3 设计方式
快捷动作不要直接写死在 UI,建议配置化:
- `id`
- `label`
- `type`
- `applicable_to`
- `prompt_template`
### 14.4 后端设计
快捷动作可通过:
- 前端传 `quick_action_id`
- 后端路由节点根据动作 id 选择 prompt
### 14.5 验收标准
- 文本动作可执行
- 代码动作可执行
- 结果进入新版本
- 不同动作提示词可扩展
---
## 15. 阶段九:模型配置统一与本地模型支持
### 15.1 目标
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
### 15.2 原则
- 后端 `config.yaml` 为真源
- 前端只展示后端暴露的模型信息
- 不维护一份脱离后端的前端常量模型表
### 15.3 本地模型支持范围
建议优先支持两类:
- `Ollama`
- OpenAI-compatible 本地服务
- LM Studio
- vLLM
- LocalAI
- one-api 风格代理
### 15.4 后端配置建议
后端模型配置应至少支持:
- `name`
- `display_name`
- `use`
- `model`
- `api_key`
- `base_url`
- `supports_vision`
- `supports_thinking`
- `supports_tool_calling`
- `is_local`
- `canvas_capabilities`
### 15.5 Canvas 模式额外要求
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
- `supports_canvas_generation`
- `supports_canvas_rewrite`
- `supports_canvas_code_edit`
- `supports_streaming_text`
- `supports_streaming_tool_calls`
### 15.6 与 open-canvas 的关系
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
### 15.7 验收标准
- 前端模型选择器可显示后端返回模型
- 可切换本地模型
- Canvas 模式能用本地模型生成和改写
---
## 16. 阶段十:Web 搜索增强
### 16.1 目标
实现类似 open-canvas 的可选 Web 搜索增强。
### 16.2 需求
- UI 提供 Web 搜索开关
- 后端在需要时走搜索节点
- 搜索结果写入线程状态
- 结果可在画布旁侧栏展示
### 16.3 首版建议
只做:
- 开关
- 搜索结果列表
- 搜索结果用于生成 artifact
暂不做复杂网页抓取编辑链路。
### 16.4 验收标准
- 打开搜索后可返回搜索结果
- 搜索结果可参与 artifact 生成
- 结果展示不阻塞主画布
---
## 17. 阶段十一:持久化、导出与文件产物联动
### 17.1 目标
让 Canvas 文档与 DeerFlow 原有文件能力协同。
### 17.2 需求
- artifact 持久化在线程 state 中
- 可手动导出为文件
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
- Canvas artifact 与 `artifacts: string[]` 可双向关联
### 17.3 推荐能力
- 导出 Markdown
- 导出代码文件
- 另存为 artifact file
- 从 artifact file 回填到 Canvas
### 17.4 验收标准
- 当前版本可导出
- 导出文件可在现有文件面板查看
- 文件型 artifact 和 Canvas artifact 能共存
---
## 18. 推荐开发顺序
建议严格按以下顺序推进:
1. 路由和目录骨架
2. Canvas 页面布局
3. 前端 artifact 数据模型
4. 后端线程状态扩展
5. 新建 Canvas Agent 图
6. 前后端流式同步
7. 整体生成与整体改写
8. 局部选区改写
9. 快捷动作
10. 本地模型支持完善
11. Web 搜索增强
12. 导出与文件联动
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
---
## 19. 里程碑定义
### M1:页面可进入
- 路由接通
- 双栏布局完成
- 本地 mock artifact 可渲染
### M2:后端状态打通
- 线程可保存结构化 artifact
- 页面刷新后能恢复
### M3:AI 生成可用
- 可新建文本/代码 artifact
- 可整体改写
- 可产生版本历史
### M4:核心体验可用
- 选区改写可用
- 快捷动作可用
- 模型切换可用
### M5:增强能力完整
- 本地模型支持完善
- Web 搜索增强
- 导出和文件联动
---
## 20. 风险与难点
### 20.1 最大难点
- 结构化 artifact 状态从无到有
- 局部选区改写的精确性
- 流式状态与版本管理的一致性
- 不破坏现有 DeerFlow 文件型 artifact 体系
### 20.2 技术风险
- 线程 state JSON 结构过大
- 大模型对局部改写指令遵循不稳定
- 本地模型 tool calling 与流式能力差异大
- 文本渲染态与源码态位置映射复杂
### 20.3 规避建议
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
- 首版只支持文本和代码两种 artifact 类型
- 首版先做整体生成和整体改写,再做局部改写
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
---
## 21. 验收总清单
### 21.1 前端
- 有独立 Canvas 路由
- 有专用目录和模块边界
- 双栏布局可用
- 文本和代码两类 artifact 可渲染
- 历史版本可切换
- 选区改写可交互
- 快捷动作可触发
- 模型可切换
### 21.2 后端
- 线程状态支持 `artifact`
- 新增 `canvas_agent` 或等价模式
- 生成、重写、局部改写可用
- 流式返回稳定
- 模型列表接口支持 Canvas 能力元数据
### 21.3 本地模型
- 可配置 Ollama
- 可配置 OpenAI-compatible 本地服务
- 前端可见并可切换
- 至少能完成文本生成和整体改写
---
## 22. 推荐首版最小可交付范围
如果要控制风险,建议首版只交付以下内容:
- 新增 `canvas/*` 路由
- 双栏布局
- 结构化 `artifact`
- `canvas_agent`
- 文本/代码 artifact 新建
- 整体生成
- 整体改写
- 历史版本切换
- 后端模型动态加载
- Ollama 或本地 OpenAI-compatible 至少一种可用
先不要把以下内容放进首版强制目标:
- 富文本块级精确编辑
- 全量快捷动作
- 复杂 Web 搜索增强
- 多 artifact 并行管理
---
## 23. 后续实施建议
建议下一步基于本文档继续输出两份落地文档:
### 23.1 前端实施清单
逐文件列出:
- 要新建哪些文件
- 哪些组件可复用
- 哪些页面要接入路由
- 哪些状态要从现有 hooks 抽离
### 23.2 后端实施清单
逐模块列出:
- 要改哪些 Python 文件
- 新增哪些状态字段
- 新增哪些 Agent 节点
- 哪些 API 需要扩展
---
## 24. 结论
这个需求很大,但完全可拆。
正确做法不是“先抄 UI”,而是按下面的主线推进:
- 先建前端 Canvas 子系统
- 再建结构化 artifact 状态
- 再建 Canvas Agent 图
- 再补局部改写和快捷动作
- 最后统一本地模型能力
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeerFlow Vite</title>
<title>CM 助手</title>
</head>
<body>
<div id="root"></div>
+538 -6
View File
@@ -48,6 +48,7 @@
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"gsap": "^3.13.0",
@@ -60,8 +61,10 @@
"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",
@@ -70,6 +73,7 @@
"sonner": "^2.0.7",
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
"tdesign-react": "^1.12.3",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
@@ -87,7 +91,8 @@
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.2",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2"
}
},
"node_modules/@ai-sdk/gateway": {
@@ -1663,6 +1668,15 @@
"node": ">=8.0.0"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4613,6 +4627,11 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="
},
"node_modules/@types/react": {
"version": "19.2.13",
"license": "MIT",
@@ -4628,6 +4647,16 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/sortablejs": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="
},
"node_modules/@types/tinycolor2": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz",
"integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4639,6 +4668,11 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA=="
},
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.4",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz",
@@ -4768,6 +4802,47 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
"dev": true,
"dependencies": {
"@babel/parser": "^7.29.3",
"@vue/shared": "3.5.34",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-core/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
"dev": true,
"dependencies": {
"@vue/compiler-core": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/shared": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
"dev": true
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
"license": "MIT",
@@ -5048,6 +5123,11 @@
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/clsx": {
"version": "2.1.1",
"license": "MIT",
@@ -5122,6 +5202,11 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/cose-base": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@@ -5696,6 +5781,44 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/docx": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
"integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "25.7.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz",
"integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==",
"dependencies": {
"undici-types": "~7.21.0"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
@@ -5839,6 +5962,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@@ -5857,6 +5986,18 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -5874,6 +6015,14 @@
}
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
@@ -5946,6 +6095,15 @@
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hast": {
"version": "1.0.0",
"license": "MIT"
@@ -6172,6 +6330,32 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6201,6 +6385,16 @@
"node": ">=0.10.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -6276,6 +6470,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -6296,8 +6495,7 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/jsesc": {
"version": "3.1.0",
@@ -6328,6 +6526,17 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/katex": {
"version": "0.16.28",
"funding": [
@@ -6401,6 +6610,14 @@
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -6664,6 +6881,30 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -6675,7 +6916,8 @@
},
"node_modules/lucide-react": {
"version": "0.562.0",
"license": "ISC",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@@ -7575,6 +7817,16 @@
}
]
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@@ -7666,6 +7918,14 @@
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
"dev": true
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ogl": {
"version": "1.0.11",
"license": "Unlicense"
@@ -7738,6 +7998,11 @@
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -7782,6 +8047,11 @@
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7869,6 +8139,34 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"engines": {
"node": ">=6"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -7878,6 +8176,14 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "19.2.4",
"license": "MIT",
@@ -7895,6 +8201,16 @@
"react": "^19.2.4"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -8039,6 +8355,74 @@
}
}
},
"node_modules/react-syntax-highlighter": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
"dependencies": {
"@babel/runtime": "^7.28.4",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^5.0.0"
},
"engines": {
"node": ">= 16.20.2"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/refractor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/prismjs": "^1.0.0",
"hastscript": "^9.0.0",
"parse-entities": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -8087,7 +8471,8 @@
},
"node_modules/rehype-raw": {
"version": "7.0.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0",
@@ -8100,7 +8485,8 @@
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
@@ -8238,11 +8624,24 @@
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -8262,6 +8661,11 @@
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/shiki": {
"version": "3.15.0",
"license": "MIT",
@@ -8284,6 +8688,11 @@
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -8332,6 +8741,14 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -8397,6 +8814,75 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tdesign-icons-react": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/tdesign-icons-react/-/tdesign-icons-react-0.5.0.tgz",
"integrity": "sha512-Gpl1Kxkb8+YXYjMsSW5W3kWy/drVB/huFLIHWhmJdUnEwUAW0Il+nDyb/BsRi51jLnalBwjYbOELEggH2qp6FQ==",
"dependencies": {
"@babel/runtime": "^7.16.5",
"classnames": "^2.2.6"
},
"peerDependencies": {
"react": ">=16.13.1",
"react-dom": ">=16.13.1"
}
},
"node_modules/tdesign-react": {
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/tdesign-react/-/tdesign-react-1.12.3.tgz",
"integrity": "sha512-zpGPUrxoGvK3ZMFIAM1GmFI2LEYzPfQSALK0egR/RSGuVaQmNSqgonGATz0N1/quAqlZjdRqVH5w6IOJ5ruyKA==",
"dependencies": {
"@babel/runtime": "~7.26.7",
"@popperjs/core": "~2.11.2",
"@types/sortablejs": "^1.10.7",
"@types/tinycolor2": "^1.4.3",
"@types/validator": "^13.1.3",
"classnames": "~2.5.1",
"dayjs": "1.11.10",
"hoist-non-react-statics": "~3.3.2",
"lodash-es": "^4.17.21",
"mitt": "^3.0.0",
"raf": "~3.4.1",
"react-fast-compare": "^3.2.2",
"react-is": "^18.2.0",
"react-transition-group": "~4.4.1",
"sortablejs": "^1.15.0",
"tdesign-icons-react": "0.5.0",
"tinycolor2": "^1.4.2",
"tslib": "~2.3.1",
"validator": "~13.15.0"
},
"peerDependencies": {
"react": ">=16.13.1",
"react-dom": ">=16.13.1"
}
},
"node_modules/tdesign-react/node_modules/@babel/runtime": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/tdesign-react/node_modules/dayjs": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
},
"node_modules/tdesign-react/node_modules/tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
},
"node_modules/tinyexec": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
@@ -8685,6 +9171,11 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "14.0.0",
"funding": [
@@ -8696,6 +9187,14 @@
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/validator": {
"version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -8810,6 +9309,23 @@
}
}
},
"node_modules/vite-plugin-source-identifier": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vite-plugin-source-identifier/-/vite-plugin-source-identifier-1.1.2.tgz",
"integrity": "sha512-Q6zN9G2yJUHcA2c1tlysKyDwBJPlKFbgwLQV7HHva9Yg64V4j6s8NSbfFJB5TiTisu+Xd/VyPUIy0A5zWEmBaA==",
"dev": true,
"dependencies": {
"@babel/core": "^7.24.0",
"@babel/parser": "^7.24.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.24.0",
"@vue/compiler-dom": "^3.4.0",
"magic-string": "^0.30.7"
},
"peerDependencies": {
"vite": ">=5.0.0"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
@@ -8867,6 +9383,22 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+6 -1
View File
@@ -50,6 +50,7 @@
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"gsap": "^3.13.0",
@@ -62,8 +63,10 @@
"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",
@@ -72,6 +75,7 @@
"sonner": "^2.0.7",
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
"tdesign-react": "^1.12.3",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
@@ -89,7 +93,8 @@
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.2",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2"
},
"packageManager": "pnpm@10.26.2"
}
+56 -155
View File
@@ -1,155 +1,56 @@
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 { QueryClientProvider } from "@/components/query-client-provider";
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 { 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}>
<ThemeProvider>
<ChatProvider>
<QueryClientProvider>{children}</QueryClientProvider>
</ChatProvider>
</ThemeProvider>
</I18nProvider>
</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 />} />
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
}
@@ -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();
}
@@ -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} />
@@ -0,0 +1,397 @@
import { useState } from "react";
import type { ElementType } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
BarChart3,
Bot,
Brain,
ChevronLeft,
ChevronRight,
CircleHelp,
Compass,
Database,
FileBox,
FileOutput,
FileText,
Folder,
FolderOpen,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
ListTodo,
MessageSquare,
MoreHorizontal,
Network,
PlayCircle,
Plus,
Scale,
Settings,
Target,
TrendingUp,
Users,
Workflow,
} from "lucide-react";
type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
children?: MenuItem[];
};
type PageSidebarProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
};
const menuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
icon: HelpCircle,
children: [
{ id: "new-qa", label: "新建问答", icon: Plus },
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/strategy/qa/general" },
{ id: "agent-qa", label: "智能体问答", icon: Bot },
{ id: "multi-agent-qa", label: "多智能体", icon: Layers },
{ id: "sixstep-chat", label: "六步对话", icon: Workflow },
{ id: "qa-records", label: "问答记录", icon: History },
{ id: "template-editor", label: "模板配置", icon: Settings },
],
},
{
id: "light-app-center",
label: "轻应用",
icon: Grid3X3,
children: [
{ id: "light-app-square", label: "应用广场", icon: Layout },
{ id: "light-app-records", label: "运行记录", icon: History },
],
},
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
{ id: "dashboard", label: "数据看板", icon: BarChart3 },
{
id: "xk-task",
label: "XK任务",
icon: Folder,
children: [
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
],
},
{
id: "plan-task",
label: "规划任务",
icon: Folder,
children: [
{ id: "plan-task-fx", label: "任务分析", icon: ListTodo },
{ id: "plan-task-action", label: "行动策略", icon: ListTodo },
],
},
{
id: "mnty-task",
label: "MNTY任务",
icon: Folder,
children: [
{ id: "mnty-task-mechanism", label: "机制研究", icon: ListTodo },
{ id: "mnty-task-plan", label: "规划任务", icon: ListTodo },
{ id: "mnty-task-zsty", label: "ZSTY任务", icon: ListTodo },
],
},
{
id: "effect-task",
label: "效果PG任务",
icon: Folder,
children: [
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
{ id: "effect-account-pg", label: "账号PG", icon: ListTodo },
],
},
{ id: "task-create", label: "创建任务", icon: Plus },
{ id: "task-single", label: "单智能体任务", icon: Bot },
{ id: "task-scene", label: "场景任务", icon: Workflow },
{ id: "task-consult-create", label: "多智能体协商", icon: MessageSquare },
{ id: "final-plan", label: "最终输出", icon: FileOutput },
],
},
{ id: "scene-list", label: "场景管理", icon: Layers },
{
id: "agent-list",
label: "智能体管理",
icon: Bot,
children: [
{ id: "agent-overview", label: "智能体列表", icon: Users, path: "/page/strategy/agents" },
{ id: "agent-trial", label: "单智能体试用", icon: PlayCircle },
],
},
{
id: "result-management",
label: "结果管理",
icon: BarChart3,
children: [
{
id: "result-planning",
label: "规划结果",
icon: Target,
children: [
{ id: "result-planning-all", label: "全部", icon: BarChart3 },
{ id: "result-planning-goal", label: "目标分析", icon: Target },
{ id: "result-planning-brand", label: "品牌策略", icon: Target },
{ id: "result-planning-content", label: "内容规划", icon: Target },
{ id: "result-planning-channel", label: "渠道规划", icon: Target },
{ id: "result-planning-narrative", label: "叙事策略", icon: Target },
],
},
{
id: "result-mnty",
label: "MNTY结果",
icon: Brain,
children: [
{ id: "result-mnty-all", label: "全部", icon: BarChart3 },
{ id: "result-mnty-mechanism", label: "机制研究", icon: Brain },
{ id: "result-mnty-plan", label: "规划设计", icon: Brain },
{ id: "result-mnty-zsty", label: "ZSTY分析", icon: Brain },
],
},
{
id: "result-effect-pg",
label: "效果PG结果",
icon: TrendingUp,
children: [
{ id: "result-effect-pg-all", label: "全部", icon: BarChart3 },
{ id: "result-effect-pg-behavior", label: "行为PG", icon: TrendingUp },
{ id: "result-effect-pg-evaluation", label: "效果复盘", icon: TrendingUp },
{ id: "result-effect-pg-info", label: "信息PG", icon: TrendingUp },
{ id: "result-effect-pg-account", label: "账号PG", icon: TrendingUp },
],
},
{
id: "result-xk",
label: "XK结果",
icon: Compass,
children: [
{ id: "result-xk-all", label: "全部", icon: BarChart3 },
{ id: "result-xk-jc", label: "任务JC", icon: Compass },
{ id: "result-xk-dispatch", label: "任务调度", icon: Compass },
],
},
{
id: "result-dq",
label: "DQ结果",
icon: Database,
children: [
{ id: "result-dq-all", label: "全部", icon: BarChart3 },
{ id: "result-dq-audience", label: "受众分析", icon: Database },
{ id: "result-dq-kb", label: "知识研究", icon: Database },
{ id: "result-dq-trend", label: "趋势研究", icon: Database },
],
},
{
id: "result-other",
label: "其他结果",
icon: MoreHorizontal,
children: [
{ id: "result-other-all", label: "全部", icon: BarChart3 },
{ id: "result-other-general", label: "通用分析", icon: MoreHorizontal },
{ id: "result-other-temp", label: "临时任务", icon: MoreHorizontal },
{ id: "result-other-other", label: "其他", icon: MoreHorizontal },
],
},
],
},
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
{ id: "resource-zz", label: "ZZ资源", icon: FileBox },
{ id: "resource-network-actor", label: "网络达人", icon: Network },
{ id: "resource-knowledge-base", label: "知识库", icon: Library },
{ id: "resource-evaluation-index", label: "评估指标", icon: Scale },
],
},
];
const defaultExpandedIds = new Set([
"qa-center",
"task-management",
"xk-task",
"result-management",
"result-planning",
]);
export function PageSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
const navigate = useNavigate();
const { pathname } = useLocation();
const iconButtonClass =
"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 = "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 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={`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
} ${
isActive
? "bg-red-50 text-red-600 font-semibold"
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
}`}
>
<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={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
collapsed && isTopLevel ? "justify-center px-2" : "gap-2 px-3"
}`}
>
<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 (
<aside
className={`fixed left-0 top-0 z-40 h-screen overflow-y-auto border-r border-red-100 bg-white shadow-sm transition-all duration-300 ${
collapsed ? "w-16" : "w-64"
}`}
>
<div
className={`flex h-16 items-center border-b border-red-100 ${
collapsed ? "justify-center px-2" : "justify-between px-6"
}`}
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-400 transition-transform duration-300 ease-out">
<FileText className="h-4 w-4 text-white" />
</div>
{!collapsed && (
<div className="overflow-hidden whitespace-nowrap animate-in fade-in-0 slide-in-from-left-2 duration-300">
<h1 className="text-sm font-semibold text-gray-900">DeerFlow</h1>
<p className="text-xs text-red-400/80">Page Navigation</p>
</div>
)}
</div>
{!collapsed && (
<button
type="button"
onClick={onToggleCollapsed}
className={iconButtonClass}
title="Collapse sidebar"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
</div>
<nav className={`${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{collapsed && (
<button
type="button"
onClick={onToggleCollapsed}
className={`flex w-full items-center justify-center ${iconButtonClass}`}
title="Expand sidebar"
>
<ChevronRight className="h-5 w-5" />
</button>
)}
{menuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
</nav>
<div className="pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full bg-red-500/5 blur-3xl" />
<div className="pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full bg-red-400/5 blur-3xl" />
</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 (
+27 -27
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
@@ -37,27 +37,27 @@ const buttonVariants = cva(
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
{...(variant !== undefined && { "data-variant": variant })}
{...(size !== undefined && { "data-size": size })}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
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 })}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };
+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,
+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 };
+90 -87
View File
@@ -24,68 +24,69 @@ 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";
}) {
return (
<SelectPrimitive.Trigger
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",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
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",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
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",
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,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
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",
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,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectLabel({
className,
@@ -100,32 +101,34 @@ function SelectLabel({
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
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",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</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",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
function SelectSeparator({
className,
+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 (
+177 -156
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,49 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
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(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
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(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
function SidebarGroupContent({
className,
@@ -495,87 +503,99 @@ 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
ref={ref}
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}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
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}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
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(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
function SidebarMenuBadge({
className,
@@ -666,37 +686,37 @@ function SidebarMenuSubItem({
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
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}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
@@ -723,4 +743,5 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
useSidebarSafe,
};
+53 -45
View File
@@ -40,52 +40,60 @@ const tabsListVariants = cva(
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
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}
/>
);
});
TabsList.displayName = TabsPrimitive.List.displayName;
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
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",
className,
)}
{...props}
/>
);
}
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",
className,
)}
{...props}
/>
);
});
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
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 };
+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,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">
{t.adminTags.name}
</th>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
{t.adminTags.tagDescription}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{t.adminTags.usageCount}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{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,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>
);
}
@@ -6,6 +6,7 @@ import {
EyeOffIcon,
MessageSquareIcon,
SquarePenIcon,
TagsIcon,
Trash2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -33,6 +34,8 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
@@ -51,6 +54,7 @@ export function AgentCard({ agent }: AgentCardProps) {
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 ?? "");
@@ -59,11 +63,18 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.skills ?? [],
);
const currentUserId = useMemo(() => getStoredAuth()?.user_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 built-in agents.
// Publish-toggle stays owner-only — built-ins are inherently visible to
// everyone so toggling published is moot.
const canEdit = isOwned || (isBuiltin && isAdmin);
const canDelete = isOwned || (isBuiltin && isAdmin);
useEffect(() => {
if (!editOpen) {
@@ -84,7 +95,7 @@ export function AgentCard({ agent }: AgentCardProps) {
]);
function handleChat() {
router.push(`/workspace/agents/${agent.id}/chats/new`);
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
async function handleDelete() {
@@ -205,6 +216,12 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1">
@@ -235,40 +252,57 @@ export function AgentCard({ agent }: AgentCardProps) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{isOwned && (
{(canEdit || canDelete || isOwned || isAdmin) && (
<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>
{isAdmin && (
<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 ? (
<EyeOffIcon className="h-3.5 w-3.5" />
) : (
<EyeIcon 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>
)}
</div>
)}
</CardFooter>
@@ -401,6 +435,16 @@ export function AgentCard({ agent }: AgentCardProps) {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
)}
</>
);
}
@@ -1,69 +1,130 @@
"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 { BotIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/agents query.
const { agents, isLoading } = useAgents({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const allAgents = useMemo(
() => agents.filter((a) => a.builtin || a.published),
[agents],
);
const myAgents = useMemo(
() => agents.filter((a) => a.user_id !== null && a.user_id === currentUserId),
[agents, currentUserId],
);
const handleNewAgent = () => {
router.push("/page/workspace/agents/new");
};
const renderGrid = (list: typeof agents) => (
<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>
);
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">
<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">{description}</p>
</div>
{showNewButton && (
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
)}
</div>
);
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")}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
<TabsList className="w-auto self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
</TabsList>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="w-[420px] max-w-[50%]"
/>
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : allAgents.length === 0 ? (
renderEmpty(t.agents.emptyDescription)
) : (
renderGrid(allAgents)
)}
</TabsContent>
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : myAgents.length === 0 ? (
renderEmpty(t.agents.emptyDescription, true)
) : (
renderGrid(myAgents)
)}
</TabsContent>
</Tabs>
</div>
);
}
@@ -6,7 +6,7 @@ import {
type ReactNode,
} from "react";
import { useSidebar } from "@/components/ui/sidebar";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env";
export interface ArtifactsContextType {
@@ -39,19 +39,19 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [autoOpen, setAutoOpen] = useState(true);
const { setOpen: setSidebarOpen } = useSidebar();
const sidebar = useSidebarSafe();
const select = useCallback(
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
setSidebarOpen(false);
sidebar?.setOpen(false);
}
if (!autoSelect) {
setAutoSelect(false);
}
},
[setSidebarOpen, setSelectedArtifact, setAutoSelect],
[sidebar, setSelectedArtifact, setAutoSelect],
);
const deselect = useCallback(() => {
@@ -34,27 +34,27 @@ const customLightTheme = basicLightInit({
},
});
export function CodeEditor({
className,
placeholder,
value,
onChange,
readonly,
disabled,
autoFocus,
settings,
streamingReadonlyFallback = true,
}: {
className?: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
settings?: unknown;
streamingReadonlyFallback?: boolean;
}) {
export function CodeEditor({
className,
placeholder,
value,
onChange,
readonly,
disabled,
autoFocus,
settings,
streamingReadonlyFallback = true,
}: {
className?: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
settings?: unknown;
streamingReadonlyFallback?: boolean;
}) {
const {
thread: { isLoading },
} = useThread();
@@ -81,8 +81,8 @@ export function CodeEditor({
className,
)}
>
{isLoading && streamingReadonlyFallback ? (
<Textarea
{isLoading && streamingReadonlyFallback ? (
<Textarea
className={cn(
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
"resize-none p-4! [&_.cm-line]:px-2! [&_.cm-line]:py-0!",
@@ -109,11 +109,11 @@ export function CodeEditor({
lineNumbers:
(settings as { lineNumbers?: boolean })?.lineNumbers ?? false,
}}
autoFocus={autoFocus}
value={value}
onChange={onChange}
/>
)}
autoFocus={autoFocus}
value={value}
onChange={onChange}
/>
)}
</div>
);
}
@@ -1,134 +1,134 @@
"use client";
import {
KeyboardIcon,
MessageSquarePlusIcon,
SettingsIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
import { SettingsDialog } from "./settings";
export function CommandPalette() {
const { t } = useI18n();
const router = useRouter();
const [open, setOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [isMac, setIsMac] = useState(false);
const handleNewChat = useCallback(() => {
router.push("/workspace/chats/new");
setOpen(false);
}, [router]);
const handleOpenSettings = useCallback(() => {
setOpen(false);
setSettingsOpen(true);
}, []);
const handleShowShortcuts = useCallback(() => {
setOpen(false);
setShortcutsOpen(true);
}, []);
const shortcuts = useMemo(
() => [
{ key: "k", meta: true, action: () => setOpen((o) => !o) },
{ key: "n", meta: true, shift: true, action: handleNewChat },
{ key: ",", meta: true, action: handleOpenSettings },
{ key: "/", meta: true, action: handleShowShortcuts },
],
[handleNewChat, handleOpenSettings, handleShowShortcuts],
);
useGlobalShortcuts(shortcuts);
useEffect(() => {
setIsMac(navigator.userAgent.includes("Mac"));
}, []);
const metaKey = isMac ? "⌘" : "Ctrl+";
const shiftKey = isMac ? "⇧" : "Shift+";
return (
<>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t.shortcuts.searchActions} />
<CommandList>
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
<CommandGroup heading={t.shortcuts.actions}>
<CommandItem onSelect={handleNewChat}>
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
{t.sidebar.newChat}
<CommandShortcut>
{metaKey}
{shiftKey}N
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenSettings}>
<SettingsIcon className="mr-2 h-4 w-4" />
{t.common.settings}
<CommandShortcut>{metaKey},</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleShowShortcuts}>
<KeyboardIcon className="mr-2 h-4 w-4" />
{t.shortcuts.keyboardShortcuts}
<CommandShortcut>{metaKey}/</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
<DialogDescription>
{t.shortcuts.keyboardShortcutsDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
{[
{ keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette },
{ keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat },
{ keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar },
{ keys: `${metaKey},`, label: t.common.settings },
{
keys: `${metaKey}/`,
label: t.shortcuts.keyboardShortcuts,
},
].map(({ keys, label }) => (
<div key={keys} className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
{keys}
</kbd>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}
"use client";
import {
KeyboardIcon,
MessageSquarePlusIcon,
SettingsIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
import { SettingsDialog } from "./settings";
export function CommandPalette() {
const { t } = useI18n();
const router = useRouter();
const [open, setOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [isMac, setIsMac] = useState(false);
const handleNewChat = useCallback(() => {
router.push("/page/workspace/chats/new");
setOpen(false);
}, [router]);
const handleOpenSettings = useCallback(() => {
setOpen(false);
setSettingsOpen(true);
}, []);
const handleShowShortcuts = useCallback(() => {
setOpen(false);
setShortcutsOpen(true);
}, []);
const shortcuts = useMemo(
() => [
{ key: "k", meta: true, action: () => setOpen((o) => !o) },
{ key: "n", meta: true, shift: true, action: handleNewChat },
{ key: ",", meta: true, action: handleOpenSettings },
{ key: "/", meta: true, action: handleShowShortcuts },
],
[handleNewChat, handleOpenSettings, handleShowShortcuts],
);
useGlobalShortcuts(shortcuts);
useEffect(() => {
setIsMac(navigator.userAgent.includes("Mac"));
}, []);
const metaKey = isMac ? "⌘" : "Ctrl+";
const shiftKey = isMac ? "⇧" : "Shift+";
return (
<>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t.shortcuts.searchActions} />
<CommandList>
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
<CommandGroup heading={t.shortcuts.actions}>
<CommandItem onSelect={handleNewChat}>
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
{t.sidebar.newChat}
<CommandShortcut>
{metaKey}
{shiftKey}N
</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenSettings}>
<SettingsIcon className="mr-2 h-4 w-4" />
{t.common.settings}
<CommandShortcut>{metaKey},</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleShowShortcuts}>
<KeyboardIcon className="mr-2 h-4 w-4" />
{t.shortcuts.keyboardShortcuts}
<CommandShortcut>{metaKey}/</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
<DialogDescription>
{t.shortcuts.keyboardShortcutsDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
{[
{ keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette },
{ keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat },
{ keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar },
{ keys: `${metaKey},`, label: t.common.settings },
{
keys: `${metaKey}/`,
label: t.shortcuts.keyboardShortcuts,
},
].map(({ keys, label }) => (
<div key={keys} className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
{keys}
</kbd>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -2,9 +2,9 @@
import type { ChatStatus } from "ai";
import {
CheckIcon,
FileTextIcon,
GraduationCapIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
PlusIcon,
@@ -81,12 +81,16 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
@@ -114,6 +118,7 @@ export function InputBox({
onFollowupsVisibilityChange,
onSubmit,
onStop,
promptPrefix,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -123,10 +128,10 @@ export function InputBox({
AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
};
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
threadId: string;
@@ -136,14 +141,24 @@ export function InputBox({
AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
},
) => void;
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
/**
* Optional toggle rendered next to the model selector. Used for the
* admin-configurable prompt-prefix switch on the chat input. Omitting the
* prop hides the button entirely.
*/
promptPrefix?: {
label: string;
on: boolean;
onToggle: () => void;
};
}) {
const { t } = useI18n();
const searchParams = useSearchParams();
@@ -164,6 +179,8 @@ export function InputBox({
null,
);
const { questions: recommendedQuestions } = useRecommendedQuestions();
useEffect(() => {
if (models.length === 0) {
return;
@@ -239,22 +256,22 @@ export function InputBox({
[onContextChange, context, supportThinking],
);
const handleReasoningEffortSelect = useCallback(
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
onContextChange?.({
...context,
reasoning_effort: effort,
});
},
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
writing_mode: !context.writing_mode,
});
}, [context, onContextChange]);
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
writing_mode: !context.writing_mode,
});
}, [context, onContextChange]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
@@ -440,7 +457,32 @@ export function InputBox({
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-4">
<div ref={promptRootRef} className="relative flex flex-col gap-6">
{extraHeader && (
<div className="flex items-center justify-center">
{extraHeader}
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && recommendedQuestions.length > 0 && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
key={q.id}
type="button"
className="rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
onClick={() => {
textInput.setInput(q.content);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
}}
>
{q.title}
</button>
))}
</div>
)}
{showFollowups && (
<div className="flex items-center justify-center pb-2">
<div className="flex items-center gap-2">
@@ -474,7 +516,7 @@ export function InputBox({
)}
<PromptInput
className={cn(
"rounded-[1.85rem] border border-[#eadfce] bg-white shadow-[0_24px_70px_-34px_rgba(120,83,42,0.32)] ring-1 ring-[#f6efe5] transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-[1.85rem]",
"rounded-[1.85rem] border border-[#eadfce] bg-white shadow-[0_24px_70px_-34px_rgba(120,83,42,0.32)] ring-1 ring-[#f6efe5] transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-[1.85rem] dark:bg-slate-800 dark:border-slate-700 dark:ring-slate-700 dark:shadow-none",
className,
)}
disabled={disabled}
@@ -483,20 +525,13 @@ export function InputBox({
onSubmit={handleSubmit}
{...props}
>
{extraHeader && (
<div className="absolute top-0 right-0 left-0 z-10">
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-center">
{extraHeader}
</div>
</div>
)}
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
<PromptInputTextarea
className={cn(
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-[#b7ab9a]",
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-[#b7ab9a] dark:placeholder:text-slate-500",
)}
disabled={disabled}
placeholder={t.inputBox.placeholder}
@@ -504,7 +539,7 @@ export function InputBox({
defaultValue={initialValue}
/>
</PromptInputBody>
<PromptInputFooter className="flex items-center border-t border-[#f4ede4] px-2 pt-2.5 pb-1.5">
<PromptInputFooter className="flex items-center border-t border-[#f4ede4] dark:border-slate-700 px-2 pt-2.5 pb-1.5">
<PromptInputTools className="gap-1.5">
{/* TODO: Add more connectors here
<PromptInputActionMenu>
@@ -515,28 +550,28 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" />
<Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none",
context.writing_mode
? "border-[#d8b878] bg-[#fff3cf] text-[#6d5625] hover:bg-[#ffedbd]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white",
)}
onClick={handleWritingModeToggle}
>
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" />
<Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none",
context.writing_mode
? "border-[#d8b878] bg-[#fff3cf] text-[#6d5625] hover:bg-[#ffedbd]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white",
)}
onClick={handleWritingModeToggle}
>
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
context.mode === "flash" ||
@@ -547,7 +582,7 @@ export function InputBox({
: "flash"
}
>
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white">
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
<div>
{context.mode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && (
@@ -709,7 +744,7 @@ export function InputBox({
</PromptInputActionMenu>
{supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white">
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" &&
@@ -825,12 +860,40 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className="gap-1.5">
{promptPrefix && (
<Tooltip
content={
promptPrefix.on
? `已开启:${promptPrefix.label}`
: `点击开启:${promptPrefix.label}`
}
>
<PromptInputButton
type="button"
onClick={promptPrefix.onToggle}
aria-pressed={promptPrefix.on}
className={cn(
"rounded-full border px-3 shadow-none transition-colors",
promptPrefix.on
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#8a7558] hover:bg-white",
)}
>
<span className="max-w-[160px] truncate text-xs font-normal">
{promptPrefix.label}
</span>
{promptPrefix.on && (
<CheckIcon className="ml-1 size-3.5 text-[#7a5417]" />
)}
</PromptInputButton>
</Tooltip>
)}
<ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
>
<ModelSelectorTrigger asChild>
<PromptInputButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 shadow-none hover:bg-white">
<PromptInputButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
<div className="flex min-w-0 flex-col items-start text-left">
<ModelSelectorName className="text-xs font-normal">
{selectedModel?.display_name}
@@ -864,7 +927,7 @@ export function InputBox({
</ModelSelectorContent>
</ModelSelector>
<PromptInputSubmit
className="rounded-full border-[#e7d9c7] bg-white shadow-[0_10px_24px_-18px_rgba(120,83,42,0.34)]"
className="rounded-full border-[#e7d9c7] bg-white shadow-[0_10px_24px_-18px_rgba(120,83,42,0.34)] dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
disabled={disabled}
variant="outline"
status={status}
@@ -872,16 +935,10 @@ export function InputBox({
</PromptInputTools>
</PromptInputFooter>
{!isNewThread && (
<div className="absolute right-0 -bottom-[17px] left-0 z-0 h-4 bg-[#fffdfa]"></div>
<div className="absolute right-0 -bottom-[17px] left-0 z-0 h-4 bg-[#fffdfa] dark:bg-background"></div>
)}
</PromptInput>
{isNewThread && searchParams.get("mode") !== "skill" && (
<div className="flex items-center justify-center pt-2">
<SuggestionList />
</div>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
@@ -1,14 +1,14 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { SendIcon } from "lucide-react";
import { useMemo, useState } from "react";
import {
Conversation,
ConversationContent,
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import {
Conversation,
ConversationContent,
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
extractPresentFilesFromMessage,
@@ -31,7 +31,10 @@ import { StreamingIndicator } from "../streaming-indicator";
import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { MessageTokenUsageList } from "./message-token-usage";
import {
MessageMetricsProvider,
MessageTokenUsageList,
} from "./message-token-usage";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
@@ -42,17 +45,21 @@ export function MessageList({
className,
threadId,
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
onClarificationSubmit,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
}) {
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
onClarificationSubmit,
onSetupAgentApprove,
onSetupAgentReject,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
@@ -64,7 +71,8 @@ export function MessageList({
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
@@ -83,13 +91,13 @@ export function MessageList({
if (message && hasContent(message)) {
return (
<div key={group.id} className="w-full">
<ClarificationCard
fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading}
message={message}
onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins}
/>
<ClarificationCard
fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading}
message={message}
onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -99,6 +107,23 @@ export function MessageList({
);
}
return null;
} else if (group.type === "assistant:setup-agent-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupAgentApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupAgentApprove}
onReject={onSetupAgentReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -244,152 +269,294 @@ export function MessageList({
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
</Conversation>
);
}
type ClarificationOption = {
id: string;
label: string;
description?: string;
};
type ClarificationPayload = {
question?: string;
context?: string | null;
clarification_type?: string;
options?: ClarificationOption[];
allow_custom?: boolean;
};
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const clarification = Reflect.get(additionalKwargs, "clarification");
if (typeof clarification !== "object" || clarification === null) {
return null;
}
return clarification as ClarificationPayload;
}
function ClarificationCard({
fallbackContent,
isLoading,
message,
onSubmit,
rehypePlugins,
}: {
fallbackContent: string;
isLoading: boolean;
message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const options = useMemo(
() =>
(payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
),
[payload?.options],
);
if (!payload) {
return (
<MarkdownContent
content={fallbackContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
);
}
const disabled = isLoading || submitting || !onSubmit;
async function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed || disabled) {
return;
}
setSubmitting(true);
try {
await onSubmit?.(trimmed);
} finally {
setSubmitting(false);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
需要你的确认
</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">
{payload.context}
</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => void submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
) : null}
</span>
</Button>
))}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={disabled}
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={disabled || !customAnswer.trim()}
onClick={() => void submitAnswer(customAnswer)}
>
<SendIcon className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
);
}
type ClarificationOption = {
id: string;
label: string;
description?: string;
};
type ClarificationPayload = {
question?: string;
context?: string | null;
clarification_type?: string;
options?: ClarificationOption[];
allow_custom?: boolean;
};
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
soul?: string;
skills?: string[];
};
function getSetupAgentApprovalPayload(
message: unknown,
): SetupAgentApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_agent_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupAgentApprovalPayload;
}
function SetupAgentApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupAgentApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
null,
);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const skills = approvalPayload.skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">
创建智能体审批
</div>
<div className="text-sm font-medium">请确认是否写入这个智能体配置。</div>
</div>
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">名称</div>
<p className="text-sm font-medium">{approvalPayload.name}</p>
</div>
) : null}
{approvalPayload.description ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.description}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">技能</div>
<div className="flex flex-wrap gap-1.5">
{skills.map((skill) => (
<span
key={skill}
className="bg-secondary text-secondary-foreground rounded-md px-2 py-1 text-xs"
>
{skill}
</span>
))}
</div>
</div>
) : null}
{approvalPayload.soul ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">SOUL.md</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.soul}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
拒绝
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
批准创建
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const clarification = Reflect.get(additionalKwargs, "clarification");
if (typeof clarification !== "object" || clarification === null) {
return null;
}
return clarification as ClarificationPayload;
}
function ClarificationCard({
fallbackContent,
isLoading,
message,
onSubmit,
rehypePlugins,
}: {
fallbackContent: string;
isLoading: boolean;
message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const options = useMemo(
() =>
(payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
),
[payload?.options],
);
if (!payload) {
return (
<MarkdownContent
content={fallbackContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
);
}
const disabled = false;
async function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed) {
return;
}
setSubmitting(true);
try {
await onSubmit?.(trimmed);
setSubmitted(true);
} finally {
setSubmitting(false);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
需要你的确认
</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">
{payload.context}
</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => void submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
) : null}
</span>
</Button>
))}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={disabled}
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={disabled || !customAnswer.trim()}
onClick={() => void submitAnswer(customAnswer)}
>
<SendIcon className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
@@ -1,28 +1,98 @@
import type { Message } from "@langchain/langgraph-sdk";
import { CoinsIcon } from "lucide-react";
import { CoinsIcon, GaugeIcon } from "lucide-react";
import { useI18n } from "@/core/i18n/hooks";
import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import {
MessageMetricsContext,
useMessageMetric,
useMessageMetrics,
type MessageMetric,
} from "./use-message-metrics";
const FULL_SCALE_TOKENS_PER_SEC = 80;
function classifySpeed(tps: number): { color: string; label: string } {
if (tps >= 50) return { color: "#16a34a", label: "极快" };
if (tps >= 30) return { color: "#65a30d", label: "流畅" };
if (tps >= 15) return { color: "#eab308", label: "一般" };
if (tps >= 5) return { color: "#f97316", label: "偏慢" };
return { color: "#ef4444", label: "缓慢" };
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const mins = Math.floor(ms / 60_000);
const secs = Math.round((ms % 60_000) / 1000);
return `${mins}m ${secs}s`;
}
function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
metric.tokensPerSec >= 100
? Math.round(metric.tokensPerSec).toString()
: metric.tokensPerSec.toFixed(1);
const prefix = metric.fromUsage ? "" : "~";
return (
<span
className="inline-flex items-center gap-1.5"
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
>
<GaugeIcon className="size-3" style={{ color }} />
<span className="font-mono tabular-nums" style={{ color }}>
{prefix}
{display}
</span>
<span className="text-muted-foreground">tok/s</span>
<span className="relative inline-block h-1 w-12 overflow-hidden rounded-full bg-current/15 align-middle">
<span
className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-200 ease-out"
style={{ width: `${fill * 100}%`, backgroundColor: color }}
/>
</span>
</span>
);
}
export function MessageTokenUsage({
className,
enabled = false,
isLoading = false,
message,
metric,
}: {
className?: string;
enabled?: boolean;
isLoading?: boolean;
message: Message;
metric?: MessageMetric;
}) {
const { t } = useI18n();
const [settings] = useLocalSettings();
const speedEnabled = settings.display.streamSpeedMeterEnabled;
const contextMetric = useMessageMetric(message.id as string | undefined);
const resolvedMetric = metric ?? contextMetric;
if (!enabled || isLoading || message.type !== "ai") {
if (message.type !== "ai") {
return null;
}
// Show speed-only line while streaming (no usage yet) if the user opted in,
// otherwise wait until usage arrives so the bar doesn't render on its own.
const usage = getUsageMetadata(message);
const showUsage = enabled && !isLoading && Boolean(usage);
const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0;
if (!showUsage && !showSpeed) {
return null;
}
return (
<div
@@ -31,12 +101,12 @@ export function MessageTokenUsage({
className,
)}
>
<span className="inline-flex items-center gap-1 font-medium">
<CoinsIcon className="size-3" />
{t.tokenUsage.label}
</span>
{usage ? (
{showUsage && usage ? (
<>
<span className="inline-flex items-center gap-1 font-medium">
<CoinsIcon className="size-3" />
{t.tokenUsage.label}
</span>
<span>
{t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)}
</span>
@@ -47,9 +117,17 @@ export function MessageTokenUsage({
{t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)}
</span>
</>
) : (
<span>{t.tokenUsage.unavailableShort}</span>
)}
) : null}
{showSpeed && resolvedMetric ? (
<>
<SpeedPill metric={resolvedMetric} />
{resolvedMetric.durationMs > 0 && (
<span className="font-mono tabular-nums">
{formatDuration(resolvedMetric.durationMs)}
</span>
)}
</>
) : null}
</div>
);
}
@@ -65,12 +143,14 @@ export function MessageTokenUsageList({
isLoading?: boolean;
messages: Message[];
}) {
if (!enabled || isLoading) {
const [settings] = useLocalSettings();
const speedEnabled = settings.display.streamSpeedMeterEnabled;
if (!enabled && !speedEnabled) {
return null;
}
const aiMessages = messages.filter((message) => message.type === "ai");
if (aiMessages.length === 0) {
return null;
}
@@ -89,3 +169,25 @@ export function MessageTokenUsageList({
</>
);
}
/**
* Hoist the metrics map computation to the top of the message list so
* descendant {@link MessageTokenUsage} components can read it via context
* instead of each running its own hook.
*/
export function MessageMetricsProvider({
messages,
isLoading,
children,
}: {
messages: Message[];
isLoading: boolean;
children: React.ReactNode;
}) {
const metrics = useMessageMetrics(messages, isLoading);
return (
<MessageMetricsContext.Provider value={metrics}>
{children}
</MessageMetricsContext.Provider>
);
}
@@ -24,7 +24,7 @@ function SkeletonBar({
export function MessageListSkeleton() {
let index = 0;
return (
<div className="flex w-full max-w-(--container-width-md) flex-col gap-12 p-8 pt-16">
<div className="flex w-full max-w-(--container-width-lg) flex-col gap-12 p-8 pt-16">
<div
role="human-message"
className="flex w-[50%] flex-col items-end gap-2 self-end"
@@ -0,0 +1,235 @@
"use client";
import type { Message } from "@langchain/langgraph-sdk";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { extractReasoningContentFromMessage } from "@/core/messages/utils";
import { getUsageMetadata } from "@/core/messages/usage";
import { textOfMessage } from "@/core/threads/utils";
/**
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
* speed indicator for mixed Chinese/English replies without dragging in a
* real tokeniser.
*/
const CHARS_PER_TOKEN = 2;
/** Sample throttle while a message is streaming. */
const SAMPLE_THROTTLE_MS = 60;
/**
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
*/
export interface MessageMetric {
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
tokensPerSec: number;
/**
* Wall-clock duration from when the message first appeared with text to
* either *now* (active) or to when the stream ended (frozen).
*/
durationMs: number;
/** True while this is the currently streaming message. */
isActive: boolean;
/**
* Whether the speed value was computed from real usage_metadata output
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
* only to add a "~" prefix when the value is approximate.
*/
fromUsage: boolean;
}
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastChars: number;
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
hadUsage: boolean;
};
function isAiMessage(message: Message): boolean {
return message.type === "ai";
}
function getMessageId(message: Message, fallbackIndex: number): string {
const id = (message.id as string | undefined) ?? undefined;
return id ?? `idx-${fallbackIndex}`;
}
function messageText(message: Message): string {
// Count both the visible answer text *and* the reasoning trace so the speed
// indicator stays meaningful during the thinking phase — for reasoning
// models the answer body can be empty for tens of seconds while the model
// streams reasoning tokens, and ignoring those would make the meter sit at
// zero. We treat the two as one continuous stream of generated characters.
const body = textOfMessage(message);
const reasoning = extractReasoningContentFromMessage(message) ?? "";
const bodyText = typeof body === "string" ? body : "";
return bodyText + reasoning;
}
/**
* Returns a Map keyed by AI message id → live metric snapshot.
*
* Recording strategy:
* - Whenever a message renders with text, we anchor `firstSeenAt`.
* - On every text-length change we push a sample and recompute the rate.
* - When `isLoading` flips to false we mark *all* still-active messages as
* ended so subsequent renders return a frozen duration.
* - Token totals come from usage_metadata when present (most accurate);
* otherwise we fall back to char-count / 2.
*/
export function useMessageMetrics(
messages: Message[],
isLoading: boolean,
): Map<string, MessageMetric> {
const timingRef = useRef<Map<string, TimingEntry>>(new Map());
const lastSampleAtRef = useRef(0);
// Bump a counter to force recomputation when samples push but message refs
// didn't change — needed because the timing map mutation alone wouldn't
// re-render anything.
const [, setTick] = useState(0);
const lastActiveIdRef = useRef<string | null>(null);
// Walk messages and update timings. We intentionally read/write the ref
// synchronously inside an effect so React isn't tracking the map identity.
useEffect(() => {
const now = performance.now();
let activeId: string | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const text = messageText(message);
if (!text) continue;
let entry = timingRef.current.get(id);
if (!entry) {
entry = {
firstSeenAt: now,
endedAt: null,
samples: [{ at: now, chars: text.length }],
lastChars: text.length,
hadUsage: false,
};
timingRef.current.set(id, entry);
}
// Track the most recent AI message as the active one (while streaming).
activeId = id;
// Sample only when actively streaming OR when content actually grew —
// throttle to keep arrays bounded.
const grew = text.length !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars: text.length });
entry.lastChars = text.length;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
}
lastSampleAtRef.current = now;
} else if (grew) {
// Still update lastChars so the next render captures it eventually.
entry.lastChars = text.length;
}
if (getUsageMetadata(message)) {
entry.hadUsage = true;
}
}
// When streaming stops, freeze any non-ended entry to the current time.
if (!isLoading) {
for (const entry of timingRef.current.values()) {
if (entry.endedAt === null) {
entry.endedAt = now;
}
}
}
lastActiveIdRef.current = activeId;
setTick((v) => v + 1);
}, [messages, isLoading]);
// Drive a slow tick while streaming so live durations refresh smoothly.
useEffect(() => {
if (!isLoading) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
return () => window.clearInterval(interval);
}, [isLoading]);
// Build the snapshot map. We compute on every render so consumers see the
// freshest duration; the underlying timing storage is the source of truth.
const out = new Map<string, MessageMetric>();
const now = performance.now();
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const entry = timingRef.current.get(id);
if (!entry) continue;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
const isActive = isLoading && id === lastActiveIdRef.current;
let tokensPerSec = 0;
let fromUsage = false;
const usage = getUsageMetadata(message);
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
fromUsage = true;
} else {
// Char-count fallback. Uses the rolling window during streaming so a
// mid-stream snapshot reflects current speed rather than average since
// the start.
if (isActive && entry.samples.length >= 2) {
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
}
}
} else if (durationMs > 0) {
// Finished message without usage data: average over full duration.
const chars = entry.lastChars;
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
}
}
out.set(id, {
tokensPerSec,
durationMs,
isActive,
fromUsage,
});
}
return out;
}
const EMPTY_METRICS: Map<string, MessageMetric> = new Map();
/**
* Context for sharing the metrics map computed by ``useMessageMetrics`` from
* MessageList down to per-message renderers like MessageListItem, without
* threading a prop through every grouping branch.
*/
export const MessageMetricsContext = createContext<Map<string, MessageMetric>>(
EMPTY_METRICS,
);
export function useMessageMetric(messageId: string | undefined): MessageMetric | undefined {
const metrics = useContext(MessageMetricsContext);
return messageId ? metrics.get(messageId) : undefined;
}
@@ -0,0 +1,329 @@
"use client";
import {
BellIcon,
CheckCheckIcon,
ChevronLeftIcon,
Loader2,
SettingsIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { useLocalSettings } from "@/core/settings";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
type NotificationView = "list" | "settings";
export function NotificationDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const [view, setView] = useState<NotificationView>("list");
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const { data: unreadCount = 0 } = useUnreadCount();
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
function handleOpenChange(next: boolean) {
onOpenChange(next);
// 关闭弹窗时回到列表视图,下次打开默认看到通知列表。
if (!next) setView("list");
}
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="flex max-h-[80vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
aria-describedby={undefined}
>
{view === "list" ? (
<>
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<DialogTitle className="text-base font-semibold">
{t.notifications.title}
</DialogTitle>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.notifications.settingsAria}
onClick={() => setView("settings")}
>
<SettingsIcon className="size-4" />
</Button>
<DialogClose asChild>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.common.close}
>
<XIcon className="size-4" />
</Button>
</DialogClose>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="w-full px-3 py-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-20 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
<div className="bg-muted flex size-14 items-center justify-center rounded-full">
<BellIcon className="text-muted-foreground size-6" />
</div>
<p className="text-muted-foreground text-sm">
{t.notifications.empty}
</p>
</div>
) : (
<ul className="flex flex-col gap-2">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer rounded-lg border px-4 py-3 transition-colors hover:bg-accent/50",
!notification.read
? "border-blue-500/30 bg-blue-500/5"
: "border-border",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
notification.read ? "bg-transparent" : "bg-blue-500",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<p
className={cn(
"truncate text-sm",
notification.read
? "font-medium"
: "font-semibold",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-xs">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</ScrollArea>
</>
) : (
<NotificationSettingsView onBack={() => setView("list")} />
)}
</DialogContent>
</Dialog>
);
}
function NotificationSettingsView({ onBack }: { onBack: () => void }) {
const { t } = useI18n();
const { permission, isSupported, requestPermission, showNotification } =
useNotification();
const [settings, setSettings] = useLocalSettings();
const handleRequestPermission = async () => {
await requestPermission();
};
const handleTestNotification = () => {
showNotification(t.settings.notification.testTitle, {
body: t.settings.notification.testBody,
});
};
const handleEnableNotification = (enabled: boolean) => {
setSettings("notification", { enabled });
};
return (
<>
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
<div className="flex items-center gap-2">
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.notifications.backAria}
onClick={onBack}
>
<ChevronLeftIcon className="size-4" />
</Button>
<DialogTitle className="text-base font-semibold">
{t.notifications.settingsTitle}
</DialogTitle>
</div>
<DialogClose asChild>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.common.close}
>
<XIcon className="size-4" />
</Button>
</DialogClose>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-4 px-3 py-6">
{!isSupported ? (
<p className="text-muted-foreground text-sm">
{t.settings.notification.notSupported}
</p>
) : (
<>
<div className="flex items-start justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t.settings.notification.description}
</p>
<Switch
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled
}
onCheckedChange={handleEnableNotification}
/>
</div>
{permission === "default" && (
<Button onClick={handleRequestPermission} variant="default">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.requestPermission}
</Button>
)}
{permission === "denied" && (
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
{t.settings.notification.deniedHint}
</p>
)}
{permission === "granted" && settings.notification.enabled && (
<Button onClick={handleTestNotification} variant="outline">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.testButton}
</Button>
)}
</>
)}
</div>
</>
);
}
@@ -117,14 +117,7 @@ export function RecentChatList() {
const handleShare = useCallback(
async (thread: AgentThread) => {
// Always use Vercel URL for sharing so others can access
const VERCEL_URL = "https://deer-flow-v2.vercel.app";
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
// On localhost: use Vercel URL; On production: use current origin
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t.clipboard.linkCopied);
@@ -180,91 +173,89 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-1">
{visibleThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<div>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
})}
</div>
</SidebarMenu>
</SidebarGroupContent>
@@ -1,67 +1,18 @@
/**
* About DeerFlow markdown content. Inlined to avoid raw-loader dependency
* About CM Assistant markdown content. Inlined to avoid raw-loader dependency
* (Turbopack cannot resolve raw-loader for .md imports).
*/
export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
export const aboutMarkdown = `# About CM Assistant
> **From Open Source, Back to Open Source**
DeerFlow (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is an open-source **super agent harness** that orchestrates **sub-agents**, **memory**, and **sandboxes** to do almost anything — powered by **extensible skills**.
CM Assistant is an internal super-agent platform that orchestrates sub-agents, memory, and sandboxes — powered by extensible skills — to research, analyze, and create on your behalf.
---
## 🚀 Core Features
* **Skills & Tools**: With built-in and extensible skills and tools, DeerFlow can do almost anything.
* **Skills & Tools**: With built-in and extensible skills and tools, CM Assistant can do almost anything.
* **Sub-Agents**: Sub-Agents help the main agent to do the tasks that are too complex to be done by the main agent.
* **Sandbox & File System**: Safely execute code and manipulate files in the sandbox.
* **Context Engineering**: Isolated sub-agent context, summarization to keep the context window sharp.
* **Long-Term Memory**: Keep recording the user's profile, top of mind, and conversation history.
---
## 🌟 GitHub Repository
![Star History Chart](https://api.star-history.com/svg?repos=bytedance/deer-flow&type=Date)
Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow)
## 🌐 Official Website
Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/)
## 📧 Support
If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech).
---
## 📜 License
DeerFlow is proudly open source and distributed under the **MIT License**.
---
## 🙌 Acknowledgments
We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants.
### Core Frameworks
- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains.
- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration.
- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications.
### UI Libraries
- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI.
- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects.
These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration.
### Special Thanks
Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0:
- **[Daniel Walnut](https://github.com/hetaoBackend/)**
- **[Henry Li](https://github.com/magiccube/)**
Without their vision, passion and dedication, \`DeerFlow\` would not be what it is today.
`;
@@ -1,55 +1,15 @@
# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
# About CM Assistant
> **From Open Source, Back to Open Source**
**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven SuperAgent harness that researches, codes, and creates.
With the help of sandboxes, memories, tools and skills, it handles
different levels of tasks that could take minutes to hours.
CM Assistant is an internal super-agent platform that orchestrates sub-agents,
memory, and sandboxes — powered by extensible skills — to research, analyze,
and create on your behalf.
---
## 🌟 GitHub Repository
## Core Features
Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow)
## 🌐 Official Website
Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/)
## 📧 Support
If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech).
---
## 📜 License
DeerFlow is proudly open source and distributed under the **MIT License**.
---
## 🙌 Acknowledgments
We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants.
### Core Frameworks
- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains.
- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration.
- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications.
### UI Libraries
- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI.
- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects.
These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration.
### Special Thanks
Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0:
- **[Daniel Walnut](https://github.com/hetaoBackend/)**
- **[Henry Li](https://github.com/magiccube/)**
Without their vision, passion and dedication, `DeerFlow` would not be what it is today.
* **Skills & Tools**: Built-in and extensible skills let CM Assistant tackle a wide range of tasks.
* **Sub-Agents**: Specialized sub-agents handle complex jobs the main agent delegates to them.
* **Sandbox & File System**: Safely execute code and manipulate files inside an isolated sandbox.
* **Context Engineering**: Isolated sub-agent context plus summarization keeps the context window sharp.
* **Long-Term Memory**: Continuously records your profile, top-of-mind notes, and conversation history.
@@ -0,0 +1,126 @@
"use client";
import { useMemo } from "react";
import { CopyIcon, LogOutIcon, UserIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { clearStoredAuth, getStoredAuth } from "@/core/auth";
import { SettingsSection } from "./settings-section";
function roleLabel(role?: string) {
if (role === "admin") return "管理员";
if (role === "user") return "普通用户";
return role || "未知角色";
}
function copy(text: string, hint = "已复制") {
if (!text) return;
navigator.clipboard
.writeText(text)
.then(() => toast.success(hint))
.catch(() => toast.error("复制失败"));
}
export function AccountSettingsPage() {
const auth = useMemo(() => getStoredAuth(), []);
if (!auth) {
return (
<SettingsSection title="个人信息" description="当前未登录或登录信息无法读取。">
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
没有可显示的账号信息。
</div>
</SettingsSection>
);
}
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? "";
const displayName = usernameFromEmail || auth.user_id;
const handleLogout = () => {
clearStoredAuth();
// Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too.
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
.catch(() => undefined)
.finally(() => {
window.location.assign("/");
});
};
return (
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
<div className="flex items-center gap-3">
<div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-full">
<UserIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="text-muted-foreground truncate text-xs">
{roleLabel(auth.system_role)}
</div>
</div>
<Badge variant={auth.system_role === "admin" ? "default" : "secondary"}>
{roleLabel(auth.system_role)}
</Badge>
</div>
<InfoRow
label="用户名"
value={displayName}
onCopy={() => copy(displayName, "用户名已复制")}
/>
<InfoRow
label="用户 ID"
value={auth.user_id}
onCopy={() => copy(auth.user_id, "用户 ID 已复制")}
/>
<InfoRow label="账号角色" value={roleLabel(auth.system_role)} />
<InfoRow
label="账号状态"
value={auth.needs_setup ? "待初始化" : "已就绪"}
/>
<div className="flex justify-end pt-2">
<Button variant="outline" size="sm" onClick={handleLogout}>
<LogOutIcon className="size-4" />
退出登录
</Button>
</div>
</div>
</SettingsSection>
);
}
function InfoRow({
label,
value,
onCopy,
}: {
label: string;
value: string;
onCopy?: () => void;
}) {
return (
<div className="flex items-center gap-3 border-t pt-3 first:border-t-0 first:pt-0">
<div className="text-muted-foreground w-20 shrink-0 text-xs">{label}</div>
<div className="min-w-0 flex-1 truncate text-sm">{value || "—"}</div>
{onCopy && value && (
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={onCopy}
aria-label={`复制${label}`}
>
<CopyIcon className="size-3.5" />
</Button>
)}
</div>
);
}
@@ -12,8 +12,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
@@ -27,6 +29,7 @@ export function AppearanceSettingsPage() {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme, systemTheme } = useTheme();
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
const [settings, setSettings] = useLocalSettings();
const themeOptions = useMemo(
() => [
@@ -107,6 +110,30 @@ export function AppearanceSettingsPage() {
</SelectContent>
</Select>
</SettingsSection>
<Separator />
<SettingsSection
title="对话体验"
description="问答过程中的辅助显示。"
>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">回答速度指示器</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
在生成回答时显示一个小胶囊,实时估算当前 token/s
并用绿到红的渐变条直观反映快慢。默认关闭。
</p>
</div>
<Switch
checked={settings.display.streamSpeedMeterEnabled}
onCheckedChange={(checked) =>
setSettings("display", { streamSpeedMeterEnabled: checked })
}
aria-label="回答速度指示器"
/>
</div>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,362 @@
"use client";
import { useEffect, useMemo, 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useUpsertMCPServer } from "@/core/mcp/hooks";
import type {
MCPServerConfig,
MCPServerTransport,
} from "@/core/mcp/types";
type EditMode = "create" | "edit";
export interface MCPServerEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: EditMode;
originalName?: string;
initialServer?: MCPServerConfig;
existingNames: string[];
}
const TRANSPORT_OPTIONS: { value: MCPServerTransport; label: string }[] = [
{ value: "stdio", label: "stdio(本地进程)" },
{ value: "http", label: "http(远端 HTTP)" },
{ value: "sse", label: "sse(远端 SSE)" },
];
const DEFAULT_SERVER: MCPServerConfig = {
enabled: true,
type: "stdio",
command: "",
args: [],
env: {},
url: "",
headers: {},
description: "",
disabled_tools: [],
};
function argsToText(args?: string[]): string {
return (args ?? []).join("\n");
}
function textToArgs(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function kvToText(map?: Record<string, string>): string {
if (!map) return "";
return Object.entries(map)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
function textToKV(text: string): { value: Record<string, string>; error?: string } {
const result: Record<string, string> = {};
const lines = text.split("\n");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
const eq = line.indexOf("=");
if (eq <= 0) {
return { value: {}, error: `每行需为 KEY=VALUE 格式:${line}` };
}
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1);
if (!key) {
return { value: {}, error: `键名不能为空:${line}` };
}
result[key] = value;
}
return { value: result };
}
export function MCPServerEditDialog({
open,
onOpenChange,
mode,
originalName,
initialServer,
existingNames,
}: MCPServerEditDialogProps) {
const upsert = useUpsertMCPServer();
const [name, setName] = useState("");
const [type, setType] = useState<MCPServerTransport>("stdio");
const [enabled, setEnabled] = useState(true);
const [description, setDescription] = useState("");
const [command, setCommand] = useState("");
const [argsText, setArgsText] = useState("");
const [envText, setEnvText] = useState("");
const [url, setUrl] = useState("");
const [headersText, setHeadersText] = useState("");
const [disabledToolsText, setDisabledToolsText] = useState("");
useEffect(() => {
if (!open) return;
const base = initialServer ?? DEFAULT_SERVER;
setName(originalName ?? "");
setType((base.type ?? "stdio") as MCPServerTransport);
setEnabled(base.enabled);
setDescription(base.description ?? "");
setCommand(base.command ?? "");
setArgsText(argsToText(base.args));
setEnvText(kvToText(base.env));
setUrl(base.url ?? "");
setHeadersText(kvToText(base.headers));
setDisabledToolsText((base.disabled_tools ?? []).join("\n"));
}, [open, initialServer, originalName]);
const nameConflict = useMemo(() => {
const trimmed = name.trim();
if (!trimmed) return false;
if (mode === "edit" && trimmed === originalName) return false;
return existingNames.includes(trimmed);
}, [name, existingNames, mode, originalName]);
const handleSubmit = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("请填写 MCP 服务器名称");
return;
}
if (nameConflict) {
toast.error(`已存在同名 MCP 服务器:${trimmedName}`);
return;
}
const env = textToKV(envText);
if (env.error) {
toast.error(`环境变量格式错误:${env.error}`);
return;
}
const headers = textToKV(headersText);
if (headers.error) {
toast.error(`Headers 格式错误:${headers.error}`);
return;
}
const args = textToArgs(argsText);
const disabledTools = textToArgs(disabledToolsText);
if (type === "stdio" && !command.trim()) {
toast.error("stdio 传输需要填写启动命令");
return;
}
if ((type === "http" || type === "sse") && !url.trim()) {
toast.error(`${type} 传输需要填写服务器 URL`);
return;
}
const server: MCPServerConfig = {
enabled,
type,
description: description.trim(),
command: type === "stdio" ? command.trim() : null,
args: type === "stdio" ? args : [],
env: type === "stdio" ? env.value : {},
url: type === "stdio" ? null : url.trim(),
headers: type === "stdio" ? {} : headers.value,
disabled_tools: disabledTools,
};
try {
await upsert.mutateAsync({
serverName: trimmedName,
server,
originalName: mode === "edit" ? originalName : undefined,
});
toast.success(mode === "create" ? "MCP 服务器已创建" : "MCP 服务器已更新");
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>
{mode === "create" ? "新增 MCP 服务器" : `编辑 MCP 服务器`}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<Field label="名称" hint="作为 MCP 服务器的唯一标识,用于工具命名前缀">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 github、filesystem"
aria-invalid={nameConflict || undefined}
/>
{nameConflict && (
<div className="text-destructive text-xs">已存在同名服务器</div>
)}
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="传输类型">
<Select
value={type}
onValueChange={(v) => setType(v as MCPServerTransport)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSPORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="启用">
<div className="flex h-9 items-center">
<Switch
checked={enabled}
onCheckedChange={setEnabled}
/>
<span className="text-muted-foreground ml-2 text-xs">
{enabled ? "已启用" : "已停用"}
</span>
</div>
</Field>
</div>
<Field label="描述">
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="给这个 MCP 服务器写一段说明,会展示在工具列表里"
/>
</Field>
{type === "stdio" && (
<>
<Field
label="启动命令"
hint="例如 npx、uvx、python,配合参数启动 MCP server 进程"
>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="npx"
/>
</Field>
<Field label="参数" hint="每行一个参数">
<Textarea
rows={3}
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
placeholder={"-y\n@modelcontextprotocol/server-github"}
/>
</Field>
<Field
label="环境变量"
hint="每行 KEY=VALUE;值以 $ 开头会在加载时解析为对应环境变量"
>
<Textarea
rows={3}
value={envText}
onChange={(e) => setEnvText(e.target.value)}
placeholder={"GITHUB_TOKEN=$GITHUB_TOKEN"}
/>
</Field>
</>
)}
{(type === "http" || type === "sse") && (
<>
<Field label="服务器 URL">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/mcp"
/>
</Field>
<Field label="Headers" hint="每行 KEY=VALUE">
<Textarea
rows={3}
value={headersText}
onChange={(e) => setHeadersText(e.target.value)}
placeholder={"Authorization=Bearer xxx"}
/>
</Field>
</>
)}
<Field
label="禁用的工具"
hint="每行一个工具名(不带服务器前缀),加载时会被过滤掉"
>
<Textarea
rows={2}
value={disabledToolsText}
onChange={(e) => setDisabledToolsText(e.target.value)}
placeholder={"some_dangerous_tool"}
/>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSubmit} disabled={upsert.isPending}>
{upsert.isPending
? "保存中..."
: mode === "create"
? "创建"
: "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<div className="text-sm font-medium">{label}</div>
{hint && <div className="text-muted-foreground text-xs">{hint}</div>}
{children}
</div>
);
}
@@ -389,7 +389,7 @@ export function MemorySettingsPage() {
try {
setIsExporting(true);
const exportedMemory = await exportMemory();
const fileName = `deerflow-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
const fileName = `cm-assistant-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
const blob = new Blob([JSON.stringify(exportedMemory, null, 2)], {
type: "application/json",
});
@@ -1,92 +0,0 @@
"use client";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
import { SettingsSection } from "./settings-section";
export function NotificationSettingsPage() {
const { t } = useI18n();
const { permission, isSupported, requestPermission, showNotification } =
useNotification();
const [settings, setSettings] = useLocalSettings();
const handleRequestPermission = async () => {
await requestPermission();
};
const handleTestNotification = () => {
showNotification(t.settings.notification.testTitle, {
body: t.settings.notification.testBody,
});
};
const handleEnableNotification = async (enabled: boolean) => {
setSettings("notification", {
enabled,
});
};
if (!isSupported) {
return (
<SettingsSection
title={t.settings.notification.title}
description={t.settings.notification.description}
>
<p className="text-muted-foreground text-sm">
{t.settings.notification.notSupported}
</p>
</SettingsSection>
);
}
return (
<SettingsSection
title={t.settings.notification.title}
description={
<div className="flex items-center gap-2">
<div>{t.settings.notification.description}</div>
<div>
<Switch
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled
}
onCheckedChange={handleEnableNotification}
/>
</div>
</div>
}
>
<div className="flex flex-col gap-4">
{permission === "default" && (
<Button onClick={handleRequestPermission} variant="default">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.requestPermission}
</Button>
)}
{permission === "denied" && (
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
{t.settings.notification.deniedHint}
</p>
)}
{permission === "granted" && settings.notification.enabled && (
<div className="flex flex-col gap-4">
<Button onClick={handleTestNotification} variant="outline">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.testButton}
</Button>
</div>
)}
</div>
</SettingsSection>
);
}
@@ -1,13 +1,6 @@
"use client";
import {
BellIcon,
InfoIcon,
BrainIcon,
PaletteIcon,
SparklesIcon,
WrenchIcon,
} from "lucide-react";
import { BrainIcon, PaletteIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -18,20 +11,18 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "tools"
| "skills"
| "notification"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
@@ -59,26 +50,23 @@ export function SettingsDialog(props: SettingsDialogProps) {
label: t.settings.sections.appearance,
icon: PaletteIcon,
},
{
id: "notification",
label: t.settings.sections.notification,
icon: BellIcon,
},
{
id: "memory",
label: t.settings.sections.memory,
icon: BrainIcon,
},
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
{
id: "account",
label: "个人信息",
icon: UserIcon,
},
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
],
[
t.settings.sections.appearance,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.skills,
t.settings.sections.notification,
t.settings.sections.about,
],
);
@@ -124,15 +112,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
</nav>
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "skills" && (
<SkillSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -1,10 +1,36 @@
"use client";
import { SparklesIcon } from "lucide-react";
import {
AlertCircle,
ArchiveIcon,
CheckCircle2,
ClockIcon,
EyeIcon,
EyeOffIcon,
FileArchive,
Loader2,
PinIcon,
PinOffIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Empty,
EmptyContent,
@@ -20,100 +46,516 @@ import {
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
useInstallSkillUpload,
useSetSkillPinned,
useSetSkillPublished,
useSkillContent,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
const { skills, isLoading, error } = useSkills();
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
) : (
<SkillSettingsList skills={skills} onClose={onClose} />
)}
<SkillSettingsList onClose={onClose} />
</SettingsSection>
);
}
function SkillSettingsList({
skills,
onClose,
}: {
skills: Skill[];
onClose?: () => void;
}) {
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("public");
const { mutate: enableSkill } = useEnableSkill();
const filteredSkills = useMemo(
() => skills.filter((skill) => skill.category === filter),
[skills, filter],
);
const [filter, setFilter] = useState<string>("mine");
const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// 把可见技能分类成 UI 关心的几个桶。
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
const all: Skill[] = [];
const mine: Skill[] = [];
const stale: Skill[] = [];
const archived: Skill[] = [];
for (const skill of skills) {
const state = skill.state ?? "active";
const isCustom = skill.category === "custom";
const isOwnedByMe =
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
if (state === "stale") {
stale.push(skill);
continue;
}
if (state === "archived") {
archived.push(skill);
continue;
}
// state === active
if (isBuiltin || !!skill.published) {
all.push(skill);
}
if (isOwnedByMe) {
mine.push(skill);
}
}
return {
activeAll: all,
activeMine: mine,
staleVisible: stale,
archivedVisible: archived,
};
}, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll;
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
};
return (
<div className="flex w-full flex-col gap-4">
<header className="flex justify-between">
<div className="flex gap-2">
<Tabs defaultValue="public" onValueChange={setFilter}>
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
</TabsList>
</Tabs>
<Button
size="sm"
variant="outline"
disabled={staleCount === 0}
onClick={() => setStaleSheetOpen(true)}
title={t.settings.skills.badgeStaleTitle}
>
<ClockIcon className="size-4" />
{t.settings.skills.entryStale.replace("{count}", String(staleCount))}
</Button>
<Button
size="sm"
variant="outline"
disabled={archivedCount === 0}
onClick={() => setArchiveSheetOpen(true)}
title={t.settings.skills.badgeArchivedTitle}
>
<ArchiveIcon className="size-4" />
{t.settings.skills.entryArchived.replace(
"{count}",
String(archivedCount),
)}
</Button>
</div>
<div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
onClose?.();
router.push("/page/workspace/skills/evolution");
}}
>
<TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setInstallOpen(true)}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
>
<Upload className="size-4" />
{t.settings.skills.installSkill}
</Button>
<Button size="sm" onClick={handleCreateSkill}>
<SparklesIcon className="size-4" />
{t.settings.skills.createSkill}
</Button>
</div>
</header>
{filteredSkills.length === 0 && (
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="max-w-xl"
/>
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{filteredSkills.length > 0 &&
{!isLoading && !error &&
filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<Item className="w-full" variant="outline" key={skill.name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">{skill.name}</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={skill.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onCheckedChange={(checked) =>
enableSkill({ skillName: skill.name, enabled: checked })
}
/>
</ItemActions>
</Item>
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
isAdmin={isAdmin}
/>
<SkillStateSheet
kind="stale"
open={staleSheetOpen}
onOpenChange={setStaleSheetOpen}
skills={staleVisible}
/>
<SkillStateSheet
kind="archived"
open={archiveSheetOpen}
onOpenChange={setArchiveSheetOpen}
skills={archivedVisible}
/>
</div>
);
}
function SkillRow({
skill,
currentUserId,
isAdmin,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
}) {
const { t } = useI18n();
const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
// the multitenancy fields still classify public skills correctly.
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canTogglePublish = canManage && !isBuiltin;
async function handleTogglePublished(next: boolean) {
try {
await setPublished.mutateAsync({ skillName: skill.name, published: next });
toast.success(
next
? t.settings.skills.publishSuccess
: t.settings.skills.unpublishSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePinned() {
const next = !skill.pinned;
try {
await setPinned.mutateAsync({ skillName: skill.name, pinned: next });
toast.success(
next ? t.settings.skills.pinSuccess : t.settings.skills.unpinSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<Item className="w-full" variant="outline">
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
)}
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" />
)}
</ItemContent>
<ItemActions>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
{skill.pinned ? (
<PinOffIcon className="size-3.5" />
) : (
<PinIcon className="size-3.5" />
)}
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<EyeOffIcon className="size-3.5" />
) : (
<EyeIcon className="size-3.5" />
)}
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</ItemActions>
</Item>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
<DialogDescription>
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
)}
<SkillDetailDialog
skill={skill}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</>
);
}
function SkillDetailDialog({
skill,
open,
onOpenChange,
}: {
skill: Skill;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { data, isLoading, error } = useSkillContent(open ? skill.name : null);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{skill.description}</DialogDescription>
</DialogHeader>
<div className="bg-muted/40 min-h-0 flex-1 overflow-auto rounded-md border">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : error ? (
<div className="text-destructive p-4 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
) : (
<pre className="text-foreground/90 whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{data?.content}
</pre>
)}
</div>
</DialogContent>
</Dialog>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
@@ -133,3 +575,292 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
</Empty>
);
}
function InstallSkillDialog({
open,
onClose,
isAdmin,
}: {
open: boolean;
onClose: () => void;
isAdmin: boolean;
}) {
const { t } = useI18n();
const dialog = t.settings.skills.installDialog;
const inputRef = useRef<HTMLInputElement | null>(null);
const [file, setFile] = useState<File | null>(null);
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const reset = useCallback(() => {
setFile(null);
setValidation(null);
setValidationError(null);
setIsDragging(false);
setTargetCategory("custom");
if (inputRef.current) inputRef.current.value = "";
}, []);
const handleClose = useCallback(() => {
if (validateMutation.isPending || installMutation.isPending) return;
reset();
onClose();
}, [installMutation.isPending, onClose, reset, validateMutation.isPending]);
const acceptFile = useCallback(
async (selected: File) => {
const lower = selected.name.toLowerCase();
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error(dialog.rejectInvalidExt);
return;
}
setFile(selected);
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync(selected);
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[dialog.rejectInvalidExt, validateMutation],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
// Stop the global chat-upload listener on `document` from also receiving this drop.
// React's stopPropagation alone doesn't reach native document listeners — we need to
// halt the underlying DOM event too.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setIsDragging(false);
const dropped = e.dataTransfer.files?.[0];
if (dropped) void acceptFile(dropped);
},
[acceptFile],
);
const onPick = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0];
if (picked) void acceptFile(picked);
},
[acceptFile],
);
const handleInstall = useCallback(
async (overwrite: boolean) => {
if (!file) return;
try {
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}",
result.skill_name,
),
);
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{dialog.title}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 text-center transition-colors",
isDragging ? "border-primary bg-accent/40" : "border-muted-foreground/30",
)}
>
<FileArchive className="text-muted-foreground size-8" />
<div className="text-muted-foreground text-sm">
{dialog.dropHint}
<button
type="button"
className="text-primary ml-1 underline underline-offset-4"
onClick={() => inputRef.current?.click()}
>
{dialog.chooseFile}
</button>
</div>
{file && (
<div className="text-muted-foreground mt-1 text-xs">
{file.name} · {(file.size / 1024).toFixed(1)} KB
</div>
)}
<input
ref={inputRef}
type="file"
accept=".zip,.skill,application/zip"
hidden
onChange={onPick}
/>
</div>
{isAdmin && (
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
<div className="flex gap-4">
{(["custom", "public"] as const).map((cat) => (
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => setTargetCategory(cat)}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
</label>
))}
</div>
</div>
)}
{validateMutation.isPending && (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{dialog.validating}
</div>
)}
{validationError && (
<div className="text-destructive flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{validationError}</span>
</div>
)}
{validation && !validationError && (
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
<div className="flex items-center gap-2 text-emerald-600">
<CheckCircle2 className="size-4" />
<span className="font-medium">{validation.skill_name}</span>
</div>
<Field label={dialog.descriptionLabel}>
<span className="line-clamp-3 whitespace-pre-wrap">
{validation.description || "—"}
</span>
</Field>
{validation.license && (
<Field label={dialog.licenseLabel}>{validation.license}</Field>
)}
{validation.allowed_tools.length > 0 && (
<Field label={dialog.toolsLabel}>
<div className="flex flex-wrap gap-1">
{validation.allowed_tools.map((tool) => (
<Badge key={tool} variant="secondary" className="text-xs">
{tool}
</Badge>
))}
</div>
</Field>
)}
{validation.warnings.length > 0 && (
<Field label={dialog.warningsLabel}>
<ul className="list-disc pl-4 text-xs">
{validation.warnings.map((w, idx) => (
<li key={idx}>{w}</li>
))}
</ul>
</Field>
)}
{isCustomConflict && (
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{dialog.conflictCustom.replace("{name}", validation.skill_name)}
</span>
</div>
)}
{isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
</span>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={installMutation.isPending}
>
{dialog.cancel}
</Button>
{canOverwrite && (
<Button
variant="destructive"
onClick={() => handleInstall(true)}
disabled={installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{dialog.replace}
</Button>
)}
<Button
onClick={() => handleInstall(false)}
disabled={!canInstall || installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{installMutation.isPending ? dialog.installing : dialog.install}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[80px_1fr] gap-2">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<div className="min-w-0">{children}</div>
</div>
);
}
@@ -0,0 +1,360 @@
"use client";
import { ArchiveIcon, ClockIcon, MoreHorizontalIcon, RotateCcwIcon, Trash2Icon } 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,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { useI18n } from "@/core/i18n/hooks";
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { cn } from "@/lib/utils";
type Kind = "stale" | "archived";
export function SkillStateSheet({
kind,
open,
onOpenChange,
skills,
}: {
kind: Kind;
open: boolean;
onOpenChange: (open: boolean) => void;
skills: Skill[];
}) {
const { t } = useI18n();
const tt = t.settings.skills;
const ts = tt.stateSheet;
const isArchived = kind === "archived";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2">
{isArchived ? (
<ArchiveIcon className="size-4" />
) : (
<ClockIcon className="size-4" />
)}
{isArchived ? ts.archivedTitle : ts.staleTitle}
<span className="text-muted-foreground text-sm font-normal">
({skills.length})
</span>
</SheetTitle>
<SheetDescription>
{isArchived ? ts.archivedDescription : ts.staleDescription}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto p-4">
{skills.length === 0 ? (
<div className="text-muted-foreground py-12 text-center text-sm">
{ts.empty}
</div>
) : (
<div className="flex flex-col gap-3">
{skills.map((skill) => (
<SkillStateRow key={skill.name} skill={skill} kind={kind} />
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
const { t } = useI18n();
const tt = t.settings.skills;
const ts = tt.stateSheet;
const setState = useSetSkillState();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const pending = setState.isPending || deleteSkill.isPending;
const isArchived = kind === "archived";
const timeLabel = useMemo(() => {
if (isArchived) {
const when = skill.last_activity_at;
return when
? ts.archivedAt.replace("{when}", formatRelative(when))
: ts.archivedAt.replace("{when}", "—");
}
const when = skill.last_activity_at;
return when
? ts.lastUsedAt.replace("{when}", formatRelative(when))
: ts.neverUsed;
}, [isArchived, skill.last_activity_at, ts.archivedAt, ts.lastUsedAt, ts.neverUsed]);
async function handleRestore() {
try {
await setState.mutateAsync({ skillName: skill.name, state: "active" });
toast.success(ts.restoreSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleArchive() {
try {
await setState.mutateAsync({ skillName: skill.name, state: "archived" });
toast.success(ts.archiveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(tt.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<div className="bg-card flex flex-col gap-2 rounded-md border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{skill.name}</span>
{isArchived ? (
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
{tt.badgeArchived}
</Badge>
) : (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title={tt.badgeStaleTitle}
>
{tt.badgeStale}
</Badge>
)}
{skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={tt.badgeSourceAgentTitle}
>
{tt.badgeSourceAgent}
</Badge>
)}
{skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={tt.badgeSourceUploadTitle}
>
{tt.badgeSourceUpload}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
{skill.description}
</p>
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
<span>{timeLabel}</span>
<span>
{skill.use_count && skill.use_count > 0
? tt.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: tt.useCountInlineZero}
</span>
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
onClick={() => void handleRestore()}
disabled={pending}
>
<RotateCcwIcon className="size-3.5" />
{ts.actionRestore}
</Button>
{!isArchived && (
<Button
size="sm"
variant="outline"
onClick={() => void handleArchive()}
disabled={pending}
>
<ArchiveIcon className="size-3.5" />
{ts.actionArchiveNow}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={pending}
title={ts.moreMenu}
>
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setViewOpen(true)}>
{ts.actionView}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setConfirmDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="size-3.5" />
{ts.actionDelete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<SkillViewDialog
skill={skill}
open={viewOpen}
onOpenChange={setViewOpen}
/>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{tt.deleteAction}</DialogTitle>
<DialogDescription>
{tt.deleteConfirm.replace("{name}", skill.name)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function SkillViewDialog({
skill,
open,
onOpenChange,
}: {
skill: Skill;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tv = t.settings.skills.viewDialog;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{tv.title}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 text-sm">
<Row label={tv.descriptionLabel}>
<span className="whitespace-pre-wrap">{skill.description}</span>
</Row>
<Row label={tv.stateLabel}>
<span className={cn("font-medium", stateColor(skill.state))}>
{skill.state ?? "active"}
</span>
</Row>
{skill.use_count !== undefined && (
<Row label={tv.useCountLabel}>{skill.use_count}</Row>
)}
{skill.last_activity_at && (
<Row label={tv.lastUsedLabel}>
{formatRelative(skill.last_activity_at)}
</Row>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{tv.close}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[80px_1fr] gap-2">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<div className="min-w-0">{children}</div>
</div>
);
}
function stateColor(state: string | undefined): string {
switch (state) {
case "archived":
return "text-muted-foreground";
case "stale":
return "text-amber-600 dark:text-amber-300";
default:
return "text-emerald-600 dark:text-emerald-300";
}
}
function formatRelative(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return iso;
const sec = Math.max(0, Math.floor(ms / 1000));
const min = Math.floor(sec / 60);
const hour = Math.floor(min / 60);
const day = Math.floor(hour / 24);
if (day >= 1) return `${day}d ago`;
if (hour >= 1) return `${hour}h ago`;
if (min >= 1) return `${min}m ago`;
return "just now";
}
@@ -1,5 +1,18 @@
"use client";
import { Pencil, Plus, Trash2 } 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,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Item,
ItemActions,
@@ -8,16 +21,54 @@ import {
ItemTitle,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
import {
useDeleteMCPServer,
useEnableMCPServer,
useMCPConfig,
} from "@/core/mcp/hooks";
import type { MCPServerConfig } from "@/core/mcp/types";
import { env } from "@/env";
import { MCPServerEditDialog } from "./mcp-server-edit-dialog";
import { SettingsSection } from "./settings-section";
export function ToolSettingsPage() {
const { t } = useI18n();
const { config, isLoading, error } = useMCPConfig();
const isAdmin = getStoredAuth()?.system_role === "admin";
const isStaticMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const [editOpen, setEditOpen] = useState(false);
const [editMode, setEditMode] = useState<"create" | "edit">("create");
const [editingName, setEditingName] = useState<string | undefined>(undefined);
const [editingServer, setEditingServer] = useState<
MCPServerConfig | undefined
>(undefined);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const servers = useMemo(() => {
if (!config) return [] as Array<[string, MCPServerConfig]>;
return Object.entries(config.mcp_servers);
}, [config]);
const existingNames = useMemo(() => servers.map(([n]) => n), [servers]);
const handleCreate = () => {
setEditMode("create");
setEditingName(undefined);
setEditingServer(undefined);
setEditOpen(true);
};
const handleEdit = (name: string, server: MCPServerConfig) => {
setEditMode("edit");
setEditingName(name);
setEditingServer(server);
setEditOpen(true);
};
return (
<SettingsSection
title={t.settings.tools.title}
@@ -26,38 +77,128 @@ export function ToolSettingsPage() {
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
<div className="text-destructive text-sm">Error: {error.message}</div>
) : (
config && <MCPServerList servers={config.mcp_servers} />
<div className="flex w-full flex-col gap-3">
{isAdmin && (
<div className="flex justify-end">
<Button
size="sm"
onClick={handleCreate}
disabled={isStaticMode}
>
<Plus className="size-4" />
新增 MCP 服务器
</Button>
</div>
)}
<MCPServerList
servers={servers}
isAdmin={isAdmin}
isStaticMode={isStaticMode}
onEdit={handleEdit}
onDelete={(name) => setDeleteTarget(name)}
/>
</div>
)}
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
</SettingsSection>
);
}
function MCPServerList({
servers,
isAdmin,
isStaticMode,
onEdit,
onDelete,
}: {
servers: Record<string, MCPServerConfig>;
servers: Array<[string, MCPServerConfig]>;
isAdmin: boolean;
isStaticMode: boolean;
onEdit: (name: string, server: MCPServerConfig) => void;
onDelete: (name: string) => void;
}) {
const { mutate: enableMCPServer } = useEnableMCPServer();
if (servers.length === 0) {
return (
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
{isAdmin
? "还没有配置任何 MCP 服务器,点上面的按钮添加一个。"
: "管理员还没有配置任何 MCP 服务器。"}
</div>
);
}
return (
<div className="flex w-full flex-col gap-4">
{Object.entries(servers).map(([name, config]) => (
{servers.map(([name, config]) => (
<Item className="w-full" variant="outline" key={name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">
<div>{name}</div>
<div className="flex flex-wrap items-center gap-2">
<span>{name}</span>
<Badge variant="outline" className="text-xs">
{config.type ?? "stdio"}
</Badge>
{config.disabled_tools &&
config.disabled_tools.length > 0 && (
<Badge variant="secondary" className="text-xs">
已禁用 {config.disabled_tools.length} 个工具
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{config.description}
{config.description ||
(config.type === "stdio"
? config.command
: config.url) ||
"暂无描述"}
</ItemDescription>
</ItemContent>
<ItemActions>
<ItemActions className="flex items-center gap-2">
{isAdmin && (
<>
<Button
size="icon"
variant="ghost"
className="size-8"
onClick={() => onEdit(name, config)}
disabled={isStaticMode}
aria-label="编辑"
>
<Pencil className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive size-8"
onClick={() => onDelete(name)}
disabled={isStaticMode}
aria-label="删除"
>
<Trash2 className="size-4" />
</Button>
</>
)}
<Switch
checked={config.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
disabled={isStaticMode || !isAdmin}
onCheckedChange={(checked) =>
enableMCPServer({ serverName: name, enabled: checked })
}
@@ -68,3 +209,49 @@ function MCPServerList({
</div>
);
}
function DeleteConfirmDialog({
target,
onOpenChange,
}: {
target: string | null;
onOpenChange: (open: boolean) => void;
}) {
const deleteMcp = useDeleteMCPServer();
const handleConfirm = async () => {
if (!target) return;
try {
await deleteMcp.mutateAsync({ serverName: target });
toast.success(`已删除 MCP 服务器:${target}`);
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog open={target !== null} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>删除 MCP 服务器</DialogTitle>
</DialogHeader>
<div className="py-2 text-sm">
确定要删除 MCP 服务器 <b>{target}</b> 吗?该操作不可撤销。
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={deleteMcp.isPending}
>
{deleteMcp.isPending ? "删除中..." : "删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,523 @@
"use client";
import {
ArrowLeftIcon,
Loader2Icon,
PlayIcon,
RefreshCwIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useCuratorConfig,
useCuratorLogs,
useCuratorStatus,
useRunCurator,
useUpdateCuratorConfig,
} from "@/core/curator/hooks";
import type { CuratorConfig } from "@/core/curator/types";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
export function SkillEvolutionPanel() {
const { t } = useI18n();
const router = useRouter();
const tt = t.settings.skillEvolution;
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">{tt.title}</h1>
<p className="text-muted-foreground mt-1 text-sm">
{tt.description}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/page/workspace/skills")}
>
<ArrowLeftIcon className="size-4" />
{tt.backToSkills}
</Button>
</header>
<StatusCard />
<ConfigCard />
<LogsCard />
</div>
);
}
function StatusCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.status;
const { status, isLoading } = useCuratorStatus();
const runCurator = useRunCurator();
const lastRunDisplay = useMemo(() => {
if (!status?.last_run_at) return tt.lastRunNever;
try {
return new Date(status.last_run_at).toLocaleString();
} catch {
return status.last_run_at;
}
}, [status?.last_run_at, tt.lastRunNever]);
const handleRun = async () => {
try {
await runCurator.mutateAsync();
toast.success(tt.runStarted);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
{status && (
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant={status.enabled ? "default" : "secondary"}>
{status.enabled ? tt.enabled : tt.disabled}
</Badge>
{status.paused && (
<Badge variant="outline">{tt.paused}</Badge>
)}
</div>
)}
</div>
<Button
size="sm"
onClick={handleRun}
disabled={runCurator.isPending}
>
{runCurator.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PlayIcon className="size-4" />
)}
{tt.runNow}
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : status ? (
<div className="grid gap-3 text-sm sm:grid-cols-2">
<StatRow label={tt.runCount}>
{status.run_count}
{tt.runCountUnit && (
<span className="text-muted-foreground ml-1">
{tt.runCountUnit}
</span>
)}
</StatRow>
<StatRow label={tt.lastRunAt}>{lastRunDisplay}</StatRow>
<StatRow label={tt.lastRunSummary} className="sm:col-span-2">
<span className="text-muted-foreground">
{status.last_run_summary ?? tt.lastRunSummaryEmpty}
</span>
</StatRow>
<StatRow label={tt.skillCounts} className="sm:col-span-2">
<span className="text-muted-foreground inline-flex flex-wrap gap-3">
<span>
{tt.total}: {status.agent_created_skills.total}
</span>
<span>
{tt.active}: {status.agent_created_skills.active}
</span>
<span>
{tt.stale}: {status.agent_created_skills.stale}
</span>
<span>
{tt.archived}: {status.agent_created_skills.archived}
</span>
</span>
</StatRow>
<StatRow label={tt.lastActions} className="sm:col-span-2">
<div className="flex flex-col gap-2 text-xs">
<div>
<div className="font-medium">{tt.llmSuggested}</div>
{status.last_consolidation_suggestions.consolidations.length ===
0 &&
status.last_consolidation_suggestions.prunings.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="text-muted-foreground mt-0.5 list-disc pl-4">
{status.last_consolidation_suggestions.consolidations.map(
(c, i) => (
<li key={`c${i}`}>
{(c.from ?? "?") + " → " + (c.into ?? "?")}
{c.reason ? `(${c.reason})` : ""}
</li>
),
)}
{status.last_consolidation_suggestions.prunings.map(
(p, i) => (
<li key={`p${i}`}>
{p.name ?? "?"}
{p.reason ? `(${p.reason})` : ""}
</li>
),
)}
</ul>
)}
</div>
<div>
<div className="font-medium">{tt.actuallyDid}</div>
{status.last_actual_actions.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="mt-0.5 flex flex-col gap-1">
{status.last_actual_actions.map((a, i) => (
<li
key={i}
className="flex flex-wrap items-center gap-1.5"
>
<span className="font-mono">{a.tool ?? "?"}</span>
{typeof a.args?.name === "string" && (
<span className="text-muted-foreground">
{String(a.args.name)}
</span>
)}
<Badge
variant={
a.status === "error"
? "destructive"
: "secondary"
}
className="px-1 text-[10px]"
>
{a.status}
</Badge>
</li>
))}
</ul>
)}
</div>
</div>
</StatRow>
</div>
) : null}
</CardContent>
</Card>
);
}
function StatRow({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-muted-foreground min-w-[6rem]">{label}</span>
<span className="font-medium">{children}</span>
</div>
);
}
function ConfigCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.config;
const { config, isLoading } = useCuratorConfig();
const updateConfig = useUpdateCuratorConfig();
const [draft, setDraft] = useState<CuratorConfig | null>(null);
useEffect(() => {
if (config && !draft) {
setDraft(config);
}
}, [config, draft]);
if (isLoading || !draft) {
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
</CardContent>
</Card>
);
}
const isDirty =
!!config &&
JSON.stringify(draft) !== JSON.stringify(config);
const handleSave = async () => {
try {
const updated = await updateConfig.mutateAsync(draft);
setDraft(updated);
toast.success(tt.saveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<FieldRow
label={tt.enabledLabel}
helper={tt.enabledHelper}
>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
/>
</FieldRow>
<FieldRow
label={tt.curatorEnabledLabel}
helper={tt.curatorEnabledHelper}
>
<Switch
checked={draft.curator.enabled}
onCheckedChange={(v) =>
setDraft({
...draft,
curator: { ...draft.curator, enabled: v },
})
}
/>
</FieldRow>
<FieldRow
label={tt.intervalHoursLabel}
helper={tt.intervalHoursHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.interval_hours}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
interval_hours: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.staleAfterDaysLabel}
helper={tt.staleAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.stale_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
stale_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.archiveAfterDaysLabel}
helper={tt.archiveAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.archive_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
archive_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.moderationModelLabel}
helper={tt.moderationModelHelper}
>
<Input
type="text"
className="max-w-[20rem]"
value={draft.moderation_model_name ?? ""}
placeholder=""
onChange={(e) =>
setDraft({
...draft,
moderation_model_name:
e.target.value.trim() === "" ? null : e.target.value,
})
}
/>
</FieldRow>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={() => config && setDraft(config)}
>
{tt.reset}
</Button>
<Button
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={handleSave}
>
{updateConfig.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
{tt.save}
</Button>
</div>
</CardContent>
</Card>
);
}
function FieldRow({
label,
helper,
children,
}: {
label: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b pb-4 last:border-b-0 last:pb-0">
<div className="flex-1">
<div className="text-sm font-medium">{label}</div>
{helper && (
<div className="text-muted-foreground mt-1 text-xs">{helper}</div>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
function LogsCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.logs;
const [autoRefresh, setAutoRefresh] = useState(true);
const { logs, isLoading, refetch } = useCuratorLogs({ autoRefresh });
const records = logs?.records ?? [];
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</div>
<div className="flex items-center gap-3">
<label className="text-muted-foreground flex items-center gap-2 text-xs">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
{tt.autoRefresh}
</label>
<Button size="sm" variant="outline" onClick={() => void refetch()}>
<RefreshCwIcon className="size-3.5" />
{tt.refresh}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading && records.length === 0 ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : records.length === 0 ? (
<div className="text-muted-foreground text-sm">{tt.empty}</div>
) : (
<div className="bg-muted/30 max-h-[420px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed">
{records.map((record, idx) => (
<div
key={`${record.ts}-${idx}`}
className={cn(
"flex gap-3",
record.level === "warning" && "text-yellow-700 dark:text-yellow-300",
record.level === "error" && "text-red-600 dark:text-red-300",
)}
>
<span className="text-muted-foreground shrink-0">
{formatTime(record.ts)}
</span>
<span className="text-muted-foreground w-14 shrink-0 uppercase">
{record.level}
</span>
<span className="whitespace-pre-wrap break-all">
{record.message === "run_started"
? "——— 本次策展开始 ———"
: record.message}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function formatTime(ts: string): string {
try {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return ts;
}
}
@@ -0,0 +1,58 @@
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Tag } from "@/core/tags";
import { resolveTagColor } from "./tag-colors";
/** A read-only (or removable) coloured chip for a single tag. */
export function TagChip({
tag,
onRemove,
className,
}: {
tag: Pick<Tag, "name" | "color">;
onRemove?: () => void;
className?: string;
}) {
const color = resolveTagColor(tag.color);
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
color.chip,
className,
)}
>
{tag.name}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="hover:opacity-70"
aria-label={`remove ${tag.name}`}
>
<XIcon className="size-3" />
</button>
)}
</span>
);
}
/** Renders a horizontal list of tag chips; nothing when the list is empty. */
export function TagChipList({
tags,
className,
}: {
tags: Pick<Tag, "name" | "color">[];
className?: string;
}) {
if (!tags || tags.length === 0) return null;
return (
<div className={cn("flex flex-wrap gap-1", className)}>
{tags.map((tag) => (
<TagChip key={tag.name} tag={tag} />
))}
</div>
);
}
@@ -0,0 +1,30 @@
// Fixed tag color palette. The backend only stores the color *name*; the
// Tailwind classes are resolved here so the class strings stay static and
// survive the production purge.
export interface TagColorDef {
name: string;
/** Classes for a rendered chip (background + text + border). */
chip: string;
/** A solid swatch used in the colour picker. */
swatch: string;
}
export const TAG_COLORS: TagColorDef[] = [
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
];
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
export function resolveTagColor(name: string | undefined | null): TagColorDef {
return (name && BY_NAME.get(name)) || TAG_COLORS[0];
}
@@ -0,0 +1,140 @@
"use client";
import { CheckIcon, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useSetResourceTags, useTags, type Tag, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
import { resolveTagColor } from "./tag-colors";
/**
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
* unbinds it immediately (no separate save step). Only tags of ``targetType``
* are offered, enforcing "a tag may only tag its own type".
*/
export function TagPickerDialog({
open,
onOpenChange,
targetType,
targetId,
currentTags,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
targetType: TagType;
targetId: string;
currentTags: Tag[];
}) {
const { t } = useI18n();
const { data: allTags, isLoading } = useTags(targetType);
const setResourceTags = useSetResourceTags();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${targetId}`;
if (open && syncedFor !== syncKey) {
setSelected(new Set(currentTags.map((tg) => tg.id)));
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
// Clicking a tag mutates immediately. Local state updates optimistically and
// rolls back if the request fails.
const toggle = (tagId: string) => {
const prev = selected;
const next = new Set(prev);
if (next.has(tagId)) next.delete(tagId);
else next.add(tagId);
setSelected(next);
setResourceTags.mutate(
{ targetType, targetId, tagIds: [...next] },
{
onError: (e) => {
setSelected(prev);
toast.error(String(e instanceof Error ? e.message : e));
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.adminTags.pickerTitle}</DialogTitle>
<DialogDescription>{t.adminTags.pickerHint}</DialogDescription>
</DialogHeader>
<div className="min-h-[120px]">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{!isLoading && (!allTags || allTags.length === 0) && (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.adminTags.pickerEmpty}
</div>
)}
{!isLoading && allTags && allTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => {
const checked = selected.has(tag.id);
const color = resolveTagColor(tag.color);
return (
<button
key={tag.id}
type="button"
onClick={() => toggle(tag.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
color.chip,
checked
? "ring-2 ring-offset-1 ring-foreground/40"
: "opacity-45 hover:opacity-90",
)}
>
{checked && <CheckIcon className="size-3.5" />}
{tag.name}
</button>
);
})}
</div>
)}
</div>
<DialogFooter className="items-center sm:justify-between">
<span className="text-muted-foreground text-xs">
{setResourceTags.isPending ? (
<span className="flex items-center gap-1">
<Loader2 className="size-3 animate-spin" />
{t.adminTags.saving}
</span>
) : (
t.adminTags.pickerSelectedCount.replace(
"{count}",
String(selected.size),
)
)}
</span>
<Button onClick={() => onOpenChange(false)}>
{t.adminTags.done}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,80 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useI18n } from "@/core/i18n/hooks";
import { useTags, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
const ALL = "__all__";
/**
* Reusable name-search + tag-filter toolbar for resource list pages.
*
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
* caller can feed the value straight into a server-side query without a
* request per keystroke. Filtering is open to all users.
*/
export function TagSearchBar({
targetType,
onSearchChange,
tagId,
onTagIdChange,
className,
}: {
targetType: TagType;
onSearchChange: (value: string) => void;
tagId: string | null;
onTagIdChange: (value: string | null) => void;
className?: string;
}) {
const { t } = useI18n();
const { data: tags } = useTags(targetType);
const [input, setInput] = useState("");
const cbRef = useRef(onSearchChange);
cbRef.current = onSearchChange;
useEffect(() => {
const id = setTimeout(() => cbRef.current(input.trim()), 300);
return () => clearTimeout(id);
}, [input]);
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
/>
</div>
<Select
value={tagId ?? ALL}
onValueChange={(v) => onTagIdChange(v === ALL ? null : v)}
>
<SelectTrigger className="w-44">
<SelectValue placeholder={t.adminTags.filterByTag} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t.adminTags.allTags}</SelectItem>
{(tags ?? []).map((tag) => (
<SelectItem key={tag.id} value={tag.id}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -50,23 +50,23 @@ export function Welcome({
</div>
{searchParams.get("mode") === "skill" ? (
<div className="text-muted-foreground text-sm">
{t.welcome.createYourOwnSkillDescription.includes("\n") ? (
{/* {t.welcome.createYourOwnSkillDescription.includes("\n") ? (
<pre className="font-sans whitespace-pre">
{t.welcome.createYourOwnSkillDescription}
</pre>
) : (
<p>{t.welcome.createYourOwnSkillDescription}</p>
)}
)} */}
</div>
) : (
<div className="text-muted-foreground text-sm">
{t.welcome.description.includes("\n") ? (
{/* {t.welcome.description.includes("\n") ? (
<pre className="font-sans whitespace-pre">
{t.welcome.description}
</pre>
) : (
<p>{t.welcome.description}</p>
)}
)} */}
</div>
)}
</div>
@@ -15,9 +15,6 @@ import {
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { GithubIcon } from "./github-icon";
import { Tooltip } from "./tooltip";
export function WorkspaceContainer({
className,
children,
@@ -90,18 +87,6 @@ export function WorkspaceHeader({
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="pr-4">
<Tooltip content={t.workspace.githubTooltip}>
<a
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
className="opacity-75 transition hover:opacity-100"
>
<GithubIcon className="size-6" />
</a>
</Tooltip>
</div>
</header>
);
}
@@ -1,97 +1,97 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) {
return (
<div
className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
className,
)}
aria-hidden
>
C
</div>
);
}
function SidebarCollapseButton({ className }: { className?: string }) {
const { open, toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
type="button"
variant="ghost"
size="icon"
className={cn(
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
className,
)}
onClick={() => toggleSidebar()}
>
{open ? (
<ChevronLeft className="size-4" strokeWidth={2} />
) : (
<ChevronRight className="size-4" strokeWidth={2} />
)}
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const { state } = useSidebar();
const titleBlock = (
<div className="min-w-0 flex-1">
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{t.pages.appName}
</div>
<div className="text-muted-foreground truncate text-[11px] leading-tight">
{t.pages.brandSubtitle}
</div>
</div>
);
return (
<div
className={cn(
"group/workspace-header border-sidebar-border/80 border-b",
className,
)}
>
{state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
</div>
</div>
) : (
<div className="flex items-center gap-2.5 px-2.5 py-3">
<BrandMark />
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/workspace/chats/new" className="min-w-0 flex-1 outline-none">
{titleBlock}
</Link>
) : (
titleBlock
)}
<SidebarCollapseButton />
</div>
)}
</div>
);
}
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
function BrandMark({ className }: { className?: string }) {
return (
<div
className={cn(
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
className,
)}
aria-hidden
>
C
</div>
);
}
function SidebarCollapseButton({ className }: { className?: string }) {
const { open, toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
type="button"
variant="ghost"
size="icon"
className={cn(
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
className,
)}
onClick={() => toggleSidebar()}
>
{open ? (
<ChevronLeft className="size-4" strokeWidth={2} />
) : (
<ChevronRight className="size-4" strokeWidth={2} />
)}
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const { state } = useSidebar();
const titleBlock = (
<div className="min-w-0 flex-1">
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{t.pages.appName}
</div>
<div className="text-muted-foreground truncate text-[11px] leading-tight">
{t.pages.brandSubtitle}
</div>
</div>
);
return (
<div
className={cn(
"group/workspace-header border-sidebar-border/80 border-b",
className,
)}
>
{state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
</div>
</div>
) : (
<div className="flex items-center gap-2.5 px-2.5 py-3">
<BrandMark />
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/page/workspace/chats/new" className="min-w-0 flex-1 outline-none">
{titleBlock}
</Link>
) : (
titleBlock
)}
<SidebarCollapseButton />
</div>
)}
</div>
);
}
@@ -1,65 +1,93 @@
"use client";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
return (
<SidebarGroup className="px-2 pt-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/workspace/chats/new"}
asChild
>
<Link className="text-sidebar-foreground" href="/workspace/chats/new">
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
<Link className="text-sidebar-foreground" href="/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/agents")}
asChild
>
<Link className="text-sidebar-foreground" href="/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/workspace/scheduled/tasks"}
asChild
>
<Link className="text-sidebar-foreground" href="/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
const isAdmin = getStoredAuth()?.system_role === "admin";
return (
<SidebarGroup className="px-2 pt-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/chats/new">
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton isActive={pathname === "/page/workspace/chats"} asChild>
<Link className="text-sidebar-foreground" href="/page/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/agents")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/skills")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/skills">
<SparklesIcon />
<span>技能</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/admin")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
<UsersIcon />
<span>{t.sidebar.users}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
);
}
@@ -1,6 +1,15 @@
"use client";
import { ChevronsUpDown, Settings2Icon, SettingsIcon } from "lucide-react";
import {
BellIcon,
ChevronsUpDown,
GaugeIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
Settings2Icon,
SettingsIcon,
TagsIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -17,7 +26,12 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
@@ -42,13 +56,17 @@ function NavMenuButtonContent({
}
export function WorkspaceNavMenu() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "skills" | "notification" | "about"
"appearance" | "memory" | "tools" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
useEffect(() => {
setMounted(true);
@@ -61,6 +79,10 @@ export function WorkspaceNavMenu() {
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
{mounted ? (
@@ -79,6 +101,15 @@ export function WorkspaceNavMenu() {
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
@@ -88,6 +119,44 @@ export function WorkspaceNavMenu() {
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/recommended-questions")
}
>
<MessageSquarePlusIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/prompt-prefix")
}
>
<MessageSquareCodeIcon />
问答前缀配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/llm-metrics")
}
>
<GaugeIcon />
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
+158
View File
@@ -0,0 +1,158 @@
/**
* 后端 API 默认地址
*/
export const DEFAULT_BACKEND_URL = 'http://10.26.128.38:8030';
/**
* 默认前端配置
*
* 包含:
* - coordinator_config: 协调者各阶段策略提示
* - agents: 默认四智能体(背景研判师、目的策略师、受众分析师、叙事策划师)
* - report_schema: 六字段报告结构定义
* - report_examples: 各字段的示例输出(问界 M7 事故场景)
* - field_requirements: 字段依赖关系定义
*/
export const DEFAULT_CONFIG = {
task_context: "",
llm_preset_id: "",
consistency_mode: "soft",
coordinator_mode: "auto",
coordinator_config: {
system_prompt_prefix: "",
round1_guidance: `这是发散阶段第1轮,草稿刚起步。
策略:优先填写草稿中空缺最关键的字段,同时识别智能体之间最尖锐的分歧——
这些分歧往往意味着草稿中某个判断还无法做出,需要通过交锋来解决。`,
middle_round_guidance: `这是发散阶段的中间轮次。
策略:审查草稿现状,找出仍然太模糊或有冲突的字段,
选择能推进这些字段的智能体,给出让草稿变得更具体的任务。`,
final_round_guidance: `这是发散阶段最后一轮。
策略:草稿必须在这轮后达到可以收尾的状态,但仍要求智能体给出完整材料包,
包括明确判断、依据链、反例、风险边界和可核验线索;最终压缩、取舍和成文由协调者完成。`,
speaker_selection_rules: "优先选择最能扩充 partial/blocked 字段材料、解决关键冲突、补充证据或论证链的智能体。",
focus_instruction_rules: "focus 必须写成内容型任务书,不能只催促 agent 继续分析。每条至少包含:目标字段、已有材料或草稿判断、真正缺口、为什么该缺口会阻塞报告、需要补充的具体材料类型、建议切入角度、预期输出形态、风险或禁止事项。指令应鼓励 agent 展开材料、证据线索、论证链、反例、分歧和边界条件,而不是只给短结论。",
revise_prompt_prefix: ""
},
agents: [
{
id: "situation",
name: "背景研判师",
dimension: "背景研判",
expertise: "你是一位资深的舆情和战略分析专家。你的任务是判断当前舆论场的格局:现在存在哪几条叙事,各自的势头如何,哪些认知锚点可以被打散,哪些比较维度可以被引入或重构,竞争对手的动作有哪些逻辑漏洞。不是做损失报告,而是做战略研判——找到可以操作的叙事空间。",
},
{
id: "purpose",
name: "目的策略师",
dimension: "传播目标",
expertise: "你是一位认知传播专家,专门设计可实现的传播目标——即通过传播内容能够在受众头脑中建立的具体认知或情绪。你不关心商业结果,只关心:这次传播结束后,目标受众的想法和感受会发生什么具体变化?用第一人称写出目标受众在成功传播后的内心独白。也可以设计竞争性目标(让受众对竞品产生质疑)。",
},
{
id: "audience",
name: "受众分析师",
dimension: "受众图谱",
expertise: "你是一位受众研究专家。你的任务是识别具体的、可操作的目标受众群体。拒绝泛化:不接受'年轻人''汽车爱好者'这类描述,必须给出年龄段、性别比例、城市层级、具体平台和具体账号/社区名称。同时要写出该群体当前的心理状态——他们现在在想什么,核心疑虑是什么,用第一人称写出他们的内心独白。",
},
{
id: "narrative",
name: "叙事策划师",
dimension: "叙事方案",
expertise: "你是一位顶尖的叙事战略家和内容策划师。你的任务是设计具体的叙事支柱:每个支柱必须包含(1)具体内容——发什么、怎么说,给出可执行的内容方向或文案草稿;(2)为什么这么设计——预期的心理机制;(3)预期效果——目标受众会产生什么认知变化;(4)风险与局限——在什么条件下会失效或反噬。叙事框架和具体消息写在一起,不要分开。",
}
],
phase_config: { diverge_rounds: 3 },
template_id: "",
template_name: "",
template_brief: "",
report_schema: {
situation_assessment: "背景研判:当前舆论场/市场/竞争格局的战略研判。包含现有几条并行叙事及其势头、可以被打散的认知锚点、竞争对手动作的逻辑漏洞、可操作的叙事空间。不是损失报告,是战略判断。",
communication_objectives: "传播目标:2-4个本方案期望在受众中建立的具体认知或情绪,用目标受众的第一人称内心独白表达。不写商业结果,不写指标,只写认知和情绪目标。可含竞争性目标。",
audience_map: "核心受众:2-4个具体受众群体,每个包含:(1)人口画像——年龄段、性别、城市层级、具体特征;(2)当前心理状态——用第一人称内心独白;(3)关键信息节点——具体平台和账号/社区名称,不接受泛化描述。",
narrative_plan: "叙事方案:2-3个叙事支柱,每个包含:(1)具体内容——可执行的内容方向或文案草稿;(2)为什么这么设计——预期心理机制;(3)预期效果——目标受众的认知变化;(4)风险与局限——失效条件。叙事框架和消息合并在一起。",
action_plan: "分阶段行动计划:按时间线的3-4个阶段,每个行动具体到:做什么、谁执行(官方/第三方/独立账号)、何时。",
risk_playbook: "风险预案:3-5个风险场景,每个含触发条件、具体应对步骤、明确禁止的动作。"
},
report_examples: {
situation_assessment: `事故发生后,舆论场中存在三条并行叙事:
- 主流情绪叙事(当前占主导):"电动车关门困死人"——聚焦电动门锁这一单一缺陷,将问界塑造为主动伤人的产品
- 竞品借势叙事:理想、小米相继发声强调"物理门把手不会消失",将安全心智差异化
- 技术中性叙事(目前声量极弱):部分汽车工程师账号指出燃油车追尾同样可能起火且无法逃生,但未被放大
核心判断:目前的"关门困死人"叙事对问界的伤害远超事故本身,因为它指向的是所有电动车门锁的系统性问题,而非这次追尾的偶发性。叙事反转的关键在于:将"问界门锁缺陷"重新定框为"追尾高速起火是极端工况,不同于日常安全风险",同时打开"燃油车在同等追尾条件下结果是否更好"这一比较维度。
此外,竞品的"物理门把手"营销动作,实质上是在暗示所有纯电动车都有同等风险——问界可以借助行业数据,将这一模糊指控反向用于质疑竞品的安全宣传逻辑。`,
communication_objectives: `目标一:让现有问界车主相信此次事故属于极端偶发工况
> "这是一辆车被高速追尾、车身严重变形后才发生的,不是日常开车会遇到的情况,和我每天通勤的安全性是两件事。"
目标二:让公众了解燃油车在同等追尾工况下的起火概率并不低于电动车
> "我以为电动车特别容易起火,看了数据才知道燃油车追尾也会爆炸,而且逃生时间更短。这个问题不是问界独有的。"
目标三:让潜在购车者相信问界已做出真实的技术改进,而非只是公关操作
> "他们不只是道歉,而是真的改了——门锁逻辑改了,还加了物理把手,有第三方测了。这和之前那些只道歉不改的品牌不一样。"
目标四(竞争性):在科技圈和华为生态用户中种下"竞品安全宣传是过度营销"的质疑
> "理想说保留物理门把手就安全了?那燃油车追尾不也照样困死人,他们这是蹭热度。"`,
audience_map: `受众一:现有问界M7车主(最优先)
- 画像:25-45岁男性为主(问界买家中男性占比约75%),华为手机深度用户,在二三线城市比例高于理想/小米,家庭用车场景为主
- 当前心理状态:"在家人追问压力下动摇,既不想认错(承认买错了),又需要一个可以说服家人的说法"
- 关键触点:AITO官方App推送、华为花粉俱乐部社区(注册用户2000万+)、各省市问界车主微信群
受众二:考虑30万内家用SUV的25-38岁已婚男性(主要流失群体)
- 画像:集中在北京、上海、成都、杭州、武汉;在懂车帝/汽车之家浏览过问界M7、理想L7对比页面;家庭有6岁以下子女的比例约40%,配偶对选车有实质否决权
- 当前心理状态:"事故前已接近下定,现在进入观望,主要疑虑是'安全有保障吗',在等一个可以放心的理由"
- 关键触点:懂车帝的问界M7评测区(日均浏览约4万次)、B站@42号车库(汽车技术向,260万粉)、@老司机测评(抖音+微博,180万粉)`,
narrative_plan: `叙事支柱一:工况重构——把"电动门锁缺陷"扩展为"追尾高速起火"议题
具体内容:发布或推动媒体发布《追尾高速事故生存数据分析》,引用可验证的事故数据:中国高速追尾事故中燃油车起火比例、燃油车在高速追尾后因油箱破裂导致无法逃生的案例、断电门锁问题在行业内的普遍性(其他品牌纯电车是否存在类似设计)。
为什么这么设计:公众对"电动车起火"的恐惧,部分来自"燃油车才是安全默认值"的认知锚点。此叙事目标不是洗白问界,而是打散原有的比较基准——把"问界门锁失效"从"品牌失职"变成"高速追尾这一极端工况下的行业性挑战"。
预期效果:目标受众在搜索"问界安全"时,搜到的不是事故视频,而是工况对比分析,认知从"问界车会困死人"转向"高速追尾是极端事故,概率极低"。
风险与局限:如果数据无法支持"燃油车风险不低于电动车"的结论,此叙事会反噬——数据必须真实可查。部分受众会将此解读为"转移焦点"。`,
action_plan: `第一阶段:止血(D+0 至 D+3)
- D+0:余承东删除原有争议微博。AITO官方发布声明,核心措辞:承认断电门锁释放机制有改进空间,公布将与第三方机构联合调查,暂停全部品牌宣传广告
- D+1:通过APP+服务顾问向全体问界M7车主发送一对一信息,提供免费安全检测预约和免费代步车
- D+2:联系@42号车库、@老司机测评,提供完整的碰撞技术资料(不要求立场,只提供信息)
第二阶段:叙事建立(第1-4周)
- 第1周:推动@42号车库发布《追尾高速起火,燃油车和电动车谁更危险?》技术向内容,问界提供数据但不参与内容制作
- 第2周:举办技术说明会,邀请10家垂直媒体,公布OTA升级方案和物理把手加装方案详细规格
- 第3周:中国汽研开始改进后车辆的独立测试,全程开放媒体记者跟拍`,
risk_playbook: `风险一:12周内发生第二起相似事故
- 触发条件:同类门锁失效导致的伤亡事故再次出现
- 应对步骤:①立即暂停全部销售;②主动召回所有已售问界M7,不等监管要求;③停止所有叙事支柱三(竞品对比)内容的推进
- 明确禁止:不得发布任何暗示"概率极低"的安抚性内容
风险二:数据核查显示燃油车追尾起火比例低于电动车
- 触发条件:第三方核查发现叙事支柱一中引用的数据不成立
- 应对步骤:①立即停止涉及数据比较的传播;②调整叙事回归"我们改进了"主线,放弃工况比较维度
- 明确禁止:不得继续引用已被质疑的数据,不得删除原文(会被截图放大)`
},
field_requirements: {
communication_objectives: {
title: "传播目标",
dependencies: ["situation_assessment"],
},
audience_map: {
title: "核心受众",
dependencies: ["communication_objectives"],
},
narrative_plan: {
title: "叙事方案",
dependencies: ["communication_objectives", "audience_map"],
},
action_plan: {
title: "分阶段行动计划",
dependencies: ["communication_objectives", "audience_map", "narrative_plan"],
},
risk_playbook: {
title: "风险预案",
dependencies: ["narrative_plan", "action_plan"],
},
}
};
+96
View File
@@ -0,0 +1,96 @@
/** localStorage 存储前缀,用于模板覆盖数据的 key */
const OVERRIDE_PREFIX = 'mope_template_override:';
/**
* 深拷贝对象
* 使用 JSON 序列化/反序列化实现简单深拷贝,
* 适用于纯数据的配置对象(不含函数、循环引用等)。
*/
export function clone(value) {
return JSON.parse(JSON.stringify(value));
}
/**
* 从后端加载模板索引列表
* @returns {Promise<Array>} 模板摘要数组 [{ template_id, template_name, file }]
*/
export async function loadTemplateIndex() {
const response = await fetch('/templates/index.json');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
return data.templates || [];
}
/**
* 加载完整模板数据
* 优先使用 localStorage 中的用户覆盖版本,
* 否则从后端静态文件加载。
*
* @param {string|Object} metaOrId - 模板 ID 字符串或包含 template_id/file 的元数据对象
* @returns {Promise<Object>} 完整模板对象
*/
export async function loadTemplateByMeta(metaOrId) {
const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id;
const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file;
const response = await fetch(file || `/templates/${templateId}.json`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const template = await response.json();
const override = getTemplateOverride(template.template_id || templateId);
return override ? clone(override) : template;
}
/**
* 加载原始模板(忽略用户覆盖)
* @param {string|Object} metaOrId - 模板标识
* @returns {Promise<Object>} 后端静态模板
*/
export async function loadOriginalTemplate(metaOrId) {
const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id;
const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file;
const response = await fetch(file || `/templates/${templateId}.json`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
/**
* 获取用户在 localStorage 中保存的模板覆盖数据
* @param {string} templateId - 模板 ID
* @returns {Object|null} 覆盖的模板对象,未覆盖时返回 null
*/
export function getTemplateOverride(templateId) {
if (!templateId) return null;
try {
const raw = localStorage.getItem(`${OVERRIDE_PREFIX}${templateId}`);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
/**
* 判断指定模板是否已被用户覆盖修改
* @param {string} templateId - 模板 ID
* @returns {boolean}
*/
export function hasTemplateOverride(templateId) {
return Boolean(getTemplateOverride(templateId));
}
/**
* 保存模板覆盖到 localStorage
* @param {string} templateId - 模板 ID
* @param {Object} template - 修改后的模板对象
*/
export function saveTemplateOverride(templateId, template) {
if (!templateId) throw new Error('缺少 template_id');
localStorage.setItem(`${OVERRIDE_PREFIX}${templateId}`, JSON.stringify(template));
}
/**
* 清除指定模板的覆盖数据,恢复为后端原始版本
* @param {string} templateId - 模板 ID
*/
export function clearTemplateOverride(templateId) {
if (!templateId) return;
localStorage.removeItem(`${OVERRIDE_PREFIX}${templateId}`);
}
+81
View File
@@ -0,0 +1,81 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
type?: 'general' | 'agent' | 'compare';
agentName?: string;
}
interface ChatContextType {
messages: ChatMessage[];
addMessage: (role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => void;
clearMessages: () => void;
deleteMessage: (id: string) => void;
}
const ChatContext = createContext<ChatContextType | undefined>(undefined);
export function ChatProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<ChatMessage[]>(() => {
try {
const stored = localStorage.getItem('qa-chat-history');
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const saveToStorage = useCallback((newMessages: ChatMessage[]) => {
try {
localStorage.setItem('qa-chat-history', JSON.stringify(newMessages));
} catch (error) {
console.error('Failed to save chat history:', error);
}
}, []);
const addMessage = useCallback((role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => {
const newMessage: ChatMessage = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
role,
content,
timestamp: Date.now(),
type,
agentName,
};
setMessages((prev) => {
const updated = [...prev, newMessage];
saveToStorage(updated);
return updated;
});
}, [saveToStorage]);
const clearMessages = useCallback(() => {
setMessages([]);
localStorage.removeItem('qa-chat-history');
}, []);
const deleteMessage = useCallback((id: string) => {
setMessages((prev) => {
const updated = prev.filter((msg) => msg.id !== id);
saveToStorage(updated);
return updated;
});
}, [saveToStorage]);
return (
<ChatContext.Provider value={{ messages, addMessage, clearMessages, deleteMessage }}>
{children}
</ChatContext.Provider>
);
}
export function useChatContext() {
const context = useContext(ChatContext);
if (context === undefined) {
throw new Error('useChatContext must be used within a ChatProvider');
}
return context;
}
+145
View File
@@ -0,0 +1,145 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
// 主题类型
export type ThemeType = 'dark' | 'light';
// 主题配置
export interface ThemeConfig {
type: ThemeType;
name: string;
colors: {
background: string;
backgroundSecondary: string;
text: string;
textSecondary: string;
primary: string;
primaryHover: string;
border: string;
accent: string;
success: string;
warning: string;
error: string;
card: string;
cardHover: string;
input: string;
inputBorder: string;
overlay: string;
};
}
// 主题配置
const themes: Record<ThemeType, ThemeConfig> = {
dark: {
type: 'dark',
name: '深色主题',
colors: {
background: 'slate-900',
backgroundSecondary: 'slate-800',
text: 'white',
textSecondary: 'slate-400',
primary: 'blue-500',
primaryHover: 'blue-400',
border: 'slate-700',
accent: 'blue-600',
success: 'emerald-500',
warning: 'amber-500',
error: 'rose-500',
card: 'slate-800',
cardHover: 'slate-700',
input: 'slate-800',
inputBorder: 'slate-600',
overlay: 'slate-900/80',
},
},
light: {
type: 'light',
name: '红白主题',
colors: {
background: 'white',
backgroundSecondary: 'red-50',
text: 'gray-900',
textSecondary: 'gray-600',
primary: 'red-600',
primaryHover: 'red-500',
border: 'red-200',
accent: 'red-700',
success: 'green-600',
warning: 'amber-500',
error: 'red-600',
card: 'white',
cardHover: 'red-50',
input: 'white',
inputBorder: 'red-200',
overlay: 'black/30',
},
},
};
// 主题上下文
interface ThemeContextType {
theme: ThemeType;
themeConfig: ThemeConfig;
toggleTheme: () => void;
setTheme: (theme: ThemeType) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// 主题提供者
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setThemeState] = useState<ThemeType>(() => {
// 从 localStorage 读取保存的主题
const saved = localStorage.getItem('strategy-theme');
return (saved as ThemeType) || 'dark';
});
// 切换主题
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
// 设置特定主题
const setTheme = (newTheme: ThemeType) => {
setThemeState(newTheme);
};
// 保存到 localStorage
useEffect(() => {
localStorage.setItem('strategy-theme', theme);
// 更新文档类名
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
}, [theme]);
const themeConfig = themes[theme];
return (
<ThemeContext.Provider value={{ theme, themeConfig, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
// 使用主题的 Hook
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
// 主题工具函数 - 生成动态类名
export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string => {
const { themeConfig } = useTheme();
return themeConfig.colors[colorKey];
};
// 组合类名工具函数
export const cn = (...classes: (string | undefined | null | false)[]): string => {
return classes.filter(Boolean).join(' ');
};
+15 -4
View File
@@ -15,8 +15,19 @@ export class AgentNameCheckError extends Error {
}
}
export async function listAgents(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents`);
export interface ListAgentsParams {
search?: string;
tagId?: string | null;
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as { agents: Agent[] };
return data.agents;
@@ -74,7 +85,7 @@ export async function checkAgentName(
);
} catch {
throw new AgentNameCheckError(
"Could not reach the DeerFlow backend.",
"Could not reach the CM Assistant backend.",
"backend_unreachable",
);
}
@@ -83,7 +94,7 @@ export async function checkAgentName(
const err = (await res.json().catch(() => ({}))) as { detail?: string };
if (BACKEND_UNAVAILABLE_STATUSES.has(res.status)) {
throw new AgentNameCheckError(
"Could not reach the DeerFlow backend.",
"Could not reach the CM Assistant backend.",
"backend_unreachable",
);
}
+12 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createAgent,
@@ -9,10 +14,13 @@ import {
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
export function useAgents() {
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
const { data, isLoading, error } = useQuery({
queryKey: ["agents"],
queryFn: () => listAgents(),
queryKey: ["agents", search, tagId],
queryFn: () => listAgents({ search, tagId }),
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
}
+3
View File
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export interface Agent {
id: string;
name: string;
@@ -11,6 +13,7 @@ export interface Agent {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
tags?: Tag[];
}
export interface CreateAgentRequest {
+14
View File
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
export interface UserListItem {
id: string;
email: string;
system_role: "admin" | "user";
skill_count: number;
agent_count: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<UserListItem[]>;
}
export async function loginByUsername(
username: string,
): Promise<LoginByUsernameResponse> {
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { listAllUsers } from "./api";
export function useAllUsers(enabled: boolean) {
return useQuery({
queryKey: ["admin", "users"],
queryFn: listAllUsers,
enabled,
});
}
+41
View File
@@ -0,0 +1,41 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
CuratorConfig,
CuratorLogs,
CuratorStatus,
} from "./types";
export async function getCuratorStatus(): Promise<CuratorStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/status`);
return response.json() as Promise<CuratorStatus>;
}
export async function getCuratorConfig(): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`);
return response.json() as Promise<CuratorConfig>;
}
export async function updateCuratorConfig(
payload: CuratorConfig,
): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return response.json() as Promise<CuratorConfig>;
}
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
method: "POST",
});
return response.json();
}
export async function getCuratorLogs(): Promise<CuratorLogs> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/logs`);
return response.json() as Promise<CuratorLogs>;
}
+90
View File
@@ -0,0 +1,90 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { getStoredAuth } from "@/core/auth";
import {
getCuratorConfig,
getCuratorLogs,
getCuratorStatus,
runCurator,
updateCuratorConfig,
} from "./api";
import type { CuratorConfig } from "./types";
// All curator-related queries are per-user data (status / config / logs all
// live under .deer-flow/users/{user_id}/), so the cache key MUST be scoped by
// user_id. Otherwise switching accounts in the same tab would let user B
// briefly read user A's cached values — and clicking "save" on that stale
// frame would silently overwrite user B's real config with user A's values.
function useCuratorKeys() {
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
return useMemo(
() => ({
status: ["curator", userId, "status"] as const,
config: ["curator", userId, "config"] as const,
logs: ["curator", userId, "logs"] as const,
}),
[userId],
);
}
export function useCuratorStatus() {
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.status,
queryFn: getCuratorStatus,
});
return { status: query.data, isLoading: query.isLoading, error: query.error };
}
export function useCuratorConfig() {
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.config,
queryFn: getCuratorConfig,
});
return { config: query.data, isLoading: query.isLoading, error: query.error };
}
export function useUpdateCuratorConfig() {
const queryClient = useQueryClient();
const keys = useCuratorKeys();
return useMutation({
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: keys.config });
void queryClient.invalidateQueries({ queryKey: keys.status });
},
});
}
export function useRunCurator() {
const queryClient = useQueryClient();
const keys = useCuratorKeys();
return useMutation({
mutationFn: () => runCurator(),
onSuccess: () => {
// Logs start from scratch on a new run — refresh so the UI clears
// immediately and then picks up the new run_started record.
void queryClient.invalidateQueries({ queryKey: keys.logs });
void queryClient.invalidateQueries({ queryKey: keys.status });
},
});
}
export function useCuratorLogs(options?: { autoRefresh?: boolean }) {
const autoRefresh = options?.autoRefresh ?? true;
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.logs,
queryFn: getCuratorLogs,
refetchInterval: autoRefresh ? 2000 : false,
});
return {
logs: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
+65
View File
@@ -0,0 +1,65 @@
export interface CuratorSubConfig {
enabled: boolean;
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
}
export interface CuratorConfig {
enabled: boolean;
moderation_model_name: string | null;
curator: CuratorSubConfig;
}
export interface CuratorAction {
tool: string | null;
args: Record<string, unknown>;
status: string;
result: string;
}
export interface CuratorConsolidation {
from?: string;
into?: string;
reason?: string;
}
export interface CuratorPruning {
name?: string;
reason?: string;
}
export interface CuratorSuggestions {
consolidations: CuratorConsolidation[];
prunings: CuratorPruning[];
}
export interface CuratorStatus {
enabled: boolean;
run_count: number;
last_run_at: string | null;
last_run_summary: string | null;
paused: boolean;
agent_created_skills: {
total: number;
active: number;
stale: number;
archived: number;
};
/** 上次运行 LLM 自述的建议(它"说"要做什么)。 */
last_consolidation_suggestions: CuratorSuggestions;
/** 上次运行的真实工具调用记录(它"实际"做了什么)。 */
last_actual_actions: CuratorAction[];
}
export interface CuratorLogRecord {
ts: string;
level: string;
message: string;
}
export interface CuratorLogs {
records: CuratorLogRecord[];
run_count: number;
last_run_at: string | null;
}
+234 -16
View File
@@ -61,11 +61,11 @@ export const enUS: Translations = {
welcome: {
greeting: "Hello, again!",
description:
"Welcome to 🦌 DeerFlow, an open source super agent. With built-in and custom skills, DeerFlow helps you search on the web, analyze data, and generate artifacts like slides, web pages and do almost anything.",
"Welcome to CM Assistant. With built-in and custom skills, CM Assistant helps you search on the web, analyze data, and generate artifacts like slides, web pages and do almost anything.",
createYourOwnSkill: "Create Your Own Skill",
createYourOwnSkillDescription:
"Create your own skill to release the power of DeerFlow. With customized skills,\nDeerFlow can help you search on the web, analyze data, and generate\n artifacts like slides, web pages and do almost anything.",
"Create your own skill to release the power of CM Assistant. With customized skills,\nCM Assistant can help you search on the web, analyze data, and generate\n artifacts like slides, web pages and do almost anything.",
},
// Clipboard
@@ -172,6 +172,7 @@ export const enUS: Translations = {
recentChats: "Recent chats",
demoChats: "Demo chats",
agents: "Agents",
users: "Users",
},
// Agents
@@ -208,13 +209,13 @@ export const enUS: Translations = {
save: "Save agent",
saving: "Saving agent...",
saveRequested:
"Save requested. DeerFlow is generating and saving an initial version now.",
"Save requested. CM Assistant is generating and saving an initial version now.",
saveHint:
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
saveCommandMessage:
"Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.",
agentCreatedPendingRefresh:
"The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.",
"The agent was created, but CM Assistant could not load it yet. Please refresh this page in a moment.",
more: "More actions",
agentCreated: "Agent created!",
startChatting: "Start chatting",
@@ -229,13 +230,13 @@ export const enUS: Translations = {
// Workspace
workspace: {
officialWebsite: "DeerFlow's official website",
githubTooltip: "DeerFlow on Github",
officialWebsite: "CM Assistant's official website",
githubTooltip: "CM Assistant on Github",
settingsAndMore: "Settings and more",
visitGithub: "DeerFlow on GitHub",
visitGithub: "CM Assistant on GitHub",
reportIssue: "Report a issue",
contactUs: "Contact us",
about: "About DeerFlow",
about: "About CM Assistant",
},
// Conversation
@@ -251,7 +252,7 @@ export const enUS: Translations = {
// Page titles (document title)
pages: {
appName: "DeerFlow",
appName: "CM Assistant",
brandSubtitle: "Smart CM Platform",
chats: "Chats",
newChat: "New chat",
@@ -278,7 +279,7 @@ export const enUS: Translations = {
writeFile: "Write file",
clickToViewContent: "Click to view file content",
writeTodos: "Update to-do list",
skillInstallTooltip: "Install skill and make it available to DeerFlow",
skillInstallTooltip: "Install skill and make it available to CM Assistant",
},
// Subtasks
@@ -315,7 +316,7 @@ export const enUS: Translations = {
actions: "Actions",
keyboardShortcuts: "Keyboard Shortcuts",
keyboardShortcutsDescription:
"Navigate DeerFlow faster with keyboard shortcuts.",
"Navigate CM Assistant faster with keyboard shortcuts.",
openCommandPalette: "Open Command Palette",
toggleSidebar: "Toggle Sidebar",
},
@@ -323,7 +324,7 @@ export const enUS: Translations = {
// Settings
settings: {
title: "Settings",
description: "Adjust how DeerFlow looks and behaves for you.",
description: "Adjust how CM Assistant looks and behaves for you.",
sections: {
appearance: "Appearance",
memory: "Memory",
@@ -335,7 +336,7 @@ export const enUS: Translations = {
memory: {
title: "Memory",
description:
"DeerFlow automatically learns from your conversations in the background. These memories help DeerFlow understand you better and deliver a more personalized experience.",
"CM Assistant automatically learns from your conversations in the background. These memories help CM Assistant understand you better and deliver a more personalized experience.",
empty: "No memory data to display.",
rawJson: "Raw JSON",
exportButton: "Export memory",
@@ -433,20 +434,178 @@ export const enUS: Translations = {
description:
"Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill",
installSkill: "Install skill",
goToEvolution: "Skill evolution",
emptyTitle: "No agent skill yet",
emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.",
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
emptyButton: "Create Your First Skill",
badgeBuiltin: "Built-in",
badgeMine: "Mine",
badgeFromOthers: "From others",
badgeFromOthersTitle: "Owned by user {user_id}",
badgePublished: "Published",
badgePublishedTitle: "Visible to all users",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
badgeArchived: "Archived",
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
badgePinned: "Pinned",
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
badgeSourceAgent: "Agent-created",
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
badgeSourceUpload: "Uploaded",
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
tabAll: "Square",
tabMine: "Mine",
entryStale: "Stale {count}",
entryArchived: "Archived {count}",
useCountInline: "Used {count} times",
useCountInlineZero: "Not used yet",
publishAction: "Publish",
unpublishAction: "Unpublish",
publishSuccess: "Skill published",
unpublishSuccess: "Skill unpublished",
pinAction: "Pin (prevent auto-archive)",
unpinAction: "Unpin",
pinSuccess: "Pinned",
unpinSuccess: "Unpinned",
deleteAction: "Delete skill",
deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
deleteSuccess: "Skill deleted",
stateSheet: {
archivedTitle: "Archived skills",
archivedDescription:
"These skills have been archived by you or the curator and can be restored at any time.",
staleTitle: "Stale skills",
staleDescription:
"These skills have been idle for a while and were flagged by the curator. Using one again will auto-revive it; you can also act on them here.",
empty: "Nothing here",
actionRestore: "Restore",
actionArchiveNow: "Archive now",
actionView: "View",
actionPin: "Pin",
actionUnpin: "Unpin",
actionDelete: "Delete permanently",
moreMenu: "More",
archivedAt: "Archived {when}",
lastUsedAt: "Last used {when}",
neverUsed: "Never used",
restoreSuccess: "Restored",
archiveSuccess: "Archived",
pinSuccess: "Pinned",
unpinSuccess: "Unpinned",
},
viewDialog: {
title: "Skill details",
descriptionLabel: "Description",
stateLabel: "State",
sourceLabel: "Source",
useCountLabel: "Use count",
lastUsedLabel: "Last used",
close: "Close",
},
installDialog: {
title: "Install skill",
dropHint: "Drop a .zip or .skill archive here, or",
chooseFile: "Choose file",
validating: "Validating archive…",
installing: "Installing…",
replace: "Replace existing",
install: "Install",
cancel: "Cancel",
nameLabel: "Name",
descriptionLabel: "Description",
licenseLabel: "License",
toolsLabel: "Allowed tools",
warningsLabel: "Warnings",
conflictCustom:
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.",
targetCategoryLabel: "Install to",
categoryCustom: "Custom skill",
categoryPublic: "Built-in (admin)",
},
},
skillEvolution: {
title: "Skill Evolution",
description:
"Manage the skill curator: let the agent age out, archive, and consolidate its self-created skills in the background.",
backToSkills: "Back to skills",
status: {
title: "Status",
enabled: "Enabled",
disabled: "Disabled",
paused: "Paused",
runCount: "Total runs",
runCountUnit: "",
lastRunAt: "Last run",
lastRunNever: "Never",
lastRunSummary: "Last summary",
lastRunSummaryEmpty: "—",
skillCounts: "Skills",
active: "Active",
stale: "Stale",
archived: "Archived",
total: "Total",
runNow: "Run now",
runStarted: "Background run started",
lastActions: "Last run",
llmSuggested: "LLM suggested (self-report)",
actuallyDid: "Actually done (tool calls)",
noActions: "None",
},
config: {
title: "Configuration",
description:
"Saves to skill_evolution.json and hot-reloads — no restart required.",
enabledLabel: "Enable skill evolution",
enabledHelper:
"When off, the agent cannot create or modify skills under custom/.",
curatorEnabledLabel: "Curator runs automatically",
curatorEnabledHelper:
"When off, the curator only runs when triggered from this page.",
intervalHoursLabel: "Run interval (hours)",
intervalHoursHelper:
"Minimum hours between automatic curator runs.",
staleAfterDaysLabel: "Stale threshold (days)",
staleAfterDaysHelper:
"Skills inactive for this many days are flagged stale.",
archiveAfterDaysLabel: "Archive threshold (days)",
archiveAfterDaysHelper:
"Stale skills inactive for this many additional days are archived.",
moderationModelLabel: "Moderation model (optional)",
moderationModelHelper:
"Leave blank to use the default chat model.",
save: "Save changes",
saveSuccess: "Configuration updated",
reset: "Reset",
},
logs: {
title: "Recent run logs",
description:
"Latest curator run only; cleared on backend restart.",
empty: "No logs yet. Clicking 'Run now' will populate this panel.",
autoRefresh: "Auto-refresh (2s)",
refresh: "Refresh now",
},
},
notification: {
title: "Notification",
description:
"DeerFlow only sends a completion notification when the window is not active. This is especially useful for long-running tasks so you can switch to other work and get notified when done.",
"CM Assistant only sends a completion notification when the window is not active. This is especially useful for long-running tasks so you can switch to other work and get notified when done.",
requestPermission: "Request notification permission",
deniedHint:
"Notification permission was denied. You can enable it in your browser's site settings to receive completion alerts.",
testButton: "Send test notification",
testTitle: "DeerFlow",
testTitle: "CM Assistant",
testBody: "This is a test notification.",
notSupported: "Your browser does not support notifications.",
disableNotification: "Disable notification",
@@ -456,4 +615,63 @@ export const enUS: Translations = {
emptyDescription: "Credits and acknowledgements will show here.",
},
},
adminUsers: {
title: "User Management",
description: "View all registered users and their associated skill and agent counts.",
email: "Email",
role: "Role",
userId: "User ID",
skillCount: "Skills",
agentCount: "Agents",
roleAdmin: "Admin",
roleUser: "User",
empty: "No users found",
},
adminTags: {
title: "Tag Management",
description: "Create and manage tags for agents, skills, and scheduled tasks. A tag may only be applied to resources of its own type.",
typeAgent: "Agent Tags",
typeSkill: "Skill Tags",
typeScheduledTask: "Scheduled Task Tags",
name: "Name",
color: "Color",
tagDescription: "Description",
usageCount: "Usage",
actions: "Actions",
empty: "No tags yet",
createButton: "New Tag",
createTitle: "Create Tag",
editTitle: "Edit Tag",
deleteConfirm: "Delete this tag? It will be removed from every resource it tags.",
namePlaceholder: "Tag name",
descriptionPlaceholder: "Optional description",
save: "Save",
cancel: "Cancel",
edit: "Edit",
delete: "Delete",
pickerTitle: "Edit Tags",
pickerHint: "Click a tag to bind or unbind it — changes are saved instantly.",
pickerEmpty: "No tags available for this type. Create some in Tag Management.",
pickerSelectedCount: "{count} selected",
saving: "Saving…",
done: "Done",
manageTags: "Tags",
searchPlaceholder: "Search by name",
filterByTag: "Filter by tag",
allTags: "All tags",
},
notifications: {
title: "Notifications",
bellAria: "Open notifications",
unreadShort: "{count} unread",
markAllRead: "Mark all read",
empty: "No notifications",
deleteAria: "Delete notification",
settingsAria: "Notification settings",
backAria: "Back",
settingsTitle: "Notification settings",
},
};
+210
View File
@@ -1,5 +1,17 @@
import type { LucideIcon } from "lucide-react";
export interface NotificationsTranslations {
title: string;
bellAria: string;
unreadShort: string;
markAllRead: string;
empty: string;
deleteAria: string;
settingsAria: string;
backAria: string;
settingsTitle: string;
}
export interface Translations {
// Locale meta
locale: {
@@ -116,6 +128,7 @@ export interface Translations {
chats: string;
demoChats: string;
agents: string;
users: string;
};
// Agents
@@ -354,9 +367,152 @@ export interface Translations {
title: string;
description: string;
createSkill: string;
installSkill: string;
goToEvolution: string;
emptyTitle: string;
emptyDescription: string;
emptyButton: string;
badgeBuiltin: string;
badgeMine: string;
badgeFromOthers: string;
badgeFromOthersTitle: string;
badgePublished: string;
badgePublishedTitle: string;
badgeStale: string;
badgeStaleTitle: string;
badgeArchived: string;
badgeArchivedTitle: string;
badgePinned: string;
badgePinnedTitle: string;
badgeSourceAgent: string;
badgeSourceAgentTitle: string;
badgeSourceUpload: string;
badgeSourceUploadTitle: string;
tabAll: string;
tabMine: string;
entryStale: string;
entryArchived: string;
useCountInline: string;
useCountInlineZero: string;
publishAction: string;
unpublishAction: string;
publishSuccess: string;
unpublishSuccess: string;
pinAction: string;
unpinAction: string;
pinSuccess: string;
unpinSuccess: string;
deleteAction: string;
deleteConfirm: string;
deleteSuccess: string;
stateSheet: {
archivedTitle: string;
archivedDescription: string;
staleTitle: string;
staleDescription: string;
empty: string;
actionRestore: string;
actionArchiveNow: string;
actionView: string;
actionPin: string;
actionUnpin: string;
actionDelete: string;
moreMenu: string;
archivedAt: string;
lastUsedAt: string;
neverUsed: string;
restoreSuccess: string;
archiveSuccess: string;
pinSuccess: string;
unpinSuccess: string;
};
viewDialog: {
title: string;
descriptionLabel: string;
stateLabel: string;
sourceLabel: string;
useCountLabel: string;
lastUsedLabel: string;
close: string;
};
installDialog: {
title: string;
dropHint: string;
chooseFile: string;
validating: string;
installing: string;
replace: string;
install: string;
cancel: string;
nameLabel: string;
descriptionLabel: string;
licenseLabel: string;
toolsLabel: string;
warningsLabel: string;
conflictCustom: string;
conflictPublic: string;
conflictPublicAdminHint: string;
installSuccess: string;
installSuccessReplaced: string;
rejectInvalidExt: string;
targetCategoryLabel: string;
categoryCustom: string;
categoryPublic: string;
};
};
skillEvolution: {
title: string;
description: string;
backToSkills: string;
status: {
title: string;
enabled: string;
disabled: string;
paused: string;
runCount: string;
runCountUnit: string;
lastRunAt: string;
lastRunNever: string;
lastRunSummary: string;
lastRunSummaryEmpty: string;
skillCounts: string;
active: string;
stale: string;
archived: string;
total: string;
runNow: string;
runStarted: string;
lastActions: string;
llmSuggested: string;
actuallyDid: string;
noActions: string;
};
config: {
title: string;
description: string;
enabledLabel: string;
enabledHelper: string;
curatorEnabledLabel: string;
curatorEnabledHelper: string;
intervalHoursLabel: string;
intervalHoursHelper: string;
staleAfterDaysLabel: string;
staleAfterDaysHelper: string;
archiveAfterDaysLabel: string;
archiveAfterDaysHelper: string;
moderationModelLabel: string;
moderationModelHelper: string;
save: string;
saveSuccess: string;
reset: string;
};
logs: {
title: string;
description: string;
empty: string;
autoRefresh: string;
refresh: string;
};
};
notification: {
title: string;
@@ -374,4 +530,58 @@ export interface Translations {
emptyDescription: string;
};
};
// Admin user management page
adminUsers: {
title: string;
description: string;
email: string;
role: string;
userId: string;
skillCount: string;
agentCount: string;
roleAdmin: string;
roleUser: string;
empty: string;
};
// Admin tag management page
adminTags: {
title: string;
description: string;
typeAgent: string;
typeSkill: string;
typeScheduledTask: string;
name: string;
color: string;
tagDescription: string;
usageCount: string;
actions: string;
empty: string;
createButton: string;
createTitle: string;
editTitle: string;
deleteConfirm: string;
namePlaceholder: string;
descriptionPlaceholder: string;
save: string;
cancel: string;
edit: string;
delete: string;
// tag picker dialog
pickerTitle: string;
pickerHint: string;
pickerEmpty: string;
pickerSelectedCount: string;
saving: string;
done: string;
manageTags: string;
// search controls on resource lists
searchPlaceholder: string;
filterByTag: string;
allTags: string;
};
// In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations;
}
+224 -18
View File
@@ -1,4 +1,4 @@
import {
import {
CompassIcon,
GraduationCapIcon,
ImageIcon,
@@ -59,13 +59,13 @@ export const zhCN: Translations = {
// Welcome
welcome: {
greeting: "你好,欢迎回来!",
greeting: "你好,欢迎使用CM助手",
description:
"欢迎使用 CM业务中心-问答管理",
"欢迎使用 CM 助手",
createYourOwnSkill: "创建你自己的 Agent SKill",
createYourOwnSkillDescription:
"创建你的 Agent Skill 来释放 CM业务中心-问答管理的潜力。通过自定义技能,CM业务中心-问答管理\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
"创建你的 Agent Skill 来释放 CM 助手的潜力。通过自定义技能,CM 助手\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
},
// Clipboard
@@ -166,6 +166,7 @@ export const zhCN: Translations = {
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "智能体",
users: "用户管理",
},
// Agents
@@ -196,13 +197,13 @@ export const zhCN: Translations = {
save: "保存智能体",
saving: "正在保存智能体...",
saveRequested:
"已提交保存请求,CM业务中心-问答管理 正在根据当前对话生成并保存初版智能体。",
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
saveHint:
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
saveCommandMessage:
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
agentCreatedPendingRefresh:
"智能体已创建,但 CM业务中心-问答管理 暂时还无法读取到它。请稍后刷新当前页面。",
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
more: "更多操作",
agentCreated: "智能体已创建!",
startChatting: "开始对话",
@@ -217,13 +218,13 @@ export const zhCN: Translations = {
// Workspace
workspace: {
officialWebsite: "访问 CM业务中心-问答管理 官方网站",
githubTooltip: "访问 CM业务中心-问答管理 的 Github 仓库",
officialWebsite: "访问 CM 助手 官方网站",
githubTooltip: "访问 CM 助手 的 Github 仓库",
settingsAndMore: "设置和更多",
visitGithub: "在 Github 上查看 CM业务中心-问答管理",
visitGithub: "在 Github 上查看 CM 助手",
reportIssue: "报告问题",
contactUs: "联系我们",
about: "关于 CM业务中心-问答管理",
about: "关于 CM 助手",
},
// Conversation
@@ -239,7 +240,7 @@ export const zhCN: Translations = {
// Page titles (document title)
pages: {
appName: "CM业务中心-问答管理",
appName: "CM 助手",
brandSubtitle: "Smart CM Platform",
chats: "对话",
newChat: "新对话",
@@ -265,7 +266,7 @@ export const zhCN: Translations = {
writeFile: "写入文件",
clickToViewContent: "点击查看文件内容",
writeTodos: "更新 To-do 列表",
skillInstallTooltip: "安装技能并使其可在 CM业务中心-问答管理 中使用",
skillInstallTooltip: "安装技能并使其可在 CM 助手 中使用",
},
uploads: {
@@ -300,7 +301,7 @@ export const zhCN: Translations = {
noResults: "未找到结果。",
actions: "操作",
keyboardShortcuts: "键盘快捷键",
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM业务中心-问答管理。",
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM 助手。",
openCommandPalette: "打开命令面板",
toggleSidebar: "切换侧边栏",
},
@@ -308,7 +309,7 @@ export const zhCN: Translations = {
// Settings
settings: {
title: "设置",
description: "根据你的偏好调整 CM业务中心-问答管理 的界面和行为。",
description: "根据你的偏好调整 CM 助手 的界面和行为。",
sections: {
appearance: "外观",
memory: "记忆",
@@ -320,7 +321,7 @@ export const zhCN: Translations = {
memory: {
title: "记忆",
description:
"CM业务中心-问答管理 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM业务中心-问答管理 更好地理解你,并提供更个性化的体验。",
"CM 助手 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM 助手 更好地理解你,并提供更个性化的体验。",
empty: "暂无可展示的记忆数据。",
rawJson: "原始 JSON",
exportButton: "导出记忆",
@@ -414,20 +415,166 @@ export const zhCN: Translations = {
title: "技能",
description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能",
installSkill: "安装技能",
goToEvolution: "自我进化",
emptyTitle: "还没有技能",
emptyDescription:
"将你的 Agent Skill 文件夹放在 CM业务中心-问答管理 根目录下的 `/skills/custom` 文件夹中。",
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
emptyButton: "创建你的第一个技能",
badgeBuiltin: "内置",
badgeMine: "我的",
badgeFromOthers: "来自他人",
badgeFromOthersTitle: "来自用户 {user_id}",
badgePublished: "已发布",
badgePublishedTitle: "该技能已对所有用户可见",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
badgePinned: "钉选",
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
badgeSourceAgent: "Agent 创建",
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
badgeSourceUpload: "用户上传",
badgeSourceUploadTitle: "由用户手动上传或编辑",
tabAll: "广场",
tabMine: "我的",
entryStale: "过时 {count}",
entryArchived: "归档 {count}",
useCountInline: "调用 {count} 次",
useCountInlineZero: "尚未调用",
publishAction: "发布",
unpublishAction: "取消发布",
publishSuccess: "已发布",
unpublishSuccess: "已取消发布",
pinAction: "钉选(防止被自动归档)",
unpinAction: "取消钉选",
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除",
stateSheet: {
archivedTitle: "归档的技能",
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
staleTitle: "过时的技能",
staleDescription: "这些技能长期未被使用,已被策展程序标记。重新使用一次会自动恢复活跃,你也可以主动处理。",
empty: "暂无内容",
actionRestore: "恢复",
actionArchiveNow: "立即归档",
actionView: "查看详情",
actionPin: "钉选",
actionUnpin: "取消钉选",
actionDelete: "永久删除",
moreMenu: "更多操作",
archivedAt: "归档于 {when}",
lastUsedAt: "最近使用 {when}",
neverUsed: "尚未使用",
restoreSuccess: "已恢复",
archiveSuccess: "已归档",
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
},
viewDialog: {
title: "技能详情",
descriptionLabel: "描述",
stateLabel: "状态",
sourceLabel: "来源",
useCountLabel: "调用次数",
lastUsedLabel: "最近使用",
close: "关闭",
},
installDialog: {
title: "安装技能",
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
chooseFile: "选择文件",
validating: "正在校验文件…",
installing: "安装中…",
replace: "覆盖已有",
install: "安装",
cancel: "取消",
nameLabel: "名称",
descriptionLabel: "描述",
licenseLabel: "许可证",
toolsLabel: "允许的工具",
warningsLabel: "警告",
conflictCustom:
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
installSuccess: `技能 '{name}' 已安装。`,
installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
targetCategoryLabel: `安装到`,
categoryCustom: `自定义技能`,
categoryPublic: `内置技能(管理员)`,
},
},
skillEvolution: {
title: "自我进化",
description:
"管理技能 curator:让 agent 在后台对自创建的技能做老化标记、归档和合并建议。",
backToSkills: "返回技能",
status: {
title: "当前状态",
enabled: "已启用",
disabled: "已停用",
paused: "已暂停",
runCount: "累计运行",
runCountUnit: "次",
lastRunAt: "上次运行",
lastRunNever: "尚未运行",
lastRunSummary: "上次摘要",
lastRunSummaryEmpty: "—",
skillCounts: "技能",
active: "活跃",
stale: "陈旧",
archived: "已归档",
total: "合计",
runNow: "立即执行",
runStarted: "已触发后台执行",
lastActions: "最近一次运行",
llmSuggested: "LLM 建议(自述)",
actuallyDid: "实际执行(工具调用)",
noActions: "无",
},
config: {
title: "配置",
description:
"保存后会写入 skill_evolution.json 并热更新,无需重启。",
enabledLabel: "启用自我进化",
enabledHelper: "关闭后 agent 不会创建或修改 custom/ 下的技能。",
curatorEnabledLabel: "curator 自动运行",
curatorEnabledHelper: "关闭后只能在此页面手动触发。",
intervalHoursLabel: "运行间隔(小时)",
intervalHoursHelper: "两次自动运行之间至少经过的小时数。",
staleAfterDaysLabel: "陈旧阈值(天)",
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
archiveAfterDaysLabel: "归档阈值(天)",
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
moderationModelLabel: "安全审核模型(可选)",
moderationModelHelper: "为空时使用默认主模型。",
save: "保存修改",
saveSuccess: "配置已更新",
reset: "重置",
},
logs: {
title: "最近运行日志",
description: "展示最近一次 curator 运行的日志,服务重启会清空。",
empty: "暂无日志。点击「立即执行」会写入新的运行记录。",
autoRefresh: "自动刷新 (2s)",
refresh: "立即刷新",
},
},
notification: {
title: "通知",
description:
"CM业务中心-问答管理 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
"CM 助手 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
requestPermission: "请求通知权限",
deniedHint:
"通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。",
testButton: "发送测试通知",
testTitle: "CM业务中心-问答管理",
testTitle: "CM 助手",
testBody: "这是一条测试通知。",
notSupported: "当前浏览器不支持通知功能。",
disableNotification: "关闭通知",
@@ -437,4 +584,63 @@ export const zhCN: Translations = {
emptyDescription: "相关的致谢信息会展示在这里。",
},
},
adminUsers: {
title: "用户管理",
description: "查看所有注册用户及其关联的技能和智能体数量。",
email: "邮箱",
role: "角色",
userId: "用户 ID",
skillCount: "技能数",
agentCount: "智能体数",
roleAdmin: "管理员",
roleUser: "普通用户",
empty: "暂无用户",
},
adminTags: {
title: "标签管理",
description: "为智能体、技能和定时任务创建并管理标签。标签只能用于其所属类型的资源。",
typeAgent: "智能体标签",
typeSkill: "技能标签",
typeScheduledTask: "定时任务标签",
name: "名称",
color: "颜色",
tagDescription: "描述",
usageCount: "使用数",
actions: "操作",
empty: "暂无标签",
createButton: "新建标签",
createTitle: "创建标签",
editTitle: "编辑标签",
deleteConfirm: "确认删除此标签?它将从所有已打标签的资源上移除。",
namePlaceholder: "标签名称",
descriptionPlaceholder: "描述(可选)",
save: "保存",
cancel: "取消",
edit: "编辑",
delete: "删除",
pickerTitle: "编辑标签",
pickerHint: "点击标签即可绑定或解绑,修改实时保存。",
pickerEmpty: "该类型暂无可用标签,请先在标签管理中创建。",
pickerSelectedCount: "已选 {count} 个",
saving: "保存中…",
done: "完成",
manageTags: "标签",
searchPlaceholder: "按名称搜索",
filterByTag: "按标签筛选",
allTags: "全部标签",
},
notifications: {
title: "通知",
bellAria: "打开通知",
unreadShort: "{count} 条未读",
markAllRead: "全部已读",
empty: "暂无通知",
deleteAria: "删除通知",
settingsAria: "通知设置",
backAria: "返回",
settingsTitle: "通知设置",
},
};
+98
View File
@@ -0,0 +1,98 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface LlmMetric {
id: string;
created_at?: string | null;
user_id?: string | null;
user_name?: string | null;
thread_id?: string | null;
run_id?: string | null;
agent_name?: string | null;
model_name?: string | null;
duration_ms: number;
input_tokens?: number | null;
output_tokens?: number | null;
total_tokens?: number | null;
tokens_per_sec?: number | null;
status: "success" | "error" | string;
error_type?: string | null;
error_message?: string | null;
}
export interface LlmMetricsListResponse {
items: LlmMetric[];
total: number;
limit: number;
offset: number;
}
export interface LlmMetricsFilters {
user_id?: string;
user_name?: string;
model_name?: string;
status?: "success" | "error";
since?: string;
until?: string;
}
export interface LlmMetricsListQuery extends LlmMetricsFilters {
limit?: number;
offset?: number;
}
function buildQuery(filters: LlmMetricsListQuery): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === "") continue;
params.set(key, String(value));
}
const qs = params.toString();
return qs ? `?${qs}` : "";
}
export async function loadLlmMetrics(
query: LlmMetricsListQuery,
): Promise<LlmMetricsListResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/llm-metrics${buildQuery(query)}`,
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail.detail ?? "Failed to load LLM metrics");
}
return (await response.json()) as LlmMetricsListResponse;
}
/**
* Trigger the Excel download. Returns the blob URL used so callers can revoke
* it if they want; the function itself handles the click and cleanup.
*/
export async function downloadLlmMetricsExcel(
filters: LlmMetricsFilters,
): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/llm-metrics/export${buildQuery(filters)}`,
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail.detail ?? "Failed to export LLM metrics");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename = (() => {
const header = response.headers.get("content-disposition") ?? "";
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
if (utf8?.[1]) return decodeURIComponent(utf8[1]);
const plain = /filename="?([^";]+)"?/i.exec(header);
return plain?.[1] ?? `llm_call_metrics.xlsx`;
})();
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { loadLlmMetrics, type LlmMetricsListQuery } from "./api";
export function useLlmMetrics(query: LlmMetricsListQuery) {
return useQuery({
queryKey: ["llmMetrics", query],
queryFn: () => loadLlmMetrics(query),
refetchOnWindowFocus: false,
placeholderData: (previous) => previous,
});
}
+42
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { loadMCPConfig, updateMCPConfig } from "./api";
import type { MCPServerConfig } from "./types";
export function useMCPConfig() {
const { data, isLoading, error } = useQuery({
@@ -42,3 +43,44 @@ export function useEnableMCPServer() {
},
});
}
export function useUpsertMCPServer() {
const queryClient = useQueryClient();
const { config } = useMCPConfig();
return useMutation({
mutationFn: async ({
serverName,
server,
originalName,
}: {
serverName: string;
server: MCPServerConfig;
originalName?: string;
}) => {
const next = { ...(config?.mcp_servers ?? {}) };
if (originalName && originalName !== serverName) {
delete next[originalName];
}
next[serverName] = server;
await updateMCPConfig({ mcp_servers: next });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
},
});
}
export function useDeleteMCPServer() {
const queryClient = useQueryClient();
const { config } = useMCPConfig();
return useMutation({
mutationFn: async ({ serverName }: { serverName: string }) => {
const next = { ...(config?.mcp_servers ?? {}) };
delete next[serverName];
await updateMCPConfig({ mcp_servers: next });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
},
});
}
+28 -1
View File
@@ -1,6 +1,33 @@
export interface MCPServerConfig extends Record<string, unknown> {
export type MCPServerTransport = "stdio" | "http" | "sse";
export interface MCPOAuthConfig {
enabled?: boolean;
token_url?: string;
grant_type?: "client_credentials" | "refresh_token";
client_id?: string | null;
client_secret?: string | null;
refresh_token?: string | null;
scope?: string | null;
audience?: string | null;
token_field?: string;
token_type_field?: string;
expires_in_field?: string;
default_token_type?: string;
refresh_skew_seconds?: number;
extra_token_params?: Record<string, string>;
}
export interface MCPServerConfig {
enabled: boolean;
type?: MCPServerTransport;
command?: string | null;
args?: string[];
env?: Record<string, string>;
url?: string | null;
headers?: Record<string, string>;
oauth?: MCPOAuthConfig | null;
description: string;
disabled_tools?: string[];
}
export interface MCPConfig {
+38 -18
View File
@@ -14,17 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
| HumanMessageGroup
| AssistantProcessingGroup
| AssistantMessageGroup
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSubagentGroup;
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
messages: Message[],
@@ -41,10 +44,11 @@ export function groupMessages<T>(
function lastOpenGroup() {
const last = groups[groups.length - 1];
if (
last &&
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification"
last &&
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval"
) {
return last;
}
@@ -66,16 +70,23 @@ export function groupMessages<T>(
}
if (message.type === "tool") {
if (isClarificationToolMessage(message)) {
if (isClarificationToolMessage(message)) {
// Add to the preceding processing group to preserve tool-call association,
// then also open a standalone clarification group for prominent display.
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:clarification",
messages: [message],
});
} else {
messages: [message],
});
} else if (isSetupAgentApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-agent-approval",
messages: [message],
});
} else {
const open = lastOpenGroup();
if (open) {
open.messages.push(message);
@@ -286,10 +297,19 @@ export function hasPresentFiles(message: Message) {
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
export function isSetupAgentApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_agent" &&
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
message.additional_kwargs.setup_agent_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
@@ -0,0 +1,60 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
NotificationItem,
NotificationListResponse,
NotificationUnreadCountResponse,
} from "./types";
async function parseDetail(res: Response, fallback: string): Promise<Error> {
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
}
export async function listNotifications(
options: { unreadOnly?: boolean; limit?: number } = {},
): Promise<NotificationItem[]> {
const params = new URLSearchParams();
if (options.unreadOnly) params.set("unread_only", "true");
if (options.limit) params.set("limit", String(options.limit));
const query = params.toString();
const url = `${getBackendBaseURL()}/api/notifications${query ? `?${query}` : ""}`;
const res = await apiFetch(url);
if (!res.ok) throw await parseDetail(res, "Failed to list notifications");
const data = (await res.json()) as NotificationListResponse;
return data.items;
}
export async function getUnreadCount(): Promise<number> {
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/unread_count`);
if (!res.ok) throw await parseDetail(res, "Failed to load unread count");
const data = (await res.json()) as NotificationUnreadCountResponse;
return data.unread;
}
export async function markNotificationRead(id: string): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}/read`,
{ method: "POST" },
);
if (!res.ok) throw await parseDetail(res, "Failed to mark notification read");
}
export async function markAllNotificationsRead(): Promise<number> {
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/read_all`, {
method: "POST",
});
if (!res.ok) throw await parseDetail(res, "Failed to mark all read");
const data = (await res.json()) as { updated: number };
return data.updated;
}
export async function deleteNotification(id: string): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) throw await parseDetail(res, "Failed to delete notification");
}
@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { getStoredAuth } from "@/core/auth";
import {
deleteNotification,
getUnreadCount,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "./api";
// Notifications are per-user data, so the cache key MUST be scoped by user_id.
// Otherwise switching accounts in the same tab would show the previous user's
// inbox until the next fetch lands.
function useNotificationKeys() {
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
return useMemo(() => {
const root = ["notifications", userId] as const;
return {
root,
list: (limit: number, unreadOnly: boolean) =>
[...root, "list", { limit, unreadOnly }] as const,
count: [...root, "unread-count"] as const,
};
}, [userId]);
}
export function useNotifications(options: { limit?: number; unreadOnly?: boolean } = {}) {
const { limit = 20, unreadOnly = false } = options;
const keys = useNotificationKeys();
return useQuery({
queryKey: keys.list(limit, unreadOnly),
queryFn: () => listNotifications({ limit, unreadOnly }),
});
}
export function useUnreadCount(refetchInterval = 30000) {
const keys = useNotificationKeys();
return useQuery({
queryKey: keys.count,
queryFn: () => getUnreadCount(),
refetchInterval,
// Keep the badge fresh when the user comes back to the tab without
// waiting for the next interval tick.
refetchOnWindowFocus: true,
});
}
function useInvalidateNotifications() {
const queryClient = useQueryClient();
const keys = useNotificationKeys();
return () => {
void queryClient.invalidateQueries({ queryKey: keys.root });
};
}
export function useMarkNotificationRead() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: (id: string) => markNotificationRead(id),
onSuccess: invalidate,
});
}
export function useMarkAllNotificationsRead() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: () => markAllNotificationsRead(),
onSuccess: invalidate,
});
}
export function useDeleteNotification() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: (id: string) => deleteNotification(id),
onSuccess: invalidate,
});
}
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";
@@ -0,0 +1,18 @@
export interface NotificationItem {
id: string;
user_id: string;
type: string;
title: string;
body: string;
payload: Record<string, unknown>;
read: boolean;
created_at: string | null;
}
export interface NotificationListResponse {
items: NotificationItem[];
}
export interface NotificationUnreadCountResponse {
unread: number;
}
Loaded 100 of 630 files, more files were not shown because too many files have changed in this diff. Show more