Compare commits

...
322 Commits
Author SHA1 Message Date
xuhdandClaude Sonnet 4.6 719ff6f210 merge: 合并 master 冲突,保留暗色主题 + 内嵌传参支持
- StudioNotebookDetailPage: 保留 notebookIdProp 内嵌传参 + useTheme 透传
- StudioNotebooksPage: 保留 onOpen 回调 + 暗色主题卡片样式

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:12:08 +08:00
xuhdandClaude Sonnet 4.6 1f74586032 feat(frontend): 来源弹窗加loading + 笔记页暗色适配 + 主题透传
- 来源上传弹窗 A系统基因库 tab 搜索时显示 loading spinner
- NetworkActor 组件初始加载时显示 loading spinner(初始状态设为 true)
- StudioNotebooksPage 笔记卡片支持暗色主题样式
- StudioNotebookDetailPage / NotebookAIWritingPage 跳转时透传 themekey 参数

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:00:50 +08:00
g zy dd631d57d4 Merge branch 'gzy' into 'master'
feat: 按钮样式变更

See merge request knowledge-base-bj/deerflow-server!131
2026-06-17 06:29:32 +00:00
ZyGuo a3c8c50212 feat: 按钮样式变更 2026-06-17 13:49:06 +08:00
吉鑫升 5f39879e4d Merge remote-tracking branch 'origin/master' into ji 2026-06-16 21:08:37 +08:00
吉鑫升andClaude Opus 4.8 342fa8265a feat: 知识批量沉淀改为后台任务,避免 nginx 504
- /ingest/threads/batch 新增 background 参数(默认 true),立即返回 {queued:true}
- 抽取循环抽出 _run_batch_ingest,后台路径显式传入 created_by
- 前端知识库页与输入框、线程 hooks/types 配套调整
- config.yaml 上下文压缩测试值

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:07:17 +08:00
董宏凯 1fa2338ac3 Merge branch 'dhk-1' into 'master'
feat(task): 任务详情(cop-task-detail)登录深链增加本地假数据开关

See merge request knowledge-base-bj/deerflow-server!130
2026-06-16 13:07:13 +00:00
董宏凯 56e17f826c Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-16 21:06:22 +08:00
董宏凯 bc5c2fa3d4 样式修改 2026-06-16 21:06:14 +08:00
fengxiangdebaozi 44cb118f95 bug修复 2026-06-16 20:59:44 +08:00
fengxiangdebaoziandClaude Opus 4.8 1ece2c5a4f fix: 修复全量模式综合管理用户排行榜泄漏到其他侧边栏
综合管理菜单存在两个 id 相同的「用户排行榜」子项,侧边栏以
key={item.id} 渲染导致 React keyed reconciliation 失效,离开综合
管理切换到其他一级菜单时重复 key 的节点无法正确卸载,残留到新的
侧边栏。删除冗余的 adminOnly 重复项(保留的项已通过 userPath 同时
覆盖 admin/非 admin)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:59:14 +08:00
董宏凯andClaude Opus 4.8 2fce5e61c9 feat(ui): 全量版通用问答侧栏深蓝主题适配 + 任务深链接口路径修正
- general-qa-sidebar:侧栏底色 bg-white → bg-sidebar(随主题,深蓝主题=深蓝);
  「新对话」按钮底色 color-mix 混 transparent 而非 white;智能体图标底色改用
  《深蓝 UI 规范》§1.1.7/§1.1.8 的蓝-青系(替换原高彩度玫瑰/琥珀/翠绿渐变)
- task-deeplink:cop-task-detail 接口路径修正 taskAnalysSearch → taskAnalyseSearch
- 同步 mock-data 路径 + CLAUDE.md 文案

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:57:21 +08:00
董宏凯andClaude Opus 4.8 d5d0494928 feat(task): 任务详情(cop-task-detail)登录深链增加本地假数据开关
- 新增 VITE_MOCK_COP_TASK_DETAIL 开关(默认 false 走真实接口;true 时该接口走本地假数据,
  供 consumer 外网不可达时调试),与 VITE_USE_REAL_DATA 独立
- utils/request.ts 按开关把 cop-task-detail 请求切到本地 mock adapter
- task-mock-data.ts 增加 cop-task-detail 假数据 + matcher/adapter
- 各 .env(development/network/production/sit) + .env.example 补充该开关说明

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:57:21 +08:00
Administrator b380575f16 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	frontend-web/src/core/page-layout/sidebar-menu.ts
#	frontend-web/src/core/system-settings/api.ts
#	frontend-web/src/core/system-settings/hooks.ts
#	frontend-web/src/strategy-components/Header.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/app/gateway/routers/system_settings.py
#	offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py
#	offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
2026-06-16 20:01:42 +08:00
AdministratorandClaude Opus 4.8 c21a1127f8 feat: 提炼模板弹窗加宽,后端配置上下文压缩测试值
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:50:33 +08:00
董宏凯 e621ce88e6 Merge branch 'dhk-1' into 'master'
feat(ai-writing): 进入写作后自动收起研究模板侧栏

See merge request knowledge-base-bj/deerflow-server!129
2026-06-16 11:50:26 +00:00
董宏凯 761491ec41 代码修改 2026-06-16 19:50:07 +08:00
董宏凯 a35ed77ff0 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-16 19:41:40 +08:00
董宏凯andClaude Opus 4.8 245ac969a7 feat(task-deeplink): 会商/智能体问答嵌入任务管理布局
任务深链(goPath=3qfx/3qfx-A 等)跳转的多智能体会商 / 单智能体问答
改为嵌进「任务管理」对应业务上下文,侧边栏保持 COH中任务→对应业务 高亮。

- StrategyRoutes: task-management 配下新增 6 条嵌入路由(每业务 roundtable + agent)
- AgentChatPage: 新增 chatBasePath 可选属性,覆盖发消息后 URL 改写 / 新会话路径
- task-deeplink: 跳转目标改为业务别 task-management 嵌入路由(BUSINESS_TASK_BASE)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:41:28 +08:00
fengxiangdebaoziandClaude Opus 4.8 ab21911696 feat(ai-writing): 进入写作后自动收起研究模板侧栏
把侧栏+内容区抽成 Provider 内组件 AIWritingBody,读 useAIWriting 状态:
status==='idle' && 非历史模式才显示研究模板侧栏;开始写作/查看历史后收起,写作区铺满。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:41:05 +08:00
董宏凯 380407ae2f Merge branch 'dhk-1' into 'master'
代码提交

See merge request knowledge-base-bj/deerflow-server!128
2026-06-16 11:31:03 +00:00
fengxiangdebaozi c0e89044c7 代码提交 2026-06-16 19:30:36 +08:00
吉鑫升andClaude Opus 4.8 4f0494a97a feat: 聊天增加回到最新悬浮钮,智能体面板生成即收起
- 复用 ConversationScrollButton 接入 MessageList,滚离底部时显示一键回到最新问答的悬浮按钮;新增 scrollButtonClassName 以适配各页输入框高度
- 智能体详情面板改用 open+selectedArtifact 判定收起,流式写文件开始即收起让位预览,不再等生成结束

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:24:48 +08:00
董宏凯 baf3fadce6 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!127
2026-06-16 11:02:34 +00:00
fengxiangdebaozi ecf068a61c Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-16 19:01:59 +08:00
fengxiangdebaozi ecaaec939d 路由修改 2026-06-16 19:01:52 +08:00
董宏凯 e3c1adb71a Merge branch 'dhk-1' into 'master'
feat(qa-center): 问答中心左侧栏改造(0616 需求 2-5)

See merge request knowledge-base-bj/deerflow-server!126
2026-06-16 10:56:47 +00:00
董宏凯 ce165ece69 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-16 18:56:13 +08:00
董宏凯 63bddfdea7 代码修改 2026-06-16 18:56:04 +08:00
fengxiangdebaozi 208a02a912 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-16 17:59:35 +08:00
fengxiangdebaozi f750b7f04d 地址修改 2026-06-16 17:59:27 +08:00
fengxiangdebaoziandClaude Opus 4.8 ba5aeefe98 feat(ai-writing): 研究模板独立实体 + 配置页 + 内置范例 + 点击直接进入写作(0616 #5)
后端:
- 新持久化包 research_templates(表 ai_writing_research_templates,create_all 建表,注册 models/__init__),deps 接 research_template_store
- 路由 /api/ai-writing/research-templates CRUD(在 ai_writing.py)
- 内置范例 网摘/周报/要讯(DEFAULT_RESEARCH_TEMPLATES + seed_defaults,表空时由 list 接口幂等播种)
- tests/test_research_templates.py(9 passed)

前端:
- api/ai-writing-research-templates.ts + hooks/useResearchTemplates.ts
- 配置页 ResearchTemplatesConfigPage(/page/canvas/research-templates)CRUD
- ResearchSidebar 研究模板改用研究模板实体;点击回填整张表单并直接 startWriting 进入写作;顶部齿轮进配置页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:59:05 +08:00
吉鑫升andClaude Opus 4.8 d82b5ed884 feat: 摘要中间件增加压缩日志,提炼模板编辑框定高
- summarization_middleware: 真正压缩与复用缓存均记录 info 日志,便于无 stream writer 场景(定时任务/渠道/测试)排查
- KnowledgeManage: 提炼模板 system prompt 编辑框固定为 50vh 高度,内部滚动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:40:14 +08:00
xu hd 12f50123dc Merge branch 'xuhd' into 'master'
Xuhd

See merge request knowledge-base-bj/deerflow-server!125
2026-06-16 09:27:02 +00:00
xuhd 4511c0dee7 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-06-16 17:26:19 +08:00
xuhd f6809349d2 来源上传组件样式优化 2026-06-16 17:26:13 +08:00
吉鑫升andClaude Opus 4.8 97da7dc87f feat: 推荐问题配置支持按标签搜索并在列表展示标签
- 后端列表接口为每个推荐问题附带 tags,并支持 tag_ids OR 过滤
- 前端配置页新增 TagSearchBar(标题/内容搜索 + 标签筛选),卡片展示标签
- 打标签后失效 recommended-questions 查询,列表即时刷新
- 过滤时禁用拖拽排序,避免破坏 sort_order

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:52:30 +08:00
董宏凯andClaude Opus 4.8 45049975c2 feat(roundtable): 多智能体会商 + 业务链条页脱敏
- 区域级脱敏 Hook useDesensitizeSubtree:对左右侧面板、顶部标题、
  业务链条选择器/列表弹窗/列表页/配置页/映射页整片脱敏(文本节点,
  跳过 input/textarea 等可编辑控件)
- 消息列表正文保持原文,仅智能体名称脱敏(senderName / 席位名 / 流程图节点)
- Step3 总结/报告/大屏、推荐弹窗等结果面板脱敏
- 沙箱产物预览不脱敏:ArtifactFileDetail 加 disableDesensitize,会商沙箱传入
- desensitize.ts 新增 desensitizeDeep 递归脱敏结构化数据

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:43:29 +08:00
fengxiangdebaoziandClaude Opus 4.8 0e4caa5a66 feat(ai-writing): 课题研究笔记本内嵌打开 + 新建/所有空间入口(0616 #5)
- 左栏点笔记本/所有空间 → 右侧内嵌打开(不跳路由),写作布局保持挂载(display:none,不丢 setup 线程),顶部「返回写作」
- ResearchSidebar 加「新建空间」(弹窗 createSearchSpace) 与「所有空间」入口
- StudioNotebookDetailPage 加可选 notebookId prop;StudioNotebooksPage 加可选 onOpenNotebook(内嵌时卡片走回调而非 Link),列表内点空间内嵌打开详情
- 内嵌笔记本详情用 ChatRuntime 包裹(修复 useArtifacts must be used within an ArtifactsProvider)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:38:22 +08:00
fengxiangdebaoziandClaude Opus 4.8 f39cffcb9a feat(qa-center): 问答中心左侧栏改造(0616 需求 2-5)
- 多智能体会商:Step1 落地左栏「业务链(public 前3) + 分析记录」;点链=勾选(autoChainResult),第一步澄清后点「进入研讨」才按链执行
- zzJC/zzXD:独立模块路由 /page/strategy/qa/{zzjc,zzxd},常驻左栏(按 agent 标签分组 + 仅本模块会话的聊天记录),点智能体模块内切换不跳页,默认落地配置类型下首个智能体
- zzJC/zzXD admin 设置:左栏齿轮→单选类型(TDesign)配置面板+智能体预览;后端 system_settings.agent_modules 持久化(GET 开放/PUT admin)+ test_agent_modules
- 课题研究:左栏「研究模板(文章类型) + 笔记本」;模板点击预填表单,笔记本点击设为素材来源(selectedNotebookId 上提 WritingFormContext)
- 通用问答:全量模式历史侧栏默认展开

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:38:22 +08:00
吉鑫升andClaude Opus 4.8 5182ea5d11 feat: 技能收藏与常驻(always-on)支持
- 新增 skill_favorites / skill_always_on 迁移与持久化模型字段
- skills 路由、存储、前端设置页支持收藏与常驻
- 调整摘要中间件与 lead agent 提示词
- 同步岗位 position_sync 及相关配置清理

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:37:43 +08:00
吉鑫升andClaude Opus 4.8 5ebbb82d66 feat: 知识库文件夹/模板、技能压缩设置与摘要窗口调优
- 知识库新增文件夹与模板支持(模型/仓储/服务/迁移),前端知识管理组件
- 岗位管理模型与接口完善
- 技能新增压缩设置页与相关存储/工具能力
- 系统设置扩展配置项
- 摘要中间件 keep 窗口改用 token 预算(16000),减少重复压缩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:23:54 +08:00
吉鑫升andClaude Opus 4.8 e6662cf673 refactor: 移除提示词模板页面及相关接口,精简岗位/标签/用户提示词模型
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:32:21 +08:00
董宏凯 fe0dc8d1a5 Merge branch 'dhk-1' into 'master'
feat(roundtable): 第二步席位执行模式(快速/思考/专业问答/多智能体)+ 子智能体任务卡片 + 后台完成跨页通知 + 链条默认模式持久化

See merge request knowledge-base-bj/deerflow-server!124
2026-06-16 06:19:04 +00:00
董宏凯 8794c51f88 提交 2026-06-16 14:18:15 +08:00
董宏凯andClaude Opus 4.8 8d1669adad feat(roundtable): 后台作业按用户并发限流 + 草稿持久化优化
后台挂起作业每个 agent 轮次(leader/seat/report/summary/dashboard)开跑前
先取该用户的后台并发额度(默认 2,ROUNDTABLE_BACKGROUND_SLOTS_PER_USER 可调),
跑完释放;同一用户多个后台作业共享额度,避免把单连接 SQLite checkpointer 和
离线模型端点打满拖垮前台实时会商。前台实时路径不经过此额度,不受限。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:17:30 +08:00
董宏凯andClaude Opus 4.8 da3d38013e feat(ai-writing): 素材收集技能调用失败时容错回退到其他技能
默认/选中技能调用失败(超时、报错、全部检索词失败)时不再直接报错,
改用其他技能继续检索:节点 emit skill_error 步骤(带真实原因),配置技能
全部失败且无新素材时跑 fallback 兜底(内置知识库检索优先,再到目录其它已
启用技能)。前端 SkillStepsCard 渲染 skill_error(玫红)/ fallback(琥珀),
时间线展示"技能报错→改用其他技能搜集"。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:15:49 +08:00
g zy b2ef4ce89e Merge branch 'gzy' into 'master'
feat: iframe增加智能体选择

See merge request knowledge-base-bj/deerflow-server!123
2026-06-16 05:07:03 +00:00
ZyGuo f525c49267 feat: iframe增加智能体选择 2026-06-16 13:06:36 +08:00
吉鑫升andClaude Opus 4.8 838e986670 feat: 导航菜单新增岗位管理与提示词模板入口
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:14:47 +08:00
吉鑫升 fa70b31873 Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	frontend-web/src/components/workspace/input-box.tsx
#	offline-backend-20260512/backend/CLAUDE.md
2026-06-16 11:07:31 +08:00
吉鑫升andClaude Opus 4.8 3855c30edd feat: 新增岗位管理、提示词模板与资源标签,完善用户提示词与推荐问题接口
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:49:36 +08:00
董宏凯 c7dccf8c0b Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-16 10:09:35 +08:00
董宏凯 2526304394 sd 2026-06-16 10:09:30 +08:00
fengxiangdebaozi e1da39253a Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-16 10:06:14 +08:00
fengxiangdebaozi 6b49741e69 代码修改 2026-06-16 10:05:54 +08:00
吉鑫升andClaude Fable 5 2f414c6e08 fix: HTML 预览子页面改用 JS 切换,避免沙箱 blob 导航被拦截
上一版通过设置 location.hash 触发 CSS :target 切页,但沙箱 blob
iframe 禁止导航到自身 blob URL,浏览器报 Not allowed to load local
resource,且 :target 仅在真实片段导航时更新,注定失效。改为完全
不动 hash,用 JS 模拟 html-multipage 技能的 .page/.pages 显示隐藏
(内联 display 覆盖类级 CSS),普通目录锚点仍平滑滚动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 09:19:15 +08:00
董宏凯andClaude Opus 4.8 9ed5420381 feat(light-app): 应用管理页加类型分类筛选 + 修复深色顶栏标题高分屏内边距
- 应用管理工具栏新增「全部/新页跳转/iframe嵌套」分类切换,选中 iframe
  嵌套时出现挂载菜单下拉,可按 mountLocation 进一步筛选(深色/浅蓝主题适配)
- 深色顶栏 .header-center-title 内边距由固定 155px 改为 8.07vw,随视口
  等比缩放以匹配 background cover,修复 4K 等高分屏两侧菜单贴近中间的问题

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:33:18 +08:00
董宏凯andClaude Sonnet 4.6 69c8c14c91 fix(nav): 修复问答梳理等带子路径页面的导航高亮丢失问题
KnowledgeBasePage 加载后自动跳转至 /knowledge/:noteId,原精确路径匹配
导致顶栏和侧边栏选中态丢失。将 hasActiveChild / isItemActive /
menuItemContainsPath 等 8 处判断统一改为前缀匹配
(pathname === path || pathname.startsWith(path + "/")),
覆盖浅色侧边栏、深色侧边栏、顶部导航栏三套组件。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:50:26 +08:00
董宏凯andClaude Sonnet 4.6 88b1a330a6 fix(roundtable): 修复弹窗被顶部导航栏遮挡的 z-index 问题
将全局 Header z-[2101] 降为 z-[45]、圆桌页内部 header z-[2000] 降为 z-[40],
使两者低于 Shadcn Dialog 遮罩层 z-50,弹窗打开时可正常覆盖导航栏。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:32:07 +08:00
董宏凯 9e481cdcaa Merge branch 'dhk-1' into 'master'
feat(theme): 浅蓝色主题适配 —— COH任务三子页 + 应用中心子页

See merge request knowledge-base-bj/deerflow-server!122
2026-06-15 10:21:08 +00:00
fengxiangdebaozi db50493577 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-15 18:20:39 +08:00
fengxiangdebaoziandClaude Opus 4.8 3907034a1d feat(roundtable): 第二步席位执行模式(快速/思考/专业问答/多智能体)+ 子智能体任务卡片 + 后台完成跨页通知 + 链条默认模式持久化
- 席位执行模式:选择参会智能体弹窗加模式选择器,映射 thinking/reasoning/subagent
  透传 recommend/chain/dag 三条编排;后端 _special_run 覆盖 seat policy(仅席位,
  总控不变);ultra 解锁席位 task 子代理(custom 流回传 task_running 实时进度)。
- 子智能体任务卡片:复用主聊天 SubtaskCard + SubtaskContext,从席位流解析 task
  调用/结果,在 Step2 气泡渲染多张任务卡片(覆盖业务链条路径)。
- 后台挂起也按所选模式跑(StartParams→inprocess gateway→run_seat 覆盖 policy)。
- 后台作业完成全工作区跨页通知:RoundtableJobNotifier 挂在 PageLayout 常驻层,
  作业终态弹「达成共识」同款浮窗。
- 业务链条可配「默认执行模式」并持久化(roundtable_chains.seat_mode;选链时弹窗
  默认用它,仍可临时改)。
- 修复后台 Step3 HTML 看板概率截断:deepseek max_tokens=4096 单轮写不完长 HTML,
  产物自愈重试上限 2→5,让 append 续写写完整(同惠及 summary/dashboard)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:20:20 +08:00
董宏凯andClaude Sonnet 4.6 b1b2540dc0 feat(theme): 浅蓝色主题适配 —— COH任务三子页 + 应用中心子页
- ThemeContext: 新增 data-theme="lightblue" 属性管理,支持红/蓝双浅色主题
- strategy-data.css: 新增 [data-theme="lightblue"] 变量覆盖块(与浅红默认共存)
- SentimentAnalyzes / TaskAnalyzes / ActionAnalyzes / KanbanColumn / TaskCard / ActionColumn:
  isDark ? dark : isLightBlue ? blue : red 三路适配
- LightAppListPage: 应用列表页品牌色(标题点、徽章、搜索 focus、计数、进入按钮)浅蓝适配
- LightAppNoteQAPage: 问答发送按钮浅蓝适配
- light-app-management.css: 新增 [data-theme="lightblue"] CSS 覆盖块(主按钮、保存、开关、输入 focus 等)
- light-app-pages.css: 浅蓝模式页面底色轻染 #F9FCFF

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:16:06 +08:00
董宏凯 5dc1ed5685 Merge branch 'dhk-1' into 'master'
fix(sandbox): 修复 Markdown 编辑保存后内容被重置回原值

See merge request knowledge-base-bj/deerflow-server!121
2026-06-15 09:11:09 +00:00
董宏凯 1ad8da5ef2 代码提交 2026-06-15 17:04:53 +08:00
董宏凯andClaude Opus 4.8 22e9f18d08 chore(chat): 移除 landing 紧凑态底部 V1.2 免责声明
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:55:10 +08:00
董宏凯andClaude Opus 4.8 b6c9265e31 feat(light-app): 轻应用中心列表/管理页深蓝主题适配
- 新增 light-app-pages.css / light-app-management.css,引入《深蓝色 UI 规范》
  tokens,.dark 下覆盖页面底图、页头文案、主按钮、表格、下拉弹层等
- 列表/管理页根节点加 className 钩子与 dark: 背景类,浅色样式不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:55:06 +08:00
董宏凯andClaude Opus 4.8 ee6ab38c37 feat(nav): 浅色模式导航重构(顶栏一级 + 侧边栏二级图标卡 + 三级右侧列)
浅色(红/蓝)主题改为与深色一致的导航逻辑,颜色经 --brand-* 变量自动切换:
- 一级导航正常横排进顶栏(白字落在 banner 上,选中加粗 + 底部粗白线)
- 二级导航为侧边栏图标卡片(图标主题色、文字 #222222、选中铺满列宽 + 右侧粗边)
- 三级导航改到侧边栏二级右侧的「三级列」:激活二级项有三级时整列展开、
  侧边栏 96→236px 整体变宽并带过渡动画,主内容左内边距同步平滑右移
- 顶栏三级横条改为仅深色用;浅色不支持侧边栏收起
- 任务管理二级项改用区分图标(ClipboardList/Activity/Gauge/Radar)
- 字体统一用全局 --font-sans,修复雅黑兜底下加粗不生效
- 抽出 getActiveThirdLevelItems / useActiveThirdLevel 供侧边栏与 PageRoutes 共用

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:53:21 +08:00
董宏凯andClaude Opus 4.8 fbd237531e fix(roundtable-share): 修复分享拿不到总控结论/各席位交付 + 分享总结报告与大屏多页
排查发现后台作业把结果写进 step2.runs[activeRun],而顶层 step2 残留前台 seed
的陈旧气泡(无交付、无结论)。_result_sections 旧逻辑「顶层为空才回退 run」因顶层
非空而永不回退,导致分享页 consensus 与 seat_deliveries 为空。改为优先读活动 run
(与前端 hydrate 一致),已有分享链接无需重建即生效。

同时把第三步另外两个产物纳入分享:
- 方案总结报告(step3.summaryReport.md,Markdown 内联渲染)
- 大屏多页(step3.dashboardJson,用应用内 React 大屏 DashboardApp 渲染,新增
  /roundtable-share/:code/dashboard 路由,与应用渲染/新窗口打开同源)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:57:33 +08:00
董宏凯andClaude Opus 4.8 49101ef0e8 feat(roundtable): 第二/三步沙箱 md 文件支持用户编辑
复用主聊天页同款 ArtifactFileDetail 编辑能力:新增 allowVirtualMarkdownEdit
开关,圆桌沙箱里 write-file 虚拟 md 在流式结束后切回真实 artifacts API
(可加载 + PATCH 保存),由 Step2SandboxLayout 与 HighFidelityReport 启用。
主聊天页等其它调用方默认关闭、行为不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:57:18 +08:00
董宏凯 8751ee7361 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-15 11:39:24 +08:00
董宏凯andClaude Haiku 4.5 01012ff300 feat(settings): 轻应用登录参数与界面模式管理员默认
- 登录时从路由参数 yUserId/access_token 获取并存入 userInfo
- 轻应用管理页路由参数新增 userName/gateDepartName/typeName 选项
- 添加系统默认界面模式设置(管理员可指定全量/简洁)
- useLayoutMode hook 支持优先级:用户自选 > 管理员默认 > 会话级

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-15 11:39:02 +08:00
fengxiangdebaoziandClaude Opus 4.8 2a2bb4d28e fix(sandbox): 修复 Markdown 编辑保存后内容被重置回原值
useArtifactContent 查询键含 publicShareCode(5 元素),而 onSuccess
的 setQueryData 仅用 4 元素,精确哈希不匹配导致写入幽灵缓存、真实缓存
仍为旧值;保存后 useEffect 据旧 content 重置 draftContent。改用
setQueriesData 前缀匹配命中真实缓存。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:37:24 +08:00
fengxiangdebaozi ce04f14f58 Merge branch 'origin/dhk-1' into dhk-1
Resolve CLAUDE.md conflict by keeping remote version.
2026-06-15 11:13:42 +08:00
fengxiangdebaozi 16b36e2b9f 代码修改 2026-06-15 11:02:33 +08:00
董宏凯 c597941128 Merge branch 'dhk-1' into 'master'
fix(roundtable): 查看编排流程图按钮直接进入基本信息步骤

See merge request knowledge-base-bj/deerflow-server!120
2026-06-14 14:29:41 +00:00
董宏凯andCursor ab22b50faf fix(roundtable): 查看编排流程图按钮直接进入基本信息步骤
移除弹窗预览,改为跳转到第二步以便同时编辑基本信息并查看流程图。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 19:37:22 +08:00
董宏凯 7785091bae Merge branch 'dhk-1' into 'master'
feat(圆桌): 业务链条配置页流程图点阵放大 + 侧边栏菜单项按设计稿还原

See merge request knowledge-base-bj/deerflow-server!119
2026-06-14 11:31:50 +00:00
董宏凯 56449ed129 feat(ui): theme & layout improvements
- Dark-blue theme background assets and styling integration
- Appearance settings with layoutMode (full/compact) preference
- Settings dialog and auth context updates
- AI writing form and panel refinements
- Open Canvas context updates
- Roundtable planning styling and dashboard enhancements
- Strategy components and header updates
- Light app management and routing improvements
- Global style enhancements for dark theme
- Various page layout and workspace routing optimizations
2026-06-14 19:30:54 +08:00
董宏凯 da2864f422 fix(chat): align compactChrome logic with layoutMode preference for dark-blue bg
ChatPage's compactChrome was still using readSidebarEnabled() alone, but
PageRoutes now honors the explicit layoutMode setting (full/compact). When a
user sets layout to 'full' without portal login, PageRoutes shows chrome but
ChatPage didn't apply workspace-chat-bg, hiding the dark-blue theme background.

Now ChatPage mirrors PageRoutes' logic: prioritize layoutMode, fallback to
readSidebarEnabled() if unset.
2026-06-14 19:30:04 +08:00
董宏凯andCursor c702119e66 feat(ui): 浅蓝主题、外观设置与圆桌看板主题适配,优化工作区布局
新增 lightblue 主题与外观设置联动,圆桌 Step3 看板/report 统一配色;工作区侧边栏、输入框与轻应用 iframe 主题参数同步更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 12:43:16 +08:00
董宏凯 30d4f9667b 样式修改 2026-06-13 20:43:18 +08:00
董宏凯andClaude Opus 4.8 cd3a71baab feat(agents): 内置智能体筛选(仅管理员)+ 内置 agent 默认用途标签
- 后端 scope=builtin(user_id IS NULL)加入 sql/memory 的 scope 过滤;
  /api/agents 扩展 scope 正则并加 admin 闸门(非管理员请求 403)
- 新增 _builtin_agent_tag_seed:启动时幂等给功能型内置 agent 按用途打标签
  (roundtable-* → 多智能体规划,ai-writing-* → AI写作),只追加不覆盖;
  无用途的内置 agent 不打标签
- 前端 agent-gallery 新增「内置智能体」Tab(仅 system_role==admin 可见),
  scope 类型在 api.ts/hooks.ts 同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:42:26 +08:00
董宏凯andClaude Opus 4.8 391409b6c0 feat(圆桌): 查看运行中作业改为接管回前端续跑 + 后台挂起续跑方案文档
- onView 运行中/待澄清作业改走 takeOverRunningJob:cancelJob 停后端 →
  handleLoadDraft 恢复作业真实线程/协调名/对话 → 解除只读闸门 →
  resumeLiveTakeover 在作业线程上由前端继续实时流式编排
- hydrateFromDraft 同步写 threadIdsRef/coordinatorNameRef,使接管同帧可读真实线程
- 新增 docs/multi-agent-后台挂起续跑实现方案.md(仅方案,待实现)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:49:19 +08:00
董宏凯andClaude Opus 4.8 25dbeb7a9a feat(圆桌): 业务链条配置页流程图点阵放大 + 侧边栏菜单项按设计稿还原
- ChainFlowDiagram 点阵背景点尺寸/不透明度上调,更接近设计稿颗粒感
- 全局侧边栏菜单项:锐利方角(2px)、选中整高左竖条、图标 hover 微放大、间距微调;保留原菜单内容与红/青配色

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:41:50 +08:00
董宏凯 12360c6f3f Merge branch 'dhk-1' into 'master'
轻应用主题参数:随当前主题色映射传值

See merge request knowledge-base-bj/deerflow-server!118
2026-06-13 10:01:30 +00:00
董宏凯andClaude Opus 4.8 61754acb87 feat(圆桌): 后台挂起按钮移至顶部暂停按钮右侧 + 实时研讨复位历史闸门
- Step2 顶部「暂停/恢复会商」右侧新增「后台挂起」按钮(查看历史时隐藏),移除底部干预栏里的旧入口
- handleConfirmAgents 从第一步开启实时研讨时复位 loadedFromDraftRef,修复看过历史后再开新讨论按钮不出现的问题
- 大屏 dashboard 多组件样式与业务链条编辑页细节调整

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:37:14 +08:00
董宏凯andClaude Opus 4.8 634cb1413b feat(圆桌): 业务链条配置页按设计稿还原
统计卡 + 筛选工具栏(我的/公共/搜索/排序/网格列表) + 富链条卡(流程脉络图),
数据来自 /api/roundtable-chains 按用户隔离;品牌主色 #CF1322,深蓝模式按规范取色。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:32:40 +08:00
董宏凯andClaude Opus 4.8 2fa40c5e96 feat(圆桌): 大屏多页席位多样式+新窗口React页+布局优化+切历史记录刷新
- 新窗口打开改为渲染应用内 React 大屏(独立全屏路由 /roundtable-dashboard,
  经 localStorage 传数据,无侧边栏),替代原自包含 HTML blob
- 总览页布局:上半两列(总控共识+竞争力排行),核心风险分布下移到数据研判中心后
- 席位交付/执行路线网格列数自适应(grid.ts):不足4个卡片撑满整行
- 席位专页主体多样式变体(SeatShowcase):框架矩阵/洞察卡阵/双栏对比/清单核对/
  发布声明,按席位序号轮转分配,总结席位固定发布声明,全部数据驱动
- 修复切换历史记录时大屏/报告不刷新:渲染期复位入口闸门+hydrate依赖草稿id,
  hydrateSaved清残留data;数据切换时大屏自动回总览页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:32:32 +08:00
董宏凯 432c12fbac @
feat(圆桌): Step3 大屏多页模板重构 + 独立 dashboard 组件化

- 新增 roundtable-planning/dashboard 模块化大屏(DashboardApp + 各分析卡片
  组件 + data-context + 类型/样式)
- Step3DashboardMode 接入新 dashboard,report-template 大屏 HTML 模板重写
- useStep3Dashboard 配套调整

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-13 15:32:49 +08:00
董宏凯 146c8e76f0 @
feat(圆桌): 业务链条显示配置人 + 配置页拆两步并新增精美流程图

- 列表/弹窗卡片显示「配置人」(后端把 owner user_id 解析为 email,
  ChainResponse 增 owner_id/owner_name,store 输出 owner_id)
- 配置编辑器拆为两步:第一步基本信息(描述改多行文本),第二步链条编排
- 第一步有编排内容时,左侧基本信息 + 右侧 ChainFlowDiagram 流程图:
  直角连线、点节点开详情抽屉、连线显示阶段目标(依赖)、滚轮缩放、
  拖动平移、全屏 + 全屏右下角缩略图

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-13 15:32:37 +08:00
董宏凯 e9f44c8f0f @
feat(圆桌): 大屏多页浅色模板重写为蓝图工程图风(参考设计图)

- 浅色 palette 改玫瑰红(#E11D48)+slate 中性+白底网格,席位色序对齐设计图
- 卡片四角红色角标 + slate 网格 overlay(蓝图风);KPI 加彩色图标
- 共识竖条摘要 + 要点列表;席位卡进度条 + 风险药丸;席位专页精致化
- 新增折线图 svgLine(趋势),donut 升级镂空中心数字+图例;契约加 line 类型
- 图表保持纯 SVG(零依赖离线自包含,沙箱/新窗口/下载/分享通用,与参考源码一致)
- 深色保持深蓝 token 套同结构

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-06-13 10:51:10 +08:00
董宏凯 f3202f95b6 Merge remote-tracking branch 'origin/dhk-1' into dhk-1 2026-06-13 10:37:16 +08:00
董宏凯 d5f319d9cb @
feat(圆桌): Step3 三产物并行 + 大屏多页模式 + 后台挂起查看恢复流程

后台挂起 Step3:
- engine 总结报告/结果绘制/大屏数据 asyncio.gather 三流并行,故障隔离
- request.state 回归修复(进程内虚拟 request 补 state),恢复后台报告
- ROUNDTABLE_JOB_GATEWAY 默认 inprocess;产物自愈重试(截断/缺失纠偏)
- 大屏后端只产 report-json(dashboardJson),前端按当前主题渲染

结果绘制大屏多页模式(模式二):
- report-template 模板渲染(report-json→大屏HTML),零截断、主题跟随
- 与模式一同款交互(流式+沙箱+可修改);沙箱新窗口打开按钮

最终方案展示:左侧席位栏+总控头像、交付/文件合并、收展动画、内联沙箱

后台挂起查看=恢复实时流程:
- handleLoadDraft 返回 Promise,await hydrate 后订阅 streamJob SSE 跟随
- saveDraftNow 强制存(发起前+查看前各一次)防丢

第二步优化:总控判断串/并行(并行单列可写)、席位上下文+交付模板、
peerDeliveries、失败重试、共识标记、流程图小窗(缩放/拖动/并行批)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-06-13 10:36:46 +08:00
fengxiangdebaoziandClaude Opus 4.8 74324f4585 轻应用主题参数:随当前主题色映射传值
- 主题参数改为按系统主题(red/dark-blue)映射取目标应用对应的值
- 应用管理弹窗用结构化映射编辑器(逐主题色配置目标值,默认同名)
- iframe 嵌套显式传入当前主题色,切主题时 src 重建
- 读取/保存时规范化映射串;解析支持 light/dark 等别名,取不到回退主题色名而非恒为 red

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:30:31 +08:00
吉鑫升andClaude Fable 5 21e53310f8 fix: HTML 预览锚点点击同步 hash,修复多页面技能子页面切换失效
预览 iframe 注入的锚点拦截脚本只 preventDefault + scrollIntoView,
URL hash 从未改变,html-multipage 技能基于 CSS :target 的子页面
切换永远不命中,子页面 section 保持隐藏,点击无效。改为拦截后
同步 location.hash 触发 :target 切页,仅当目标元素已渲染时再平滑滚动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:32 +08:00
董宏凯 a7596001c5 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 21:46:05 +08:00
董宏凯 93243298e1 代码修改 2026-06-12 21:45:55 +08:00
fengxiangdebaozi ca098e821f Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 21:39:48 +08:00
fengxiangdebaozi 00d6bd8595 主题色修改 2026-06-12 21:38:38 +08:00
董宏凯 f0c85c4795 后台挂起功能修改 2026-06-12 21:38:20 +08:00
吉鑫升andClaude Opus 4.8 74113b3e6e feat: 智能体与技能列表显示创建时间字段
- 新增共享 formatDateTime 工具(YYYY-MM-DD HH:mm)
- agent-card 卡片/列表视图在发布者下方显示创建时间
- skill 设置页 metaNodes 显示创建时间

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:20:33 +08:00
董宏凯 cca954d01c Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 20:29:03 +08:00
吉鑫升 d825cc7cfe Merge branch 'ji' 2026-06-12 20:28:27 +08:00
董宏凯 ffca4261d7 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 20:27:39 +08:00
董宏凯 865a0bfaf6 代码修改 2026-06-12 20:27:34 +08:00
吉鑫升andClaude Opus 4.8 595da9f339 fix: 私密广场首次加载默认 page=1,使搜索/标签过滤生效
私密广场标签页传 page: pages.private,初始为 undefined,被 JSON.stringify 丢弃后后端走非分页分支忽略 search/tag_ids。补上 ?? 1 兜底,与主标签页一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:27:29 +08:00
吉鑫升andClaude Opus 4.8 44b4a4beba feat: 注册知识入库接口 + 优化大规模知识图谱力导向布局
- 后端 Gateway 挂载 /api/knowledge-ingest 路由(发布 md 制品)
- 知识图谱力模拟按节点规模缩放斥力/截断距离/冷却速率,避免数百节点时布局长时间不收敛
- 斥力与碰撞改为基于均匀网格的近邻对计算,并增加每帧最大位移限制防止抖动与飞出

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:14:22 +08:00
董宏凯 c66c359432 Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-server!117
2026-06-12 11:33:18 +00:00
fengxiangdebaozi 1214ea0d91 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 19:30:56 +08:00
fengxiangdebaozi cfa05e01c4 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 19:30:46 +08:00
Administrator 6a71272b79 Merge remote-tracking branch 'origin/master' into ji 2026-06-12 18:48:20 +08:00
Administrator b9b55b4e5b fix: 调整提问导航悬浮索引到右侧居中 2026-06-12 18:48:18 +08:00
董宏凯andCursor 3f2aef2925 Merge remote-tracking branch 'origin/dhk-1' into dhk-1,保留本地应用中心菜单配置。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:48:11 +08:00
fengxiangdebaozi 7a8a29639f 代码修改 2026-06-12 18:45:04 +08:00
董宏凯andCursor becfd9b211 Merge branch 'origin/dhk-1' into dhk-1,解决 sidebar-menu 冲突并保留应用中心菜单。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:38:53 +08:00
fengxiangdebaozi c6b17d5946 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 18:34:14 +08:00
fengxiangdebaozi e24e058ab2 代码修改 2026-06-12 18:32:58 +08:00
Administrator e74ef4b7dc Merge branch 'ji' into master 2026-06-12 18:28:13 +08:00
董宏凯 c2f6d4fd6f Merge branch 'dhk-1' into 'master'
AI 写作四阶段内置智能体 Phase 1-4 落地

See merge request knowledge-base-bj/deerflow-server!116
2026-06-12 10:20:27 +00:00
Administrator 2862eee33b chore: 提交本地修改 2026-06-12 18:09:07 +08:00
fengxiangdebaozi 3ad920457a Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 17:54:10 +08:00
fengxiangdebaozi 768c6860f5 feat: Header 顶部二三级菜单与 AI 写作表单收起优化 2026-06-12 17:53:52 +08:00
董宏凯andCursor 369efc784b 应用管理页样式对齐参考设计,修复参数明细弹层被裁切问题。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 17:50:56 +08:00
xu hd 522327d807 Merge branch 'xuhd' into 'master'
feat(studio): 上传组件用我的笔记 token + 关闭上传弹窗刷新来源列表

See merge request knowledge-base-bj/deerflow-server!115
2026-06-12 09:38:20 +00:00
xuhd 915233c5b6 Merge remote-tracking branch 'origin/master' into xuhd 2026-06-12 17:24:13 +08:00
吉鑫升andClaude Opus 4.8 229830ee89 feat: 智能体 md 产物一键发布到外部知识入库接口
- 智能体新增「入库模板名」(template_name) 配置,持久化到 config.yaml
  并经 AgentCreate/Update/Response 回读(NewAgentPage / AgentDetailPanel)
- 新增 POST /api/knowledge-ingest/publish:把 md 标题+全文发到 knowes,
  标题字段先「名称」失败再用同一 nodeName 换「标题」重试一次
- md 产物预览右上角新增「发布入库」按钮(仅智能体会话且配置了模板名时显示)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:38:04 +08:00
董宏凯andCursor dd329e7a4f 圆桌第二步支持按席位筛选会商记录,左侧角色卡与消息头像联动。
新增 seat-filter 归一化匹配逻辑,点击角色卡或气泡头像可只看该席位发言,并支持一键显示全部。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:36:58 +08:00
g zy da56a53a9c Merge branch 'gzy' into 'master'
Gzy

See merge request knowledge-base-bj/deerflow-server!114
2026-06-12 08:33:39 +00:00
ZyGuo 3f014a6fe1 feat: chats_iframe去掉侧边栏和主题 2026-06-12 16:32:28 +08:00
吉鑫升andClaude Fable 5 d9d5780ea7 feat: 提问导航悬浮索引 + 知识库召回会话级开关 + 智能体技能白名单收紧
- 新增 QuestionNavigator:聊天/智能体页右侧悬浮提问索引,点击滚动定位到对应消息(message-list 为 human 消息生成锚点 id)
- 知识库召回开关从单会话头部按钮迁移到侧边栏,改为会话级内存开关(刷新重置,不再写 localStorage),开启时所有对话默认携带 knowledge_rag_enabled
- 智能体可配置技能收紧为「内置 + 自己的 + 已发布到广场的」(新增 core/skills/utils.ts 的 isAgentSkillCandidate,NewAgentPage 与 agent-detail-panel 共用)
- 智能体详情面板在 artifact 预览展开/新文件生成时自动收起让位
- 启用 html-multipage 技能并更新后端 CLAUDE.md 文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:10:00 +08:00
董宏凯 9002ae157a Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 15:48:53 +08:00
董宏凯 f279613f5d 代码提交 2026-06-12 15:48:48 +08:00
董宏凯andCursor b58e731c19 圆桌会商各步接入流式回答速度指示器,复用主聊天 SpeedPill 与全局开关。
新增 DialogueSpeedMeter 与 useDialogueSpeedMetrics,在 Step1/2/3 气泡底部展示 tok/s 与耗时。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 15:43:58 +08:00
fengxiangdebaozi 9d73f27146 feat: light app center and iframe refactor 2026-06-12 15:40:09 +08:00
吉鑫升andClaude Fable 5 81f2a48c6d feat: 公开嵌入页主题支持外部指定 + 排行榜入口移至左上角
- 公开页(/share、/public、/roundtable-share)新增 ?theme= 与
  deerflow:set-theme postMessage 两种外部主题指定方式,经
  usePublicEmbedTheme 接成 next-themes forcedTheme,不写 localStorage
- 排行榜入口从欢迎语旁移到 workspace-header 左上角应用名旁
- 新增 docs/embed-public-pages-theme.md 嵌入主题使用文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:12:17 +08:00
吉鑫升andClaude Fable 5 57a87d6322 feat: MySQL 索引长度兼容 + 启动自动补列 + 未发布徽标与查询加载态
后端:
- 索引/唯一键改用 mysql_length 前缀索引,适配 MySQL 767 字节限制(error 1071):knowledge_notes、knowledge_entities、knowledge_graph_blacklist、agent_skills、feedback
- engine.py 新增 _ensure_orm_columns_sync:启动时对比 ORM 元数据,自动 ALTER TABLE 补齐缺失的可空/带默认值列及其索引
- 新增 scripts/audit_index_key_lengths.py 审计全部索引键长度
- 更新 backend/CLAUDE.md 迁移说明

前端:
- 智能体卡片/技能列表:所有者与管理员可见未发布徽标
- 智能体广场:服务端查询进行中时灰显旧结果并悬浮加载提示
- 排行榜入口从 Header 移到欢迎语旁
- threads/hooks.ts:抽取 updateCachedThreadLists 统一处理数组与分页两种缓存形态

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:51:50 +08:00
董宏凯 728a4b1170 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 14:10:08 +08:00
董宏凯 9de85591bc 提交 2026-06-12 14:09:12 +08:00
董宏凯andClaude Fable 5 086c280e5d 圆桌第三步流程图模式 + 后台 DAG 任务编排
- 方案总结报告:用户要求结构化输出/流程图时跳过 md 直出 flow-json 流程图,
  报告用文字展示;报告与流程图均可多轮修改、互相切换(detectFlowIntent 识别意图)
- 流程图画布放右侧沙箱位(sidePanel 插槽)不覆盖消息列表,可与 md 预览切换;
  节点随容器宽度自适应缩放并 fitView 居中
- 后台挂起支持真 DAG 编排(分层并行/阶段目标/总控提示词),新增
  orchestration_plan 持久化列 + 迁移;大弹窗流程图按 DAG 分层渲染

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:52:17 +08:00
AdministratorandClaude Opus 4.8 da677f87d5 feat: 公开页背景色支持 iframe 外部配置 + CM助手旁排行榜入口
- 新增 usePublicBackground:公开页(排行榜/分享/会商分享/计划任务)支持
  嵌套方通过 ?bg=/?text= 查询参数或 postMessage 覆盖背景/文字色
- Header 左上角 CM助手 旁新增「排行榜」按钮,新标签打开公开排行榜页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:21:25 +08:00
吉鑫升andClaude Fable 5 28098b3155 feat: 知识库实体筛选、技能白名单收紧、嵌入主题与提问组件优化
- 知识库笔记列表支持按实体精确筛选(路由/服务/仓储层透传,关系图索引查询)
- 自定义智能体技能白名单在 skill_view 与 skill_manage 的 list/view 别名上同样生效;
  config 未声明 skills 时视为无可见技能
- 嵌入圆桌主题解析默认深蓝,强制主题期间不再写 localStorage 覆盖主站选择
- 智能体提问建议拆出 agent-question-chips 组件,编辑器与详情面板联动优化
- 新增 qa-client 问答测试脚本

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:09:32 +08:00
xuhdandClaude Opus 4.8 c268348e29 feat(studio): 上传组件用我的笔记 token + 关闭上传弹窗刷新来源列表
- document-upload-popup: 新增 getNotebookToken(),上传/导入到笔记后端
  (source/import、documents/fileupload)改用「我的笔记」token;
  其余请求由调用方在 init.headers 自带 Authorization,不再注入本系统 token
- query-client-provider: 导出 queryClient 单例,便于非 hook 处读取缓存的 token
- network-actor: consumerUrl 改为读 VITE_AIPI_CONSUMER_URL;修复 source/import
  headers 语法并去掉重复 token
- StudioNotebookDetailPage: DocumentUploadDialogProvider 增加 onClose,
  关闭上传弹窗时刷新来源列表与 source_count

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:14:49 +08:00
fengxiangdebaozi b33426416b Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-12 09:19:47 +08:00
fengxiangdebaozi cf3dd2d225 代码修改 2026-06-12 09:19:38 +08:00
吉鑫升 11bc442f10 Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-12 09:19:23 +08:00
ZyGuo 41ce95cbf1 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy 2026-06-12 09:07:23 +08:00
董宏凯 75258db0a2 提交 2026-06-12 00:38:17 +08:00
董宏凯andClaude Fable 5 df8bf895ef 圆桌第三步与业务链条增强:主题跟随/插话暂停/提示词优化/结构化流程图
- 结果绘制跟随日/夜间主题(深色按深蓝 UI 规范令牌绘制),提示词注入真实生成时间
- 绘制支持暂停与随时插话(线程存活时免带原料续线程省 token)、失败自动重试一次、草稿主题不匹配提示条
- 总结报告优先执行用户最终总结要求(结构化输出/重点席位可整体替换默认章节)
- 业务链条总控提示词与各阶段目标加「AI 优化」按钮(新接口 POST /api/roundtable-chains/optimize-prompt)
- 总结报告 flow-json 结构化流程图:流式增量绘制、最新连线蚂蚁线动画、拖拽/缩放/复位/全屏、节点详情弹窗、沙箱切换按钮/无沙箱自动打开
- roundtable-report / roundtable-summary 两个内置智能体 SOUL 更新(双主题、多轮修改允许读底稿、KPI 估算标注、流程数据契约)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:37:45 +08:00
Administrator 1b86373a54 feat: 知识库标签/实体关联跳转 + 会话重连容错
前端:
- Wiki 标签与实体改为可点击,弹出 RelatedNotesDialog 查看关联知识
- 知识库首次进入自动打开最近笔记,避免空白阅读区
- 流式重连识别 "Run not found",清理 sessionStorage 残留 key 并静默回退历史

后端:
- 线程鉴权区分 not found / not accessible,并支持 DB 切换产生的孤儿线程认领
- run 查询/join 在内存未命中时回退持久化存储,重启后返回干净的终止 SSE 而非 404
2026-06-11 23:19:09 +08:00
董宏凯andCursor a4a9a4d26e AI 写作四阶段内置智能体 Phase 1-4 落地
新增 ai-writing-researcher/outliner/writer/editor 种子 agent、_agent_runner 与 use_builtin_agents 灰度开关;四节点可按 flag 切换 SOUL 模式;时间线补大纲规划师配色并更新改造文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:58:38 +08:00
董宏凯 1a41ac97d2 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-11 22:09:40 +08:00
董宏凯 55aab67383 需求梳理 2026-06-11 22:09:35 +08:00
fengxiangdebaozi 76e2e67af8 Merge branch 'dhk-1' into master 2026-06-11 21:52:39 +08:00
fengxiangdebaozi b682ff5d08 Step3 总结报告支持随时插话、暂停与附件上传
生成中可打断并续问,输入框支持上传参考文件并随消息注入总结线程。
2026-06-11 21:51:59 +08:00
fengxiangdebaozi 2d74f8883e Step3 总结报告与顶栏主题背景图
新增 roundtable-summary 智能体及前后端 Step3 Markdown 总结流程;顶栏在浅/深主题下分别使用红/深蓝横幅背景。
2026-06-11 21:51:59 +08:00
吉鑫升andClaude Fable 5 e7b51a30a3 fix: 智能体可见性对齐——列表可见即可打开问答,消除 404
get_visible 此前比列表过滤更严格(仅 内置/本人/已发布),导致私密广场
智能体(转私密强制 published=false)和「我的」里已收藏但被取消发布的
智能体能在列表看到、点开问答却 404(前端连带报 X.map is not a function)。

- AgentRepository._visible_filter / MemoryAgentStore._is_fetchable:
  单条读取放宽为所有列表口径的并集(+is_private 豁免、+本人收藏豁免)
- 详情接口与 _enforce_agent_access 运行门禁:管理员回退 get_any,
  对齐 ?admin=true 全量列表
- 普通未发布草稿对陌生人仍不可见,隐私不放开
- 本地新增 tests/test_agent_visibility.py 覆盖 SQL/Memory 双实现
  (tests/ 目录在本仓库被 gitignore,不随提交)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:25:36 +08:00
吉鑫升 0b98d18483 Merge remote-tracking branch 'origin/master' into ji 2026-06-11 20:28:15 +08:00
吉鑫升andClaude Fable 5 3d6f35391e feat: 对话列表显示总数,SOUL 编辑区高度自适应收敛
- 新增 POST /threads/count:与 /threads/search 同一套过滤(标题搜索、
  系统会话排除),仅返回 {total};ThreadMetaStore 增加 count 方法
  (SQL 走 COUNT(*),JSON 过滤分批扫描计数)
- 对话页面搜索框下常驻显示「共 N 条对话」,分页条显示 当前页/总页数
  (useThreadsCount,仅随筛选条件变化,翻页不重复请求)
- 智能体右侧 SOUL 预览改为 40vh 上限自适应展开(不再全部展开);
  详情面板/卡片编辑/创建页的 SOUL 输入框统一为适中高度 + 可拖拽

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:26:44 +08:00
吉鑫升 10047de745 Merge remote-tracking branch 'origin/master' 2026-06-11 20:00:26 +08:00
吉鑫升 82600a3404 Merge branch 'ji' 2026-06-11 19:59:58 +08:00
董宏凯 b73eb77b95 Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-server!113
2026-06-11 11:59:48 +00:00
董宏凯 1fca24765a 代码修改 2026-06-11 19:59:23 +08:00
吉鑫升andClaude Opus 4.8 f107ebc46d feat: 完善知识图谱交互与 Agent/线程持久化
- 知识图谱画布、侧边栏、详情面板交互优化,新增图谱相关 API 与 hooks
- Agent 卡片与详情面板调整,对话页面重构
- 线程元数据持久化增强(base/memory/sql),knowledge 仓储与导出补充
- 网关路由 agents/knowledge/threads 同步更新

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:58:34 +08:00
吉鑫升 f2ffae8a22 Merge remote-tracking branch 'origin/master' into master 2026-06-11 18:59:15 +08:00
吉鑫升andClaude Opus 4.8 ea78fe9726 fix(checkpointer): use_postgres 回退为 false 恢复本地 SQLite 聊天历史
- 发版 40206dc9 将 checkpointer 切到空的远程 Postgres 导致历史不可见,
  且迁移/dual-read 未生效;翻回 SQLite 恢复历史
- config.yaml(backend): 注释掉暂不使用的模型条目

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:58:28 +08:00
吉鑫升andClaude Opus 4.8 7d61bc09bc feat: 新增表格文件预览,优化知识图谱与 Agent 详情交互
- 新增 spreadsheet-preview 组件及 xlsx 解析工具,支持产物中的表格文件预览
- 优化知识图谱画布、详情面板及样式
- 调整 Agent 详情面板、聊天与消息列表交互

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:43:09 +08:00
董宏凯 b1231fd981 Merge branch 'dhk-1' into 'master'
wujie应用联调

See merge request knowledge-base-bj/deerflow-server!112
2026-06-11 09:41:04 +00:00
董宏凯andCursor bcc3d617a0 Merge branch 'ji' into dhk-1
Resolve column_additions_mysql.sql conflict: keep square_id columns from master and roundtable_drafts.task_id from dhk-1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:40:08 +08:00
fengxiangdebaozi 6890176c76 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-11 17:38:43 +08:00
fengxiangdebaozi d12c50ea4e 样式修改 2026-06-11 17:38:36 +08:00
董宏凯 31525e79a8 套用wujie修改 2026-06-11 17:36:20 +08:00
董宏凯 c48317672c 样式修改 2026-06-11 17:21:24 +08:00
董宏凯 f3ca996116 wujie应用联调 2026-06-11 17:11:28 +08:00
吉鑫升 5daa4b3e20 Merge remote-tracking branch 'origin/master' into ji 2026-06-11 16:39:30 +08:00
吉鑫升 80e84de868 Merge remote-tracking branch 'origin/hlh' into ji 2026-06-11 16:35:18 +08:00
吉鑫升 7efd811e75 feat: update knowledge and agent UI 2026-06-11 16:18:38 +08:00
xu hd 5e68f81a1f Merge branch 'xuhd' into 'master'
chore(env): 添加我的笔记相关环境变量配置

See merge request knowledge-base-bj/deerflow-server!111
2026-06-11 08:05:17 +00:00
吉鑫升andClaude Fable 5 bebe4feccf chore(leaderboard): 用户排行榜当天快照刷新间隔默认调整为半天
ADMIN_LEADERBOARD_TODAY_TTL_SECONDS 默认值 300s -> 43200s,
当天数据约每半天重算一次;调度器 tick、凌晨定稿与手动“刷新当天”不变。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:57:27 +08:00
吉鑫升andClaude Opus 4.8 e684de30bd feat(knowledge): 知识库 wiki/知识图谱重构,新增发布广场选择弹窗
- 知识库页拆分出 wiki 组件(侧边栏/目录树/Markdown 阅读器等)
- 知识图谱页拆分出 knowledge-graph 组件并增强交互
- 新增发布广场选择弹窗 publish-square-picker-dialog
- config.yaml 新增 MiniMax-M2.5 / Ling-flash-2.0 / DeepSeek-V4-Pro 模型

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:52:25 +08:00
xuhd bd0d922737 Merge branch 'xuhd' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd 2026-06-11 14:20:29 +08:00
xuhd 1668c1da87 Merge remote-tracking branch 'origin/master' into xuhd
# Conflicts:
#	frontend-web/.env.production
#	frontend-web/.env.sit
#	frontend-web/package.json
#	offline-backend-20260512/backend/config.yaml
2026-06-11 14:19:01 +08:00
xuhdandClaude Opus 4.8 95dec0e72e fix(studio): 修复流式返回时空间引用角标渲染错乱
Streamdown 的 parseIncompleteMarkdown 会把流式途中未闭合的 [[cite:N
当作未完成链接改写成 [[cite:N](streamdown:incomplete-link),导致
rehypeCitations 匹配失败、引用闪成坏链接。送入渲染前剥除未完成的引用
标记,待 ]] 到齐再由 rehype 渲染为上标。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:11:21 +08:00
liuq_1233 5a33f66ad6 修改上传来源的文件类型提示 2026-06-11 11:58:52 +08:00
xuhdandClaude Opus 4.8 33276dae76 docs(knowledge): 经济交往时间线 markdown 排版规范化
在加粗事件标题后补空行,统一列表前的空白。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:45:47 +08:00
xuhdandClaude Opus 4.8 b0e48b6bda refactor(studio): 文案“我的笔记/笔记本”统一改为“我的空间”
涉及空间详情页、AI 写作表单/上下文/类型、researcher 注释及 env 注释。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:45:35 +08:00
吉鑫升andClaude Opus 4.8 f0aec4cd7f feat(square): 新增发布广场功能,智能体/技能按广场分类展示
- 后端新增 PublishSquare/PublishSquaresSettings 配置与读写接口
- agents/skills 增加 square_id 字段及对应迁移脚本
- 前端新增发布广场设置页、技能转移选择器,画廊按广场分 Tab 展示
- lead_agent prompt 及若干持久化层适配调整

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:37:42 +08:00
xuhdandClaude Opus 4.8 5ed636bce4 chore(frontend): env 注释中“笔记本”改为“空间”
统一前端各环境 env 文件注释文案,与界面命名保持一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:31:49 +08:00
xuhdandClaude Opus 4.8 fa6191b448 fix(studio): 移除来源卡片悬停 tooltip,失败来源显示报错并可删除
- 去掉来源卡片 hover 时弹出的自定义 tooltip div
- status 为 failed 的来源卡片显示红色报错图标与提示,禁用选中,保留删除按钮

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:30:52 +08:00
董宏凯 93ca273312 Merge branch 'tjl' into 'master'
tjl

See merge request knowledge-base-bj/deerflow-server!110
2026-06-11 03:21:02 +00:00
xuhd 82d14351fc 修改名称 笔记本改为 空间 2026-06-11 11:19:26 +08:00
吉鑫升andClaude Opus 4.8 3845d5090b feat(knowledge): 支持文件导入沉淀知识,知识图谱可视化随主题切换
- 后端新增 /api/knowledge/ingest/file:上传 md/txt/pdf/word/ppt/excel,
  转 Markdown 后走 wiki-capture LLM 蒸馏为陈述式知识笔记,失败回退逐字草稿
- service.capture_file + 文档专用蒸馏提示词,按主题拆分 1~4 页
- 知识图谱导出 HTML 支持 ?theme=light|dark,布局稳定后冻结,
  按度数缩放节点,悬停/点击聚焦邻居
- 前端知识库页新增「导入文件」对话框与 ingestKnowledgeFile API
- 智能体问答页与简介侧边栏 UI 优化;批量人物蒸馏脚本提取正文逻辑修正

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:25:27 +08:00
吉鑫升andClaude Opus 4.8 17bb3715d1 feat(agents): 智能体问答页落地态展示常用问题入口并默认展开简介栏
- 落地页 Hero 以"Hi,我是{名称}"问候,将管理员配置的常用问题渲染为可点击的引导气泡,点击即发起对话
- 右侧智能体简介栏改为默认展开,作为主要"专家信息"卡片,仍可手动收起

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:52:27 +08:00
ZyGuo f28426063f Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy 2026-06-11 09:46:10 +08:00
吉鑫升 6efe1b80c5 Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-11 09:34:45 +08:00
吉鑫升andClaude Opus 4.8 01696f75e9 新增批量人物蒸馏脚本,精简排行榜相关接口
- batch-distill: 独立单文件脚本,按时间窗调用线上服务批量蒸馏人物档案
- 精简 admin_users / public_leaderboard 路由及前端排行榜页面

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:34:34 +08:00
AdministratorandClaude Opus 4.8 e97017b297 feat(agents): 智能体常用问题——侧边栏展示并一键提问
新增 common_questions(常用问题)字段:后端贯通 agents_config、
config.yaml 读写与 create/update API,前端 Agent 类型补充字段并在
AgentChatPage 新增 AgentInfoSidebar 组件展示常用问题,点击即填充并提交。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:34:39 +08:00
AdministratorandClaude Opus 4.8 22222308a1 feat(summarization): 非破坏性上下文压缩——保留原始对话仅在请求时瞬态压缩
重写 DeerFlowSummarizationMiddleware:将 stock before_model 的
RemoveMessage(REMOVE_ALL_MESSAGES) 改为 no-op,改在 wrap_model_call 中
瞬态压缩为 [summary, *recent],完整原始对话仍保留在 state 中,前端继续
渲染用户真实问答。summary 消息标记 hide_from_ui 且永不持久化,按 thread
缓存以避免每次模型调用都触发 LLM。前端 isHiddenFromUIMessage 额外隐藏旧
版本持久化的 name="summary" 消息。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:34:28 +08:00
Administrator 7fb3870247 @
feat(summarization): 非破坏性上下文压缩——保留原始对话仅在请求时瞬态压缩

重写 DeerFlowSummarizationMiddleware:将 stock before_model 的
RemoveMessage(REMOVE_ALL_MESSAGES) 改为 no-op,改在 wrap_model_call 中
瞬态压缩为 [summary, *recent],完整原始对话仍保留在 state 中,前端继续
渲染用户真实问答。summary 消息标记 hide_from_ui 且永不持久化,按 thread
缓存以避免每次模型调用都触发 LLM。前端 isHiddenFromUIMessage 额外隐藏旧
版本持久化的 name="summary" 消息。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-11 08:34:09 +08:00
董宏凯 c57696cf80 Merge branch 'dhk-1' into 'master'
接口地址修改

See merge request knowledge-base-bj/deerflow-server!109
2026-06-10 13:57:49 +00:00
董宏凯 0784f30e23 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-10 21:57:12 +08:00
董宏凯 9af93ecde6 接口地址修改 2026-06-10 21:57:04 +08:00
fengxiangdebaozi d9e3a87911 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-10 19:39:06 +08:00
fengxiangdebaozi f01789a17e 多智能体会商上传文件 2026-06-10 19:38:58 +08:00
董宏凯 688514eab6 Merge branch 'dhk-1' into 'master'
接口地址修改

See merge request knowledge-base-bj/deerflow-server!108
2026-06-10 11:25:24 +00:00
董宏凯 3d069179d6 接口地址修改 2026-06-10 19:24:58 +08:00
董宏凯 f90a6e7082 Merge branch 'dhk-1' into 'master'
code

See merge request knowledge-base-bj/deerflow-server!107
2026-06-10 11:14:40 +00:00
董宏凯 406c02ad07 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-10 19:08:50 +08:00
董宏凯 3bc824b462 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-10 19:08:39 +08:00
董宏凯 c705e0b5ae 样式修改 2026-06-10 19:08:32 +08:00
吉鑫升 3a04ba5726 Merge remote-tracking branch 'origin/master' into ji 2026-06-10 19:01:16 +08:00
董宏凯 60a6db8b47 Merge branch 'dhk-1' into 'master'
feat(roundtable): 第一步识别出的意图支持手动编辑并保存

See merge request knowledge-base-bj/deerflow-server!106
2026-06-10 10:59:29 +00:00
fengxiangdebaozi cf5550ab0a 接口地址修改 2026-06-10 18:58:55 +08:00
董宏凯 19aeba5fab Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-10 17:44:51 +08:00
董宏凯 d9afd8d887 代码提交 2026-06-10 17:44:44 +08:00
hkdong 7422f083b4 tjl 2026-06-10 17:19:27 +08:00
xuhd 06508cfb7c Merge remote-tracking branch 'origin/master' into xuhd 2026-06-10 17:15:51 +08:00
xuhdandClaude Opus 4.8 980d8ba44f chore(deps): declare mermaid dependency
补上 b0fdeab9 引入 mermaid 图表渲染(roundtable 产物预览)时漏提交的
依赖声明,否则全新 pnpm install 后构建会因找不到 mermaid 失败。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:15:28 +08:00
吉鑫升andClaude Opus 4.8 d658b05ee9 feat(skills): 安装成功后引导填写/AI生成中文名 + 生成超时提至60s
- 上传安装技能成功后,弹窗进入「设置中文名」步骤:用户可手动填写,或用所选大模型自动生成默认值,亦可跳过(回退英文名)
- 技能中文名生成超时由 30s 提高到 60s(思考模式构建时已关闭)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:14:42 +08:00
xuhdandClaude Opus 4.8 123fe36ee4 refactor(studio): rename 笔记本 to 空间 in workspace nav and notebook pages
统一术语:侧边栏「新建笔记本/所有笔记本」、笔记列表与笔记本管理页的
新建按钮与弹窗标题,全部由"笔记本"改为"空间"。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:13:23 +08:00
xuhdandClaude Opus 4.8 4bf4b96fa9 feat(notebook): redesign ai-writing header to space-info-only glass pill
移除品牌"索财笔记"与右侧"这是我的笔记"标签,header 只保留空间信息;
空间徽章重做成玻璃胶囊(渐变图标徽标 + 已连接状态点),高度 44→52px,
顶部加品牌高光线与 hover 微光,延续索财深蓝品牌色阶。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:06:21 +08:00
fengxiangdebaozi b8680275e5 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-10 17:01:25 +08:00
fengxiangdebaozi e03ca7b4bd 代码修改 2026-06-10 17:01:17 +08:00
吉鑫升andClaude Opus 4.8 3178b4c45e fix(auth): CSRF/鉴权中间件兼容反向代理路径前缀 + 无会话请求豁免
清空浏览器缓存后(尤其 token 换登录)报「CSRF token missing」。根因:
反向代理把请求转发到带前缀的路径(如 /xxx/api/v1/auth/login/token),
FastAPI 用 root_path 剥前缀做路由,但 Starlette 中间件跑在路由之前,
看到的仍是带前缀的 path,导致 CSRF 豁免名单与公开路径白名单(精确/前缀
匹配)全部失配。清缓存前靠 localStorage 残留 token 当 Bearer 绕过 CSRF,
清掉后裸奔才暴露。

- 新增 proxy_path.app_relative_path:匹配前按 root_path / X-Forwarded-Prefix
  剥掉代理前缀(无前缀时 no-op)。
- csrf_middleware:should_check_csrf / is_auth_endpoint 改用应用相对路径;
  补上漏收的 /api/v1/auth/login/token 豁免;新增「无 access_token 会话
  cookie 则跳过 CSRF」兜底(无会话请求不存在可被跨站伪造利用的会话)。
- auth_middleware:公开路径判断改用应用相对路径,避免 /xxx/api/public/*
  分享页被误判 401。
- langgraph_auth:无 access_token cookie 时跳过 CSRF,与网关链路一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:51:01 +08:00
吉鑫升andClaude Opus 4.8 413f92e270 fix(migrations): 合并 alembic 多头(skill_name_zh 与 chain-stages 分支)
20260609_01(skills.name_zh)直接挂在 20260608_01 之下,与
20260608_02..06(chain-stages/parallel-agent 分支)形成两个 head,导致
`alembic upgrade head` 报 Multiple head revisions。新增空 merge 版本
20260609_02 同时指向 20260608_06 与 20260609_01,使 head 归一。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:39:37 +08:00
董宏凯 bd426ed1d3 da 2026-06-10 12:51:13 +08:00
董宏凯 ca9960c8bc 代码修改 2026-06-10 12:51:05 +08:00
xuhd 6b9e1f3c2a 2222 2026-06-10 12:37:13 +08:00
xuhd b8d3a549df buggggg 2026-06-10 11:27:48 +08:00
吉鑫升andClaude Opus 4.8 546dec67a1 fix(agents): 私有 tab 未解锁时隐藏搜索栏
私有广场只有在功能可用且校验过密码后才解锁;未解锁时展示门禁/解锁提示并
隐藏关键词/标签搜索栏(此时没有可筛选的列表)。TagSearchBar 按 tab key 重挂载,
切 tab 时内部关键词输入与展开状态随父级筛选一起重置。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:24:52 +08:00
吉鑫升andClaude Opus 4.8 d61aa853f1 feat(admin): 用户排行榜每日快照——手动刷新当天 + 凌晨自动定稿前一天
- 新增 POST /api/admin/users/leaderboard/refresh-today:同步强制重建当天快照
  并返回合并区间;前端 useRefreshTodayLeaderboard + AdminUserLeaderboardPage
  「刷新当天」按钮
- Gateway 调度循环在每日 ADMIN_LEADERBOARD_FINALIZE_HOUR(默认凌晨1点)对前一天
  强制重算一次,避免临近午夜落库的 runs/tool-calls 漏统计;判定逻辑下沉为纯函数
  deerflow.persistence.admin_stats.finalize.should_finalize_previous_day
- 新增 scripts/sql/column_additions_mysql.sql:既有 MySQL 库手动补列的幂等脚本
  (Alembic 自动迁移已禁用,create_all 不改既有表)
- CLAUDE.md 补充排行榜快照、Schema 迁移约定,并补齐技能/知识库选模型的文档

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:24:21 +08:00
吉鑫升andClaude Opus 4.8 96e0d695c0 feat(skills,knowledge): 中文名补全与知识库沉淀支持可选大模型并明确大模型报错
技能中文名:
- 单个「AI 生成」与「一键补充中文名」均可在请求体 model_name 选择大模型
- 一键补充改为仅管理员开放
- _generate_skill_name_zh 在模型自身失败(构建/超时/API 报错)时抛
  SkillNameZhModelError,与模型返回无效名称区分;单个接口返回
  502「大模型报错,无法补全技能中文名」,批量在 model_error 中带回代表性报错

知识库沉淀:
- 对话保存到知识库 / 批量导入均新增可选 model_name(透传至 capture_thread
  → extract_thread_llm_multi)
- 批量返回 llm_fallback 计数;模型异常时仍按既有逻辑回退规则抽取并落草稿,
  前端明确提示「已改用规则抽取」(保存按钮保持后台异步)

前端新增 components/workspace/model-name-select.tsx 复用下拉(默认=系统配置模型)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:23:01 +08:00
吉鑫升andClaude Opus 4.8 8fbbfc8093 feat(frontend): 广场搜索体验优化与切换tab重置筛选
- 智能体广场:切换 我的/广场/私密广场 时清空重置搜索/标签/排序/发布者筛选
- 通用搜索栏(TagSearchBar):新增「搜索」按钮与回车立即触发(跳过防抖)、
  搜索中转圈提示,新增 hideTags / searchPlaceholder 选项
- 定时任务广场:新增客户端搜索(名称/摘要/创建者,兼容脱敏文本)
- agents 分页 hook 暴露 isFetching 以驱动搜索中状态

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:37:39 +08:00
ZyGuo 853a349265 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy 2026-06-10 09:19:37 +08:00
ZyGuo 621d4c82af chore: apik 2026-06-10 09:18:38 +08:00
Administrator 87e0ae683e Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/config.yaml
#	offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
#	offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py
2026-06-10 09:09:26 +08:00
AdministratorandClaude Opus 4.8 44c0f6189b wip: 合并 master 前暂存本地改动
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:36:47 +08:00
董宏凯 64afa9a1ed 代码提交 2026-06-09 22:52:12 +08:00
AdministratorandClaude Opus 4.8 1ce15fbb15 style(knowledge): 实体改回纯展示(不可点击),chip 精致化
按需求去掉点击筛选,实体为静态彩色 chip:色点 + 名称 + 胶囊型类型标签。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:34:59 +08:00
董宏凯 ace30af202 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-09 20:08:06 +08:00
董宏凯 559340143e 代码提交 2026-06-09 19:51:56 +08:00
董宏凯 ae29bb4ca8 Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-server!105
2026-06-09 11:48:57 +00:00
AdministratorandClaude Opus 4.8 f6af9379b7 feat(knowledge): 笔记页实体可点击筛选 + chip 美化
- 实体改为按钮:点击即在左侧列表筛出提及该实体的知识(清空来源/状态筛选)
- chip 加阴影/hover 上浮/类型 ring 标签/实体区 Sparkles 图标,更精致

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:48:20 +08:00
董宏凯 57e286b8ad Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-09 19:45:51 +08:00
fengxiangdebaozi 7acedfd385 代码修改 2026-06-09 19:41:48 +08:00
董宏凯 946799d19d Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-09 19:41:08 +08:00
董宏凯 7282a32859 提交代码 2026-06-09 19:41:01 +08:00
董宏凯 5080f1dc39 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!104
2026-06-09 09:19:14 +00:00
fengxiangdebaozi cba165933c 提交代码 2026-06-09 17:18:40 +08:00
xuhdandClaude Opus 4.7 e54f42c0eb fix(skill_manage): refuse agent writes to human-authored skills
Even after the previous owner check, the agent could still patch/edit
a UI-uploaded skill (source=upload) it happened to own. Uploaded skills
represent human-reviewed content; letting the agent rewrite them risks
silent drift from what the user approved.

Add _assert_skill_is_agent_authored which reads usage.json and refuses
unless source==agent. Wired into all five write actions
(create/edit/patch/delete/write_file/remove_file). Skills with no usage
entry default to source=upload, so legacy and adopted-orphan skills are
also refused — only skills the agent created via mark_agent_created in
the create flow stay editable.

Note: this check is independent of skill_store availability (it reads
the usage file, not the DB), so it runs even when skill_store is None.
Tests that exercise non-create paths against pre-existing skills will
need to call mark_agent_created first or mock get_entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 16:00:44 +08:00
xuhdandClaude Opus 4.7 c493639119 feat(skill-review): strict-mode prompt + raise min_tool_calls 3 to 5
The review agent was creating too many low-value skills. Three changes
tighten the funnel:

- review_worker prompt: rewritten in "保守模式". Default decision is
  do-nothing. To create, all five AND conditions must hold (>=5 tool
  calls with step dependencies, an explicit fail->fix->success episode,
  cross-scenario reusability, no >=70% overlap with skill_list, can
  produce >=300 chars of substance). Adds 7 explicit NO categories
  (single-tool loops, one-off queries, LLM common knowledge, etc.)
  and a 4-question self-check before any create. Hard cap of 1 new
  skill per review, with patch preferred over create.
- min_tool_calls: 3 -> 5. Matches the prompt's ">=5 steps" hard
  condition so trivial turns never reach the LLM reviewer.
- Updated config.yaml, the default-value assertion in
  test_skill_review_worker, and the middleware docstring's "(default
  3)" annotation to keep all three references in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 16:00:29 +08:00
董宏凯andClaude Opus 4.8 cc5ada1f3d feat(roundtable): 第一步识别出的意图支持手动编辑并保存
「当前意图摘要」卡新增编辑态:核心目标(必填)、约束、关键假设(可增删列表),
保存后由 useStep1Intent.updateIntentReady 覆盖权威 intentReady,下游(Step 2
种子消息 buildInitialIntent、侧栏摘要卡、step2Locked)随之生效,并落草稿持久化。

持久化用「计数 + effect」触发 handleManualSave,避免 enqueueSave 闭包在 setState
flush 前读到旧意图的竞态(与 step2/step3 计数触发器同构)。聊天气泡内的绿色意图卡
保持为历史记录,右侧「当前意图摘要」为可编辑的当前值。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:58:24 +08:00
xuhdandClaude Opus 4.7 539a8bcec8 fix(skill_manage): block cross-user writes on patch/edit/write_file/remove_file
Only delete had a DB owner check via _assert_agent_may_delete. The other
four write actions only called ensure_custom_skill_is_editable, which
just verifies the directory exists under skills/custom/ — it does not
check owner_user_id. Any agent that knew a skill's name could overwrite
or strip support files from another user's UI-uploaded skill.

Generalise the helper to _assert_agent_may_modify (same three rules:
no DB row / built-in owner=None / owner mismatch) and call it from
edit, patch, delete, write_file, and remove_file. Hoist user_id +
skill_store to the top of the lock block so the four newly-guarded
branches stay one-liners and create/delete reuse the same handles.
Behaviour preserved when skill_store is None (no DB) — matches the
existing delete fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 15:49:26 +08:00
xuhdandClaude Opus 4.7 4416348c51 fix(studio): hide sidebar group until token resolves; remove VITE_STUDIO_ENABLED
- workspace-nav-studio: return null while isLoading so the "工作室" group
  no longer briefly mounts and then unmounts when getTokenByEmail returns
  "用户不存在" (404). Skeleton branch became dead code, removed.
- studio/hooks: switch all three token queries from retry: 1 to false —
  account-not-found is a permanent error; retry just doubled isError latency.
- env: drop VITE_STUDIO_ENABLED everywhere (env.ts + 7 env files +
  workspace-nav-chat-list). Sidebar availability is now decided at runtime
  by token success instead of a build-time flag.
- env: add VITE_AIPI_PRESENT_URL + VITE_AIPI_KNOWES_URL for the document
  upload popup's A-system jump + B-system knowledge iframe; reuse the
  existing VITE_AIPI_KNOWLEDGE_URL for the Y-system iframe.
- document-upload-popup: replace 8 hardcoded URLs (6 present, 1 knowes,
  1 knowlibrary) with the corresponding env vars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 15:48:24 +08:00
xuhd bd0eceda61 Merge remote-tracking branch 'origin/master' into xuhd
# Conflicts:
#	offline-backend-20260512/backend/config.yaml
2026-06-09 15:43:40 +08:00
fengxiangdebaozi dddd3c1670 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-09 14:06:18 +08:00
fengxiangdebaozi 10795d55e6 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-09 14:06:01 +08:00
fengxiangdebaozi 230fab013a 代码合并 2026-06-09 14:05:51 +08:00
董宏凯 2082d20b88 Merge branch 'dhk-1' into 'master'
feat(roundtable): 业务链条流程图改弹窗 + 每阶段「本阶段目标」注入总控

See merge request knowledge-base-bj/deerflow-server!103
2026-06-09 05:43:00 +00:00
董宏凯andClaude Opus 4.8 c230f7a6c6 feat(parallel-agents): 子智能体禁派活/脚本(防模仿总控) + 面板能力完善
后端 parallel_agents.py:每个并行席位 excluded_tools 增加 agent_orchestration
(派活) 与 bash(脚本逃逸),并在任务消息里加「角色边界」护栏——根治弱模型拿到
带「总控/业务链条」语气的任务后模仿总控输出「总控调度指令…」、绕道 glob/bash
翻找派活脚本的问题。新增 /api/parallel-agents/auth/token 取 token 端点
(auth/csrf 豁免)。

parallel-agents-vue 面板:协作澄清卡片、步骤图标/步骤展示、API 与类型完善等。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:41:39 +08:00
董宏凯andClaude Opus 4.8 3405795371 fix(roundtable): 业务链条「本阶段目标」串行链可保存/回显 + 新增「总控提示词」
本阶段目标 bug:每步只有一个智能体(无并行)时,stageGroupsToStagePayload/
stageGroupsToStageGoals 一律折叠为 null(旧设计=线性链),导致目标保存即丢、
无从回显。改为「有并行 或 任一 stage 填了目标」就落显式分层,串行带目标链按
分层 DAG 跑(总控逐阶段按目标派活);BusinessChainPicker 同步:有并行/有目标/
有总控提示都走 dag。

新增「总控提示词」(per-chain,仅 dag 模式生效):写给总控的整体派活指引。
后端 roundtable_chains 加 coordinator_prompt LONGTEXT 列 + migration 20260608_06;
前端编辑器加输入框,注入 buildDagStageLeaderTask 每阶段派活提示。填了总控提示
也强制显式分层(forceExplicit),否则纯线性链里被静默忽略。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:41:20 +08:00
AdministratorandClaude Opus 4.8 b70ed38d51 feat(knowledge): 图谱跟随删除 + 笔记页展示实体 + 标签美化
图谱不随删除变化的根因:实体/关系删除时从不清理,build_graph 又把所有实体
都当节点,导致已删笔记的实体(六福珠宝/COMEX/CM助手…)永久残留。

- build_graph 只保留被「活笔记」提及的实体,孤儿实体节点不再出现(render 层兜底,
  历史脏数据无需迁移即生效)
- delete_note 删除时 clear_note_relations + prune_orphan_entities,DB 同步收敛
- get_note 返回 entities(repo.list_note_entities),笔记详情页展示实体
- 前端:实体按类型彩色 chip 展示(人物/机构/概念/产品/技术/地区…),标签改为 pill 样式
- 测试:get_note 返回实体 + 删除后图谱与实体表同步收敛

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:39:25 +08:00
AdministratorandClaude Opus 4.8 70bb2783b9 chore(knowledge): 每次沉淀必打入口/结果日志,便于确认是否触发
success 路径原先完全静默,导致"看不到日志"无法区分"没触发"还是"日志不可见"。
现在每次 capture_thread 至少打 [knowledge] capturing… + 结果/跳过/失败 各一行。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:11:54 +08:00
AdministratorandClaude Opus 4.8 d9fc7c9fdf chore(knowledge): 沉淀决策日志统一加 [knowledge] 前缀,跳过提到 WARNING 级
便于 docker logs 一条 grep 抓全部捕获决策(跳过/回退/失败):
  docker logs deerflow-vite-gateway 2>&1 | grep "\[knowledge\]"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:55:44 +08:00
董宏凯 1938c80061 代码提交 2026-06-08 22:52:18 +08:00
AdministratorandClaude Opus 4.8 76320ca5a3 fix(knowledge): 模型判定 should_ingest=false 时直接跳过,不再落草稿
日志显示寒暄/无效会话并非 LLM 调用失败,而是模型正确返回 should_ingest=false。
上一版把这种情况也当失败回退、堆成草稿,反而把无价值对话存进了库。

- llm_extractor: 模型显式 should_ingest=false 且无 notes → 抛 LLMRejectedIngest
- capture_thread: 捕获后抛 EmptyThreadError → 整条跳过、不入库(寒暄/总结此页这类不再进库)
- 仅当真正失败(API 报错 / 非 JSON)才回退规则抽取并落草稿
- 兼容包装 extract_thread_llm 吞掉该异常返回 None
- 更新测试(should_ingest=false 跳过)+ CLAUDE.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:49:00 +08:00
AdministratorandClaude Opus 4.8 4aef81284a feat(knowledge): 出处标记渲染 + 相关笔记按标签精确匹配 + LLM 回退落草稿
A) 前端把 ^[inferred]/^[ambiguous] 渲染成「推测」「存疑」小角标(原先原样显示文本)
B) 相关笔记只按"区分性标签"精确匹配(去掉标题/摘要关键词检索——中文分词导致
   助手/知识图谱/页面 等泛词误关联,如「AI助手打招呼」被错误拉进 Related);
   无区分性标签则不关联、不渲染 ## Related 段
C) LLM 提炼启用但失败时不再静默回退成"原文堆砌":
   - 回退笔记落为 draft(草稿)而非 approved,便于复核
   - capture_thread 记 WARNING 并返回 llm_fallback;router 透出该标志
   - llm_extractor 在 JSON 解析失败/无 notes 时补充可诊断的 WARNING 日志

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:22:28 +08:00
AdministratorandClaude Opus 4.8 e7ff07debd feat(knowledge): 项目名改 zncm + 去除元链接噪音 + 创建人显示人名
- project_name 默认 deerflow → zncm(config.yaml + 配置默认 + 模块内字面量)
- related_links() 返回空:笔记 Related 不再注入 index/_meta/taxonomy/hub 结构链接,
  只保留真实相关笔记的可点 [[标题]];无相关笔记且无来源时不再渲染 ## Related 段
- 移除随之多余的项目 hub 页脚手架(index.md/_meta/taxonomy.md 仍由 obsidian 脚手架保留,可解析)
- GET /notes/{id} 用 auth provider 把 created_by/updated_by(UUID) 解析为邮箱
  (created_by_name/updated_by_name),前端 footer 显示人名、解析不到才显示短 id
- 仅对新沉淀笔记生效,现有笔记不动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:50:56 +08:00
AdministratorandClaude Opus 4.8 df3fac1032 feat(knowledge): 可跳转 wikilink + 真实相关 note + 一对话多页
- [[wikilink]] 解析器:note id / vault_path / 标题 / vault 元页 / missing
- 前端 MarkdownRenderer 把非数字 [[...]] 渲染成可点链接,元页弹窗查看,缺失置灰
- vault 脚手架补建项目 hub 页,三个结构性链接(index/taxonomy/hub)始终可跳
- capture 时按标题+摘要+区分性标签检索同主题 note,注入真实 [[标题]] 到 Related + 记 related 图谱边
- LLM 提取支持 notes[] 数组,一次对话拆 1~4 页并按标题互链(multi_note_enabled 开关)
- 新增接口 POST /resolve、GET /notes/{id}/wikilinks、GET /vault-page
- 新增配置 related_notes_enabled / related_notes_limit / multi_note_enabled

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:20:31 +08:00
吉鑫升andClaude Opus 4.8 92a46003f8 feat: 知识库一键后台保存 + 对话置顶仅首页 + 排行榜页面增强
- 知识库「保存」改为一键触发,无需填表;后端经 BackgroundTasks 异步沉淀到 wiki,点击立即返回 {queued:true}
- 对话列表置顶分区仅在第一页展示,翻页后不再重复顶置
- 公开/管理员排行榜页面重构与维度增强;排行榜页改为整页统一滚动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:59:55 +08:00
Administrator 24db448cfd Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-08 19:12:34 +08:00
AdministratorandClaude Opus 4.8 e750df3c0d docs: 知识库阶段二至四(RAG/治理/图谱)更新 CLAUDE.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:33:08 +08:00
AdministratorandClaude Opus 4.8 81e4fd92ce feat(knowledge): 阶段三 知识治理 + 阶段四 图谱增强
阶段三(治理):
- sensitive.py:写入前脱敏 token/密钥/手机号/身份证/邮箱(sensitive_redaction_enabled)
- 版本快照:更新前写 knowledge_note_versions;GET /notes/{id}/versions
- dedup.py:标题/摘要 Jaccard + 内容哈希查重;GET /duplicates、POST /merge(来源并入主条目、重复项归档)
- auto_ingest.py:独立后台队列/线程沉淀,不阻塞问答;中间件 after_agent 在 auto_ingest_enabled 时入队;价值判断(最小字符)+ skip_existing 去重;自动沉淀落 draft 待审

阶段四(图谱):
- LLM 抽取 entities/relations;service 存 knowledge_entities/knowledge_relations 并连边
- exporter.build_graph 支持实体节点 + note→entity/entity→entity 边;graph.html/json 增强
- GET /api/knowledge/graph 返回产品图谱数据

前端:知识库页「查重合并」对话框 + 相关 api/hooks
配置:sensitive_redaction_enabled / dedup_similarity_threshold / entity_extraction_enabled
测试:tests/test_knowledge.py 20 passed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:31:33 +08:00
AdministratorandClaude Opus 4.8 683cc6d76e feat(knowledge): 阶段二 向量 RAG 与召回注入
- embeddings.py:OpenAI 兼容 embedding 客户端,未配置时关键词兜底
- chunking.py + knowledge_embeddings 表:Markdown 分块与向量索引(Python 余弦)
- search.py:keyword/vector/hybrid 三种模式,向量不可用自动降级
- 笔记创建/更新自动重建向量索引;新增 /reindex 维护接口
- KnowledgeRagMiddleware:按对话级开关 knowledge_rag_enabled 注入 <knowledge-context>,接入 lead agent 中间件链(默认随 config.knowledge.rag_enabled)
- service.build_rag_context(wiki-query 风格分层召回)
- 笔记版本快照(knowledge_note_versions)+ GET /notes/{id}/versions
- 前端:聊天页「知识库」对话级 RAG 开关 + 类型字段
- 新增 entities/relations 表(阶段四预置)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:18:04 +08:00
吉鑫升 fb692a0555 Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji 2026-06-08 18:02:14 +08:00
吉鑫升andClaude Opus 4.8 40206dc94d fix: Postgres store 自愈连接池 + 启用 PG checkpointer + 分析查询超时按服务端适配
- async_provider: AsyncPostgresStore 改用连接池(check_connection 校验),
  避免单条长连接在服务重启/空闲超时后永久失效;池大小可经
  DEER_FLOW_PG_POOL_MIN/MAX 环境变量调整
- admin_users: 排行榜分析查询的每语句超时按服务端区分
  (MySQL>=5.7.4 max_execution_time / MariaDB max_statement_time /
  MySQL<=5.6 跳过 / PostgreSQL statement_timeout)
- config.yaml: checkpointer.use_postgres 置为 true

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:01:55 +08:00
吉鑫升andClaude Opus 4.8 50d70a5050 feat: 对话列表支持分页、收藏、置顶
收藏(is_favorite)/置顶(is_pinned)作为布尔标记存入对话已有 metadata,
复用现有 PATCH /api/threads/{id} 与 search 往返,无需后端改动与迁移。

- hooks: 新增 useUpdateThreadMetadata + useToggleThreadPin/Favorite
- utils: 新增 isThreadPinned/isThreadFavorite/sortThreadsPinnedFirst
- 侧边栏: 置顶优先排序 + 下拉菜单收藏/置顶项 + 置顶图标
- 对话页: 置顶区 + 全部/收藏过滤 + 页码翻页(每页20) + 行内收藏/置顶按钮
- i18n: zh-CN/en-US/types 新增相关文案

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:01:03 +08:00
AdministratorandClaude Opus 4.8 8463289f75 feat(knowledge): LLM 提炼陈述式知识(wiki-capture 规范),规则抽取兜底
之前沉淀直接堆砌对话原文。现默认用大模型按 wiki-capture 规范提炼为 Context/Finding/Reasoning/Implications/Related 结构化陈述式知识,过滤寒暄与中间推理;LLM 不可用或解析失败时回退规则抽取。新增 llm_extractor.py + 配置 llm_extract_enabled/extract_model_name/extract_max_input_chars。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:00:46 +08:00
董宏凯andClaude Opus 4.8 b101ee7fc9 feat(roundtable): 业务链条左栏卡片拖动手柄 + 加入按钮 + 智能体详情抽屉
- 左栏候选卡片加拖动手柄(与右栏席位一致),仅点手柄才拖动
- 加入改为显式「加入」按钮,点卡片本体改为打开详情抽屉
- 右栏席位操作区加「查看详情」按钮,打开同一抽屉
- 新增 AgentDetailSheet:按需拉取(useAgent)展示名称/模型/描述/
  标签/技能+工具组/人设,对齐智能体详情页内容

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:00:42 +08:00
AdministratorandClaude Opus 4.8 d06c8d49d7 feat: 工作台左侧导航新增「知识库」入口
之前只加在了 strategy shell 的 sidebar-menu,工作台 shell 用的是 WorkspaceNavChatList,这里补上入口指向 /page/workspace/knowledge。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:55:11 +08:00
AdministratorandClaude Opus 4.8 e193c2df21 feat: 接入知识库聊天入口与前端路由
在聊天页工具栏接入「保存到知识库」按钮,并补回 /page/workspace/knowledge 列表/详情/图谱路由(此前被回退丢失)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:43:55 +08:00
AdministratorandClaude Opus 4.8 cc99ebba81 feat: 产品内知识库(obsidian-wiki 兼容 vault)
后端:新增自包含 deerflow.knowledge 模块(models/repository/schemas/
extractor/sources/markdown_vault/manifest/obsidian_adapter/exporter/
search/service),通过 deps.py 装配为 app.state.knowledge_service,
挂载 /api/knowledge 路由;config.yaml 增加 knowledge 配置段,
AppConfig 注册 KnowledgeConfig,ORM 注册 4 张 knowledge_* 表。

前端:新增知识库页面(KnowledgeBasePage/KnowledgeGraphPage)、
core/knowledge api/hooks/types、聊天侧 knowledge-save-trigger,
侧边栏增加「知识库」入口。

知识库为全局共享,不做 user_id 隔离,写入记录 created_by/updated_by
作为审计。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:38:28 +08:00
吉鑫升andClaude Opus 4.8 80d2db6315 feat: 公开用户排行榜页面 + 修复 checkpointer 连接与前端依赖
- 新增公开排行榜页面与后端 public_leaderboard 路由
- 完善 skills/agents 路由
- checkpointer Postgres 改用连接池(自愈), 解决连接关闭后定时任务连环失败
- 修复前端 mermaid 依赖未声明导致 Vite 解析失败 (新增 pnpm-lock)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:09:28 +08:00
董宏凯 c05e7df48e Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-08 16:31:00 +08:00
董宏凯andClaude Opus 4.8 834df64650 feat(roundtable): 业务链条流程图改弹窗 + 每阶段「本阶段目标」注入总控
- 业务链条配置页流程图从底部内嵌改为按钮 + Dialog 弹窗查看
- 每个 Stage 新增可选「本阶段目标」文本框,告诉总控本阶段要产出
  什么、如何依赖上一阶段产出(仅分层 DAG 生效)
- StageGroups 重构为 StageGroup[]({seats, goal?}),goal 随 stage
  在增删改/重排中自动跟随;新增 stageGoals 持久化(与 stages 同下标对齐)
- 运行时注入:buildDagStageLeaderTask(总控派活,主)+ pickDagSeatTask
  (席位任务,兜底)
- 后端 roundtable_chains 加 stage_goals 列 + 校验 + Alembic 20260608_04
- 老链条/草稿无该字段时不注入,行为不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:10:46 +08:00
吉鑫升 8113b822c5 docs: add product knowledge base plan 2026-06-08 16:02:43 +08:00
AdministratorandClaude Opus 4.8 a53e565e5a feat: 管理菜单增加「用户排行榜」入口
侧边栏综合管理与右上角工作区下拉菜单新增「用户排行榜」(admin only),
指向 /page/strategy/admin/user-leaderboard,并默认展开综合管理分组。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:47:15 +08:00
AdministratorandClaude Opus 4.8 d4a71ce384 feat: 浏览器扩展支持交互式页面操作 (browser_act)
在原有只读抓取 (browser_fetch_page) 基础上,新增 observe→act 循环,
让 Agent 能在用户的 Chrome 中实际操作网页:点击/输入/滚动/等待/页内跳转。

后端:
- 新工具 browser_act,按 session 复用标签页,返回带编号的可交互元素清单+正文
- browser_context 任务队列扩展 kind/session_id/action 与 observation 结果
- /api/browser-context 路由下发 act 任务、接收 observation
- BrowserContextConfig 增加 actions_enabled 等开关,tools.py 两级门控
- 接线 app.py 路由挂载、app_config.py 配置字段、config.yaml 工具注册

前端:
- ChatPage 处理「发送当前页」回跳的 browser_context query 参数并自动提交

扩展 (browser-extension/deer-web-clipper):
- shared.js 自包含注入函数 deerObservePage / deerPerformAction
- background.js 按 kind 分发任务,session→tab 映射存 chrome.storage.session

测试:tests/test_browser_act.py(队列往返 + 工具校验 + 模拟扩展成功路径,8 passed)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:47:00 +08:00
g zy 004853f030 Merge branch 'gzy_iframedf' into 'master'
feat: 登录token回传至宿主

See merge request knowledge-base-bj/deerflow-server!102
2026-06-08 04:35:35 +00:00
ZyGuo 37be59b0dd feat: 登录token回传至宿主 2026-06-08 12:33:24 +08:00
Administrator 0001f86826 feat: 完善公开任务预览和用户问答历史 2026-06-07 21:18:16 +08:00
xuhdandClaude Sonnet 4.6 9adb0fee28 fix(notebook): preserve selected notebook/docs when switching material source with studioToken
- Initialize materialSource via WritingFormProvider initialMaterialSource prop
- Don't clear selectedNotebookId/selectedDocumentIds on source switch when studioToken is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:58:34 +08:00
xuhdandClaude Sonnet 4.6 c577833e7c fix(notebook): default material source to notebook when studioToken or initialNotebookId is set
Replace useEffect-based approach with synchronous effectiveMaterialSource
derived value to avoid first-render flash and timing issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:53:10 +08:00
xuhdandClaude Sonnet 4.6 d57bcd9aa2 feat(notebook): lock material source to notebook when studioToken is provided
- Auto-switch materialSource to 'notebook' on mount when initialNotebookId is set
- Replace material source dropdown with static label when studioToken is present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:45:24 +08:00
xuhdandClaude Sonnet 4.6 0e7149eec3 feat(notebook): load notebook space via token and pre-select docs/sources
- NotebookAIWritingPage reads `token` from URL, fetches space data from
  notebook-token API (search_space_id, doc_ids, user_token) and space
  name from searchspaces API; keep-alive ping every 50s resets Redis TTL
- Pass user_token as studioToken through AIWritingPage → AIWritingLayout
  → WritingFormPanel → AIWritingPanel → AIWritingForm to skip
  getTokenByEmail when studio token is already available
- useStudioNotebooks accepts enabled flag; useNotebookSources accepts
  overrideToken to bypass the internal token fetch
- AIWritingForm hides notebook selector when studioToken is provided
  (notebook is pre-locked) and pre-selects initialDocumentIds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:38:08 +08:00
xuhd e53c1a0f7b feat(notebook): pre-select notebook source when notebook_id is in URL 2026-06-05 13:35:35 +08:00
xuhd e689493723 feat(notebook): add /page/notebook/ai-writing route for 索财笔记 view 2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 e6d3226c47 feat(studio): replace notes source upload with multi-tab popup
- Add document-upload-popup.tsx with tabs for B系统/A系统/Y系统/XWT/upload
- Add network-actor.tsx for XWT search tab
- Add empty.tsx for empty state
- Refactor SourcesDialog: left panel becomes single "上传来源" button that opens new popup
- Wire DocumentUploadDialogProvider around StudioNotebookDetailPage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 2c2650ff07 feat(skill-evolution): expose model_name as per-user configurable field
前端恢复模型选择器,后端允许 model_name 写入 per-user skill_evolution.json。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 603d667608 fix(skill-evolution): strip non-user-configurable fields from per-user overrides
load_user_overrides 只保留 interval_hours/stale_after_days/archive_after_days,
过滤掉老文件中的 enabled、model_name 等字段,防止旧数据覆盖全局开关。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 e9125b0900 test(curator): add scheduler startup non-blocking and execution tests
验证 curator 调度器不阻塞启动、启动延迟后真实执行、无锁时跳过。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 d0c696e523 refactor(skill-evolution): simplify config UI to 3 user-editable fields
前端配置页只保留运行间隔、陈旧阈值、归档阈值三个字段,
其余配置(enabled、model_name 等)走 config.yaml 全局默认。
后端 PUT /api/curator/config 仅保存这三个字段到 per-user skill_evolution.json。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
xuhd 6f9afcdba2 修复技能审核agent提示词 2026-06-05 13:32:19 +08:00
xuhd 705de81c78 fix(skill-review): use uuid per review run to avoid checkpoint context bleed 2026-06-05 13:32:19 +08:00
xuhd 335bc3cf48 fix(skill-review): propagate user_id through review chain for correct DB ownership 2026-06-05 13:32:19 +08:00
xuhd 857798ff2f fix(skill-review): pass RunnableConfig with thread_id to satisfy ThreadDataMiddleware 2026-06-05 13:32:19 +08:00
xuhd 3955bd72b5 refactor(skill-review): use create_agent with skill_evolution disabled to allow autonomous skill creation 2026-06-05 13:32:19 +08:00
xuhd b5d80025e9 fix(skill-review): lower CREATE threshold and log agent decision 2026-06-05 13:32:19 +08:00
xuhd 5b5e4f8ff0 fix(thread-data): guard runtime.context None on line 110 2026-06-05 13:32:19 +08:00
xuhd ad5bd22c5e refactor(skill-review): use make_lead_agent instead of create_react_agent
Reuses the full middleware chain (ToolErrorHandlingMiddleware,
LLMErrorHandlingMiddleware, etc.) so tool errors like 'already exists'
and invalid SKILL.md are converted to ToolMessages by the middleware,
not silently swallowed or crashing the review task.
2026-06-05 13:32:19 +08:00
xuhd b2ed6f1acf fix(skill-review): add strict SKILL.md format spec to review prompt 2026-06-05 13:32:19 +08:00
xuhd 2a8364e902 fix(skill-review): use runtime.context to get thread_id 2026-06-05 13:32:19 +08:00
xuhd 23a18ec318 fix(skill-review): after_agent must be sync, not async 2026-06-05 13:32:19 +08:00
xuhd d713f0209d feat(curator): restrict per-user config to archiving thresholds only 2026-06-05 13:32:19 +08:00
xuhd 0a21d37146 chore(config): enable skill_evolution, review_worker, and curator (24h) in config.yaml 2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 c645142f82 feat(skill-evolution): register SkillReviewMiddleware in lead agent chain
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 0e7a1de4db feat(skill-evolution): add SkillReviewMiddleware to trigger post-turn review
Implements SkillReviewMiddleware that hooks into after_agent to call
maybe_start_review() as a background task after each agent turn, without
blocking the HTTP response. Exceptions are caught and logged only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 ef3ef433c1 feat(skill-evolution): add SkillReviewWorker for passive post-turn skill creation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
xuhd 0da91be9b1 test(skill-evolution): fix unused import and add max_messages assertion 2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 69454ada69 feat(skill-evolution): add ReviewWorkerConfig to SkillEvolutionConfig
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
xuhdandClaude Sonnet 4.6 1126fedea8 chore(env): 添加我的笔记相关环境变量配置
- VITE_AIPI_FRONTEND_URL: 我的笔记前端地址
- VITE_STUDIO_ENABLED: 控制侧边栏我的笔记入口显示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:32:19 +08:00
601 changed files with 95186 additions and 8369 deletions

No files matched your search

+2 -1
View File
@@ -4,7 +4,8 @@
"PowerShell(uv --version)",
"PowerShell(netstat -ano)",
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })"
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })",
"Bash(curl -s -X POST -H \"Content-Type: application/json\" -d '{\"username\":\"demo\"}' http://localhost:8001/api/v1/auth/login/username)"
]
}
}
+4 -1
View File
@@ -1,7 +1,7 @@
# Workspace/editor
.vscode/
frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
@@ -84,6 +84,9 @@ offline-backend-20260512/backend/tests/*
deploy
.history
# Cloned third-party repos (local only)
git-clone/
# Repo-root offline dependency pack (local only)
/dist/
+3 -1
View File
@@ -78,11 +78,13 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
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)
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
### Configuration
Submodule _skills_src/ai-skills added at cde1c8385e.
Submodule _skills_src/anthropics-skills added at 5754626092.
Submodule _skills_src/awesome-claude-skills added at 92568c1eda.
Submodule _skills_src/ce-kit added at d4973c4827.
+1
Submodule _skills_src/csm added at 3fa16a94e0.
Submodule _skills_src/superpowers added at 6fd4507659.
Submodule _skills_src/tapestry-skills added at 80e1dc56df.
+33
View File
@@ -0,0 +1,33 @@
# 批量人物蒸馏 → llmwiki
一个**完全独立的单文件脚本 + 内置定时器**:读 Excel 名单,串行调用线上 DeerFlow 服务问答接口,
复用「女娲 / huashu-nuwa」技能逐个蒸馏人物,**由脚本抓取回复并保存**成 llmwiki 风格 markdown。
- **不联网**:只用已配置的知识库查询技能(`knowledge-search-v2`)检索资料。
- **定时执行**:每天 **23:00 ~ 次日 08:00** 自动跑,到点暂停、次日继续。
- **md 由 py 保存**:agent 只产出内容,脚本抓最终回复写成 `.md`(不让项目自己写文件)。
## 你只需要做两件事
1. **改一行地址**:打开 `distill_people.py`,把顶部 `BASE_URL = "..."` 改成你的线上服务地址。
2. **放名单**:同目录放 `people.xlsx`(或 `people.csv`),**第一列 = 人名**,其余列=可选提示。
其余(用户名 `distiller`、口令 `123ewq`、技能名、时间窗)都已内置,不用填。
## 运行
```bash
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行(推荐挂后台)
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
```
读 `.xlsx` 需要 `openpyxl`;用 `.csv` 则零额外依赖。
## 输出(都在 output/ 一个文件夹)
- `<人名>.md` —— 每个人的 llmwiki 档案
- `manifest.json` —— 执行清单(状态/耗时/run_id)
- `index.md` —— 可读清单表(谁完成/失败,点开看 md)
**断点续跑**:已完成的人自动跳过;中途往名单里加人,下个执行窗会带上。
+563
View File
@@ -0,0 +1,563 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
批量人物蒸馏 → llmwiki markdown(完全独立的单文件客户端 + 内置定时器)
特点
----
- 串行(一个一个来)调用正在运行的 DeerFlow 服务问答接口
- 复用线上「女娲 / huashu-nuwa」技能做提炼;**不联网**,只用已配置的
知识库查询技能(knowledge-search-v2)检索资料
- agent 只负责产出内容,**markdown 由本脚本抓取最终回复后自己保存**
- 内置时间窗:每天 23:00 ~ 次日 08:00 才执行,到点自动暂停、次日继续
- 断点续跑:已完成的人自动跳过;中途加名单也会被带上
你只需要改一处:下面的 BASE_URL(线上服务地址)。其余都不用填。
名单
----
脚本同目录放 people.xlsx 或 people.csv,**第一列 = 人名**,其余列=可选提示。
运行
----
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime, time as dtime
from pathlib import Path
try:
import requests
except ImportError:
sys.exit("缺少依赖 requests:pip install requests")
# ════════════════════════════════════════════════════════════════════════
# 只改这一行:你的线上服务地址(不带结尾斜杠)
# ════════════════════════════════════════════════════════════════════════
BASE_URL = "http://47.94.209.59:2026/"
# ════════════════════════════════════════════════════════════════════════
# ---- 以下都已内置,正常无需改动 ----
USERNAME = "distiller" # 免密自动注册
PASSWORD = "123ewq" # 共享口令(你们开了口令门)
NUWA_SKILL = "huashu-nuwa" # 女娲技能
KB_SKILL = "knowledge-search-v2" # 知识库查询技能(不联网,用它检索)
DEPTH = "full" # full 完整 / fast 精简
HERE = Path(__file__).resolve().parent
OUTPUT_DIR = HERE / "output"
PEOPLE_FILE_DEFAULT = HERE / "people.example.csv"
# 执行时间窗:23:00 ~ 次日 08:00(跨午夜)
WIN_START = dtime(23, 0)
WIN_END = dtime(8, 0)
PER_PERSON_TIMEOUT = 1800 # 单人最长等待(秒)
POLL_INTERVAL = 5 # 轮询间隔(秒)
HTTP_TIMEOUT = 60
TERMINAL_STATUS = {"success", "error", "timeout", "interrupted"}
# ================================ llmwiki 模板 ================================
LLMWIKI_TEMPLATE = """\
---
name: <中文名/常用名>
aliases: [<别名/英文名...>]
type: person
era: <在世 | 历史人物>
domains: [<领域1>, <领域2>]
distilled_at: <YYYY-MM-DD>
sources_count: <检索到的来源条数>
primary_source_ratio: <一手来源占比, 0~1 的小数>
confidence: <high | medium | low>
---
# <人物名>
> **一句话定位**:<此人看世界的独特方式,一句话>
## 身份卡
- **活跃年代**:
- **核心身份**:
- **自述(50字, 以其语气)**:
## 核心观点(真信念 · 反复出现的主张)
1. ...
## 心智模型(3–7 个;每个含:一句话 / 证据场景≥2 / 应用 / 局限 / 来源)
### 1. <模型名>
- **一句话**:
- **证据场景**:
- **应用方式**:
- **局限**:
- **来源**:
## 决策启发式(5–10 条 · 如果 X 则 Y,带案例)
- 若 <X> → <Y>
## 表达 DNA
| 维度 | 特征 |
|------|------|
| 句式 | |
| 高频词 | |
| 幽默 | |
| 确定性 | |
| 引用习惯 | |
## 价值观与反模式
- **价值观排序**:
- **明确反对**:
## 内在张力(≥2 对矛盾)
- <A> ↔ <B>
## 经历时间线
| 年份 | 事件 | 思想意义 |
|------|------|---------|
| | | |
## 代表语录
- “...” —(出处/年份,标一手/二手)
## 智识谱系
- **受影响于**:
- **影响了**:
## 外部评价与争议
-
## 诚实边界
- <至少 3 条具体局限,含信息截止日期>
## 调研来源
**一手(N)**:
**二手(N)**:
"""
def build_prompt(name: str, hints: str, depth: str) -> str:
depth_note = (
"本次走【完整模式】:尽量覆盖著作/对话/表达/他者评价/决策/时间线 6 个维度。"
if depth == "full"
else "本次走【精简模式】:重点覆盖核心观点、心智模型、时间线三块即可。"
)
hint_block = f"\n补充提示(来自名单其它列,可作为检索关键词):{hints}\n" if hints.strip() else ""
return f"""\
你的任务:把人物【{name}】蒸馏成一份 llmwiki 风格的人物档案 markdown。
请复用本服务中已启用的「女娲 / {NUWA_SKILL}」技能的提炼方法论
(心智模型三重验证、表达DNA分析、诚实边界)。
⚠️ 本环境**无法联网,严禁使用 WebSearch 等任何联网工具**。
所有资料**只能通过已配置的知识库查询技能「{KB_SKILL}」检索获取**。
请充分调用该知识库技能,围绕该人物多角度检索(生平、观点、著作、语录、决策、评价等),
基于检索到的内容做提炼。知识库里查不到的部分,如实在「诚实边界」标注,不要编造。
{depth_note}
{hint_block}
输出要求:
1. **不要写任何文件,不要调用 write_file / present_files**。
2. 直接在你的回复正文里输出**完整中文 markdown 全文**,严格按下面模板结构
(保留各级标题,逐项填实,不要保留尖括号占位符),**不要任何额外解说、前言或结语**:
------------------ 模板开始 ------------------
{LLMWIKI_TEMPLATE}
------------------ 模板结束 ------------------
现在开始,独立完成,不要向我提问。"""
# ================================ HTTP 客户端 ================================
class DeerFlowClient:
def __init__(self, base_url: str):
self.base = base_url.rstrip("/")
self.s = requests.Session()
self.token: str | None = None
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.token:
h["Authorization"] = f"Bearer {self.token}" # 只用 Bearer,不带 cookie → 跳过 CSRF
return h
def login(self) -> bool:
url = f"{self.base}/api/v1/auth/login/username"
params = {"password": PASSWORD} if PASSWORD else None
try:
r = self.s.post(url, params=params, json={"username": USERNAME}, timeout=HTTP_TIMEOUT)
except requests.RequestException as e:
print(f" [登录] 连接失败:{e}")
return False
if r.status_code == 200:
self.token = r.json().get("access_token")
self.s.cookies.clear()
print(f" [登录] 成功,用户={USERNAME}")
return True
print(f" [登录] 返回 {r.status_code}:{r.text[:200]}")
return False
def ensure_skill_enabled(self, name: str) -> None:
try:
r = self.s.get(f"{self.base}/api/skills", headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
skills = r.json().get("skills", [])
hit = next((x for x in skills if x.get("name") == name), None)
if hit is None:
print(f" [技能] 警告:服务里没找到 '{name}'")
return
if hit.get("enabled"):
print(f" [技能] '{name}' 已启用")
return
pr = self.s.put(f"{self.base}/api/skills/{name}", headers=self._headers(),
json={"enabled": True}, timeout=HTTP_TIMEOUT)
print(f" [技能] 启用 '{name}' → {pr.status_code}")
except requests.RequestException as e:
print(f" [技能] 检查 '{name}' 失败(忽略):{e}")
def create_thread(self) -> str:
r = self.s.post(f"{self.base}/api/threads", headers=self._headers(), json={}, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["thread_id"]
def start_run(self, thread_id: str, prompt: str) -> str:
body = {
"input": {"messages": [{"role": "user", "content": prompt}]},
"excluded_tools": ["ask_clarification"], # 禁澄清,无人值守不卡住
}
r = self.s.post(f"{self.base}/api/threads/{thread_id}/runs",
headers=self._headers(), json=body, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["run_id"]
def poll_run(self, thread_id: str, run_id: str) -> str:
deadline = time.monotonic() + PER_PERSON_TIMEOUT
last = ""
while time.monotonic() < deadline:
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}",
headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
st = r.json().get("status", "")
if st != last:
print(f" ...状态:{st}")
last = st
if st in TERMINAL_STATUS:
return st
except requests.RequestException as e:
print(f" 轮询出错(重试):{e}")
time.sleep(POLL_INTERVAL)
return "timeout"
def fetch_result_text(self, thread_id: str, run_id: str) -> str | None:
"""抓取该 thread 的最终 AI 回复文本(由 py 保存为 md)。
注意:本部署的 `/runs/{rid}/messages`、`/messages`、`/events` 端点都返回空
(事件库按登录用户过滤,后台 worker 写入时未带 user_id → 读不到)。
可靠来源是 LangGraph 检查点状态 `/api/threads/{tid}/state` → values.messages,
其中每条消息形如 {"type": "human"|"ai"|"tool", "content": <str | 内容块列表>, ...}。
取最后一条有正文的 ai 消息即可。
"""
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/state",
headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code != 200:
print(f" 取回复返回 {r.status_code}:{r.text[:200]}")
return None
messages = (r.json().get("values") or {}).get("messages") or []
# 从后往前找最后一条「有正文」的 AI 回复(跳过只含工具调用的中间消息)
for msg in reversed(messages):
if not isinstance(msg, dict) or msg.get("type") != "ai":
continue
txt = _extract_text(msg)
if txt and txt.strip():
return txt
except requests.RequestException as e:
print(f" 取回复出错:{e}")
return None
def _extract_text(payload) -> str:
"""从一条消息的 content 里提取纯文本。
payload 可能是:
- LangChain 消息的 model_dump 字典(正文在 payload["content"])
- 直接的字符串
- 内容块列表([{"type":"text","text":"..."}, ...])
"""
if payload is None:
return ""
# 若是整条消息的 model_dump,正文在其 content 字段
if isinstance(payload, dict):
c = payload.get("content", payload.get("text", ""))
else:
c = payload
if isinstance(c, str):
return c
if isinstance(c, list):
parts = []
for b in c:
if isinstance(b, dict):
# 只取文本块,跳过 tool_use / thinking 等非文本块
if b.get("type") in (None, "text", "text_block"):
parts.append(b.get("text") or b.get("content") or "")
elif b.get("text"):
parts.append(b["text"])
elif isinstance(b, str):
parts.append(b)
return "\n".join(p for p in parts if p)
return str(c) if c else ""
def _clean_markdown(text: str) -> str:
"""去掉 agent 可能套上的 ```markdown ... ``` 围栏,并规整 YAML frontmatter。"""
t = text.strip()
m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```$", t, re.S)
if m:
t = m.group(1).strip()
return _normalize_frontmatter(t)
def _normalize_frontmatter(text: str) -> str:
"""修复模型常见的 frontmatter 跑偏,让 YAML 能正常解析。
模型经常把字段名写成 markdown 加粗(``**name:**``)、加空行、留行尾双空格,
这些都会让 frontmatter 解析失败。这里把开头 ``---`` 到下一个 ``---`` 之间的内容
规整为干净的 ``key: value`` 形式。正文(第二个 ``---`` 之后)保持原样。
"""
if not text.startswith("---"):
return text
# 拆出 frontmatter 块:开头 --- 与下一行单独的 --- 之间
m = re.match(r"^---[ \t]*\n(.*?)\n---[ \t]*\n?(.*)$", text, re.S)
if not m:
return text
body_fm, rest = m.group(1), m.group(2)
out_lines = []
for line in body_fm.splitlines():
s = line.rstrip() # 去行尾双空格
s = s.replace("**", "") # 去 markdown 加粗标记
if not s.strip():
continue # 丢掉空行
out_lines.append(s)
return "---\n" + "\n".join(out_lines) + "\n---\n\n" + rest.lstrip("\n")
# ================================ 名单读取 ================================
def slugify(name: str) -> str:
s = re.sub(r'[\\/:*?"<>|]+', "", name).strip()
s = re.sub(r"\s+", "_", s)
return s or "unnamed"
def read_people(path: Path) -> list[dict]:
if not path.exists():
return []
rows: list[list[str]] = []
if path.suffix.lower() in (".xlsx", ".xlsm"):
try:
import openpyxl
except ImportError:
sys.exit("读取 .xlsx 需要 openpyxl:pip install openpyxl(或把名单存成 .csv)")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
for row in wb.active.iter_rows(values_only=True):
rows.append(["" if c is None else str(c).strip() for c in row])
elif path.suffix.lower() == ".csv":
with open(path, encoding="utf-8-sig", newline="") as f:
rows = [[c.strip() for c in r] for r in csv.reader(f)]
else:
sys.exit(f"不支持的名单格式:{path.suffix}(用 .xlsx 或 .csv)")
if rows and re.search(r"(姓名|名字|人物|name)", rows[0][0] if rows[0] else "", re.I):
rows = rows[1:]
people, seen = [], set()
for r in rows:
if not r or not r[0].strip():
continue
name = r[0].strip()
if name in seen:
continue
seen.add(name)
hints = " | ".join(x for x in r[1:] if x.strip())
people.append({"name": name, "hints": hints})
return people
# ================================ 时间窗 ================================
def in_window(now: datetime | None = None) -> bool:
now = now or datetime.now()
t = now.time()
return t >= WIN_START or t < WIN_END # 跨午夜
def seconds_until_window(now: datetime | None = None) -> int:
now = now or datetime.now()
if in_window(now):
return 0
target = now.replace(hour=WIN_START.hour, minute=WIN_START.minute, second=0, microsecond=0)
return max(1, int((target - now).total_seconds()))
def sleep_interruptible(total: int) -> None:
end = time.monotonic() + total
while time.monotonic() < end:
time.sleep(min(30, max(1, int(end - time.monotonic()))))
# ================================ 蒸馏单人 ================================
def distill_one(client: DeerFlowClient, person: dict, out_dir: Path,
manifest: dict, save_manifest, depth: str) -> None:
name, hints = person["name"], person["hints"]
slug = slugify(name)
md_path = out_dir / f"{slug}.md"
t0 = time.time()
rec = {"name": name, "slug": slug, "status": "running",
"started_at": _now(), "file": f"{slug}.md"}
manifest[name] = rec
save_manifest()
try:
tid = client.create_thread()
run_id = client.start_run(tid, build_prompt(name, hints, depth))
status = client.poll_run(tid, run_id)
rec.update(thread_id=tid, run_id=run_id, run_status=status)
text = client.fetch_result_text(tid, run_id) if status == "success" else None
if status == "success" and text:
md = _clean_markdown(text)
md_path.write_text(md, encoding="utf-8")
rec["status"] = "succeeded"
rec["chars"] = len(md)
print(f" ✅ 完成 → {md_path.name}({len(md)} 字,耗时 {int(time.time()-t0)}s)")
else:
rec["status"] = "failed"
rec["error"] = f"run={status}, text={'有' if text else '无'}"
if text:
(out_dir / f"{slug}.partial.md").write_text(text, encoding="utf-8")
print(f" ❌ 失败(run={status})")
except Exception as e:
rec["status"] = "failed"
rec["error"] = str(e)
print(f" ❌ 异常:{e}")
rec["finished_at"] = _now()
rec["seconds"] = int(time.time() - t0)
manifest[name] = rec
save_manifest()
# ================================ 主流程 ================================
def main():
ap = argparse.ArgumentParser(description="批量人物蒸馏 → llmwiki(定时窗 23:00~08:00)")
ap.add_argument("excel", nargs="?", default=str(PEOPLE_FILE_DEFAULT),
help="名单文件(.xlsx 或 .csv),默认同目录 people.xlsx")
ap.add_argument("--now", action="store_true", help="忽略时间窗,立刻跑完整批(测试用)")
ap.add_argument("--redo", action="store_true", help="忽略已完成,全部重做")
args = ap.parse_args()
excel = Path(args.excel)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
manifest_path = OUTPUT_DIR / "manifest.json"
manifest: dict = {}
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
manifest = {}
# 校验名单存在
if not read_people(excel):
sys.exit(f"名单为空或不存在:{excel}\n请放 people.xlsx(第一列写人名)。")
# --redo:一次性清掉名单里这些人的旧记录,让他们重做一遍;
# 之后本次会话内跑成功的会正常计为「已完成」,循环才会往后推进
# (否则每轮 pending() 都把已完成的人当未完成,永远卡在第 1 个人)
if args.redo:
for p in read_people(excel):
manifest.pop(p["name"], None)
client = DeerFlowClient(BASE_URL)
if not client.login():
sys.exit("登录失败。请检查 BASE_URL 是否指向正在运行的服务、口令是否正确。")
client.ensure_skill_enabled(NUWA_SKILL)
client.ensure_skill_enabled(KB_SKILL)
def save_manifest():
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
_write_index(OUTPUT_DIR, read_people(excel), manifest)
def pending() -> list[dict]:
people = read_people(excel)
out = []
for p in people:
r = manifest.get(p["name"], {})
done = r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists()
if not done:
out.append(p)
return out
print(f"启动 | 服务={BASE_URL} | 输出={OUTPUT_DIR}")
print(f"模式={'立即跑完' if args.now else '定时窗 23:00~08:00'} | 名单={excel}")
try:
while True:
todo = pending()
if not todo:
print(f"[{_now()}] 全部已完成,待命中(每小时复查名单)...")
if args.now:
break
sleep_interruptible(3600)
continue
if not args.now and not in_window():
wait = seconds_until_window()
nxt = datetime.now().replace(hour=WIN_START.hour, minute=0, second=0, microsecond=0)
print(f"[{_now()}] 不在执行窗口,剩 {len(todo)} 人待蒸;"
f"休眠到 {nxt.strftime('%H:%M')}(约 {wait//60} 分钟)...")
sleep_interruptible(wait)
continue
# 在窗口内(或 --now):处理一个人,然后回到循环重新判断窗口/名单
p = todo[0]
idx_total = len(read_people(excel))
done_n = sum(1 for v in manifest.values() if v.get("status") == "succeeded")
print(f"[{_now()}] ({done_n}/{idx_total} 已完成) 蒸馏:{p['name']}")
distill_one(client, p, OUTPUT_DIR, manifest, save_manifest, DEPTH)
except KeyboardInterrupt:
print("\n已手动停止。重跑脚本会从未完成处继续。")
ok = sum(1 for r in manifest.values() if r.get("status") == "succeeded")
fail = sum(1 for r in manifest.values() if r.get("status") == "failed")
print(f"\n结束:成功 {ok} / 失败 {fail}")
print(f"结果目录:{OUTPUT_DIR} | 清单:{manifest_path} | 索引:{OUTPUT_DIR/'index.md'}")
def _now() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
def _write_index(out_dir: Path, people: list[dict], manifest: dict) -> None:
lines = ["# 蒸馏执行清单", "", f"更新时间:{_now()}", "",
"| # | 人物 | 状态 | 字数 | 耗时 | 文件 |",
"|---|------|------|------|------|------|"]
badge = {"succeeded": "✅完成", "failed": "❌失败", "running": "⏳进行中"}
for i, p in enumerate(people, 1):
r = manifest.get(p["name"], {})
st = badge.get(r.get("status", ""), "⌛排队")
f = r.get("file", "")
link = f"[{f}]({f})" if r.get("status") == "succeeded" and f else "-"
lines.append(f"| {i} | {p['name']} | {st} | {r.get('chars','-')} | {r.get('seconds','-')} | {link} |")
(out_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
姓名,领域(可选提示),资料链接或备注(可选)
查理·芒格,投资/多元思维,
理查德·费曼,物理/教学,
纳瓦尔·拉维坎特,创业/杠杆,https://nav.al/
1 姓名 领域(可选提示) 资料链接或备注(可选)
2 查理·芒格 投资/多元思维
3 理查德·费曼 物理/教学
4 纳瓦尔·拉维坎特 创业/杠杆 https://nav.al/
@@ -0,0 +1,136 @@
# zncm Web Clipper
这是给 zncm 使用的 Chrome 插件,安装后默认连接 `config.js` 中配置好的服务地址。
默认地址配置在 `browser-extension/deer-web-clipper/config.js`:
```js
globalThis.ZNCM_WEB_CLIPPER_CONFIG = Object.freeze({
defaultWebBaseUrl: "http://47.94.209.59:2026",
defaultApiBaseUrl: "http://47.94.209.59:2026",
});
```
如果部署地址变化,只需要修改 `config.js` 里的这两个字段,然后重新加载插件。
正常情况下不需要手动配置 URL,也不需要手动复制 token。
## 能做什么
1. 把当前浏览器标签页发送到 zncm,作为 Markdown 附件进入聊天。
2. 接收 zncm 聊天中由 `browser_fetch_page` 工具创建的网页抓取任务。
3. 插件在 Chrome 中打开目标网页,读取可见文本和表格,再把结果回传给 zncm。
4. 在普通网页右下角显示“zncm 页面问答”浮窗,直接把当前页面内容交给 zncm 问答。
5. 接收 zncm 的 `browser_act` 交互式操作任务:在 Chrome 中打开页面并按 zncm 的指令**操作页面**——点击、输入、滚动、等待、页内跳转,每一步把页面上的可交互元素清单和正文回传给 zncm,由 zncm 决定下一步(observe→act 循环)。
## 页面操作(browser_act)
除了只读抓取,zncm 还能驱动插件**实际操作网页**,完成登录、填表、翻页找数据等多步任务:
1. zncm 调用 `browser_act` 工具,先 `navigate` 打开页面。
2. 插件打开标签页(同一会话会复用这个标签页),扫描页面上的可交互元素,给每个元素编号后连同正文回传给 zncm。
3. zncm 根据元素编号决定下一步(点第 3 个按钮、在第 1 个输入框填字……),再次调用 `browser_act`。
4. 插件执行动作、重新扫描页面、回传新状态,如此循环,直到任务完成。
5. 任务结束时 zncm 用 `action="close"` 关闭该会话标签页。
支持的动作:`navigate`(打开/跳转 URL)、`click`(点击元素)、`type`(输入文字,可选回车提交)、`scroll`(滚动到元素或下翻一屏)、`wait`(等待某 CSS 选择器出现)、`extract`(重新读取正文)、`observe`(仅重新扫描)、`close`(关闭会话标签页)。
> 提示:页面操作能力较强(可点击任意按钮、提交表单),可在后端 `config.yaml` 用 `browser_context.actions_enabled: false` 关闭,只保留只读抓取。
## 后端启用
在 `offline-backend-20260512/backend/config.yaml` 中保持:
```yaml
browser_context:
enabled: true # 只读抓取 + 页面问答
actions_enabled: true # 交互式页面操作 browser_act(不需要可设为 false)
```
修改配置后需要重启后端。
## 安装插件
1. 打开 Chrome 的 `chrome://extensions`。
2. 开启右上角的“开发者模式”。
3. 点击“加载已解压的扩展程序”。
4. 选择目录:`browser-extension/deer-web-clipper`。
5. 安装后插件名会显示为 `zncm Web Clipper`。
## 安装即用
插件默认已经开启:
- 自动识别已打开 zncm 页面的服务地址。
- 自动同步已登录 zncm 的 token。
- 自动轮询 zncm 聊天触发的浏览器抓取任务。
- 页面问答框默认开启。
如果你打开的是 `config.js` 里配置的默认服务器,通常无需进入设置页。
如果部署地址变了,保持一个已登录的 zncm 页面打开,然后在插件设置里点击:
- `重新识别服务地址`
- `同步 token`
## 自动同步 Token
插件会自动从两个位置获取登录 token:
1. zncm API 域名下的 `access_token` Cookie,包括 HttpOnly Cookie。
2. 已登录 zncm 前端页面里的浏览器本地登录态。
切换登录账号后,插件后台每次轮询任务前都会重新同步 token;点击插件里的“同步 token”也可以立即刷新。
如果当前浏览器已经登出 zncm,且打开的 zncm 页面里没有 token,插件会清空旧 token,避免继续用上一个账号请求。
## 页面问答框
安装插件后,普通 `http` / `https` 页面右下角会出现 `AI` 按钮。点击后可以在当前网页上直接提问,例如:
```text
总结这页的核心结论
提取页面里的价格和时间
这张表里哪个指标最高?
```
插件会读取当前页面可见文本和表格,发送到 zncm,并把 zncm 的回答显示回浮窗里。多轮追问会复用同一个 zncm 会话;如果页面 URL 变化,会自动重新开始当前页会话。
是否显示问答框可以自己控制:
- 插件弹窗里勾选或取消“启用页面问答框”。
- 插件设置页里勾选或取消“启用页面问答框”。
关闭问答框只会隐藏页面浮窗,不会影响“发送当前页”和 zncm 聊天中的 `browser_fetch_page` 抓取任务。
## 手动发送当前网页
点击插件图标,选择“发送当前页”。
插件会读取当前页面的可见文本和表格,发送到 zncm,并打开一个新的 zncm 聊天页面。zncm 会把网页内容作为上传文件注入到首条问题里。
## 在 zncm 聊天中触发浏览器抓取
在 zncm 中输入带有明确 URL 的问题,例如:
```text
使用 browser_fetch_page 打开 https://example.com/report,提取页面里的关键表格数据并总结。
```
执行流程:
1. zncm 调用 `browser_fetch_page` 工具创建浏览器任务。
2. 插件后台轮询到任务。
3. 插件在 Chrome 中打开目标网页。
4. 插件提取页面文本和表格。
5. 插件把结果回传给 zncm。
6. zncm 基于回传内容回答你的问题。
这个方式适合读取需要浏览器登录态、内网权限或当前 Chrome 环境才能访问的页面。
## 注意事项
- 目标 URL 必须是明确的 `http` 或 `https` 地址。
- 插件不能读取 `chrome://` 等浏览器内部页面。
- 如果目标网站阻止扩展脚本注入,抓取可能失败。
- 后台任务默认会打开新标签页;是否抓取完成后关闭标签页,可在插件设置里配置。
@@ -0,0 +1,905 @@
importScripts("config.js", "shared.js");
const ALARM_NAME = "deer-browser-task-poll";
const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
let pollTimer = null;
let isPolling = false;
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
const updates = {};
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
}
if (!settings.webBaseUrl || settings.webBaseUrl === LEGACY_LOCAL_WEB_BASE_URL) {
updates.webBaseUrl = DEER_CLIPPER_DEFAULTS.webBaseUrl;
}
if (!settings.workerId) {
updates.workerId = deerMakeWorkerId();
}
if (Object.keys(updates).length > 0) {
await chrome.storage.sync.set(updates);
Object.assign(settings, updates);
}
return settings;
}
function pollIntervalMs(settings) {
const seconds = Number(settings.taskPollIntervalSeconds || DEER_CLIPPER_DEFAULTS.taskPollIntervalSeconds);
return Math.max(3, seconds) * 1000;
}
function originMatchPattern(value) {
try {
const url = new URL(deerNormalizeBaseUrl(value));
return `${url.protocol}//${url.host}/*`;
} catch {
return "";
}
}
function normalizeServiceBase(value) {
return deerNormalizeBaseUrl(value || "");
}
function serviceBaseFromTabUrl(value) {
try {
const url = new URL(value);
const path = url.pathname.replace(/\/+$/, "");
if (path === "/deerflow" || path.startsWith("/deerflow/")) {
return `${url.origin}/deerflow`;
}
return url.origin;
} catch {
return "";
}
}
function apiCandidatesForWebBase(webBase, currentApiBase) {
const candidates = [];
const add = (value) => {
const normalized = normalizeServiceBase(value);
if (normalized && !candidates.includes(normalized)) {
candidates.push(normalized);
}
};
add(currentApiBase);
add(webBase);
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
add(LEGACY_LOCAL_API_BASE_URL);
try {
const url = new URL(normalizeServiceBase(webBase));
if (["5173", "5174", "3000"].includes(url.port)) {
const apiUrl = new URL(url.href);
apiUrl.port = "8001";
add(apiUrl.href);
}
} catch {
// Ignore invalid candidates.
}
return candidates;
}
async function browserContextStatus(apiBaseUrl) {
try {
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
credentials: "include",
});
if (!response.ok) {
return null;
}
const data = await response.json().catch(() => null);
if (data && typeof data.enabled === "boolean") {
return data;
}
} catch {
return null;
}
return null;
}
function tabLooksLikeZncm(tab) {
const value = `${tab.url || ""} ${tab.title || ""}`.toLowerCase();
return (
value.includes("deerflow") ||
value.includes("deer") ||
value.includes("zncm") ||
value.includes("/page/workspace") ||
value.includes("/chats/") ||
value.includes("#/login")
);
}
async function findOpenZncmTabs(settings) {
const tabs = [];
const seen = new Set();
const addTabs = (items) => {
for (const tab of items || []) {
if (tab?.id && !seen.has(tab.id)) {
seen.add(tab.id);
tabs.push(tab);
}
}
};
for (const pattern of [
originMatchPattern(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl),
originMatchPattern(DEER_CLIPPER_DEFAULTS.webBaseUrl),
]) {
if (!pattern) {
continue;
}
try {
addTabs(await chrome.tabs.query({ url: pattern }));
} catch {
// Continue with broader discovery below.
}
}
try {
const allTabs = await chrome.tabs.query({ url: ["http://*/*", "https://*/*"] });
addTabs(allTabs.filter(tabLooksLikeZncm));
} catch {
// Ignore tab discovery failures.
}
return tabs;
}
async function discoverServiceBases(settings, force = false) {
if (!settings.autoDiscoverServiceUrl && !force) {
return settings;
}
let webBaseUrl = normalizeServiceBase(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
let discoveredWebBaseUrl = webBaseUrl;
let source = "configured";
const tabs = await findOpenZncmTabs(settings);
if (tabs.length > 0) {
const tabBase = serviceBaseFromTabUrl(tabs[0].url);
if (tabBase) {
discoveredWebBaseUrl = tabBase;
source = `tab:${tabs[0].url || tabBase}`;
}
}
let apiBaseUrl = normalizeServiceBase(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
let verified = false;
for (const candidate of apiCandidatesForWebBase(discoveredWebBaseUrl, apiBaseUrl)) {
const status = await browserContextStatus(candidate);
if (status) {
apiBaseUrl = candidate;
webBaseUrl = discoveredWebBaseUrl;
verified = true;
source = status.enabled ? `${source}:browser_context_enabled` : `${source}:browser_context_disabled`;
break;
}
}
if (!verified && force) {
source = `${source}:api_not_verified`;
}
const next = {
...settings,
apiBaseUrl,
webBaseUrl,
lastServiceSyncAt: new Date().toISOString(),
lastServiceSyncSource: source,
};
if (
force ||
settings.apiBaseUrl !== next.apiBaseUrl ||
settings.webBaseUrl !== next.webBaseUrl ||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
) {
await chrome.storage.sync.set({
apiBaseUrl: next.apiBaseUrl,
webBaseUrl: next.webBaseUrl,
lastServiceSyncAt: next.lastServiceSyncAt,
lastServiceSyncSource: next.lastServiceSyncSource,
});
}
return next;
}
async function getCookieToken(settings) {
if (!chrome.cookies?.get) {
return null;
}
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
try {
const cookie = await chrome.cookies.get({ url: apiBaseUrl, name: "access_token" });
if (cookie?.value) {
return { token: cookie.value, source: `cookie:${new URL(apiBaseUrl).host}:access_token` };
}
} catch (error) {
console.warn("Failed to read zncm access_token cookie", error);
}
return null;
}
async function getTokenFromOpenZncmTab(settings) {
const tabs = await findOpenZncmTabs(settings);
let sawZncmTab = false;
for (const tab of tabs) {
if (!tab.id) {
continue;
}
sawZncmTab = true;
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: deerExtractAuthTokenFromPage,
});
const tokenInfo = result?.result;
if (tokenInfo?.token) {
const tabBase = serviceBaseFromTabUrl(tab.url);
if (tabBase && settings.autoDiscoverServiceUrl && settings.webBaseUrl !== tabBase) {
await chrome.storage.sync.set({
webBaseUrl: tabBase,
lastServiceSyncAt: new Date().toISOString(),
lastServiceSyncSource: `token-tab:${tab.url || tabBase}`,
});
}
return {
token: tokenInfo.token,
source: tokenInfo.source || `tab:${tab.url || "open-deer-tab"}`,
};
}
} catch (error) {
console.warn("Failed to read zncm token from tab", tab.id, error);
}
}
if (sawZncmTab) {
return { token: "", source: "open-deer-tab", clearStale: true };
}
return null;
}
async function syncAuthToken(settings = null, forceStatus = false) {
let current = settings || (await getSettings());
current = await discoverServiceBases(current);
if (!current.tokenSyncEnabled && !forceStatus) {
return current;
}
const tokenInfo = (await getTokenFromOpenZncmTab(current)) || (await getCookieToken(current));
if (!tokenInfo?.token) {
if (tokenInfo?.clearStale && current.accessToken) {
const next = {
...current,
accessToken: "",
lastTokenSyncSource: "cleared:open-deer-tab-no-token",
lastTokenSyncAt: new Date().toISOString(),
};
await chrome.storage.sync.set({
accessToken: "",
lastTokenSyncSource: next.lastTokenSyncSource,
lastTokenSyncAt: next.lastTokenSyncAt,
});
return next;
}
return current;
}
const next = {
...current,
accessToken: tokenInfo.token,
lastTokenSyncSource: tokenInfo.source,
lastTokenSyncAt: new Date().toISOString(),
};
if (
forceStatus ||
current.accessToken !== next.accessToken ||
current.lastTokenSyncSource !== next.lastTokenSyncSource
) {
await chrome.storage.sync.set({
accessToken: next.accessToken,
lastTokenSyncSource: next.lastTokenSyncSource,
lastTokenSyncAt: next.lastTokenSyncAt,
});
}
return next;
}
async function refreshAlarm(settings) {
await chrome.alarms.clear(ALARM_NAME);
if (!settings.taskPollingEnabled) {
return;
}
const minutes = Math.max(0.5, pollIntervalMs(settings) / 60000);
await chrome.alarms.create(ALARM_NAME, { delayInMinutes: minutes, periodInMinutes: minutes });
}
async function scheduleNextPoll(delayMs = null) {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
const settings = await getSettings();
if (!settings.taskPollingEnabled) {
return;
}
const ms = delayMs ?? pollIntervalMs(settings);
pollTimer = setTimeout(() => {
void pollOnce().catch((error) => console.warn("zncm task poll failed", error));
}, ms);
}
async function waitForTabLoad(tabId, timeoutSeconds) {
const timeoutMs = Math.max(5, Number(timeoutSeconds || 30)) * 1000;
try {
const tab = await chrome.tabs.get(tabId);
if (tab.status === "complete") {
return true;
}
} catch {
return false;
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve(false);
}, timeoutMs);
function listener(updatedTabId, changeInfo) {
if (updatedTabId === tabId && changeInfo.status === "complete") {
clearTimeout(timer);
chrome.tabs.onUpdated.removeListener(listener);
resolve(true);
}
}
chrome.tabs.onUpdated.addListener(listener);
});
}
async function captureFromTab(tabId, maxContentChars) {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: deerExtractCurrentPage,
});
const page = result?.result;
if (!page?.content) {
throw new Error("当前页面没有可读取的文本。");
}
const limit = Math.max(1000, Number(maxContentChars || 200000));
if (page.content.length > limit) {
page.content = `${page.content.slice(0, limit)}\n\n[Truncated by zncm browser task limit]`;
}
return page;
}
async function postTaskResult(taskId, settings, payload) {
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
const response = await fetch(`${apiBaseUrl}/api/browser-context/tasks/${encodeURIComponent(taskId)}/result`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...deerAuthHeaders(settings),
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
throw new Error(detail || `HTTP ${response.status}`);
}
return data;
}
function zncmApiBaseCandidates(settings) {
const candidates = [];
const add = (value) => {
const normalized = deerNormalizeBaseUrl(value || "");
if (normalized && !candidates.includes(normalized)) {
candidates.push(normalized);
}
};
add(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
try {
const url = new URL(deerNormalizeBaseUrl(settings.apiBaseUrl || ""));
if (url.pathname && url.pathname !== "/") {
add(url.origin);
}
} catch {
// Ignore invalid configured API URL.
}
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
return candidates;
}
async function zncmFetchJson(settings, path, options) {
let lastError = null;
for (const apiBaseUrl of zncmApiBaseCandidates(settings)) {
try {
const response = await fetch(`${apiBaseUrl}${path}`, options);
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await response.json().catch(() => ({}))
: { detail: await response.text().catch(() => "") };
if (response.ok) {
return { data, apiBaseUrl, status: response.status };
}
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
lastError = new Error(detail || `HTTP ${response.status}`);
lastError.status = response.status;
lastError.detail = detail;
lastError.apiBaseUrl = apiBaseUrl;
if (response.status !== 404) {
throw lastError;
}
} catch (error) {
lastError = error;
if (error?.status && error.status !== 404) {
throw error;
}
}
}
throw lastError || new Error("zncm 服务请求失败。");
}
function zncmMakeThreadId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `zncm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function zncmBuildPagePrompt(page, content, truncated, followUp) {
const title = page.title || "未知标题";
const url = page.url || "未知 URL";
const capturedAt = new Date().toISOString();
if (followUp) {
return [
"你是 zncm 页面问答助手。请继续基于本会话里已经读取的网页内容回答用户问题。",
"如果已有网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。",
`当前页面标题:${title}`,
`当前页面 URL:${url}`,
`本轮提问时间:${capturedAt}`,
].join("\n\n");
}
return [
"你是 zncm 页面问答助手。请直接基于当前网页内容回答用户问题。",
"如果网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。",
`页面标题:${title}`,
`页面 URL:${url}`,
`读取时间:${capturedAt}`,
truncated ? "说明:页面内容较长,本次已按插件限制截断。" : "说明:页面内容如下。",
"当前网页内容:",
page.contentType === "text" ? `\`\`\`text\n${content}\n\`\`\`` : content,
].join("\n\n");
}
function zncmExtractLatestAnswer(values) {
const messages = Array.isArray(values?.messages) ? values.messages : [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index] || {};
const type = String(message.type || message.role || "").toLowerCase();
if (type !== "ai" && type !== "assistant") {
continue;
}
const content = message.content;
if (typeof content === "string" && content.trim()) {
return content.trim();
}
if (Array.isArray(content)) {
const text = content
.map((part) => {
if (typeof part === "string") return part;
if (part && typeof part.text === "string") return part.text;
return "";
})
.filter(Boolean)
.join("\n")
.trim();
if (text) {
return text;
}
}
}
return "";
}
async function askZncmViaRunsWait(settings, payload, page, question) {
const contentLimit = 60000;
const rawContent = String(page.content || "");
const truncated = rawContent.length > contentLimit;
const content = truncated ? `${rawContent.slice(0, contentLimit)}\n\n[Truncated locally by zncm Web Clipper]` : rawContent;
const threadId = payload.threadId || zncmMakeThreadId();
const promptPrefix = zncmBuildPagePrompt(page, content, truncated, Boolean(payload.threadId));
const { data } = await zncmFetchJson(settings, "/api/runs/wait", {
method: "POST",
headers: {
"Content-Type": "application/json",
...deerAuthHeaders(settings),
},
body: JSON.stringify({
assistant_id: "lead_agent",
input: {
messages: [
{
type: "human",
content: question,
additional_kwargs: {
prompt_prefix: promptPrefix,
},
},
],
},
config: {
configurable: {
thread_id: threadId,
},
},
metadata: {
source: "zncm_page_assistant",
browser_context_url: page.url || "",
browser_context_title: page.title || "",
browser_context_fallback: true,
},
stream_mode: ["values"],
on_disconnect: "continue",
multitask_strategy: "reject",
}),
});
return {
success: true,
thread_id: threadId,
run_id: "",
answer: zncmExtractLatestAnswer(data) || "zncm 没有返回可显示的答案。",
truncated,
message: "zncm 页面问答已完成。",
};
}
async function askZncmAboutPage(payload) {
let settings = await getSettings();
settings = await discoverServiceBases(settings);
settings = await syncAuthToken(settings);
if (!settings.pageAssistantEnabled) {
throw new Error("页面问答框已关闭。");
}
const page = payload?.page || {};
const question = String(payload?.question || "").trim();
if (!question) {
throw new Error("请输入要询问的问题。");
}
if (!page.content) {
throw new Error("当前页面没有可读取的正文。");
}
try {
const { data } = await zncmFetchJson(settings, "/api/browser-context/ask", {
method: "POST",
headers: {
"Content-Type": "application/json",
...deerAuthHeaders(settings),
},
body: JSON.stringify({
thread_id: payload.threadId || null,
title: page.title || "",
url: page.url || "",
content: page.content || "",
content_type: page.contentType || "markdown",
question,
}),
});
return data;
} catch (error) {
const detail = String(error?.detail || error?.message || "");
if (error?.status === 404 && !/browser context integration is disabled/i.test(detail)) {
return askZncmViaRunsWait(settings, payload, page, question);
}
throw error;
}
}
async function getActSessionMap() {
try {
const stored = await chrome.storage.session.get({ actSessions: {} });
return stored.actSessions && typeof stored.actSessions === "object" ? stored.actSessions : {};
} catch {
return {};
}
}
async function setActSessionTab(sessionId, tabId) {
if (!sessionId) return;
const map = await getActSessionMap();
map[sessionId] = tabId;
await chrome.storage.session.set({ actSessions: map }).catch(() => {});
}
async function removeActSession(sessionId) {
if (!sessionId) return;
const map = await getActSessionMap();
if (sessionId in map) {
delete map[sessionId];
await chrome.storage.session.set({ actSessions: map }).catch(() => {});
}
}
async function resolveSessionTabId(sessionId) {
const map = await getActSessionMap();
const tabId = map[sessionId];
if (!tabId) return null;
try {
await chrome.tabs.get(tabId);
return tabId;
} catch {
await removeActSession(sessionId);
return null;
}
}
async function performActionInTab(tabId, action) {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: deerPerformAction,
args: [action],
});
return result?.result || { status: "ok", message: "" };
}
async function observeTab(tabId, maxElements) {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: deerObservePage,
args: [maxElements],
});
const observation = result?.result;
if (!observation) {
throw new Error("当前页面无法读取交互元素。");
}
return observation;
}
async function runActTask(task, settings) {
const sessionId = task.session_id || "";
const action = task.action || {};
const actionType = String(action.type || "").toLowerCase();
const maxElements = 150;
try {
if (!sessionId) {
throw new Error("act 任务缺少 session_id。");
}
if (actionType === "close") {
const tabId = await resolveSessionTabId(sessionId);
if (tabId) {
await chrome.tabs.remove(tabId).catch(() => {});
}
await removeActSession(sessionId);
await postTaskResult(task.id, settings, {
kind: "act",
observation: { action_status: "ok", message: "Tab closed.", url: "", title: "", elements: [], text: "" },
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
});
return;
}
let tabId = await resolveSessionTabId(sessionId);
if (actionType === "navigate") {
const url = String(action.url || task.url || "");
if (!url) {
throw new Error("navigate 动作缺少 url。");
}
if (tabId) {
await chrome.tabs.update(tabId, { url });
} else {
const tab = await chrome.tabs.create({ url, active: false });
tabId = tab.id;
await setActSessionTab(sessionId, tabId);
}
await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds);
await delay(800);
} else {
if (!tabId) {
throw new Error("会话标签页不存在,请先用 navigate 打开页面。");
}
const outcome = await performActionInTab(tabId, action);
// Give the page a moment to react / navigate, then settle.
await delay(400);
await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds);
await delay(500);
if (outcome.status === "error") {
const observation = await observeTab(tabId, maxElements).catch(() => ({ elements: [], text: "", url: "", title: "" }));
await postTaskResult(task.id, settings, {
kind: "act",
observation: { ...observation, action_status: outcome.status, message: outcome.message },
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
});
return;
}
}
const observation = await observeTab(tabId, maxElements);
await postTaskResult(task.id, settings, {
kind: "act",
title: observation.title || "",
url: observation.url || "",
observation: { ...observation, action_status: "ok", message: "" },
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
});
} catch (error) {
await postTaskResult(task.id, settings, {
kind: "act",
error: error instanceof Error ? error.message : String(error),
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
}).catch((submitError) => console.warn("Failed to submit zncm act task error", submitError));
}
}
async function runFetchTask(task, settings) {
let tabId = null;
try {
const tab = await chrome.tabs.create({ url: task.url, active: false });
tabId = tab.id;
await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds);
await delay(750);
const page = await captureFromTab(tabId, task.max_content_chars);
await postTaskResult(task.id, settings, {
kind: "fetch",
title: page.title,
url: page.url,
content: page.content,
content_type: page.contentType || "markdown",
metadata: {
source: "zncm-web-clipper",
worker_id: settings.workerId,
instruction: task.instruction || "",
},
});
} catch (error) {
await postTaskResult(task.id, settings, {
kind: "fetch",
error: error instanceof Error ? error.message : String(error),
metadata: {
source: "zncm-web-clipper",
worker_id: settings.workerId,
},
}).catch((submitError) => console.warn("Failed to submit zncm task error", submitError));
} finally {
if (tabId && settings.closeTaskTabs) {
await chrome.tabs.remove(tabId).catch(() => {});
}
}
}
async function runTask(task, settings) {
if ((task.kind || "fetch") === "act") {
await runActTask(task, settings);
return;
}
await runFetchTask(task, settings);
}
async function pollOnce() {
if (isPolling) {
return { ok: true, message: "任务轮询正在运行。" };
}
isPolling = true;
try {
let settings = await getSettings();
settings = await discoverServiceBases(settings);
settings = await syncAuthToken(settings);
if (!settings.taskPollingEnabled) {
return { ok: false, message: "任务轮询已关闭。" };
}
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
const url = `${apiBaseUrl}/api/browser-context/tasks/next?worker_id=${encodeURIComponent(settings.workerId)}`;
const response = await fetch(url, { headers: deerAuthHeaders(settings) });
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
throw new Error(detail || `HTTP ${response.status}`);
}
if (!data.task) {
return { ok: true, message: "暂无待处理的 zncm 浏览器任务。" };
}
await runTask(data.task, settings);
return { ok: true, message: `已完成 zncm 浏览器任务 ${data.task.id}。` };
} finally {
isPolling = false;
await scheduleNextPoll();
}
}
async function startup() {
let settings = await getSettings();
settings = await discoverServiceBases(settings);
settings = await syncAuthToken(settings);
await refreshAlarm(settings);
await scheduleNextPoll(1000);
}
chrome.runtime.onInstalled.addListener(() => void startup());
chrome.runtime.onStartup.addListener(() => void startup());
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === "sync") {
void startup();
}
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === ALARM_NAME) {
void pollOnce().catch((error) => console.warn("zncm alarm poll failed", error));
}
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === "zncm-discover-service") {
getSettings()
.then((settings) => discoverServiceBases(settings, true))
.then((settings) =>
sendResponse({
ok: Boolean(settings.apiBaseUrl && settings.webBaseUrl),
message: `服务地址:${settings.webBaseUrl};API:${settings.apiBaseUrl}`,
apiBaseUrl: settings.apiBaseUrl,
webBaseUrl: settings.webBaseUrl,
source: settings.lastServiceSyncSource || "",
}),
)
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message?.type === "zncm-sync-token") {
syncAuthToken(null, true)
.then((settings) => {
const hasToken = Boolean(settings.accessToken);
sendResponse({
ok: hasToken,
message: hasToken
? `Token 已从 ${settings.lastTokenSyncSource || "zncm"} 同步。`
: "没有找到 zncm token。请打开已登录的 zncm 页面后重试。",
source: settings.lastTokenSyncSource || "",
});
})
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message?.type === "zncm-page-agent-ask") {
askZncmAboutPage(message)
.then((result) => sendResponse({ ok: true, ...result }))
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message?.type === "zncm-open-options") {
chrome.runtime.openOptionsPage()
.then(() => sendResponse({ ok: true }))
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message?.type !== "zncm-poll-now") {
return false;
}
pollOnce()
.then((result) => sendResponse(result))
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
});
void startup();
@@ -0,0 +1,9 @@
// Default deployment settings for zncm Web Clipper.
//
// Change these two values before loading or packaging the extension when the
// zncm service address changes. Users can still override them in the extension
// settings page after installation.
globalThis.ZNCM_WEB_CLIPPER_CONFIG = Object.freeze({
defaultWebBaseUrl: "http://47.94.209.59:2026",
defaultApiBaseUrl: "http://47.94.209.59:2026",
});
@@ -0,0 +1,737 @@
(() => {
if (window.top !== window || window.__zncmPageAssistantLoaded) {
return;
}
window.__zncmPageAssistantLoaded = true;
const HOST_ID = "zncm-page-assistant-host";
const state = {
enabled: true,
open: false,
busy: false,
threadId: "",
pageUrl: location.href,
messages: [],
};
let host = null;
let shadow = null;
let ui = {};
function sendRuntimeMessage(message) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(message, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || {});
});
});
}
function makeElement(tag, className = "", text = "") {
const element = document.createElement(tag);
if (className) {
element.className = className;
}
if (text) {
element.textContent = text;
}
return element;
}
function setStatus(message) {
if (ui.status) {
ui.status.textContent = message || "";
}
}
function setBusy(busy) {
state.busy = busy;
if (ui.send) ui.send.disabled = busy;
if (ui.input) ui.input.disabled = busy;
if (ui.panel) ui.panel.classList.toggle("is-busy", busy);
}
function resetThreadIfPageChanged() {
if (state.pageUrl === location.href) {
return;
}
state.pageUrl = location.href;
state.threadId = "";
state.messages = [];
renderMessages();
setStatus("页面已变化,下一次提问会重新读取当前页面。");
}
function destroyWidget() {
if (host) {
host.remove();
}
host = null;
shadow = null;
ui = {};
}
function renderMessages() {
if (!ui.messages) {
return;
}
ui.messages.replaceChildren();
if (state.messages.length === 0) {
const empty = makeElement("div", "empty");
const emptyTitle = makeElement("strong", "", "问问当前页面");
const emptyText = makeElement("span", "", "可以提问页面里的数据、表格、段落和结论。");
empty.append(emptyTitle, emptyText);
ui.messages.appendChild(empty);
return;
}
for (const message of state.messages) {
const item = makeElement("div", `message ${message.role}`);
const avatar = makeElement("div", "avatar", message.role === "user" ? "你" : "zn");
const bubble = makeElement("div", "bubble");
const label = makeElement("div", "message-label", message.role === "user" ? "你" : "zncm");
const body = makeElement("div", "message-body", message.text);
if (message.pending) {
body.classList.add("pending");
}
bubble.append(label, body);
item.append(avatar, bubble);
ui.messages.appendChild(item);
}
ui.messages.scrollTop = ui.messages.scrollHeight;
}
function setOpen(open) {
state.open = open;
if (!ui.panel || !ui.launcher) {
return;
}
ui.panel.classList.toggle("open", open);
ui.launcher.classList.toggle("open", open);
ui.launcher.setAttribute("aria-expanded", String(open));
if (open) {
resetThreadIfPageChanged();
setTimeout(() => ui.input?.focus(), 50);
}
}
async function ask(question) {
const normalized = String(question || "").trim();
if (!normalized || state.busy) {
return;
}
resetThreadIfPageChanged();
setBusy(true);
setStatus("正在读取页面并发送给 zncm...");
state.messages.push({ role: "user", text: normalized });
const pending = { role: "assistant", text: "zncm 正在理解当前页面...", pending: true };
state.messages.push(pending);
renderMessages();
ui.input.value = "";
try {
if (typeof deerExtractCurrentPage !== "function") {
throw new Error("页面读取脚本未加载,请刷新当前页面后重试。");
}
const page = deerExtractCurrentPage();
const response = await sendRuntimeMessage({
type: "zncm-page-agent-ask",
page,
question: normalized,
threadId: state.threadId || "",
});
if (!response.ok) {
throw new Error(response.message || "zncm 没有返回答案。");
}
state.threadId = response.thread_id || state.threadId;
pending.text = response.answer || "zncm 没有返回答案。";
pending.pending = false;
setStatus(response.truncated ? "已回答。页面内容较长,本次按配置做了截断。" : "已回答。");
} catch (error) {
pending.text = error instanceof Error ? error.message : "页面问答失败。";
pending.pending = false;
setStatus("本次问答失败。");
} finally {
setBusy(false);
renderMessages();
ui.input.focus();
}
}
function buildWidget() {
host = document.createElement("div");
host.id = HOST_ID;
document.documentElement.appendChild(host);
shadow = host.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = `
:host {
all: initial;
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.launcher,
.panel {
position: fixed;
z-index: 2147483647;
font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.launcher {
right: 20px;
bottom: 20px;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
height: 44px;
min-width: 112px;
border: 1px solid rgba(15, 118, 110, 0.28);
border-radius: 8px;
padding: 0 12px 0 10px;
background: #0f766e;
color: #fff;
box-shadow: 0 12px 34px rgba(15, 118, 110, 0.28), 0 2px 8px rgba(17, 24, 39, 0.12);
cursor: pointer;
font-weight: 750;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.launcher:hover {
transform: translateY(-2px);
box-shadow: 0 16px 40px rgba(15, 118, 110, 0.32), 0 4px 12px rgba(17, 24, 39, 0.14);
}
.launcher-mark {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 7px;
background: rgba(255, 255, 255, 0.18);
font-size: 12px;
letter-spacing: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.launcher.open {
background: #115e59;
}
.panel {
right: 20px;
bottom: 72px;
width: min(430px, calc(100vw - 24px));
max-height: min(640px, calc(100vh - 96px));
display: none;
grid-template-rows: auto auto minmax(180px, 1fr) auto;
overflow: hidden;
border: 1px solid #d6d8dc;
border-radius: 8px;
background: #ffffff;
color: #171717;
box-shadow: 0 24px 70px rgba(17, 24, 39, 0.2), 0 1px 2px rgba(17, 24, 39, 0.08);
}
.panel.open {
display: grid;
animation: zncm-panel-in 160ms ease-out;
}
@keyframes zncm-panel-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.header {
display: grid;
gap: 10px;
padding: 14px;
background: linear-gradient(180deg, #f4fffc 0%, #ffffff 84%);
border-bottom: 1px solid #e5e7eb;
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.brand {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
}
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex: 0 0 auto;
border-radius: 8px;
background: #0f766e;
color: #fff;
font-weight: 800;
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.18);
}
.brand-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.brand-copy strong {
color: #111827;
font-size: 15px;
line-height: 1.25;
}
.brand-copy span {
overflow: hidden;
color: #6b7280;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
flex: 0 0 auto;
gap: 6px;
}
.icon-button {
display: grid;
place-items: center;
width: auto;
min-width: 34px;
height: 30px;
padding: 0 8px;
border: 1px solid #dfe3e8;
border-radius: 7px;
background: #fff;
color: #374151;
cursor: pointer;
font: inherit;
font-size: 12px;
white-space: nowrap;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.icon-button:hover {
border-color: #0f766e;
color: #0f766e;
background: #f0fdfa;
}
.page-chip {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
min-width: 0;
border: 1px solid #dbeafe;
border-radius: 8px;
padding: 8px 10px;
background: #f8fbff;
color: #334155;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.page-chip-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #0f766e;
}
.page-chip-text {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
min-height: 20px;
padding: 9px 14px;
border-bottom: 1px solid #f0f2f5;
color: #475569;
background: #fbfcfd;
font-size: 12px;
}
.messages {
display: grid;
align-content: start;
gap: 12px;
overflow: auto;
padding: 14px;
background: linear-gradient(180deg, #f7f8fa 0%, #f3f6f8 100%);
}
.empty {
display: grid;
gap: 5px;
padding: 16px;
border: 1px solid #dfe7ed;
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #fbfefd 100%);
color: #4b5563;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
}
.empty strong {
color: #111827;
font-size: 14px;
}
.empty span {
font-size: 13px;
}
.message {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.message.user {
grid-template-columns: minmax(0, 1fr) 28px;
}
.message.user .avatar {
grid-column: 2;
}
.message.user .bubble {
grid-column: 1;
justify-self: end;
}
.avatar {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #e6fffb;
color: #0f766e;
font-size: 11px;
font-weight: 800;
}
.message.user .avatar {
background: #111827;
color: #fff;
}
.bubble {
display: grid;
gap: 4px;
max-width: 100%;
}
.message-label {
color: #6b7280;
font-size: 11px;
}
.message.user .message-label {
text-align: right;
}
.message-body {
max-width: 330px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #171717;
white-space: pre-wrap;
overflow-wrap: anywhere;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.message.user .message-body {
border-color: #0f172a;
background: #0f172a;
color: #fff;
}
.message-body.pending {
color: #6b7280;
}
.message-body.pending::after {
content: "";
display: inline-block;
width: 18px;
height: 4px;
margin-left: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #0f766e 0 33%, transparent 33% 100%);
background-size: 9px 4px;
animation: zncm-thinking 900ms linear infinite;
vertical-align: middle;
}
@keyframes zncm-thinking {
from {
background-position: 0 0;
}
to {
background-position: 18px 0;
}
}
.composer-wrap {
display: grid;
gap: 10px;
padding: 12px 14px 14px;
background: #fff;
border-top: 1px solid #e5e7eb;
}
.quick {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.quick button {
border: 1px solid #d6d8dc;
border-radius: 8px;
padding: 8px 10px;
background: #fbfcfd;
color: #374151;
cursor: pointer;
font: inherit;
font-size: 12px;
text-align: left;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease;
}
.quick button:hover {
border-color: #0f766e;
color: #0f766e;
background: #f0fdfa;
transform: translateY(-1px);
}
.composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: end;
}
.composer textarea {
min-height: 46px;
max-height: 130px;
resize: vertical;
border: 1px solid #cfd6dd;
border-radius: 8px;
padding: 10px 11px;
color: #171717;
background: #fbfcfd;
font: inherit;
outline: none;
}
.composer textarea:focus {
border-color: #0f766e;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
.composer button {
height: 42px;
border: 0;
border-radius: 8px;
padding: 0 14px;
background: #0f766e;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 750;
box-shadow: 0 8px 18px rgba(15, 118, 110, 0.2);
transition: transform 140ms ease, background 140ms ease, box-shadow 140ms ease;
}
.composer button:hover {
background: #115e59;
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(15, 118, 110, 0.24);
}
.panel.is-busy .brand-mark,
.panel.is-busy .launcher-mark {
opacity: 0.82;
}
.composer button:disabled,
.quick button:disabled,
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 480px) {
.launcher {
right: 12px;
bottom: 12px;
min-width: 96px;
}
.panel {
right: 12px;
bottom: 64px;
width: calc(100vw - 24px);
max-height: calc(100vh - 80px);
}
.message-body {
max-width: calc(100vw - 104px);
}
.quick {
grid-template-columns: 1fr;
}
}
`;
const launcher = makeElement("button", "launcher");
launcher.type = "button";
launcher.title = "打开 zncm 页面问答";
launcher.setAttribute("aria-expanded", "false");
launcher.append(makeElement("span", "launcher-mark", "zn"), makeElement("span", "", "页面问答"));
const panel = makeElement("section", "panel");
const header = makeElement("header", "header");
const headerTop = makeElement("div", "header-top");
const brand = makeElement("div", "brand");
const brandCopy = makeElement("div", "brand-copy");
const title = makeElement("strong", "", "zncm 页面问答");
const subtitle = makeElement("span", "", "读取当前页面并回答");
brandCopy.append(title, subtitle);
brand.append(makeElement("div", "brand-mark", "zn"), brandCopy);
const actions = makeElement("div", "header-actions");
const settings = makeElement("button", "icon-button", "设置");
settings.type = "button";
settings.title = "打开插件设置";
const minimize = makeElement("button", "icon-button", "-");
minimize.type = "button";
minimize.title = "收起";
actions.append(settings, minimize);
headerTop.append(brand, actions);
const pageChip = makeElement("div", "page-chip");
const pageTitle = makeElement("span", "page-chip-text", document.title || location.hostname);
pageChip.append(makeElement("span", "page-chip-dot"), pageTitle);
header.append(headerTop, pageChip);
const status = makeElement("div", "status", "已连接当前页面。");
const messages = makeElement("div", "messages");
const composerWrap = makeElement("div", "composer-wrap");
const quick = makeElement("div", "quick");
for (const text of ["总结此页", "提取关键数据", "列出待办", "解释表格"]) {
const chip = makeElement("button", "", text);
chip.type = "button";
chip.addEventListener("click", () => ask(text));
quick.appendChild(chip);
}
const composer = makeElement("form", "composer");
const input = makeElement("textarea");
input.placeholder = "问当前页面...";
input.rows = 2;
const send = makeElement("button", "", "发送");
send.type = "submit";
composer.append(input, send);
composerWrap.append(quick, composer);
panel.append(header, status, messages, composerWrap);
shadow.append(style, panel, launcher);
ui = { launcher, panel, subtitle, pageTitle, status, messages, input, send };
renderMessages();
launcher.addEventListener("click", () => setOpen(!state.open));
minimize.addEventListener("click", () => setOpen(false));
settings.addEventListener("click", () => {
sendRuntimeMessage({ type: "zncm-open-options" }).catch((error) => {
setStatus(error instanceof Error ? error.message : "无法打开插件设置。");
});
});
composer.addEventListener("submit", (event) => {
event.preventDefault();
ask(input.value);
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
ask(input.value);
}
});
}
function ensureWidget() {
if (!state.enabled) {
destroyWidget();
return;
}
if (document.getElementById(HOST_ID)) {
return;
}
buildWidget();
}
async function loadSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
state.enabled = Boolean(settings.pageAssistantEnabled);
ensureWidget();
}
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync" || !changes.pageAssistantEnabled) {
return;
}
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
ensureWidget();
if (state.enabled) {
setStatus("页面问答框已开启。");
}
});
setInterval(() => {
if (!state.enabled || !ui.pageTitle) {
return;
}
if (state.pageUrl !== location.href) {
resetThreadIfPageChanged();
}
ui.pageTitle.textContent = document.title || location.hostname;
}, 1200);
void loadSettings();
})();
@@ -0,0 +1,24 @@
{
"manifest_version": 3,
"name": "zncm Web Clipper",
"version": "0.2.0",
"description": "Send pages to zncm and execute browser-backed fetch tasks.",
"permissions": ["activeTab", "alarms", "cookies", "scripting", "storage", "tabs"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "zncm Web Clipper",
"default_popup": "popup.html"
},
"options_page": "options.html",
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["config.js", "shared.js", "content.js"],
"run_at": "document_idle",
"all_frames": false
}
]
}
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zncm Web Clipper Settings</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="options">
<h1>zncm Web Clipper Settings</h1>
<label>
API 地址
<input id="apiBaseUrl" />
</label>
<label>
前端地址
<input id="webBaseUrl" />
</label>
<label class="checkbox">
<input id="autoDiscoverServiceUrl" type="checkbox" />
自动识别已打开 zncm 页面的服务地址
</label>
<div class="actions">
<button id="discoverService" type="button">重新识别服务地址</button>
</div>
<p id="serviceStatus" class="status"></p>
<label>
Access Token
<textarea id="accessToken" rows="5" placeholder="会自动从已登录 zncm 页面或 access_token Cookie 获取。也可以手动粘贴。"></textarea>
</label>
<label class="checkbox">
<input id="tokenSyncEnabled" type="checkbox" />
自动同步已登录 zncm 的 token
</label>
<div class="actions">
<button id="syncToken" type="button">Sync token now</button>
</div>
<p id="tokenStatus" class="status"></p>
<label class="checkbox">
<input id="pageAssistantEnabled" type="checkbox" />
启用页面问答框
</label>
<label>
默认首问
<textarea id="defaultPrompt" rows="4"></textarea>
</label>
<label class="checkbox">
<input id="taskPollingEnabled" type="checkbox" />
启用 zncm 聊天触发的浏览器抓取任务
</label>
<label>
Worker ID
<input id="workerId" placeholder="自动生成" />
</label>
<label>
任务轮询间隔秒数
<input id="taskPollIntervalSeconds" type="number" min="3" step="1" />
</label>
<label>
页面加载超时秒数
<input id="taskPageLoadTimeoutSeconds" type="number" min="5" step="1" />
</label>
<label class="checkbox">
<input id="closeTaskTabs" type="checkbox" />
抓取完成后关闭任务标签页
</label>
<button id="save" type="button">保存</button>
<p id="status" class="status"></p>
</main>
<script src="config.js"></script>
<script src="shared.js"></script>
<script src="options.js"></script>
</body>
</html>
@@ -0,0 +1,148 @@
const textFields = [
"apiBaseUrl",
"webBaseUrl",
"accessToken",
"defaultPrompt",
"workerId",
"taskPollIntervalSeconds",
"taskPageLoadTimeoutSeconds",
];
const checkboxFields = [
"autoDiscoverServiceUrl",
"tokenSyncEnabled",
"pageAssistantEnabled",
"taskPollingEnabled",
"closeTaskTabs",
];
function formatServiceStatus(settings) {
if (!settings.lastServiceSyncAt) {
return `当前服务地址:${settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl};API:${settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl}`;
}
return `当前服务地址:${settings.webBaseUrl || ""};API:${settings.apiBaseUrl || ""}\n上次识别:${settings.lastServiceSyncAt}(${settings.lastServiceSyncSource || "unknown source"})`;
}
function formatTokenStatus(settings) {
if (!settings.lastTokenSyncAt) {
return "Token 尚未自动同步。";
}
return `上次 token 同步:${settings.lastTokenSyncAt}(${settings.lastTokenSyncSource || "unknown source"})`;
}
async function loadSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
if (!settings.workerId) {
settings.workerId = deerMakeWorkerId();
await chrome.storage.sync.set({ workerId: settings.workerId });
}
document.getElementById("apiBaseUrl").placeholder =
DEER_CLIPPER_DEFAULTS.apiBaseUrl || "在 config.js 中配置默认 API 地址";
document.getElementById("webBaseUrl").placeholder =
DEER_CLIPPER_DEFAULTS.webBaseUrl || "在 config.js 中配置默认前端地址";
for (const field of textFields) {
document.getElementById(field).value = settings[field] ?? "";
}
for (const field of checkboxFields) {
document.getElementById(field).checked = Boolean(settings[field]);
}
document.getElementById("serviceStatus").textContent = formatServiceStatus(settings);
document.getElementById("tokenStatus").textContent = formatTokenStatus(settings);
}
async function saveSettings() {
const next = {};
for (const field of textFields) {
next[field] = document.getElementById(field).value.trim();
}
for (const field of checkboxFields) {
next[field] = document.getElementById(field).checked;
}
next.apiBaseUrl = deerNormalizeBaseUrl(next.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
next.webBaseUrl = deerNormalizeBaseUrl(next.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
next.defaultPrompt = next.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT;
next.workerId = next.workerId || deerMakeWorkerId();
next.taskPollIntervalSeconds = Math.max(3, Number(next.taskPollIntervalSeconds || DEER_CLIPPER_DEFAULTS.taskPollIntervalSeconds));
next.taskPageLoadTimeoutSeconds = Math.max(5, Number(next.taskPageLoadTimeoutSeconds || DEER_CLIPPER_DEFAULTS.taskPageLoadTimeoutSeconds));
await chrome.storage.sync.set(next);
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
document.getElementById("status").textContent = "已保存。";
document.getElementById("serviceStatus").textContent = formatServiceStatus(settings);
document.getElementById("tokenStatus").textContent = formatTokenStatus(settings);
}
function sendDiscoverServiceMessage() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: "zncm-discover-service" }, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || { ok: false, message: "服务地址识别没有返回结果。" });
});
});
}
function sendSyncTokenMessage() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: "zncm-sync-token" }, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || { ok: false, message: "Token 同步没有返回结果。" });
});
});
}
async function discoverServiceNow() {
const button = document.getElementById("discoverService");
button.disabled = true;
document.getElementById("serviceStatus").textContent = "正在识别服务地址...";
try {
await chrome.storage.sync.set({
autoDiscoverServiceUrl: document.getElementById("autoDiscoverServiceUrl").checked,
apiBaseUrl: deerNormalizeBaseUrl(document.getElementById("apiBaseUrl").value || DEER_CLIPPER_DEFAULTS.apiBaseUrl),
webBaseUrl: deerNormalizeBaseUrl(document.getElementById("webBaseUrl").value || DEER_CLIPPER_DEFAULTS.webBaseUrl),
});
const response = await sendDiscoverServiceMessage();
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
document.getElementById("apiBaseUrl").value = settings.apiBaseUrl || "";
document.getElementById("webBaseUrl").value = settings.webBaseUrl || "";
document.getElementById("serviceStatus").textContent = response.message || formatServiceStatus(settings);
} catch (error) {
document.getElementById("serviceStatus").textContent = error instanceof Error ? error.message : "服务地址识别失败。";
} finally {
button.disabled = false;
}
}
async function syncTokenNow() {
const button = document.getElementById("syncToken");
button.disabled = true;
document.getElementById("tokenStatus").textContent = "正在同步 zncm token...";
try {
await chrome.storage.sync.set({
tokenSyncEnabled: document.getElementById("tokenSyncEnabled").checked,
apiBaseUrl: deerNormalizeBaseUrl(document.getElementById("apiBaseUrl").value || DEER_CLIPPER_DEFAULTS.apiBaseUrl),
webBaseUrl: deerNormalizeBaseUrl(document.getElementById("webBaseUrl").value || DEER_CLIPPER_DEFAULTS.webBaseUrl),
});
const response = await sendSyncTokenMessage();
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
document.getElementById("accessToken").value = settings.accessToken || "";
document.getElementById("apiBaseUrl").value = settings.apiBaseUrl || "";
document.getElementById("webBaseUrl").value = settings.webBaseUrl || "";
document.getElementById("serviceStatus").textContent = formatServiceStatus(settings);
document.getElementById("tokenStatus").textContent = response.message || formatTokenStatus(settings);
} catch (error) {
document.getElementById("tokenStatus").textContent = error instanceof Error ? error.message : "Token 同步失败。";
} finally {
button.disabled = false;
}
}
document.getElementById("save").addEventListener("click", saveSettings);
document.getElementById("discoverService").addEventListener("click", discoverServiceNow);
document.getElementById("syncToken").addEventListener("click", syncTokenNow);
void loadSettings();
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zncm Web Clipper</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="popup">
<h1>zncm Web Clipper</h1>
<label>
首问
<textarea id="prompt" rows="4"></textarea>
</label>
<label class="checkbox">
<input id="pageAssistantEnabled" type="checkbox" />
启用页面问答框
</label>
<div class="actions">
<button id="send" type="button">发送当前页</button>
<button id="syncToken" type="button">同步 token</button>
<button id="pollNow" type="button">检查任务</button>
<button id="options" type="button" class="secondary">设置</button>
</div>
<p id="status" class="status"></p>
</main>
<script src="config.js"></script>
<script src="shared.js"></script>
<script src="popup.js"></script>
</body>
</html>
+151
View File
@@ -0,0 +1,151 @@
function setStatus(message) {
document.getElementById("status").textContent = message;
}
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
throw new Error("No readable active tab found.");
}
return tab;
}
async function captureFromActiveTab(tabId) {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: deerExtractCurrentPage,
});
if (!result?.result?.content) {
throw new Error("The current page has no readable text to send.");
}
return result.result;
}
async function submitToZncm(page, settings, prompt) {
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
const response = await fetch(`${apiBaseUrl}/api/browser-context/capture`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...deerAuthHeaders(settings),
},
body: JSON.stringify({
title: page.title,
url: page.url,
content: page.content,
content_type: page.contentType,
prompt,
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
throw new Error(detail || `HTTP ${response.status}`);
}
return data;
}
async function sendCurrentPage() {
const sendButton = document.getElementById("send");
sendButton.disabled = true;
setStatus("正在读取当前页面...");
try {
await syncToken({ silent: true });
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
const prompt = document.getElementById("prompt").value.trim() || settings.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT;
const tab = await getActiveTab();
const page = await captureFromActiveTab(tab.id);
setStatus("正在发送到 zncm...");
const result = await submitToZncm(page, settings, prompt);
const webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
await chrome.tabs.create({ url: `${webBaseUrl}/${result.chat_url}`.replace(/\/#/g, "/#") });
setStatus("已发送,zncm 聊天页面已打开。");
} catch (error) {
setStatus(error instanceof Error ? error.message : "发送失败。");
} finally {
sendButton.disabled = false;
}
}
function sendSyncTokenMessage() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: "zncm-sync-token" }, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || { ok: false, message: "Token 同步没有返回结果。" });
});
});
}
function sendPollNowMessage() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: "zncm-poll-now" }, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || { ok: true, message: "Poll requested." });
});
});
}
async function syncToken(options = {}) {
const button = document.getElementById("syncToken");
button.disabled = true;
if (!options.silent) {
setStatus("正在同步 zncm token...");
}
try {
const response = await sendSyncTokenMessage();
if (!options.silent) {
setStatus(response.message || "Token sync finished.");
}
return response;
} catch (error) {
if (!options.silent) {
setStatus(error instanceof Error ? error.message : "Token 同步失败。");
}
return { ok: false, message: error instanceof Error ? error.message : "Token 同步失败。" };
} finally {
button.disabled = false;
}
}
async function pollNow() {
const button = document.getElementById("pollNow");
button.disabled = true;
setStatus("正在检查 zncm 浏览器任务...");
try {
await syncToken({ silent: true });
const response = await sendPollNowMessage();
setStatus(response.message || "任务检查完成。");
} catch (error) {
setStatus(error instanceof Error ? error.message : "任务检查失败。");
} finally {
button.disabled = false;
}
}
async function togglePageAssistant() {
const enabled = document.getElementById("pageAssistantEnabled").checked;
await chrome.storage.sync.set({ pageAssistantEnabled: enabled });
setStatus(enabled ? "页面问答框已开启。" : "页面问答框已关闭。");
}
async function init() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
document.getElementById("prompt").value = settings.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT;
document.getElementById("pageAssistantEnabled").checked = Boolean(settings.pageAssistantEnabled);
document.getElementById("send").addEventListener("click", sendCurrentPage);
document.getElementById("syncToken").addEventListener("click", () => syncToken());
document.getElementById("pollNow").addEventListener("click", pollNow);
document.getElementById("pageAssistantEnabled").addEventListener("change", togglePageAssistant);
document.getElementById("options").addEventListener("click", () => chrome.runtime.openOptionsPage());
}
void init();
@@ -0,0 +1,389 @@
const DEER_CLIPPER_DEFAULT_PROMPT =
"\u8bf7\u57fa\u4e8e\u521a\u521a\u53d1\u9001\u7684\u7f51\u9875\u5185\u5bb9\uff0c\u5148\u7528\u4e2d\u6587\u603b\u7ed3\u8981\u70b9\uff0c\u5e76\u7b49\u5f85\u6211\u7ee7\u7eed\u63d0\u95ee\u3002";
const ZNCM_EXTENSION_CONFIG = globalThis.ZNCM_WEB_CLIPPER_CONFIG || {};
const ZNCM_DEFAULT_WEB_BASE_URL = String(ZNCM_EXTENSION_CONFIG.defaultWebBaseUrl || "").trim().replace(/\/+$/, "");
const ZNCM_DEFAULT_API_BASE_URL = String(ZNCM_EXTENSION_CONFIG.defaultApiBaseUrl || "").trim().replace(/\/+$/, "");
const DEER_CLIPPER_DEFAULTS = {
apiBaseUrl: ZNCM_DEFAULT_API_BASE_URL,
webBaseUrl: ZNCM_DEFAULT_WEB_BASE_URL,
autoDiscoverServiceUrl: true,
lastServiceSyncAt: "",
lastServiceSyncSource: "",
accessToken: "",
tokenSyncEnabled: true,
lastTokenSyncAt: "",
lastTokenSyncSource: "",
defaultPrompt: DEER_CLIPPER_DEFAULT_PROMPT,
pageAssistantEnabled: true,
taskPollingEnabled: true,
workerId: "",
taskPollIntervalSeconds: 5,
taskPageLoadTimeoutSeconds: 30,
closeTaskTabs: false,
};
function deerNormalizeBaseUrl(value) {
return String(value || "").trim().replace(/\/+$/, "");
}
function deerAuthHeaders(settings) {
const token = String(settings.accessToken || "").trim();
if (!token) {
return {};
}
return {
Authorization: /^bearer\s+/i.test(token) ? token : `Bearer ${token}`,
};
}
function deerMakeWorkerId() {
return `zncm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function deerExtractAuthTokenFromPage() {
const storageKeys = ["deerflow.auth"];
const tokenFieldNames = new Set(["access_token", "accessToken", "token", "id_token", "idToken"]);
function looksLikeToken(value) {
const text = String(value || "").trim();
return text.length > 20 && !/\s/.test(text);
}
function findToken(value, depth = 0) {
if (value == null || depth > 4) {
return "";
}
if (typeof value === "string") {
const text = value.trim();
if (!text) {
return "";
}
if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
try {
return findToken(JSON.parse(text), depth + 1);
} catch {
return looksLikeToken(text) ? text : "";
}
}
return looksLikeToken(text) ? text : "";
}
if (typeof value !== "object") {
return "";
}
for (const [key, nested] of Object.entries(value)) {
if (tokenFieldNames.has(key) && looksLikeToken(nested)) {
return String(nested).trim();
}
}
for (const nested of Object.values(value)) {
const token = findToken(nested, depth + 1);
if (token) {
return token;
}
}
return "";
}
function scanStorage(storage, storageName) {
for (const key of storageKeys) {
const token = findToken(storage.getItem(key));
if (token) {
return { token, source: `${storageName}:${key}` };
}
}
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (!key || !/(auth|token|session)/i.test(key)) {
continue;
}
const token = findToken(storage.getItem(key));
if (token) {
return { token, source: `${storageName}:${key}` };
}
}
return null;
}
try {
return scanStorage(window.localStorage, "localStorage") || scanStorage(window.sessionStorage, "sessionStorage");
} catch (error) {
return { token: "", source: "", error: error instanceof Error ? error.message : String(error) };
}
}
function deerExtractCurrentPage() {
const maxLocalChars = 220000;
function cleanText(value) {
return String(value || "")
.replace(/\s+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function escapeCell(value) {
return cleanText(value).replace(/\|/g, "\\|").replace(/\n/g, " ");
}
function tableToMarkdown(table, index) {
const rows = Array.from(table.rows || []).map((row) =>
Array.from(row.cells || []).map((cell) => escapeCell(cell.innerText)),
);
if (rows.length === 0 || rows.every((row) => row.length === 0)) {
return "";
}
const width = Math.max(...rows.map((row) => row.length));
const normalized = rows.map((row) => {
const next = row.slice();
while (next.length < width) next.push("");
return next;
});
const header = normalized[0];
const divider = Array.from({ length: width }, () => "---");
const body = normalized.slice(1);
return [
`### Table ${index + 1}`,
"",
`| ${header.join(" | ")} |`,
`| ${divider.join(" | ")} |`,
...body.map((row) => `| ${row.join(" | ")} |`),
].join("\n");
}
const clone = document.body ? document.body.cloneNode(true) : null;
if (clone) {
clone.querySelectorAll("script, style, noscript, svg, canvas, iframe").forEach((node) => node.remove());
}
const title = document.title || "";
const url = location.href;
const description = document.querySelector('meta[name="description"]')?.getAttribute("content") || "";
const main = clone?.querySelector("article, main, [role='main']") || clone;
const text = cleanText(main?.innerText || document.body?.innerText || "");
const tables = Array.from(document.querySelectorAll("table"))
.map((table, index) => tableToMarkdown(table, index))
.filter(Boolean);
const parts = [];
if (description.trim()) {
parts.push(`## Meta Description\n\n${cleanText(description)}`);
}
if (text) {
parts.push(`## Page Text\n\n${text}`);
}
if (tables.length > 0) {
parts.push(`## Tables\n\n${tables.join("\n\n")}`);
}
let content = parts.join("\n\n");
if (content.length > maxLocalChars) {
content = `${content.slice(0, maxLocalChars)}\n\n[Truncated locally by zncm Web Clipper]`;
}
return { title, url, content, contentType: "markdown" };
}
// Injected into the page to build an indexed list of interactive elements plus
// the visible page text. Each interactive element is tagged with
// `data-deer-index` so a follow-up deerPerformAction call can find it by index.
function deerObservePage(maxElements) {
const limit = Math.max(10, Number(maxElements || 150));
const DEER_ATTR = "data-deer-index";
document.querySelectorAll(`[${DEER_ATTR}]`).forEach((node) => node.removeAttribute(DEER_ATTR));
function isVisible(el) {
if (!(el instanceof Element)) return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 1 && rect.height <= 1) return false;
const style = window.getComputedStyle(el);
if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false;
if (el.disabled) return false;
return true;
}
function labelFor(el) {
const aria = el.getAttribute("aria-label");
if (aria && aria.trim()) return aria.trim();
const placeholder = el.getAttribute("placeholder");
const title = el.getAttribute("title");
const name = el.getAttribute("name");
const text = (el.innerText || el.textContent || "").trim();
if (text) return text.replace(/\s+/g, " ");
if (placeholder && placeholder.trim()) return placeholder.trim();
if (title && title.trim()) return title.trim();
if (el.value && String(el.value).trim()) return String(el.value).trim();
if (name && name.trim()) return name.trim();
const alt = el.querySelector?.("img[alt]")?.getAttribute("alt");
if (alt && alt.trim()) return alt.trim();
return "";
}
const selector = [
"a[href]",
"button",
"input:not([type=hidden])",
"select",
"textarea",
"[role=button]",
"[role=link]",
"[role=tab]",
"[role=menuitem]",
"[role=checkbox]",
"[role=radio]",
"[role=switch]",
"[contenteditable=true]",
"[onclick]",
].join(",");
const seen = new Set();
const elements = [];
let index = 0;
for (const el of document.querySelectorAll(selector)) {
if (index >= limit) break;
if (seen.has(el) || !isVisible(el)) continue;
seen.add(el);
el.setAttribute(DEER_ATTR, String(index));
const tag = el.tagName.toLowerCase();
let label = labelFor(el);
if (label.length > 200) label = label.slice(0, 200);
const entry = { index, tag, label };
const type = el.getAttribute("type") || el.getAttribute("role");
if (type) entry.type = type;
if (tag === "input" || tag === "textarea" || tag === "select") {
const value = String(el.value || "");
if (value) entry.value = value.slice(0, 80);
}
if (tag === "a") {
const href = el.getAttribute("href") || "";
if (href) entry.href = href.slice(0, 200);
}
elements.push(entry);
index += 1;
}
// Inline text extraction so this function stays self-contained when injected
// via chrome.scripting.executeScript (no dependency on other content-script fns).
let text = "";
try {
const clone = document.body ? document.body.cloneNode(true) : null;
if (clone) {
clone.querySelectorAll(`script, style, noscript, svg, canvas, iframe, [${DEER_ATTR}-skip]`).forEach((node) => node.remove());
}
const main = clone?.querySelector("article, main, [role='main']") || clone;
text = String(main?.innerText || document.body?.innerText || "")
.replace(/\s+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
} catch {
text = "";
}
return {
url: location.href,
title: document.title || "",
elements,
text,
};
}
// Injected into the page to perform a single action on the element previously
// tagged by deerObservePage (by index). Returns {status, message}.
async function deerPerformAction(action) {
const DEER_ATTR = "data-deer-index";
const type = String(action?.type || "").toLowerCase();
function findByIndex(idx) {
if (idx === undefined || idx === null || idx < 0) return null;
return document.querySelector(`[${DEER_ATTR}="${idx}"]`);
}
function fireInput(el, value) {
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
if (setter) {
setter.call(el, value);
} else {
el.value = value;
}
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
try {
if (type === "observe" || type === "extract") {
return { status: "ok", message: type === "extract" ? "Page text re-read." : "Observed page." };
}
if (type === "scroll") {
const el = findByIndex(action.index);
if (el) {
el.scrollIntoView({ block: "center", behavior: "instant" });
return { status: "ok", message: "Scrolled element into view." };
}
window.scrollBy(0, Math.round(window.innerHeight * 0.85));
return { status: "ok", message: "Scrolled down one viewport." };
}
if (type === "wait") {
const selector = String(action.wait_for || "").trim();
const timeoutMs = Math.max(500, Number(action.timeout_ms || 8000));
if (!selector) {
await new Promise((resolve) => setTimeout(resolve, Math.min(timeoutMs, 3000)));
return { status: "ok", message: "Waited." };
}
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (document.querySelector(selector)) {
return { status: "ok", message: `Element ${selector} appeared.` };
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
return { status: "timeout", message: `Element ${selector} did not appear within ${timeoutMs}ms.` };
}
if (type === "click") {
const el = findByIndex(action.index);
if (!el) return { status: "error", message: `No element with index ${action.index}. Re-observe the page.` };
el.scrollIntoView({ block: "center", behavior: "instant" });
if (typeof el.focus === "function") el.focus();
el.click();
return { status: "ok", message: `Clicked element ${action.index}.` };
}
if (type === "type") {
const el = findByIndex(action.index);
if (!el) return { status: "error", message: `No element with index ${action.index}. Re-observe the page.` };
el.scrollIntoView({ block: "center", behavior: "instant" });
if (typeof el.focus === "function") el.focus();
const value = String(action.text ?? "");
if (el.isContentEditable) {
el.textContent = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
} else {
fireInput(el, value);
}
if (action.submit) {
const enterInit = { bubbles: true, cancelable: true, key: "Enter", code: "Enter", keyCode: 13, which: 13 };
el.dispatchEvent(new KeyboardEvent("keydown", enterInit));
el.dispatchEvent(new KeyboardEvent("keyup", enterInit));
const form = el.form || el.closest("form");
if (form && typeof form.requestSubmit === "function") {
try {
form.requestSubmit();
} catch {
form.submit?.();
}
}
}
return { status: "ok", message: `Typed into element ${action.index}${action.submit ? " and submitted" : ""}.` };
}
return { status: "error", message: `Unsupported action type: ${type}` };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : String(error) };
}
}
@@ -0,0 +1,96 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #171717;
background: #fafafa;
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
h1 {
margin: 0 0 14px;
font-size: 16px;
}
.popup,
.options {
display: grid;
gap: 12px;
padding: 14px;
}
.popup {
width: 340px;
}
.options {
max-width: 720px;
}
label {
display: grid;
gap: 6px;
font-weight: 600;
}
label.checkbox {
grid-template-columns: auto 1fr;
align-items: center;
font-weight: 500;
}
label.checkbox input {
width: auto;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
input,
textarea {
width: 100%;
border: 1px solid #d4d4d4;
border-radius: 6px;
padding: 8px;
background: #fff;
color: inherit;
font: inherit;
font-weight: 400;
}
textarea {
resize: vertical;
}
button {
border: 0;
border-radius: 6px;
padding: 9px 12px;
background: #111827;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 600;
}
button.secondary {
background: #e5e7eb;
color: #111827;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.status {
min-height: 20px;
margin: 0;
color: #525252;
white-space: pre-wrap;
}
+13 -6
View File
@@ -1,6 +1,12 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
@@ -25,14 +31,15 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_FRONTEND_URL=http://localhost:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
+6 -4
View File
@@ -15,15 +15,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
+13 -3
View File
@@ -14,8 +14,8 @@
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build)
#
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
# 若浏览器直连后端报 CORS,请在 Gateway 配置 GATEWAY_CORS_ORIGINS,包含前端源站;无界嵌入时还要加宿主源站,例如:
# GATEWAY_CORS_ORIGINS=http://127.0.0.1:5174,http://localhost:5174,http://127.0.0.1:5183,http://localhost:5183
# --- 开发环境(见 .env.development)---
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
@@ -23,11 +23,21 @@
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
# --- 任务看板(任务分析 / 三情分析)数据源开关 ---
# true=走真实接口;false 或未设=这两页使用本地假数据(演示用,无需后端)。
# .env.development / .env.production / .env.network / .env.sit 均默认 true(真实数据),
# 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。
# VITE_USE_REAL_DATA=true
# --- 任务详情(登录深链 cop-task-detail)数据源开关 ---
# 默认真实接口(未设/false);改成 true 时该接口走本地假数据(consumer 外网不可达时调试用)。
# 与 VITE_USE_REAL_DATA 独立。改后需重启 dev / 重新打包生效。
# VITE_MOCK_COP_TASK_DETAIL=false
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+12 -4
View File
@@ -1,6 +1,12 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
@@ -25,14 +31,16 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
+16 -4
View File
@@ -1,12 +1,20 @@
# 生产打包(pnpm build)— 部署前请改成实际内网/生产地址
# 打包后变量会写入 dist,运行时不可再改
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
@@ -19,17 +27,21 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的空间前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -5,4 +5,3 @@
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
# VITE_STUDIO_ENABLED=true
+19 -4
View File
@@ -1,12 +1,20 @@
# SIT 环境打包(pnpm build:sit / npm run build:sit)— 接口地址为 UAT 的 sit 版本
# 打包后变量会写入 dist,运行时不可再改
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.sit.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.sit.4.cn/gpt
# 多轮检索接口
@@ -19,17 +27,24 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.sit.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.sit.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,253 @@
# AI 写作「四阶段内置智能体化」改造方案
> 目标:把 AI 写作现有的 **素材收集专家 / 写作大纲 / 作家写作 / 编辑审核** 四个功能,
> 从「硬编码的 LangGraph 节点 + 硬编码提示词」改造为由**内置功能型智能体**驱动——
> 每个阶段对应一个可配置的内置 agent(独立人设 SOUL、可配模型、可挂技能/工具)。
本文给出现状梳理、目标设计、以及**分步可独立交付的实现计划**。
---
## 0. 一句话结论
不推倒重来。**保留现有图的编排骨架、4 个用户干预点、流式与状态机,只把每个阶段节点的「大脑」从「硬编码 prompt 直调 LLM」替换为「加载对应内置 agent 的 SOUL+配置、在子线程上跑该 agent、按结构化契约解析其输出」。** 这样前端几乎不动,复杂逻辑(严格模式、素材不足求助、逐章流式、技能检索步骤)全部保留。
---
## 1. 现状梳理(精确到文件)
AI 写作是一条 LangGraph 图:`packages/harness/deerflow/agents/ai_writing/graph.py`
```
intent_parser → researcher → (pause 素材确认) → writer_outline → (pause 大纲确认)
→ writer_draft → (pause 草稿确认 / 素材不足求助) → editor → (pause 编辑打回) → done
```
四个待改造阶段(均为**硬编码节点**,直接调 LLM、不走 agent 运行时):
| 阶段(用户语言) | 节点文件 | 提示词 | 产出(`state.py`) |
|---|---|---|---|
| 素材收集专家 | `nodes/researcher.py` | `prompts/researcher_prompts.py` | `material_package`(keywords + materials) |
| 写作大纲 | `nodes/writer_outline.py` | `prompts/writer_prompts.py` | `current_outline`(Outline JSON) |
| 作家写作 | `nodes/writer_draft.py` | `prompts/writer_prompts.py` | `current_draft`(Markdown,逐章流式) |
| 编辑审核 | `nodes/editor.py` | `prompts/editor_prompts.py` | `review_result`(评分 + 问题列表 JSON) |
关键事实:
- **LLM 调用方式**:节点通过 `nodes/_utils.py` 的 `get_model()` + `llm_json()` **直接调 LLM**,提示词写死在 `prompts/*.py`。**没有**走 agent 人设 / 技能 / 工具体系。
- **模型**:全程单一 `state.model_name`(用户在写作表单里选的主模型),各阶段无法分别配。
- **干预点 / 流式 / 严格模式 / 素材不足求助**:都在 `graph.py` 的 pause 节点和 `writer_draft` 里,逻辑较重,需原样保留。
- **前端**:`frontend-web/src/open-canvas/contexts/AIWritingContext.tsx` 是状态机(`researching/writing_outline/writing_draft/reviewing/...`),时间线 `AIWritingTimeline.tsx` 按 `progress_events`(已带 `agent_name` 字段)渲染。
---
## 2. 可复用的现成机制(不用造轮子)
代码库里**已经有**一套成熟的「内置功能型智能体」范式——圆桌的 `roundtable-intent / roundtable-recommender / roundtable-coordinator`:
1. **资源即代码**:`app/gateway/routers/_roundtable_seed_assets/<agent-id>/`
- `config.yaml`(`id` / `name` / `description`,可扩展 `model` / `skills` / `tool_groups`)
- `SOUL.md`(人设 + 职责 + 工作流 + **结构化输出契约**)
2. **种子器**:`app/gateway/routers/_roundtable_seed.py::ensure_roundtable_functional_agents`
- 启动时 + 相关路由入口自愈:目录不存在才从资源字节级落盘到 `.deer-flow/agents/<id>/` + DB(**已存在则永不覆盖**,尊重本地编辑)。
3. **运行**:在**独立 thread** 上跑该内置 agent(见 `app/gateway/routers/intent.py`,`INTENT_AGENT_ID = "roundtable-intent"`),通过 lead agent 运行时把 SOUL.md 作系统提示,流式经 LangGraph `custom`/`messages` 通道透传。
4. **结构化输出约定**:SOUL 规定模型末尾输出固定 **marker + ```json```**(如 `[INTENT_READY]\n```json{...}```),后端用正则解析(`intent.py::_INTENT_READY_PATTERN`)。
> 我们要做的,本质就是**照搬这套范式,给 AI 写作做 4 个功能型 agent**,并让对应的图节点改成「跑这个 agent + 解析其结构化输出」。
另有 agents 通用体系可复用:`agents` 表、`/api/agents` CRUD、智能体管理页(配模型/技能/工具)——4 个写作 agent 落盘后天然出现在这里,可被 admin 编辑。
---
## 3. 目标设计
### 3.1 四个内置写作智能体(建议 id / 职责)
| Agent id | 名称 | 取代节点 | 结构化输出 marker | 产出 schema |
|---|---|---|---|---|
| `ai-writing-researcher` | 素材收集专家 | `researcher` | `[MATERIALS_READY]` | `MaterialPackage` |
| `ai-writing-outliner` | 大纲规划师 | `writer_outline` | `[OUTLINE_READY]` | `Outline` |
| `ai-writing-writer` | 作家 | `writer_draft` | `[SECTION_READY]`(逐章) | 章节 Markdown |
| `ai-writing-editor` | 编辑 | `editor` | `[REVIEW_READY]` | `ReviewResult` |
每个 agent 自带:
- `config.yaml`:`id / name / description` +(扩展)`model`(该阶段专用模型,可空=回退用户主模型)、`skills`(如 researcher 挂检索技能)、`tool_groups`。
- `SOUL.md`:由现有 `prompts/*.py` 内容迁移改写,**追加结构化输出契约**(严格 marker + JSON 格式 + 反例,仿 `roundtable-intent` SOUL 的「硬规则」写法,压制弱模型乱输出)。
### 3.2 节点改造方式(核心)
每个阶段节点保留**输入/输出契约不变**(仍读写 `state` 的同一批字段),内部从:
```
# 旧:硬编码 prompt 直调
model = get_model(config, state["model_name"])
result = llm_json(model, SYSTEM_PROMPT, USER_PROMPT.format(...))
```
改为:
```
# 新:跑对应内置 agent,按 marker 解析
result = await run_writing_agent(
agent_id="ai-writing-outliner",
state=state, config=config,
user_payload=<本阶段输入>, # 意图/素材/大纲/草稿等拼成的一段输入
marker="[OUTLINE_READY]", # 结构化输出契约
)
```
新增统一助手 `nodes/_agent_runner.py::run_writing_agent(...)`:在子 thread 上跑指定内置 agent(复用 `intent.py` 的 run/stream 机制)、把流式 chunk 透传到 `custom` 通道(前端时间线不回归)、用复用的 `_utils.parse_json` 多级容错解析 marker 后的 JSON。
### 3.3 为什么选这个方案(A)而非全多智能体重写(B)
- **方案 A(推荐)**:保留图骨架,逐节点替换大脑。前端基本不动;干预点/流式/严格模式/素材求助原样保留;可**按阶段灰度 + feature flag 回退**,风险可控。
- **方案 B(不推荐)**:仿圆桌重做成多 agent 编排,需重写编排、状态、流式、4 个干预点对接,工作量数倍、回归面巨大,收益(更"纯"的多 agent)对本场景并不必要。
---
## 4. 配置与可管理性
- **落盘**:仿 roundtable 新增 `_ai_writing_seed_assets/` + 一个 `ensure_ai_writing_functional_agents()` 种子器,在 Gateway 启动与 ai_writing 会话入口自愈。
- **管理界面**:4 个 agent 落盘后即出现在智能体管理页,admin 可编辑 **SOUL(人设)/ 模型 / 挂载技能**。可选:在「文章类型配置页」旁加一个「写作智能体配置」入口,或给这 4 个打一个 `tag` 便于筛选。
- **模型优先级**:节点跑 agent 时 `该 agent config.model > 用户写作表单选的主模型 model_name`(保留现有"用户选模型"语义,新增 per-阶段覆盖能力)。
- **技能**:researcher 现有的技能检索能力(`search/skill_search.py`)改为 `ai-writing-researcher` 的**挂载技能**,admin 可增减;检索步骤事件(`SkillStep`)形态保留给前端展示。
---
## 5. 分步实现计划(每步可独立交付 / 验收)
### Phase 0 · 契约冻结
- 定 4 个 agent 的 `id / name`、每个的 **marker + JSON schema**(与 `state.py` 的 `MaterialPackage/Outline/DraftArticle/ReviewResult` 严格对齐)。
- **验收**:本文 §3.1 / §6 的契约评审通过、字段与现有 TypedDict 一一对应。
### Phase 1 · 内置 agent 种子化(后端,纯新增,不接管流程)
- 新建 `app/gateway/routers/_ai_writing_seed_assets/<4 个 agent>/{config.yaml, SOUL.md}`;SOUL 由现有 `prompts/*.py` 迁移改写 + 追加结构化输出契约。
- 仿 `_roundtable_seed.py` 写 `ensure_ai_writing_functional_agents()`,挂到启动 lifespan + ai_writing 入口。
- **验收**:启动后 4 个 agent 出现在 `.deer-flow/agents/`、DB、`/api/agents`,能在智能体管理页查看/编辑;**此阶段不改图,写作流程行为不变**。
### Phase 2 · 节点接入「跑内置 agent」(后端,逐阶段灰度)
- 新增 `nodes/_agent_runner.py::run_writing_agent(...)`(子线程跑 agent + 流式透传 + marker 解析)。
- 加 feature flag:`config.yaml → ai_writing.use_builtin_agents.{editor,outliner,researcher,writer}`(默认 false=走旧节点)。
- **接入顺序(由易到难)**:
1. **editor**(最简单:小 JSON、无检索、无逐章流式)→ flag 打开端到端验证。
2. **outliner**(输出 Outline JSON)。
3. **researcher**(保留技能检索步骤事件、素材确认暂停)。
4. **writer**(最难:保留**逐章流式** + **严格模式素材不足求助** `blocked_sections`;建议**仍由节点控制"逐章循环"**,每章调一次 `ai-writing-writer`,`section_help` 暂停逻辑不动)。
- 每接一个,旧 prompt 与旧逻辑**保留**,节点按 flag 二选一。
- **验收**:每个阶段 flag 打开后端到端走通,产出结构与旧版一致,干预点 / 流式 / 严格模式 / 求助暂停均不回归。
### Phase 3 · 每阶段 agent 配置化
- per-agent 模型覆盖生效(`agent.config.model > model_name`)。
- researcher 的检索能力改为挂载技能,admin 可增减。
- 智能体管理页可编辑这 4 个的 SOUL / 模型 / 技能;config 热加载,新会话即生效、不重启前端。
- **验收**:admin 改某阶段 agent 的模型 / SOUL / 技能后,新会话立即按新配置运行。
### Phase 4 · 前端适配(小改)
- 时间线 / 状态文案:阶段名读对应 agent 的 `name`(`progress_events.agent_name` 已存在,基本沿用,去掉写死文案)。
- 可选:写作表单 / 设置加「查看·配置写作智能体」入口,跳智能体管理页。
- **验收**:前端展示各阶段对应的智能体名;配置入口可达。
### Phase 5 · 清理收口
- 四阶段稳定后,默认打开全部 flag、移除旧 prompt 直调路径与 flag(prompts 历史留在 git)。
- 更新 `offline-backend-20260512/backend/CLAUDE.md` 架构描述。
- **验收**:回归测试通过、冗余删除、文档同步。
---
## 6. 结构化输出契约(与现有 state 对齐)
各 agent SOUL 末尾必须输出 `marker + ```json``` 块,schema 直接对齐 `state.py`:
- `ai-writing-researcher` → `[MATERIALS_READY]`:`{ "keywords": string[], "materials": Material[] }`(`Material` 见 `state.py`,含 `id/title/content/source/relevance_score/source_type/...`)。
- `ai-writing-outliner` → `[OUTLINE_READY]`:`Outline = { "title": string, "sections": OutlineSection[] }`(`OutlineSection = { section_title, key_points: KeyPoint[], material_ids }`)。
- `ai-writing-writer` → 逐章 `[SECTION_READY]`:`{ "section_title": string, "markdown": string, "needs_more_material"?: { "reason": string } }`(`needs_more_material` 触发严格模式求助 `blocked_sections`)。
- `ai-writing-editor` → `[REVIEW_READY]`:`ReviewResult = { "verdict": "pass"|"reject", "fact_score", "logic_score", "language_score", "overall_score", "pass_threshold", "revision_notes": ReviewIssue[] }`。
解析复用 `nodes/_utils.py::parse_json`(多级容错),marker 正则仿 `intent.py::_INTENT_READY_PATTERN`。
---
## 7. 风险与注意
1. **弱模型结构化输出不稳**:SOUL 写严格格式 + 反例 + 「输出 marker 后立刻闭嘴」硬规则(仿 `roundtable-intent` SOUL §0);解析层复用 `parse_json` 多级降级。
2. **writer 的逐章流式 + 严格模式求助最复杂**:建议**循环控制权留在节点**(每章调一次 agent),不要让 agent 一次吐全文,否则 `blocked_sections` / `section_help` / 逐章流式都要重做。
3. **researcher 技能检索步骤事件**(`SkillStep`)前端有专门卡片:迁移时保留这些 `progress_events` 形态。
4. **种子器 never-overwrite**:迭代 SOUL 后**老部署不会自动更新**(与圆桌同一问题)。运维更新办法:删 `.deer-flow/agents/<id>/` 重新种子,或后续给种子器加「内置功能 agent 按版本/hash 覆盖」开关。
5. **子线程隔离**:跑写作 agent 的子 thread 与主 ai_writing thread 状态隔离,注意 checkpointer 与清理(沿用 ai_writing 既有的会话清理)。
6. **离线内网**:researcher 的检索一律走技能(不联网),与现有「离线部署禁 web_search、走 skill」策略一致。
---
## 8. 涉及文件清单(落地时对照)
**后端(主要改动)**
- 新增 `app/gateway/routers/_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}`
- 新增 `app/gateway/routers/_ai_writing_seed.py`(种子器,仿 `_roundtable_seed.py`)
- 新增 `packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py`(`run_writing_agent`)
- 改 `nodes/{researcher,writer_outline,writer_draft,editor}.py`(按 flag 二选一)
- 改 `config.yaml`(新增 `ai_writing.use_builtin_agents.*` 开关;可选 per-agent 默认模型)
- 保留 `prompts/*.py`(回退用,Phase 5 删)
**前端(小改)**
- 改 `src/open-canvas/components/ai-writing/AIWritingTimeline.tsx`(阶段名读 agent name)
- 可选:写作表单/设置加「配置写作智能体」入口(跳 `/page/...` 智能体管理页)
**文档**
- 本文件
- Phase 5 收口时更新 `offline-backend-20260512/backend/CLAUDE.md`
---
## 9. 实施记录(2026-06-11,Phase 1-4 已落地)
### 已交付
- **Phase 1**:`_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}` + `_ai_writing_seed.py::ensure_ai_writing_functional_agents()`,挂启动 lifespan(`_sync_legacy_agents` 之前)+ `ai_writing.list_sessions / list_article_types` 入口自愈。
- **Phase 2**:`nodes/_agent_runner.py`(`stream_writing_agent` / `run_writing_agent` / `parse_marker_json` / `resolve_agent_model_name` / `use_builtin_agent`);四节点按 `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` 二选一(默认全 false,config 按 mtime 热加载,改完即对新会话生效)。agent 目录缺失时节点**静默回退旧 prompt 路径**,写作流程永不因 agent 被删而中断。
- **Phase 3(部分)**:per-agent 模型覆盖已生效(优先级 `agent config.model > 节点显式回退模型(keyword_model/rank_model)> 表单主模型 model_name`);SOUL/模型在智能体管理页可编辑、按 mtime 热加载。researcher「检索能力改挂载技能」未做(仍走节点内 SkillPicker 编排)。
- **Phase 4**:时间线 agent 名由后端 `progress_events.agent_name` 动态下发(flag 开启时读 agent config 的 `name`);`TimelineStep.tsx` 补「大纲规划师」配色(未命中名字回退灰色,admin 改名不报错)。
- **测试**:`tests/test_ai_writing_seed.py`(资源完整性 / 契约 marker / never-overwrite)+ `tests/test_ai_writing_agent_runner.py`(marker 多级解析 / 模型优先级 / flag / 流式端到端 / SOUL 缺失降级),共 31 例。
### 与 §6 契约的修订(以实现为准)
为保住「逐章流式打字机」「review_chunk/review_item_done 分维度卡片」「素材确认/求助暂停」等前端形态,编排控制权全部留在节点,agent 只承担**单次结构化产出**:
| Agent | 实际契约 |
|---|---|
| `ai-writing-researcher` | 两个子任务:`[KEYWORDS_READY]` + `{"keywords": []}`;`[MATERIALS_RANKED]` + `{"ranked_ids": [], "summary": ""}`。素材本体来自节点的检索编排(agent 不联网),`MaterialPackage` 仍由节点组装 |
| `ai-writing-outliner` | `[OUTLINE_READY]` + 完整 `Outline` JSON(新规划 / 修改模式共用,payload 开头标明任务模式) |
| `ai-writing-writer` | **无 JSON marker**:每章直接输出 Markdown 正文(保流式);严格模式素材不足只输出一行 `[[SECTION_BLOCKED]] 原因`(沿用原哨兵) |
| `ai-writing-editor` | 每个维度一次调用:`[REVIEW_READY]` + `{"score": int, "issues": []}`;综合分/verdict/pass_threshold 仍由节点按 config 计算(不信任 LLM 自判通过) |
另一处架构修订:**不在子 thread 上经 lead agent 运行时跑**(§3.2 原设想复用 `intent.py` 机制,但其 loopback 在 app 层,harness 图节点不可 import;且单轮结构化调用无需 thread/checkpointer/中间件成本)。`run_writing_agent` 直接「SOUL 作 system prompt + 单条输入」调模型,规避了 §7.5 的子线程隔离/清理风险。researcher 挂技能(Phase 3 剩余项)如需完整工具体系再评估接入运行时。
### 已知存量测试失败(与本次无关)
`test_ai_writing_graph.py`(mock 已删除的 `intent_parser.llm_json`)、`test_ai_writing_word_count.py`(mock 已删除的 `writer_draft.get_model`)、`test_ai_writing_draft_headings.py`(断言三级结构升级前的 `####→###` 行为)、`test_ai_writing_outline_revise.py`(断言旧 prompt 文案),改造前即失败,待另行清理。
## 10. Phase 3 收尾:检索类型两项化 + 配置技能驱动检索(2026-06-13 已实施)
本节落地了 §9 遗留的「researcher 检索改挂载技能」,并按新需求重新设计了检索类型与 PAUSE-1 交互。
### 10.1 检索类型(material_source)收敛为两项
- 写作表单「素材来源」改名「检索类型」,只剩 `general`(通用检索,默认)/ `notebook`(我的空间);旧值 `knowledge_base` / `skill` 由 researcher 节点按 `general` 兼容(老会话 resume 不受影响)。
- 前端:`AIWritingForm.tsx`(选项/标签)、`WritingFormContext.tsx`(默认值/类型/applyProposal 旧值映射)、`types/ai-writing.ts`、`message-list.tsx`(审批卡标签兼容旧值)。
### 10.2 通用检索 = 素材收集专家配置的技能编排
- `ai-writing-researcher` agent 的 `config.yaml → skills` 是权威配置(智能体管理页可增删/替换);种子默认 `[knowledge-base-search]`。
- 内置技能 **knowledge-base-search(知识库检索)**:把原 web/intranet 关键词并发检索包装成技能。researcher 对它特殊处理——不拼 description,直接对 Step1 生成的检索词做原生并发检索(行为与旧 knowledge_base 模式一致)。其它配置技能按 `意图 + description` 拼定向 query 检索。按配置顺序执行、累积达 `max_materials` 早停(`stop_early`)。
- 技能配置为空/全部失效 → 内存兜底回退知识库检索(`researcher._load_configured_skills`),检索永不瘫痪。
- 种子:`_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` → `skills/public/`(never-overwrite);旧部署的 researcher `config.yaml` 缺 `skills` 键时就地补默认(显式 `skills: []` 不动)。`_ai_writing_seed.py::ensure_ai_writing_functional_agents()` 一并处理。
### 10.3 PAUSE-1 输入框 → 匹配技能检索并**追加**
- 用户在素材确认卡输入自然语言(如「检索 A 的数据」)→ `re_search + user_query`:配置技能 >1 个时 `SkillPicker(candidates=配置技能)` 让模型按输入匹配排序(流式打字机保留),单技能直接用;用用户原话(非知识库技能附 description)检索。
- **追加语义**:保留原素材列表与顺序,新素材按 URL 去重后追加在末尾(ID 续号),跳过 LLM 重排与 max_materials 截断(仅 100 条硬上限),summary 显示「追加检索到 X 条新素材,当前共 Y 条」。原 20 条 + 新 20 条 = 40 条。
- 「技能检索」独立按钮已删除(通用检索本来就走技能编排);后端继续兼容旧 `enable_skill_search` action(按 user_query 追加处理)。无输入的「重新搜索」仍为覆盖式重检。
- 前端:`InterventionCard.tsx`(按钮/文案,hasQuery 时按钮文案「检索并追加素材」)、`MaterialsToolbar.tsx`(提示语)、`useAIWritingStream.ts`(素材卡回到 researching 一律置 skillSearchPending,让 SkillStepsCard 占位即时挂载)。
### 10.4 测试
- `tests/test_ai_writing_material_source.py` 重写(9 例):缺省/旧值→通用检索、自定义技能 description query、user_query 追加+URL 去重+多技能 picker 匹配(candidates 断言)、无输入重检覆盖、空配置回退。
- `tests/test_ai_writing_seed.py` 扩展(+5 例):技能资产存在、researcher 种子含默认 skills、技能种子 never-overwrite、缺 skills 键就地补丁、显式 `[]` 不动。
@@ -1,228 +0,0 @@
# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。
---
## 一、功能概览
| 交互 | 用户感知 | 组件 | 触发方式 |
|------|----------|------|----------|
| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` |
| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` |
| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 |
画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。
---
## 二、在 AI 写作中的挂载路径
```mermaid
flowchart TB
AIWritingPage --> CanvasResizableLayout
CanvasResizableLayout --> AIWritingDraftPanel
AIWritingDraftPanel --> DraftStreamingPreview
AIWritingDraftPanel --> TextArtifactRenderer
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
TextArtifactRenderer --> WritingBubbleMenu
TextArtifactRenderer --> WritingParagraphMenu
TextArtifactRenderer --> TiptapEditor
TextArtifactRenderer --> WritingAiMenu
```
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|------|----------|-----------------|
| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** |
| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** |
| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) |
相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
入口文件:
- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer`
- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单
---
## 三、技术栈:编辑器 vs 浮层
### 3.1 Tiptap 编辑器(有使用)
| 包 | 用途 |
|----|------|
| `@tiptap/react` | `useEditor`、`EditorContent` |
| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 |
| `@tiptap/extension-placeholder` | 空文档占位符 |
| `tiptap-markdown` | Markdown ↔ 文档互转 |
| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) |
扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。
### 3.2 浮层菜单(自研,非 Tiptap 官方插件)
| 包 | 是否用于滑词/段落菜单 |
|----|----------------------|
| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) |
| `@tiptap/react` 的 `<BubbleMenu>` | **未使用** |
两套菜单均采用:
1. 监听 Tiptap / DOM 事件更新位置与选区;
2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切;
3. `position: fixed` + `getBoundingClientRect()` 定位。
命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
---
## 四、核心文件索引
| 文件 | 职责 |
|------|------|
| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 |
| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight |
| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 |
| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable |
| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 |
| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 |
| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 |
| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 |
| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 |
| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 |
| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 |
| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 |
---
## 五、滑词:`WritingBubbleMenu`
### 5.1 行为
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。
- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。
- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。
### 5.2 实现要点
```text
editor.on("selectionUpdate")
→ 读取 editor.state.selection (from, to, empty)
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
```
关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。
点击「AI 改写」时:
1. `editor.commands.setAiHighlight(from, to)` 高亮选区;
2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。
---
## 六、段落菜单:`WritingParagraphMenu`
### 6.1 行为
- 鼠标在编辑器内移动时,检测光标下的**块级节点**;
- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮;
- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
### 6.2 实现要点
```text
editor.view.dom mousemove
→ editor.view.posAtCoords({ left, top })
→ doc.resolve(pos).before(depth) 定位块起点
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
```
点击 ✨ 时:
1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`);
2. `setAiHighlight` + `setWritingSelection`;
3. 触发与滑词相同的 `onAiRewrite()` 流程。
关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。
---
## 七、AI 改写数据流
```mermaid
sequenceDiagram
participant User
participant Menu as BubbleMenu / ParagraphMenu
participant Ctx as CanvasUIContext
participant Renderer as TextArtifactRenderer
participant API as rewrite API
User->>Menu: 选中文本或点段落 ✨
Menu->>Ctx: setWritingSelection
Menu->>Renderer: onAiRewrite()
Renderer->>Renderer: phase = menu, WritingAiMenu
User->>Renderer: 选择润色/扩写等
Renderer->>API: useWritingRewrite.rewrite
API-->>Renderer: 流式结果
Renderer->>Renderer: WritingRewritePreview
User->>Renderer: 接受改写
Renderer->>Renderer: applyReplace / applyInsert
```
- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。
- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
---
## 八、与顶部工具栏、目录栏的关系
同一 `TextArtifactRenderer` 编辑模式下还包含:
| 组件 | 作用 |
|------|------|
| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 |
| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading |
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。
---
## 九、开发与改造建议
### 9.1 若要改用官方 Bubble Menu
1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`);
2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准);
3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
### 9.2 新增浮层按钮
- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。
- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。
### 9.3 流式阶段禁止交互
不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。
---
## 十、相关文档
- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表
- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验)
---
## 十一、依赖版本(摘录)
以 [package.json](../package.json) 为准,编辑器相关主要为:
- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4`
- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现
- `tiptap-markdown`:Markdown 扩展
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。
@@ -1,274 +0,0 @@
# AI 写作 ·「用户自带大纲」手动测试用例
> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。
> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx)
---
## 一、测试目标
验证三件事:
1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。
2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。
3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。
---
## 二、前置条件
| 项 | 要求 |
|----|------|
| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 |
| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) |
| 账号 | 已登录(或无鉴权模式) |
| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 |
---
## 三、通用操作步骤
除非用例另有说明,每条用例都按以下步骤执行:
1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。
2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。
3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。
4. 文章类型 / 目标字数 / 素材来源保持默认即可。
5. 点击「开始 AI 写作」。
6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。
7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。
> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。
---
## 四、测试用例
### TC-01 Markdown 三级 + 文章标题
**输入大纲:**
```
# 新能源汽车出口为何高速增长
## 引言
### 出口数据创新高
#### 同比增幅
#### 全球排名变化
### 市场背景
## 增长动因
### 成本优势
```
**预期大纲结构:**
- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节)
- 章节1 `引言`
- 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化`
- 小节 `市场背景`(无子要点)
- 章节2 `增长动因`
- 小节 `成本优势`
**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。
---
### TC-02 中文编号三级(一、/(一)/ 1.)
**输入大纲:**
```
一、引言
(一)背景
(二)意义
二、现状分析
(一)市场数据
1. 国内市场
2. 海外市场
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场`
- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字)
**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。
---
### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1)
**输入大纲:**
```
1. 引言
1.1 研究背景
1.2 研究意义
2. 现状
2.1 行业概况
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `研究背景`、`研究意义`
- 章节2 `现状` → 小节 `行业概况`
---
### TC-04 缩进 bullet 表示层级
**输入大纲:**
```
- 引言
- 背景
- 意义
- 现状
- 数据
- 细节展开
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开`
**校验重点:** 缩进深度正确映射成层级。
---
### TC-05 只有一级目录(平铺 Markdown)
**输入大纲:**
```
## 引言
## 现状
## 对策
## 结论
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。
**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。
**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。
---
### TC-06 只有一级目录(中文编号)
**输入大纲:**
```
一、引言
二、现状
三、结论
```
**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。
---
### TC-07 纯文本平铺(无任何标记)
**输入大纲:**
```
引言
现状
对策
总结
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。
**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。
---
### TC-08 格式混乱 / 散文化(走 LLM 兜底)
**输入大纲:**
```
我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。
希望先讲背景,再讲现状,最后给出展望。
```
**预期行为:**
- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。
- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。
- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。
**校验重点:** 流程不崩、能产出可用大纲。
---
### TC-09 保真校验(标题原样保留)
**输入大纲:**
```
一、A股市场2024年回顾与展望
(一)宏观经济(GDP/CPI)影响
(二)流动性与利率
二、行业轮动主线
```
**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离:
- 章节1 `A股市场2024年回顾与展望`
- 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率`
- 章节2 `行业轮动主线`
**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。
---
### TC-10 大纲确认卡里再编辑 / 重新规划
**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。
**操作:**
1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。
2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。
**预期行为:**
- 手动改后确认:正文按改后的大纲生成。
- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。
**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。
---
### TC-11 回归:不填大纲(默认流程不受影响)
**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。
**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。
**校验重点:** 增量功能不污染老流程。
---
### TC-12 素材关联(仍走检索)
**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。
**预期行为:**
- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。
- 关联**只填素材、不改大纲标题与结构**。
**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。
---
## 五、缺陷记录模板
| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 |
|------|------------------|------|------|----------|------|
| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | |
| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | |
| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | |
| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | |
| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | |
| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | |
| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | |
| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | |
| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | |
| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | |
| TC-11 | 留空 | 走自动规划 | | ☐ | |
| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | |
---
## 六、备注
- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。
- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。
- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。
@@ -0,0 +1,154 @@
# 公开页 · iframe 嵌入外观对接说明(主题 / 背景色)
本文档面向**嵌入方(宿主系统)**,说明如何在用 `<iframe>` 嵌入 DeerFlow 公开页时,从外部控制页面的**整体明暗主题(light/dark)**与**背景色 / 文字色**,使其与宿主站点的视觉风格保持一致。
前端实现见:`src/core/embed/background.ts`(参数解析与 postMessage 监听)、`src/App.tsx`(主题接入 `forcedTheme`)。
---
## 0. 速读
| 想做什么 | 怎么做 |
|----------|--------|
| 嵌入时指定深色主题 | iframe `src` 加 `?theme=dark` |
| 嵌入时指定背景/文字色 | iframe `src` 加 `?bg=%23001529&text=%23ffffff`(`#` 需编码为 `%23`) |
| 宿主切主题时同步切换 | `postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*")` |
| 宿主动态改背景色 | `postMessage({ type: "deerflow:set-bg", bg: "#001529", text: "#fff" }, "*")` |
| 什么都不传 | 页面使用 DeerFlow 自带默认配色,互不影响 |
---
## 1. 适用页面
以下公开页(免登录、只读)均支持本文档的全部参数:
| 页面 | 路由 |
|------|------|
| 会话分享页 | `/#/share/{shareCode}` |
| 圆桌分享页 | `/#/roundtable-share/{shareCode}`、`/#/roundtable-share/{shareCode}/report` |
| 用户排行榜 | `/#/public/leaderboard` |
| 定时任务公开页 | `/#/public/scheduled-tasks/{taskId}`(含 `/runs/{runId}/fullscreen` 全屏页) |
> 注意:站点使用 **hash 路由**,路径在 `#` 之后。
> 嵌入聊天页(`/#/embed/chats/*`、`/#/embed/roundtable`)有自己的主题约定,见 `embed-iframe-chat.md`,不在本文范围内。
---
## 2. URL 查询参数(嵌入时一次性指定)
### 2.1 参数列表
| 参数 | 别名 | 作用 | 合法值 |
|------|------|------|--------|
| `theme` | — | 切整体明暗主题 | 见 2.2 取值映射表 |
| `bg` | `background` | 页面根容器背景色 | 见 2.3 颜色格式 |
| `text` | `fg` | 页面根容器文字色 | 见 2.3 颜色格式 |
三个参数相互独立,可任意组合;不传或传非法值时该项忽略、沿用默认。
### 2.2 `theme` 取值映射
| 传入值(不区分大小写) | 实际主题 |
|------|------|
| `dark`、`dark-blue`、`深蓝`、`深色` | 深色 |
| `light`、`red`、`红白`、`浅色` | 浅色 |
| 其他 / 不传 | 忽略,沿用站内默认(浅色) |
### 2.3 颜色格式(`bg` / `text`)
颜色值经过白名单校验,仅接受以下写法,其余一律丢弃(防注入):
- **hex**:`#fff`、`#ffffffcc` 等 3/4/6/8 位(`#` 可省略;URL 里 `#` 必须编码为 `%23`)
- **函数式**:`rgb()` / `rgba()` / `hsl()` / `hsla()`,括号内仅允许数字、逗号、百分号、空格、`/`
- **命名色**:纯英文字母,如 `white`、`transparent`
### 2.4 参数位置
参数放在 hash 路由之后(推荐)或 `#` 之前都能识别;两处都传时**hash 路由内的优先**:
```
https://<host>/#/public/leaderboard?theme=dark ← 推荐
https://<host>/?theme=dark#/public/leaderboard ← 也支持
```
### 2.5 示例
```html
<!-- 深色主题 -->
<iframe src="https://<host>/#/public/leaderboard?theme=dark"></iframe>
<!-- 浅色主题 + 自定义背景与文字色(# 编码为 %23) -->
<iframe src="https://<host>/#/share/Ab3xYz?theme=light&bg=%23f5f7fa&text=%23333333"></iframe>
<!-- 主题与背景叠加:整页走深色变量,根背景用宿主品牌色 -->
<iframe src="https://<host>/#/roundtable-share/Ab3xYz?theme=dark&bg=%23001529"></iframe>
```
---
## 3. postMessage(嵌入后动态切换)
适用于宿主站点支持用户切主题、需要 iframe 实时跟随的场景。向 iframe 的 `contentWindow` 发消息即可,**无需等待子页面回执**(监听器在应用挂载时注册)。
### 3.1 消息格式
```js
const frame = document.getElementById("deerflow-frame");
// 切整体明暗主题(theme 取值同 2.2)
frame.contentWindow.postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*");
// 改背景色 / 文字色(字段名与 URL 参数一致,bg/background、text/fg 均可)
frame.contentWindow.postMessage({ type: "deerflow:set-bg", bg: "#001529", text: "#fff" }, "*");
// 兼容别名:type 也可写 "deerflow:set-background"
```
说明:
- 两类消息相互独立,可只发其一;`set-bg` 里 `bg`、`text` 也可只传其一,未传的字段保持当前值。
- 非法颜色值 / 不认识的 `theme` 取值会被忽略,不会清空已有设置。
- targetOrigin 建议按需收紧(写 DeerFlow 站点的 origin 而非 `"*"`)。
### 3.2 宿主主题联动示例
```js
function syncDeerflowTheme(isDark) {
const win = document.getElementById("deerflow-frame").contentWindow;
win.postMessage({ type: "deerflow:set-theme", theme: isDark ? "dark" : "light" }, "*");
win.postMessage(
{ type: "deerflow:set-bg", bg: isDark ? "#001529" : "#ffffff" },
"*",
);
}
// iframe 首次加载用 URL 参数兜底,之后切换走 postMessage
myThemeStore.subscribe((theme) => syncDeerflowTheme(theme === "dark"));
```
---
## 4. 优先级与默认行为
同一项配置多处传入时,按以下优先级取值(高 → 低):
1. **postMessage** 动态设置的值
2. hash 路由内的 URL 参数(`#/路由?theme=...`)
3. `#` 之前的 URL 参数(`?theme=...#/路由`)
4. 都没传 → DeerFlow 默认配色(浅色主题 + 页面自带 Tailwind 配色)
其他保证:
- 外部主题只影响**当前 iframe 的渲染**,不写 localStorage——不会污染用户直接访问 DeerFlow 主站时自己选择的主题。
- `bg`/`text` 是内联样式,作用在页面根容器上,优先级高于主题变量;与 `theme` 叠加时以 `bg`/`text` 为准。
---
## 5. 常见问题排查
| 现象 | 排查 |
|------|------|
| `theme=dark` 不生效 | 确认路由属于第 1 节列出的公开页;确认参数拼在 hash 路由后(`/#/share/xx?theme=dark`),而不是写成了 `/#/share/xx#theme=dark` |
| `bg=#fff` 不生效 | URL 里 `#` 没编码会被浏览器截断,必须写 `%23fff`;或直接省略 `#` 写 `bg=fff` |
| postMessage 无反应 | 确认消息体是对象且 `type` 拼写为 `deerflow:set-theme` / `deerflow:set-bg`;确认在 iframe `load` 之后发送 |
| 颜色被忽略 | 检查是否符合 2.3 白名单格式(如 `linear-gradient(...)` 等复杂值不支持) |
| 主题切了但个别区域颜色没变 | 该区域可能使用了固定配色;把页面路由与截图反馈给 DeerFlow 维护方补充 `dark:` 样式 |
-265
View File
@@ -1,265 +0,0 @@
# 多智能体协调 API 接口文档
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
路由前缀:`/api/multi-agent`
## 概览
提供一个"总控 + 多个子智能体"的协调编排接口。
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
调用方按下面顺序使用:
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
## 1. 初始化多智能体集群
### `POST /api/multi-agent/init`
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
#### 请求体
```json
{
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
"main_agent_name": "main-controller",
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
#### 响应(200)
```json
{
"status": "success",
"main_agent_name": "main-controller",
"thread_ids": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
"us-intelligence-gathering-agent": "thread-uuid-zzz"
}
}
```
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_names` 为空 |
| `401` | 未携带 `Authorization` 头 |
| `500` | 协调智能体创建失败且不是"已存在"错误 |
---
## 2. 流式调度
### `POST /api/multi-agent/run/stream`
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
#### 请求体
```json
{
"agent_type": "leader",
"d_agent_thread_id": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
},
"agent_name": "main-controller",
"new_message": "请分析特朗普的支持率",
"skill_stop_names": [],
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
#### 响应
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
##### a. 透传上游事件
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
##### b. 最后一帧:调度状态
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
**`agent_type == "leader"`:**
```
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
```
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
**`agent_type == "special"`:**
```
data: {"status": "子智能体xxx完成yyy工作"}
```
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
#### 副作用(重要)
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
| `401` | 未携带 `Authorization` 头 |
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
---
## 3. 前端调用流程示例
```ts
// 1. 拿 token
const loginResp = await fetch("/api/v1/auth/login/username", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "yourname" }),
});
const { access_token } = await loginResp.json();
const authHeaders = {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
};
// 2. 初始化
const initResp = await fetch("/api/multi-agent/init", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_names: [
"taiwan-intelligence-gathering-agent",
"us-intelligence-gathering-agent",
],
main_agent_name: "main-controller",
model: "deepseek-chat",
}),
});
const { thread_ids } = await initResp.json();
// 3. 流式调用 leader
async function streamLeader(message: string, model = "deepseek-chat") {
const resp = await fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "leader",
d_agent_thread_id: thread_ids,
agent_name: "main-controller",
new_message: message,
skill_stop_names: [],
model,
}),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
let dispatched: [string, string][] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
try {
const payload = JSON.parse(line.slice(5).trim());
if (Array.isArray(payload.status)) {
dispatched = payload.status;
}
// 其余事件按 LangGraph SDK 协议自行渲染
} catch {
/* 非 JSON 帧,可忽略或交给现有渲染器 */
}
}
}
return dispatched;
}
// 4. 对派活清单逐个调用 special
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
return fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "special",
d_agent_thread_id: thread_ids,
agent_name: agentName,
new_message: task,
skill_stop_names: [],
model,
}),
});
// 同样按 SSE 解析
}
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
const dispatched = await streamLeader("请分析特朗普的支持率");
for (const [agentName, task] of dispatched) {
await streamSpecial(agentName, task);
}
const next = await streamLeader("根据上面收集到的信息出最终结论");
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
```
---
## 4. 注意事项
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
---
## 5. 相关接口(底层依赖)
本接口内部转发以下 Gateway 原生接口,了解细节可查:
| 接口 | 路由 | 用途 |
|---|---|---|
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
@@ -1,124 +0,0 @@
# 多智能体「后台挂起」—— 当前状态、遗留问题与下次继续指南
> 配套设计文档:`multi-agent-background-run-dev.md`(分 Phase 0-9 的总方案)。
> 本文记录**截至本次会话的实际进展 + 还没解决的遗留问题**,方便下次直接续接。
---
## 一、整体状态:跑通到哪了
「后台挂起」= Step2 把研讨交给后端**进程内驱动真模型**(leader→各席位→共识→报告),
survive 刷新/关页,对话与报告作为一个 **run 写进草稿**(和手动跑同一存取),可在历史回看。
**真网关已在本地验证可跑通**(2026-06-04 日志确认:roundtable-coordinator + 各席位 +
roundtable-report 真调 deepseek、真搜索、跑到 report run success)。但还有几个**内容/产物层面的遗留问题**(见第三节)。
### 已完成(可用)
- ✅ 后端持久化:`roundtable_jobs` 表(迁移 `20260604_01` / `20260605_01` dialogues / `20260605_02` step3)。
- ✅ 编排引擎 `packages/harness/deerflow/agents/roundtable_orchestrator/`(recommend + chain + 报告,纯逻辑可单测)。
- ✅ 控制面 `app/gateway/routers/roundtable_jobs.py`:start/get/by-draft/stream(SSE)/resume/cancel/admin-cleanup。
- ✅ 执行器 `app/gateway/roundtable_job_executor.py`:后台任务 + `set_current_user` + 生命周期 + **每轮把对话作为 `job-<id>` run 写进 `draft.step2.runs[]`(同手动存取)** + done 写 step3。
- ✅ 真网关 `app/gateway/roundtable_inprocess_gateway.py`:`SimpleNamespace(app)` + `start_run` 进程内驱动 agent;复用 `multi_agent._extract_dispatch` 解析派活;checkpointer 读最终 messages。
- ✅ 模拟网关 `roundtable_orchestrator/simulated_gateway.py`(不调真模型,前端联调用)。
- ✅ 前端:`api/roundtable-jobs.ts`、`lib/job-types.ts`、Step2「后台挂起」按钮、历史下拉 loading、`DispatchChainModal`/`DispatchChainJobModal`(流程图 + 对话面板 markdown + 加宽 + 澄清作答 + 取消 + 查看)。
- ✅ 上下文「广播」(inline):席位/总控 prompt 带上「任务背景 + 前序各席位交付 + 别提问」约束(修「席位拿不到关键信息、反问用户、总控瞎编」)。
- ✅ 后端测试:`tests/test_roundtable_jobs.py` / `test_roundtable_orchestrator.py` / `test_roundtable_job_executor.py` / `test_roundtable_jobs_api.py` 全过。
---
## 二、怎么启动 / 验证(每次开工先做)
1. **开真网关**(默认是模拟网关):已写进 `offline-backend-20260512/.env`:
```
ROUNDTABLE_JOB_GATEWAY=inprocess
```
启动日志应出现 `[roundtable-jobs] gateway = inprocess (real models)`。
2. **必须用 `make gateway`(不带 --reload)跑后端!**
`make dev` 的 `--reload` 监视 `backend/` 目录,agent 运行时往 `.deer-flow/` 写文件
(memory.json / checkpoint / 沙箱输出)会触发热重载,**把长时后台任务杀掉**。
(改进项:给 uvicorn 加 `--reload-exclude '.deer-flow/*'`,或始终用 `make gateway`。)
3. **直接查库验证**(最有效,PowerShell 控制台是 GBK,导出到文件看中文):
```bash
cd offline-backend-20260512/backend
# 查最新作业的 dialogues / step3
PYTHONPATH=. uv run python -c "import sqlite3,json; con=sqlite3.connect('.deer-flow/data/deerflow.db'); ..."
```
- 作业表:`.deer-flow/data/deerflow.db` → 表 `roundtable_jobs`(`dialogues`/`step3` 列)。
- 草稿表:`roundtable_drafts` → `step2` 列里应有 `runs[ {id:"job-<jobId>", jobId, jobStatus, step2RoundtableDialogues:[...], step3:{...}} ]`。
4. **前端验证**:后台挂起 → 等跑完 → 从历史加载该草稿 → Step2 应显示后台研讨完整对话(走现有 `normalizeStep2Runs`/`hydrateStep2Run`)。
---
## 三、遗留问题(下次重点,已按优先级排)
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)
- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads/<id>/user-data/outputs`;
`run.step3.html` 回退成报告 agent 的**纯文字**(不含 `<html>`),第3步画不出东西。
- **已做**:`build_report_prompt`(`roundtable_orchestrator/engine.py`)已对齐前端
`useStep3Report.buildReportPrompt`,明确「用 write_file 写入 /mnt/user-data/outputs/方案总览.html」;
`_read_report_html`(`roundtable_inprocess_gateway.py`)已改为扫 outputs 目录任意 `.html`。
- **仍没解决**:报告 agent(deepseek-chat)还是没调 `write_file`。
- **下次怎么查**:
1. 对比**前端那条能写出 HTML 的报告 run** 的确切调用(`useStep3Report.ts` 里 `streamMultiAgent`
调 `/api/multi-agent/run/stream` 的 body:`agent_type`/`excluded_tools`/`context` 等),
和后台 `_run_agent_turn`(`roundtable_inprocess_gateway.py`,agent_name=roundtable-report,
`excluded_tools=_REPORT_EXCLUDED`)逐项比对,找出差异(怀疑 reasoning_effort / thinking /
某个 context 字段 / recursion 步数导致它没走到 write_file)。
2. 看报告 run 的最终 messages(checkpoint)里有没有 write_file 的 tool_call(被 dangling 截断?)。
3. 报告 agent 的 SOUL:`app/gateway/routers/_roundtable_seed/roundtable-report/SOUL.md`——确认它默认行为。
### P1 —— 内容质量(本次刚加 inline 广播,待真机验证)
- **已做**:席位/总控 prompt 注入「任务背景 + 前序交付 + 别提问」。
- **待验证**:真机跑一次,看席位是否基于真实上下文产出(不再反问/凭记忆瞎编)。
- **若 inline 不够**:考虑实现**真·thread 广播**(像 `multi_agent._append_thread_message` 那样把
「用户意图 / 派活 / 各席位交付」追加进各 thread 历史)。进程内要避开 HTTP loopback 鉴权,
得用 checkpointer 直接 append human 消息(参考 `threads` 路由 POST `/state` 的内部逻辑)。这块较重,先看 inline 够不够。
### P2 —— 对话「没有步骤(steps)」
- **现象**:手动跑的气泡带工具调用步骤卡(`onPhaseChange` → `appendStepIfNew` → `Step2Dialogue.steps`);
后台对话只存了最终正文,没有 steps。
- **下次怎么做**:后台 `_run_agent_turn` 跑完后,从 checkpoint 的 messages 里抽出 tool_calls,
映射成 `Step2Dialogue.steps`(`{id,key,label,ts,args}`),在执行器 `_to_step2_dialogue` 里带上。
纯展示增强,不影响内容/报告。
### P3 —— 双跑浪费 + --reload 坑(工程健壮性)
- 进 Step2 时前端会自动跑一遍本地研讨(真模型、真线程);点后台挂起后前端那条 run 在后端还会跑完
(`stopOrchestration` 只断客户端、停不了后端 run)→ 和后台作业短暂双跑、双倍 token。
- 优化方向:进 Step2 改成「显式开始」而非自动跑;或后台挂起时真正 cancel 前端那条 run。
- `make dev` 的 `--reload` 会杀后台任务(见第二节);改进项:`--reload-exclude '.deer-flow/*'`。
---
## 四、关键文件清单(下次定位用)
**后端**
| 文件 | 作用 |
|------|------|
| `app/gateway/roundtable_inprocess_gateway.py` | 真网关:进程内驱动 leader/seat/re 编排循环 + `build_report_prompt` + inline 广播(`_seat_message`/`_deliveries_block`) |
| `.../roundtable_orchestrator/progress.py` | 进度落库 + `on_dialogue` 每轮回调 |
| `.../persistence/roundtable_jobs/` | job 表 model/sql |
| `app/gateway/deps.py` | `ROUNDTABLE_JOB_GATEWAY` 选网关 + 注入 executor(draft_store) |
**前端**
| 文件 | 作用 |
|------|------|
| `src/roundtable-planning/api/roundtable-jobs.ts` | 作业 API + SSE |
| `src/roundtable-planning/lib/job-types.ts` | 类型(RoundtableJob/JobDialogue/...) |
| `src/roundtable-planning/components/DispatchChainModal.tsx` / `DispatchChainJobModal.tsx` | 流程图弹窗 + 对话面板 |
| `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 后台挂起按钮、历史下拉、**runs[] 多运行模型**(`hydrateStep2Run`/`getStep2SnapshotWithRuns`/`normalizeStep2Runs`) |
| `src/roundtable-planning/api/drafts.ts` | `DraftStep2RunSnapshot`(带 jobId/jobStatus/step3) |
---
## 五、关键架构事实(避免重复踩坑)
1. **真网关进程内驱动**:`start_run(body, thread_id, SimpleNamespace(app=app))` + `set_current_user`,
不走 HTTP(loopback 调 `/api/multi-agent/*` 不可行:`_auth_headers` 只转发用户 token + owner check)。
2. **后台作业首启自建全新线程**(`StartParams.is_resume`),绝不复用前端本地研讨的线程(会撞 409)。
3. **存取统一**:后台作业 = `draft.step2.runs[]` 里一个带 jobId 的 run,和手动跑同一存取/hydrate 路径,
**不要再开 observe/回显分支**(之前试过,被否)。
4. **聊天记录确实存上了**(job.dialogues + draft 的 run);之前"没存上"是测了旧代码 / 看的是前端本地残缺数据。
5. 多 worker / SSE:状态进 DB、SSE 走 DB 轮询;真网关进程内无 loopback。
@@ -0,0 +1,198 @@
# 后台挂起「在当前进度继续」实现方案
> 状态:**待实现**(仅方案,未改代码)
> 关联:[[multi-agent-background-run-dev.md]]、[[multi-agent-frontend-dev.md]]、memory `project-roundtable-background`
> 目标:点「后台挂起」后,后端作业**在前端当前研讨进度上接着跑**,而不是新建空线程从头重跑。
---
## 1. 现象与根因
### 现象
在第二步研讨进行中点「后台挂起」,转入后台的作业是**从头重新开始执行**(重新初始化席位、从原始 intent 重新派活),而不是接着前端已经讨论到的进度往下走。
### 根因
后端「起作业」路由 `start_job` **从不设 `is_resume`**(默认 `False`),而执行器据此**新建一套全新线程**:
```python
# offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py (_run, 约 215 行)
init = getattr(gateway, "init_threads", None)
if init is not None and not params.is_resume:
thread_ids, coordinator_name = await init() # ← 建全新空线程
```
加上前端 `startBackgroundJob` **故意不传 `threadIds`**、用 `buildInitialIntent()` 作第一轮 leader prompt,于是后台作业 = 空线程 + 原始意图 = 从头重跑。
> 这是**原设计的有意取舍**。`startBackgroundJob` 注释写明:不复用前端线程,因为「那些线程可能有活跃 run,复用会撞 409」。本方案就是要在可控前提下打开「复用前端线程续跑」这条路。
### 后端其实已支持续跑
澄清续跑路由 `resume_job`(`roundtable_jobs.py` 约 293 行)已经在做「在原线程上接着派活」:
```python
executor.start_job(StartParams(
...,
seed_message=_RESUME_PREFIX + answer, # 续跑式提示,而非原始 intent
thread_ids=row.get("thread_ids"), # 复用作业自己的线程
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
is_resume=True, # ← 关键:跳过 init_threads,直接复用
...
))
```
执行器在 `is_resume=True` 时直接用 `params.thread_ids` + `params.coordinator_name`(不调 `init_threads`),交给 `run_orchestration` 续跑。**所以能力已存在,`start_job` 只是没走这条路。**
---
## 2. 设计目标与关键事实
- 「同进度续跑」= 后台作业复用**前端当前会话的真实线程**(总控 + 各席位),这些线程的 checkpoint 里已存着到目前为止的完整研讨上下文,续跑 = 在原线程上 post 一条「继续」消息让总控接着派活。
- 前端编排是**逐轮**的:每轮一个 `POST /api/multi-agent/run/stream`(leader 或某席位),**轮与轮之间线程空闲**。只有「某一轮在飞时」线程才被占用。
- 后端复用现成线程是**已验证路径**(`resume_job` 天天在用),唯一新增的不同点是:这次复用的是「前端会话线程」而非「上一个后台作业的线程」——但线程同属一个后端线程库,复用方式完全一致。
---
## 3. 改造点(最小集)
### 3.1 后端:让 `start_job` 支持复用线程
文件:`offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py`
1. `StartJobRequest` 增加字段:
```python
resume: bool = False # true + 携带 threadIds ⇒ 复用这些线程续跑,不新建
```
2. `start_job` 里把它透传给 `StartParams`:
```python
is_resume = bool(body.resume and body.threadIds)
executor.start_job(StartParams(
...,
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
is_resume=is_resume, # ← 新增
...
))
```
其余执行器逻辑**不动**(它在 `is_resume=True` 时已能复用 `params.thread_ids`)。
> 幂等闸门(同草稿未终态作业直接返回)保持不变;续跑挂起前前端应保证该草稿没有在跑的旧作业。
### 3.2 前端 API:补 `resume` 入参
文件:`frontend-web/src/roundtable-planning/lib/job-types.ts`
- `StartJobPayload` 增加 `resume?: boolean`。
文件:`frontend-web/src/roundtable-planning/api/roundtable-jobs.ts`
- `toStartBody` 增加 `resume: p.resume ?? false`。
### 3.3 前端:`startBackgroundJob` 改为复用线程续跑
文件:`frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(`startBackgroundJob` 约 1119 行)
当前端已有现成会话(`step2.threadIds` 且 `step2.coordinatorName` 非空,说明研讨已 init)时,改传:
```ts
const canContinue = !!(step2.threadIds && step2.coordinatorName);
await startJob({
draftId: draftId ?? undefined,
// 续跑:用「继续」式 seed,而不是 buildInitialIntent()(后者会让总控从头理解任务)
intent: buildInitialIntent(), // 仍传,作为无线程回退时的首轮 prompt
seedMessage: canContinue
? "用户已将本次研讨转入后台,请基于现有进展继续上一轮的协调与派活;若已可收口则直接综合输出结论。"
: undefined,
agents,
model: selectedModel || undefined,
orchestrationMode,
chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 关键三件套:复用前端真实线程 + 协调名 + resume 标志
threadIds: canContinue ? step2.threadIds ?? undefined : undefined,
coordinatorName: canContinue ? step2.coordinatorName ?? undefined : undefined,
resume: canContinue,
});
```
> 没有现成会话(极早期、线程未建)时 `canContinue=false`,自动回退到「新建线程 + 原 intent」的旧行为,安全。
### 3.4 前端:挂起前先停本地编排(避免交出去时线程还在飞)
文件:同上,`handleBackgroundSuspend`(约 1161 行)
当前顺序是 `startBackgroundJob()` → `resetToStep1()`(reset 里才 `stopOrchestration`)。复用线程时必须**先停再交**:
```ts
const handleBackgroundSuspend = useCallback(async () => {
if (step2.selectedAgents.length === 0) { /* toast 提示后 return */ }
// 1. 先彻底停掉前端在飞的那一轮,尽量让线程在交给后台前变空闲
step2.stopOrchestration();
step2.setIsRoundTableRunning(false);
try {
// 2. 再起后台作业(复用线程续跑)
await startBackgroundJob();
} catch (err) { /* toast 失败后 return */ }
// 3. 最后重置回第一步(后台作业继续在历史记录里跑)
resetToStep1();
triggerToast("🚀 已转入后台,将基于当前进度继续研讨…");
void drafts.refresh();
}, [...]);
```
---
## 4. 关键风险:线程占用(409)与三种处理
复用前端线程的唯一风险:**在某一轮在飞时挂起**,`stopOrchestration()` 只断了客户端 SSE,服务端那次 run 可能还在跑一小会儿;后台作业首轮 leader 向同一总控线程 post → 撞 409 / rollback。
| 方案 | 做法 | 取舍 |
|---|---|---|
| **A(推荐起步)** | 仅前端 stop + 后端 `is_resume` 复用,接受小概率 409 | 实现最简,真正同进度续跑;轮间挂起 100% 安全,仅「某轮在飞时挂起」有小概率 409 —— 走编排循环**现有 rollback/重试容错**兜底 |
| **B(最稳)** | 同 A,但后端在复用线程续跑前,**先 cancel 这些线程上可能残留的 run**(参考 takeover 里 `cancelJob` 的 `task.cancel()` 思路,落到 thread-run 级别)再 post | 几乎无 409;后端要多写「按 thread_id 取消活跃 run」的逻辑 |
| **C(不推荐)** | 不复用线程,新建空线程,但把「目前为止的研讨」塞进 seed_message 让总控续写 | 零 409,但**席位子线程上下文全丢**、不是真正同进度、leader prompt 臃肿 |
> 建议:先上 **A**,真机验证 409 命中率;若频繁再加 **B** 的后端线程级 cancel。
---
## 5. 适用范围(哪些挂起入口改续跑)
三个触发源(见 `autoSuspendRef`):
1. **手动「后台挂起」按钮**(`handleBackgroundSuspend`)——**改续跑**。能在 start 前干净 stop,最适合复用线程。
2. **路由卸载自动挂起**(切到其它页面,`fire`)——可改续跑(能走完整异步链);卸载瞬间 stop 不一定彻底,按 A 方案的容错兜底。
3. **刷新/关标签页 keepalive**(`beforeunload` 的 `beacon` → `startJobKeepalive`)——**建议保持新建线程**。关页时无法保证前端 run 已停,复用线程 409 风险最高;这里走「新建线程 + 原 intent」的安全回退即可(用户可在历史记录里点「查看」再用我们已实现的 takeover 接管回前端续跑)。
> 落地顺序建议:先只改 #1,验证稳定后再决定是否推广到 #2。
---
## 6. 与已实现「查看=接管回前端」的关系
二者是一对对称操作,复用同一套「线程同属一个后端库、可跨前后端续跑」的事实:
- **后台挂起(本方案)**:前端 → 后端。前端 stop,把前端线程交给后台 `is_resume` 续跑。
- **查看接管(已实现,见 memory)**:后端 → 前端。`cancelJob` 停后台,`hydrateFromDraft` 恢复作业真实线程,`resumeLiveTakeover()` 由前端在那些线程上续跑。
实现本方案时可直接参照 takeover 的线程复用与 `is_resume` 经验。
---
## 7. 测试
后端(`make test`):
- 新增/扩展 `tests/test_roundtable_jobs.py`:`start_job` 带 `resume=true + threadIds` ⇒ `StartParams.is_resume=True` 且**不调** `init_threads`、直接用传入线程;`resume=false` 或无 `threadIds` ⇒ 走 `init_threads` 新建(回归旧行为)。
- 回归:`tests/test_roundtable_inprocess_request.py`、`tests/test_roundtable_step3_pipeline.py`(确认续跑不破坏 Step3 双产物)。
前端(`pnpm typecheck`)+ 真机:
- 研讨进行到几轮后点「后台挂起」→ 历史记录里点「查看」→ 确认后台是**接着已有对话**往下派活,而非从第 1 轮重来。
- 轮间挂起 / 某轮在飞时挂起两种时机各验一次,观察是否出现 409(A 方案下应被 rollback 容错吸收)。
---
## 8. 落地清单(TL;DR)
- [ ] 后端 `StartJobRequest.resume` 字段 + `start_job` 透传 `is_resume`
- [ ] 前端 `StartJobPayload.resume` + `toStartBody`
- [ ] 前端 `startBackgroundJob`:有会话时传 `threadIds/coordinatorName/seedMessage/resume`
- [ ] 前端 `handleBackgroundSuspend`:调整为 **先 stop 再 start 再 reset**
- [ ] 后端测试(resume 复用 vs 新建分支)
- [ ] 真机验证「接着进度跑」+ 409 命中率
- [ ](可选)方案 B:后端复用线程前按 thread_id 取消残留 run
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,375 @@
# 浅蓝色 UI 规范
> 本文档由设计稿截图(`1.1 全局样式-色彩`、`1.2 全局样式-色彩附录`)识别整理而成。
> 全部色值已与"色彩附录"编号表逐一核对,保证准确。
---
## 1.1 色彩体系总览
调色体系分为 **主色** 与 **辅色** 两大类。每个色相按用途分为若干阶梯:
| 阶梯 | 数量 | 用途 |
| --- | --- | --- |
| 浅色 | 3 | 用于大面积底色 |
| 中亮色 | 6 | 用于标签、图表、提示等 |
| 深色 | 4(+基准色,共 5 个深阶) | 用于小面积按钮、图标等 |
| 线性渐变色 | 1 | 用于特殊装饰性小面积底色 |
| 径向渐变色 | 1 | 用于特殊装饰性小面积底色 |
> 中性色(灰阶)略有不同:浅色用于背景 / 白色文字及分割线,中亮色用于备注文字及图标,深色用于正文,且**无渐变色**。
---
## 主色
### 主色 - 红(基准 `#CF1322`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FEFAFA` · `#FEF6F6` · `#FEEDED` |
| 中亮色 | `#FDDADC` · `#FCB5B9` · `#FA9196` · `#F86C73` · `#F74750` · `#F5222D` |
| 深色 | `#CF1322` · `#BC0D1E` · `#A8071A` · `#820014` · `#5C0011` |
| 线性渐变色 | `#F5222D` → `#A8071A` |
| 径向渐变色 | `#F86C73` → `#F5222D` |
### 主色 - 蓝(基准 `#0062D9`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#F9FCFF` · `#F4F9FF` · `#EAF4FF` |
| 中亮色 | `#D4E9FF` · `#AAD2FF` · `#80BCFF` · `#55A6FE` · `#2B8FFE` · `#0079FE` |
| 深色 | `#0062D9` · `#0056C6` · `#004AB3` · `#00368C` · `#002466` |
| 线性渐变色 | `#0079FE` → `#004AB3` |
| 径向渐变色 | `#55A6FE` → `#0079FE` |
---
## 辅色
### 辅色 - 橙(基准 `#D97B00`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FFFCF9` · `#FFFAF4` · `#FFF6EA` |
| 中亮色 | `#FFEED4` · `#FFDDAA` · `#FFCC80` · `#FFBB55` · `#FFAA2B` · `#FF9900` |
| 深色 | `#D97B00` · `#C66D00` · `#B35F00` · `#8C4600` · `#663000` |
| 线性渐变色 | `#FF9900` → `#B35F00` |
| 径向渐变色 | `#FFBB55` → `#FF9900` |
### 辅色 - 绿(基准 `#1EB07F`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FAFEFD` · `#F6FDFB` · `#EEFCF7` |
| 中亮色 | `#DCF8EE` · `#BAF1DD` · `#97EBCD` · `#74E4BC` · `#52DDAB` · `#2FD69A` |
| 深色 | `#1EB07F` · `#189D72` · `#118A65` · `#07634B` · `#043D30` |
| 线性渐变色 | `#2FD69A` → `#118A65` |
| 径向渐变色 | `#74E4BC` → `#2FD69A` |
### 辅色 - 青蓝(基准 `#2F9ACC`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FBFDFE` · `#F7FCFE` · `#EFFAFE` |
| 中亮色 | `#E0F5FD` · `#C1EBFB` · `#A2E1F9` · `#83D7F7` · `#64CDF5` · `#45C3F3` |
| 深色 | `#2F9ACC` · `#2788B9` · `#1E76A6` · `#115580` · `#0B3959` |
| 线性渐变色 | `#45C3F3` → `#1E76A6` |
| 径向渐变色 | `#83D7F7` → `#45C3F3` |
### 中性色 - 灰(基准 `#222222`)
| 用途 | 色值 |
| --- | --- |
| 浅色(背景、白色文字及分割线) | `#FFFFFF` · `#FAFAFA` · `#F5F5F5` |
| 中亮色(备注文字及图标) | `#E8E8E8` · `#DBDBDB` · `#C1C1C1` · `#A6A6A6` · `#7F7F7F` · `#575757` |
| 深色(正文) | `#3D3D3D` · `#222222` |
---
## 1.2 色彩附录(编号对照表)
编号规则:`ZS` = 主色(主色调),`FS` = 辅色 / 中性色。`-01~-03` 为浅色,`-04~-09` 为中亮色,`-10~-13` 为深色,`-14` 为线性渐变色,`-15` 为径向渐变色;不带后缀的为该色相基准深色。
### 主色 ZS
| 编号 | 红 (ZS-001) | 编号 | 蓝 (ZS-002) |
| --- | --- | --- | --- |
| ZS-001 | `#CF1322` | ZS-002 | `#0062D9` |
| ZS-001-01 | `#FEFAFA` | ZS-002-01 | `#F9FCFF` |
| ZS-001-02 | `#FEF6F6` | ZS-002-02 | `#F4F9FF` |
| ZS-001-03 | `#FEEDED` | ZS-002-03 | `#EAF4FF` |
| ZS-001-04 | `#FDDADC` | ZS-002-04 | `#D4E9FF` |
| ZS-001-05 | `#FCB5B9` | ZS-002-05 | `#AAD2FF` |
| ZS-001-06 | `#FA9196` | ZS-002-06 | `#80BCFF` |
| ZS-001-07 | `#F86C73` | ZS-002-07 | `#55A6FE` |
| ZS-001-08 | `#F74750` | ZS-002-08 | `#2B8FFE` |
| ZS-001-09 | `#F5222D` | ZS-002-09 | `#0079FE` |
| ZS-001-10 | `#BC0D1E` | ZS-002-10 | `#0056C6` |
| ZS-001-11 | `#A8071A` | ZS-002-11 | `#004AB3` |
| ZS-001-12 | `#820014` | ZS-002-12 | `#00368C` |
| ZS-001-13 | `#5C0011` | ZS-002-13 | `#002466` |
| ZS-001-14 | `#F5222D` → `#A8071A` | ZS-002-14 | `#0079FE` → `#004AB3` |
| ZS-001-15 | `#F86C73` → `#F5222D` | ZS-002-15 | `#55A6FE` → `#0079FE` |
### 辅色 / 中性色 FS
| 编号 | 橙 (FS-001) | 绿 (FS-002) | 青蓝 (FS-003) | 中性灰 (FS-004) |
| --- | --- | --- | --- | --- |
| FS-00n | `#D97B00` | `#1EB07F` | `#2F9ACC` | `#222222` |
| -01 | `#FFFCF9` | `#FAFEFD` | `#FBFDFE` | `#FFFFFF` |
| -02 | `#FFFAF4` | `#F6FDFB` | `#F7FCFE` | `#FAFAFA` |
| -03 | `#FFF6EA` | `#EEFCF7` | `#EFFAFE` | `#F5F5F5` |
| -04 | `#FFEED4` | `#DCF8EE` | `#E0F5FD` | `#E8E8E8` |
| -05 | `#FFDDAA` | `#BAF1DD` | `#C1EBFB` | `#DBDBDB` |
| -06 | `#FFCC80` | `#97EBCD` | `#A2E1F9` | `#C1C1C1` |
| -07 | `#FFBB55` | `#74E4BC` | `#83D7F7` | `#A6A6A6` |
| -08 | `#FFAA2B` | `#52DDAB` | `#64CDF5` | `#7F7F7F` |
| -09 | `#FF9900` | `#2FD69A` | `#45C3F3` | `#575757` |
| -10 | `#C66D00` | `#189D72` | `#2788B9` | `#3D3D3D` |
| -11 | `#B35F00` | `#118A65` | `#1E76A6` | — |
| -12 | `#8C4600` | `#07634B` | `#115580` | — |
| -13 | `#663000` | `#043D30` | `#0B3959` | — |
| -14 | `#FF9900` → `#B35F00` | `#2FD69A` → `#118A65` | `#45C3F3` → `#1E76A6` | — |
| -15 | `#FFBB55` → `#FF9900` | `#74E4BC` → `#2FD69A` | `#83D7F7` → `#45C3F3` | — |
> 注:中性灰(FS-004)无渐变阶,最深至 `#222222`(基准)/`#3D3D3D`(FS-004-10),用于正文文字。
---
# 组件规范
> 以下为各组件的视觉规范与实现方式。颜色统一引用上文色值,建议在项目中先抽取为 CSS 变量 / Tailwind token,组件只引用语义化变量。
## 语义化 Token 建议
把色彩体系映射成语义变量,组件层只用语义名,便于换肤与维护:
```css
:root {
/* 品牌 / 主色(蓝) */
--color-primary: #0062D9; /* 主按钮、实底选中:ZS-002 深色基准 */
--color-primary-hover: #0079FE; /* 悬浮态:ZS-002-09 */
--color-primary-active: #0056C6; /* 按下态:ZS-002-10 */
--color-primary-bg: #EAF4FF; /* 浅底选中底色:ZS-002-03 */
--color-primary-border: #0079FE; /* 描边选中边框 */
/* 中性 */
--color-text: #3D3D3D; /* 正文:FS-004-10 */
--color-text-secondary: #A6A6A6; /* 次要 / 占位符:FS-004-07 */
--color-border: #DBDBDB; /* 默认边框:FS-004-05 */
--color-bg-disabled: #F5F5F5; /* 失效底色:FS-004-03 */
--color-fill-disabled: #A6A6A6; /* 主按钮失效底色:FS-004-07 */
--color-surface: #FFFFFF; /* 卡片 / 控件底 */
}
```
> 项目使用 Tailwind v4 + shadcn,可在 `@theme` 中声明这些变量后用 `bg-primary` / `text-primary` 等使用;下文示例同时给出等价的 Tailwind 写法。
---
## 2.1 基础 - 按钮(Button)
### 按钮尺寸
| 规格 | 高度 | 圆角 | 用途 |
| --- | --- | --- | --- |
| 小 | **28px** | 4px | 空间小的板块内部,如表格中、卡片标题等 |
| 正常 | **32px** | 4px | 常用按钮(默认尺寸) |
水平内边距约 12–16px,字号 14px,图标与文字间距 4–8px。
### 常用按钮类型
| 类型 | 视觉 | 用途 |
| --- | --- | --- |
| 主按钮 | 实底 `--color-primary`,白字 | 一个操作区域**只能有一个**主按钮 |
| 主按钮·失效 | 实底灰 `--color-fill-disabled`,白字 | 主按钮禁用态 |
| 次要按钮 | 白底 + `--color-primary` 边框 + 蓝字 | 重要度低于主按钮,高于默认按钮 |
| 默认按钮 | 白底 + `--color-border` 边框 + `--color-text` | 默认动作(取消等) |
| 默认·失效 | `--color-bg-disabled` 底 + 浅边框 + `--color-text-secondary` | 次要/默认按钮禁用态 |
| 文本按钮 | 无边框无底,蓝字(可加下划线) | 表格操作、文件下载等 |
| 图标按钮 | 仅图标,可选 描边/实底/浅底 × 方形/圆形 | 增强辨识度或节省空间 |
实现(Tailwind 示意):
```tsx
// 主按钮
<button className="h-8 px-4 rounded text-sm text-white bg-[#0062D9]
hover:bg-[#0079FE] active:bg-[#0056C6]
disabled:bg-[#A6A6A6] disabled:cursor-not-allowed">确定</button>
// 次要按钮(次按钮)
<button className="h-8 px-4 rounded text-sm text-[#0062D9] bg-white
border border-[#0062D9] hover:bg-[#EAF4FF]">点击</button>
// 默认按钮
<button className="h-8 px-4 rounded text-sm text-[#3D3D3D] bg-white
border border-[#DBDBDB] hover:border-[#0079FE] hover:text-[#0079FE]
disabled:bg-[#F5F5F5] disabled:text-[#A6A6A6] disabled:border-[#DBDBDB]">取消</button>
// 文本按钮
<button className="text-sm text-[#0062D9] hover:underline">文本按钮样式</button>
// 图标按钮(实底圆形)
<button className="size-7 grid place-items-center rounded-full text-white bg-[#0062D9]
hover:bg-[#0079FE]"><TrashIcon className="size-4" /></button>
```
图标按钮三种底 × 两种形状:
- **描边**:白底 + `--color-border` 边框,图标用 `--color-text`
- **实底**:`--color-primary` 底 + 白图标
- **浅底**:`--color-primary-bg`(`#EAF4FF`)底 + 蓝图标
- 形状:方形 `rounded`(4px)/ 圆形 `rounded-full`
### 按钮组合
| 组合 | 说明 |
| --- | --- |
| 文字 + 下拉按钮 | 按钮右侧带 `⌄` 箭头;有 默认 / 主按钮 两种变体 |
| 图标 + 文字按钮 | `+ 正常`,前置图标;有 描边 / 实底 两种变体 |
| 文本按钮 + 图标 | 蓝色文本链按钮:`更多 »` `展开 ⌄` `下一页 »|` `收起 ⌃` `|⌜ 上一页` |
| 主按钮 + 默认按钮 | `确定`(主) `取消`(默认) `返回`(默认) 并排,间距 8–12px |
### 其他状态
- **悬浮状态(hover)**:主按钮悬浮时底色变亮(`--color-primary-hover` `#0079FE`),用于 Banner / 登录 / 视觉冲击强的场景。
- **加载中(loading)**:按钮内前置旋转图标 `⟳ 加载中`,期间 `disabled` 并降低交互;用于异步操作等待反馈。
```tsx
<button className="h-8 px-4 rounded text-sm text-white bg-[#0062D9] inline-flex items-center gap-1.5" disabled>
<Spinner className="size-4 animate-spin" /> 加载中
</button>
```
---
## 5.9 输入 - 单选框(选择按钮 / Segmented Radio)
一组互斥选项,外观为按钮组。两种排布 × 三种选中样式。
### 排布方式
| 排布 | 说明 |
| --- | --- |
| 拼接式 | 选项首尾相连,仅最外侧两角圆角,相邻共用 1px 边框 |
| 分离式 | 选项之间留间距(约 8px),每个独立 4px 圆角,各自带完整边框 |
### 选中(已选)样式 × 三种
| 样式 | 已选 | 未选 |
| --- | --- | --- |
| 描边式 | 白底 + 蓝边框 `--color-primary` + 蓝字 | 白底 + `--color-border` 边框 + `--color-text` |
| 浅底式 | `--color-primary-bg`(`#EAF4FF`) 底 + 蓝字 + 蓝边框 | 同上 |
| 实底式 | `--color-primary` 实底 + 白字 | 同上 |
- **带统计**:选项文字后追加计数,如 `已选(230)` / `未选(230)`,计数用次要色 `--color-text-secondary`。
实现(拼接式 + 描边选中):
```tsx
<div className="inline-flex rounded overflow-hidden border border-[#DBDBDB] divide-x divide-[#DBDBDB]">
{options.map((o) => (
<button key={o.value}
className={cn(
"h-8 px-4 text-sm bg-white",
value === o.value
? "text-[#0062D9] ring-1 ring-inset ring-[#0079FE] z-10" // 描边选中
: "text-[#3D3D3D] hover:text-[#0079FE]"
)}>
{o.label}{o.count != null && <span className="text-[#A6A6A6]">({o.count})</span>}
</button>
))}
</div>
```
> 浅底式把选中态换成 `bg-[#EAF4FF] text-[#0062D9]`;实底式换成 `bg-[#0062D9] text-white`。
> 分离式去掉 `divide-x` / `overflow-hidden`,外层用 `gap-2`,每个按钮单独 `rounded border`。
### 按钮式页签(Tab as Button)
图标 + 文字的页签型按钮:
- **激活** 两种:描边(白底 + 蓝边框 + 蓝字)/ 实底(`--color-primary` 底 + 白字)
- **正常**:白底 + `--color-border` 边框 + `--color-text`,前置图标
- 结构与拼接式选择按钮一致,区别在每项前置一个功能图标。
---
## 5.11 输入 - 选择器(Select / Dropdown)
单项输入选择,下拉单选。
### 状态
| 状态 | 触发框 |
| --- | --- |
| 占位 | `请选择` 占位文字用 `--color-text-secondary`,灰边框 `--color-border`,右侧 `⌄` |
| 展开/聚焦 | 边框变 `--color-primary`,箭头翻转为 `⌃` |
| 已选 | 显示选中项文字(正文色),保持聚焦时蓝边框 |
### 下拉面板
- 白底、圆角 4px、投影(如 `shadow-lg`),最大高度后出现滚动条
- 选项默认 `--color-text`;**hover / 选中** 项:`--color-primary-bg`(`#EAF4FF`) 底 + `--color-primary` 文字
- **分组**:分组标题用小号灰字 `--color-text-secondary`,组之间用 1px 分割线 `--color-border`
实现示意:
```tsx
// 触发框
<button className="h-8 w-full px-3 inline-flex items-center justify-between rounded
border border-[#DBDBDB] bg-white text-sm
data-[state=open]:border-[#0062D9]">
<span className={selected ? "text-[#3D3D3D]" : "text-[#A6A6A6]"}>{selected ?? "请选择"}</span>
<ChevronDown className="size-4 text-[#A6A6A6] transition-transform data-[state=open]:rotate-180" />
</button>
// 下拉项
<li className={cn(
"h-9 px-3 flex items-center text-sm cursor-pointer rounded-sm",
active ? "bg-[#EAF4FF] text-[#0062D9]" : "text-[#3D3D3D] hover:bg-[#EAF4FF]"
)}>{label}</li>
// 分组标题
<div className="px-3 py-1.5 text-xs text-[#A6A6A6]">分组标题1</div>
```
> 推荐直接基于 Radix `Select`(项目已有 shadcn 封装)改这些类名落地。
---
## 5.14 输入 - 开关(Switch)
两种形态:
### 1. 滑块开关(多用于表单、表格中)
- **开**:胶囊底 `--color-primary`,白色圆形滑块在右,左侧白字「开」
- **关**:胶囊底灰 `--color-text-secondary`/`#C1C1C1`,滑块在左,右侧灰字「关」
- 切换时滑块带左右过渡动画
```tsx
<button role="switch" aria-checked={on}
className={cn(
"relative h-6 w-12 rounded-full transition-colors text-[11px] text-white",
on ? "bg-[#0062D9]" : "bg-[#C1C1C1]"
)}>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white transition-all",
on ? "left-[26px]" : "left-0.5")} />
<span className={cn("absolute top-1/2 -translate-y-1/2", on ? "left-2" : "right-2 text-[#7F7F7F]")}>
{on ? "开" : "关"}
</span>
</button>
```
### 2. 单选框作为开关(分段式)
- `开 | 关` 两段式:激活段 `--color-primary` 实底白字,未激活段白底灰字 + `--color-border` 边框
- 即 5.9 拼接式「实底选择按钮」的两选项特例
```tsx
<div className="inline-flex rounded overflow-hidden border border-[#DBDBDB]">
<button className={cn("h-7 px-4 text-sm", on ? "bg-[#0062D9] text-white" : "bg-white text-[#3D3D3D]")}>开</button>
<button className={cn("h-7 px-4 text-sm border-l border-[#DBDBDB]", !on ? "bg-[#0062D9] text-white" : "bg-white text-[#3D3D3D]")}>关</button>
</div>
```
+161
View File
@@ -0,0 +1,161 @@
# 需求文档 · 问答中心左侧栏改造(0616)
> 整理自 2026-06-16 需求草稿。本批需求统一围绕「问答中心」菜单下 5 个页面入口的**左侧侧边栏**进行改造,目标是让每个业务页面的左栏从「通用导航」变为「贴合该业务场景的快捷入口 + 历史记录」。
>
> 菜单结构参考:`frontend-web/src/core/page-layout/sidebar-menu.ts` 中 `qa-center`(问答中心)分组,含 5 个子项,正好对应下文 5 条需求。
## 0. 背景与共性约定
| 需求 | 菜单项 | id | 现有路由 | 对应页面/组件 |
|------|--------|----|----------|--------------|
| 1 | 通用问答 | `general-qa` | `/page/workspace/chats/new` | Workspace Chat + 左栏(`recent-chat-list.tsx` 等) |
| 2 | 多智能体会商 | `multi-agent-roundtable` | `/page/strategy/qa/multi-agent-roundtable` | `roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 3 | zzJC支持 | `qa-zzjc` | `/page/strategy/agents/6bf/chats/new` | `pages/AgentChatPage.tsx` |
| 4 | zzXD应用 | `qa-zzxd` | `/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new` | `pages/AgentChatPage.tsx` |
| 5 | 课题研究 | `qa-research` | `/page/canvas/ai-writing` | `open-canvas/pages/AIWritingPage.tsx` |
**共性约定(适用于全部 5 条,除非单条另有说明):**
- 「更多」一律为可点击入口;点击后跳转到对应的完整列表/管理页(具体目标见各条「待确认」)。
- 列表为空时的兜底:参考各条「现状」中的级联/空态规则;无内容时该区块整体隐藏或显示占位文案(待 UI 设计确认,默认隐藏,与现有 `recent-chat-list.tsx` 一致)。
- 左栏宽度、折叠交互沿用现有侧边栏体系(`SidebarGroup` 系列组件)。
- 所有「智能体」相关数据复用 `core/agents`(`Agent` 类型已含 `is_pinned` / `is_favorite` / `featured_order`,scope 支持 `mine` / `square` / `builtin`)。
---
## 1. 通用问答 — 左侧栏改造
**页面**:`/page/workspace/chats/new`
### 1.1 现状
当前左栏主要展示「最近会话」列表(`recent-chat-list.tsx`,已过滤 `scheduler` / `bootstrap` / `roundtable` 类型线程,置顶会话优先)。
### 1.2 目标布局(自上而下)
左栏从上到下依次为三块:**新建回答按钮 → 常用智能体 → 聊天记录**。
#### ① 顶部:新建回答
- 左栏最顶部放置「**新建回答**」按钮(主操作)。
- 点击 = 开启一次全新的通用问答会话(等价于跳转 `/page/workspace/chats/new`)。
#### ② 常用智能体(最多 3 个 + 更多)
- 展示「常用智能体」卡片/列表项,**最多 3 个**。
- 数据来源采用**级联兜底**逻辑(取到即用,不足再降级):
1. 若用户有**置顶智能体**(`is_pinned = true`)→ 展示置顶的;
2. 否则展示**用户自己的智能体**(`scope = "mine"`);
3. 仍没有 → 展示**默认广场智能体**(`scope = "square"` / `builtin`,即广场默认推荐)。
- 三者按上述优先级取,最终最多取 3 个。
- 下方显示「**更多**」入口。
- 点击某个常用智能体 → 进入该智能体的对话(待确认:是否为该智能体新建会话)。
#### ③ 聊天记录(最多 10 条 + 更多)
- 展示用户的问答聊天记录,**最多 10 条**(沿用现有 `useThreads` + 过滤规则)。
- 下方显示「**更多**」入口。
- 点击「更多」→ **跳转到问答记录页面**(完整聊天记录列表页)。
### 1.3
- 「常用智能体」点击后「打开该智能体主页」
- 「常用智能体」的「更多」跳转目标页 智能体管理 `/page/strategy/agents`。
- 「聊天记录」的「问答记录页面」具体路由http://localhost:5173/#/page/workspace/chats。
- 级联兜底是(置顶组够 3 个就完全不展示我的/广场)如果不够「补足到 3 个
---
## 2. 多智能体会商 — 左侧栏重做
**页面**:`/page/strategy/qa/multi-agent-roundtable`(`RoundtablePlanningPage`)
### 2.1 现状
圆桌会商页已有业务链选择、分析草稿等能力(`roundtable-planning/api/chains.ts`、`hooks/useRoundtableDrafts.ts`)。需求要求**左栏整体重做**。
### 2.2 目标布局(自上而下)
#### ① 顶部:业务链(公共,前 3 个)
- 顶部展示**业务链**,仅取**公共**链条(`listChains("public")`,即所有已发布 `is_public = true` 的链条)。
- 仅展示**前 3 个**。
- 点击某条业务链 → 以该业务链启动一次会商(沿用现有"会商自动串联圆桌"流程,见记忆 `business-chain-mapping`)。
#### ② 下方:分析记录
- 业务链下方展示「**分析记录**」列表。
- 数据来源:圆桌分析草稿/历史(`useRoundtableDrafts` / `listDrafts`)。
- 点击某条分析记录 → 打开对应的历史会商分析。
### 2.3 待确认
- 「业务链」是否需要「更多」入口跳转到业务链条配置页(`/page/strategy/business-chains`)?需求未提及,默认仅展示前 3 个、无更多。 需要更多入口
- 「分析记录」是否需要分页/「更多」?需求未提及,默认展示全部或合理上限 分析记录默认全部吧。
- 公共业务链「前 3 个」的排序口径(创建时间倒序?已有 `listChains` 默认 newest first) 按照现在默认的显示来吧,然后要支持用户单选,如果用户选择后,点击进入多智能体研讨按钮时,直接按照选择的业务链进去执行,如果用户取消勾选,或没有勾选就还是打开弹窗。
---
## 3. zzJC支持 — 左侧栏改造(按类型聚合智能体)
**页面**:`/page/strategy/agents/6bf/chats/new`(当前为单一智能体 `6bf` 的对话页)
### 3.1 现状
当前 zzJC支持直接进入某个固定智能体(id=`6bf`)的对话页(`AgentChatPage`)。
### 3.2 目标布局
- **左栏按「类型」分组**,展示该类型下的智能体列表("类型"待确认,推测为智能体 `tags` / 分类)。
- 智能体分组**下方**展示**聊天记录**。
- 左栏**宽度与通用问答(需求 1)保持一致**("左侧是问答宽")。
### 3.3 Admin 设置入口
- 在 **admin 账号权限下**,页面**右侧顶部显示「设置」图标按钮**(齿轮图标)。
- 点击设置按钮 → 打开设置面板,可对该模块进行配置。
- 非 admin 账号不显示设置按钮(沿用 `sidebar-menu.ts` 中 `adminOnly` 同款角色判断)。
### 3.4 待确认
- 「类型」的确切含义:是智能体的 `tags`,还是 zzJC 业务自定义的一组分类?
- 现 zzJC 入口是单个智能体 `6bf`;改为「按类型展示多个智能体」后,**进入该菜单时默认落地页是什么**(智能体列表?还是仍默认进 `6bf` 对话)?是否需要新建一个 zzJC 聚合页。
- Admin「设置」具体可配置项:配置「该模块纳入哪些类型/哪些智能体」?还是其他(如默认智能体、欢迎语)?需明确设置项清单。
- 「聊天记录」过滤范围:仅 zzJC 相关会话,还是全部会话?
---
## 4. zzXD应用 — 左侧栏改造
**页面**:`/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new`
### 4.1 需求
**与需求 3(zzJC支持)完全一致**:
- 左栏按类型展示该类型下的智能体 + 下方聊天记录;
- 左栏宽度同通用问答;
- admin 账号下右侧顶部显示设置图标按钮,可设置。
### 4.2 待确认
- 同需求 3 全部待确认项。
- zzXD 与 zzJC 是否共用同一套「按类型 + 设置」组件(仅数据源/默认智能体不同),还是各自独立配置?建议**抽象为同一可复用组件**,通过参数区分。
---
## 5. 课题研究 — 左侧栏改造(研究模板 + 笔记本)
**页面**:`/page/canvas/ai-writing`(`AIWritingPage`)
### 5.1 目标布局(自上而下)
#### ① 上半部分:研究模板
- 左栏上半部分展示「**研究模板**」。
- 研究模板 = **原来的「文章类型」**(`open-canvas/hooks/useArticleTypes.ts` 的 `articleTypes`,即 AI 写作表单里的「文章类型」选择项)。
- 点击某个研究模板 → 以该模板/文章类型发起课题研究(沿用现有 `articleType` 设置逻辑)。
#### ② 下半部分:笔记本
- 左栏下半部分展示「**笔记本**」。
- 笔记本内容 = **「简洁模式」左侧菜单下方展示的笔记本内容**(`useStudioNotebooks`,即 `workspace-nav-studio.tsx` 中渲染的 studio notebooks 列表)。
- 点击某个笔记本 → 打开该笔记本(沿用现有 `/page/workspace/studio/notebooks/{id}` 行为,或作为课题研究的素材来源 `materialSource = 'notebook'`,待确认)。
> 备注:草稿原文写作「简介模式」,结合代码应为「**简洁模式**」(`appearance-settings-page.tsx` 中的全局界面模式:全量 / 简洁;简洁模式下笔记本在左栏菜单展示)。
### 5.2 待确认
- 「研究模板」点击行为:仅预填表单 `articleType`,还是直接开始生成?
- 「笔记本」点击行为:跳转笔记本详情页,还是将笔记本设为当前课题研究的素材来源(`materialSource='notebook'`)?
- 上/下两部分是否各自可折叠、是否需要「更多」入口与数量上限?需求未提及。
---
## 6. 整体待确认与建议
1. **复用性**:需求 3 / 4(zzJC / zzXD)布局完全相同,建议实现为同一套「按类型分组 + 聊天记录 + admin 设置」可复用组件,通过配置区分模块。
2. **左栏宽度统一**:需求 1 / 3 / 4 都要求"问答宽",建议统一左栏宽度常量,避免各页面各写一套。
3. **「更多」目标页**:需求 1 涉及「智能体更多」「聊天记录更多→问答记录页」两个跳转目标需先确定路由是否已存在。
4. **Admin 设置项清单**:需求 3 / 4 的设置面板具体能配置什么,需要产品给出字段清单后才能落地。
5. **入口语义变更**:需求 3 / 4 把原「单一智能体对话」入口改为「按类型聚合」,菜单 `path` 与默认落地页可能需要调整,需同步确认。
+10540
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -74,6 +74,7 @@
"hast": "^1.0.0",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"mermaid": "^11.15.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
+64 -11
View File
@@ -7,37 +7,70 @@ import { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale";
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import {
resolveEmbedRoundtableTheme,
resolveEmbedTheme,
} from "@/core/embed/params";
import { usePublicEmbedTheme } from "@/core/embed/background";
import { readWujieProps } from "@/core/embed/wujie";
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
import PublicScheduledTaskPage, {
PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "light" : undefined;
const publicEmbedTheme = usePublicEmbedTheme();
const forcedTheme = useMemo(() => {
const routeTheme = resolveForcedThemeForPath(location.pathname);
if (routeTheme) return routeTheme;
if (location.pathname === "/embed/roundtable") {
const wp = readWujieProps();
const raw =
(typeof wp.theme === "string" && wp.theme.trim()) ||
new URLSearchParams(location.search).get("theme")?.trim() ||
"dark";
return resolveEmbedRoundtableTheme(raw);
}
if (location.pathname.startsWith("/embed/chats")) {
const raw = new URLSearchParams(location.search).get("theme")?.trim();
return raw ? resolveEmbedTheme(raw) : undefined;
}
if (PUBLIC_EMBED_PREFIXES.some((p) => location.pathname.startsWith(p))) {
return publicEmbedTheme;
}
return undefined;
}, [location.pathname, location.search, publicEmbedTheme]);
return (
<NextThemesProvider
attribute="class"
storageKey="strategy-theme"
defaultTheme="light"
themes={["light", "dark", "lightblue"]}
enableSystem={false}
disableTransitionOnChange
forcedTheme={forcedTheme}
>
{/* QueryClientProvider must wrap ThemeProvider because the custom
ThemeProvider reads the admin-configured default via useQuery. */}
<QueryClientProvider>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
@@ -53,32 +86,47 @@ function AppProviders({ children }: { children: React.ReactNode }) {
);
}
/**
* 根路由 `/` → `/login` 重定向,**保留 query string**。
* 外部系统的任务深链落在 `#/?taskId=...&goPath=...`,裸 `<Navigate to="/login">` 会丢掉
* query,导致 LoginPage 读不到参数;这里把 search 一并带过去。
*/
function RootRedirect() {
const { search } = useLocation();
return <Navigate to={`/login${search}`} replace />;
}
export function App() {
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only roundtable share — landing + full result report. */}
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage />}
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage report />}
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
{/* Public read-only scheduled-task runs viewer — no authentication. */}
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
<Route
path="/embed/chats/:thread_id"
element={
@@ -87,6 +135,11 @@ export function App() {
</ChatRuntime>
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="62" viewBox="0 0 1920 62"><defs><style>.a{fill:url(#a);}.b{fill:rgba(0,98,217,0.1);}.c{fill:#fff;}.d{fill:rgba(0,98,217,0.2);}.e{filter:url(#h);}.f{filter:url(#e);}.g{filter:url(#b);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#0b3776"/><stop offset="1" stop-color="#0c1935"/></linearGradient><filter id="b"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="c"/><feFlood flood-color="#0062d9" flood-opacity="0.102" result="d"/><feComposite operator="out" in="SourceGraphic" in2="c"/><feComposite operator="in" in="d"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="e"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="f"/><feFlood flood-color="#0062d9" flood-opacity="0.102" result="g"/><feComposite operator="out" in="SourceGraphic" in2="f"/><feComposite operator="in" in="g"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="h"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="i"/><feFlood flood-color="#0062d9" flood-opacity="0.2" result="j"/><feComposite operator="out" in="SourceGraphic" in2="i"/><feComposite operator="in" in="j"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(-2009 -3361)"><rect class="a" width="1920" height="62" transform="translate(2009 3361)"/><g data-type="innerShadowGroup"><path class="b" d="M0,0H318L268,62H0Z" transform="translate(2009 3361)"/><g class="g" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H318L268,62H0Z"/></g></g><g data-type="innerShadowGroup"><path class="b" d="M0,0H310L260,62H0Z" transform="translate(2009 3361)"/><g class="f" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H310L260,62H0Z"/></g></g><g data-type="innerShadowGroup"><path class="d" d="M0,0H302L252,62H0Z" transform="translate(2009 3361)"/><g class="e" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H302L252,62H0Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080" viewBox="0 0 1920 1080"><image width="1920" height="1080" xlink:href="data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMwaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA5LjEtYzAwMSA3OS4xNDYyODk5Nzc3LCAyMDIzLzA2LzI1LTIzOjU3OjE0ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgMjUuMCAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OUVERkMwNjI0Q0RCMTFGMDg5MTZCMkY2RDg1RkVEQkUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OUVERkMwNjM0Q0RCMTFGMDg5MTZCMkY2RDg1RkVEQkUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5RURGQzA2MDRDREIxMUYwODkxNkIyRjZEODVGRURCRSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5RURGQzA2MTRDREIxMUYwODkxNkIyRjZEODVGRURCRSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pv/uAA5BZG9iZQBkwAAAAAH/2wCEAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwODxAPDgwTExQUExMcGxsbHB8fHx8fHx8fHx8BBwcHDQwNGBAQGBoVERUaHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fH//AABEIBaAKAAMBEQACEQEDEQH/xACVAAEBAQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgBAQEBAQEBAAAAAAAAAAAAAAABAgMEBRAAAQMDAwIEBAYCAgICAAENAQARITFBAlFhEnEDgZGhIrHBMhPw0eFCUgTxBWIjcjMUBoKSokOywtIkFeJTFmMHEQEBAQEAAwEBAAIDAQEAAAAAARECITEDEkFhBFFxEyIy/9oADAMBAAIRAxEAPwD/ADJ3O73PuZjnk3I1J1XqcWfu91/ry8ygfd7n88urlA+73W+vJ+pQT7nd/nlG5QX7vdE88tpKCDu9z+WXmUFPd7tOeUblA+73f5nzKB97ufyy8ygfe7teeXmVA+73J9+TdSgfe7n88m6lBPu92nLLzKov3e4R9eXVygn3e5/PLzKB93uH9+XmUD7vd/nl5lA+73a88o3KB93u/wA8vMoq/d7rtzy0qUD7vdvnkPEoh93ufzy8yig7vdrzPmUQ+73K88vNA+93P5ZeZRT7vcB+vLzKiH3e4w9+XmUU+73P55HxKIfd7tTkfMoH3e6/15eZQPu93+eXmUD7vdec8vMoH3e7/LLq5QT7nd/nl5lFX73dpyy8ygHu93+eW0lUPu90/vPmUQPd7v8APLzKB93u/wAz5lA+73a88vMop97uv9eXmUD7vd/nl5lED3e6z88tpKB93ufzy8ygfd7v88vMoL93ut9WUblBPudz+eXmUF+53f55eaB93ufzy6uUA93uv9eXmVQ+73P55eZQPu93+ZPiUAd3uP8AXl5lA+73f55eZQPvd1/rPmURfu93+eXmUE+73f55eZVF+73P55P1KB93u/yy8ygfd7rPzPmUA97u/wAz5lUPu9yvM+ZQPu9xvryHiUAd3ufzPiSgfd7leeXVygv3e7/M9HKCfd7v8svMoL93u/zy8yqIO73f5HzKC/e7rPzy8ygfd7ls8vMoh97ufzy2koH3O7/M+ZQPu9z+eXmUU+73P55dHKB93uU55eZQPu9wfvy8ygn3e7/PJupQX7vd/nk3UoH3e7/PLzKQfs/9f/tv6eX+qyJ7gxyxxIyxL1ZvVeueXyPp8+p2/HZ97uHPIjPJiYkrzd5vh9bn0n3e7/PLq5WVT7vd/nl5lA+73H+vLzKgfd7l88vMoH3e5/PLzKAO73bZZeZQD3O5bPI+JRT7vc/nl5lBPu9x/ry8yoL93uT7svMoIe73K88vMoH3e7/PLzKC/d7n88m1coH3e40Z5N1KIfd7n88vMoA7vc/nl5lFPu9yvPLzKgn3e5/PLzKoDu93+eXmUD7vdf68vMoH3e7/ADy8ygfd7v8AM+ZUD7vd/nk/UoIe73R+/J+pQX7vcpzy8ygfd7leeXmUD7vd/nkPEoB73c/nl5lA+73f55eZQPu92+eXmVA+73f55eZVD7vctmfMoJ93umeWXmUD7vdb68vMqB93u/zPmVRfu93+eQ8Sgfc7tOeXmUD7vc/mT4lA+73P55eZQT7nc/nl5lBfu93+Z8ygfd7p/efMoH3e7/LLzKgfd7lOeXmVQ+73f55eZQPu9z+eXmUD7vd/mfMoH3e7/PLzKB93ut9eT9Sgfd7v88vMoH3e431k+JQPu9z+eXmUF+73f55eZVD7nc/nl5lBPud3+eXmVBfu9z+R8yqh93u/zy8ygfd7t88vMoq/d7jkc8tpKCHu9z+eXmUD7vcb68vMoH3e6f35eZQPu9z+eXmUD7nddueRGjlA+53R+8t1KB93uu/PLzKC/e7o/fl5lUT7vdj35dHKAO53W+vLzKCnu9xvqy8ygv3e9/PLzKIfd7rfXk3UoH3O649x8ygfd7pP15eZQPu9yPefMwgfd7kjlkPEop93u/yy8yqh93uH9+XmUD7vcMc8urlAHd7n88vMpA+73f5ZeZVD7vdvlk53KAe73f5ZeZUD7nd/nk3UoJ93ufyy8ygfd7l88vMoOv8AV/uZdnv493PlmMX9pyIdwyJZsTu/2Mu538+5iTjjkTkMXJZMWMfe7teeU7lAHd7v88n6lA+73KjPLzKo32v7f9jtl+33csTsSkqWad3+3/Y7uXLudzLI6klW3STGD3e5/M+ZRU+73Qfry8yoB7vcvnl5lA+73THPLaSgfc7388o3QB3e4/15eZQPu9z+eXRygfd7n88vMoi/d7v8z5lFT7nc/nlvJQUd3uV5ZdHKIn3e40Z5byUF+73R+/LzKB97ufyy8ygn3O5/PLzKB93u/wAzTUoL9zufzPmUD7vc/mW6lA+73G+sv1KB93uU55RuVRR3u7fPKdygfd7grnltJQD3O4R9eXRygfd7k+8nxKofc7p/fltJQPu91/rPmYQD3e7/ADy8yiH3e6G9+T9Sgfd7n88vMqi/c7p/efMoJ9zu/wA8vMoL93uP9WXgSgv3e5fPLzKofc7v/wDcy8ymCDu9z+eT9Sgfc7v88tpKgp7ncf68urlBPu9w/uy8ygfd7n8st5KB93ufyy8ygfd7n88p3Kgp7vdf68n6lUPud3+eXVyiB7ndH78vMoJ93un9+QazlBfu9yvLLzKAO73p958ygfc7v88t5KYan3e7QZ5P1KC/d7ls8t5KCfd7k+8kdSgfd7jfXkfEqB93vWzy8SVRfvd1m5mdygn3O6I55eZUD73ceMy3UoH3e68ZZOdyqL9zu3yy2koIO73XnM+ZUFHe7xLcsnO5VD7vdEc8n6qAO53ZbPI+JTDUHe7gB9+XmUgDud2+WWgkpgfdz4znl5lBB3e7Tkd5KBj3O4x9xi7lSQqjvd3+eQ8Sgfd7teWTauUDn3WnPKNymGh7vcrzyO7mqofc7onmZ3Kgfd7wD8y4oHKB9zumueXmUU+73T+7JjdyiB7ndY+8ltygfc7rPyym7lFT7ndb68n6lTBfu9z+eR8SqiHu9wBxnlpUqKDud0//AKQ+ZQPu9wge7LzKoDu9z+eTdSoH3e7/ADy8ygfd7teR8ygn3e67c8iTSSin3u431FxUOVBD3e63/sy1qUF+73D+8v1KBj3e7Puy8yqH3O67cj1coJ93u2zLdSgp7ubfXl5lBPu93+eRrcqCnud2pyPmUEHd7g/fl5lA+73ZHPLq5hBfu9z+eQbcqCc+88ZF+pQU9zuGeeT9UE+93Kc8h0KKo73c/nl5lED3e63/ALMtalUPu93+eXRyoJ93u/zIOjlFX7vcYe8+ZQPu94zzy6uUEPc7gH15TuWUA93u/wAyB1KCfc7j/WX6lBfu93+WQaklAPd7tszO5QPu9y+WR8Sgg7vd/nl4koL93uuRzMblA+73Sfryncqh93uH9+U7lA+53P55eZQT7ndvnkT1KB93u055dXKgfd7v8yAayVQ+73Kcy2rlBfu9yTzy8ygn3e659+T3YlQPu912OeT6uUwPudz+WXmgfd7rxnk3UoIO93aDPLq5QPu9Line truncated

After

Width:  |  Height:  |  Size: 431 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="152" height="42.359" viewBox="0 0 152 42.359"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 536 150"><image width="536" height="150" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhgAAACWCAYAAACcofGRAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7tnVmSJLmSXTMiMiKzqt7A12S3cAcU4Sa4Cn7zj39cA4WbpEjvgMIm+/ENNWRmDNSrgMLV4BgUk7l5pHtJlnvYAIPBBj129QJ29+H2ubXAshZ4u1tW9F4F//cP++/D/7zANvdqz5Xb+Y8f3lYWnyz7f1xgm9N38m7/dpu+D7cCj9gC+988j9gKtzpNaoErBYo9IOIGDZPOsUnF7AEjVwsfN+CYdJZ998XcAOO7PwVGGuCKgGIVROwJDv/7pmzw2fpPO6oGq0DkquDjBhwjd8nved0bYHzPR79r368AKmbCxCqAuMFC19nXvdIqKJkJIFcBHTfY6D4Hv8MVb4DxHR70tl0+MFDMAomZEHEDh7bT62hLzwSRWfBxaPC4AcfRTuEj1ecGGEc6GoeqyzsHixlQcYOJQ52xyyozAzpmwMYNNJYd4lvBa1rgBhhr2vVKSz0gVIyqFCMg8R4A4j8c3Lfxzzv6KVZdlSMAMgoeh4SOm6qx6lS7tnJvgHFtR2xqfQ8EFL0gMQIQ0pZHBImjg8HU83BCYUcElRHwkCbpBZBDgccNOCac4VdZxA0wrvKwjVb6AGDRAxQjMHFpiNgbGP58cOUiPoX/tLOScWkgGYGPHug4BHDcQGP0zn1t698A49qOWFd9rxAoemHiEiAxGx6uDQ66zskFK82GlEtASC94tELHDTgWnIC3IuMWuAHGuz4nvhOw2BsqZgHFDST2ufpmgcfewNEDGzfQ2Oecum3F1AI3wDA107UtdEGwaE19tCgVe4JED0TcgOHaLpR0fXuAZE/4aAGPqwKOWwrlfVxAp724Aca7OKIXAoprhYkeeMB5cg0A8e8vdC7Muo7+1xUEmR4AQfushJB3CR1XcC7MOu/faTk3wLjqA3uBYNICFVZ1YqUy0QoTl4aIaweES19PlwaUVvg4AnS0qBwX8W7cQOPSl1Xv9m+A0dtyF1tvZ6i4FqBoAYm9IWI2NPz17vu8bv/wNvetn3vDSAt8rAAPq8pxaOC4wcbFQk/Hhr/PG1VHQx1jlR3h4hrA4ohQMQsmvleI6L3QZsHHXtBxSdi4etC4QUbvZbL3ejfA2LvFm7d3xVAxM/VhhYmV6sQIPNyAofnMX7rCCJCshBAreMxUOCzAcVhV4wYbS6+TwcJvgDHYgGtW3wkqrCrF3l4KC0ysAIlegLhWePh5p/PspysNAr0QsgJALOAxCzoswIEbnxU6dvNtXOl5tiaIHKLUG2Ac4jBIJXa44VugYk+guARMtIDEpeFhLwg41HXQUJlLw0sLhMwGj72gYyZw7AIbN9BouIKWLnoDjKXN21L4FcHFjNTH3mBxdKg4Akj8wyTz6L9ONmO2XEay7CXA41KwcSTQsKgaN8joOaOvcp0bYFz0sC2GCotagf2vKRZ7AMWslIcVJPZSJmaCwywAuOg5P2HjMwFmLxCxwscslaMGHTPSKTVlwwIbOB2WA8dN0Zhw1XUVcQOMrmYbXekAYLEaKmoKxQygsMDESpDohYfVoPDL4vNr9PTPrf/j4kDQCyYrIcQCHjOgYzVwzICNG2isurIuVu4NMHZr+sU3/ZpaUQMKtMOIUlECilGYuARI9MDDKDhcKxjsdg0ZNzQKKj0gMhtC9gCPEnSMKBw12MBhrKkbN9gwnuzHXuwGGMuPz0KwGIWKIwLFnjDRChE9AHGDhuVX2NAGemCkFUBmwcdq6LgUcFwUNharZkMn5/WvfAOMpcfwHcLFKqWiBhazUh0tUNEKFO8RJr5UjJ+fDmDonH0Nt0JHC3DsBRsjaZUVoFFTNW6QMfssPkR5N8CYfhjeGVRcAij2hIkWiDgCQNQC/vTz+WAFHgFoWgDEAh97QMeRgOMGGwe7qNZV5wYYU9r2BhXFZlypTlgUCStE7A0Qe8HC7xeenyPXz992kqf3hhIrgKyGj1pKpQc6ZqsbN9gYuYIOv+4NMIYO0aIb94i3otdXMVOpKAHFiDoxCyZWgsQMaDgqEAxdKzusPANYVsKIBTwuCR2twDEbNnCKlIDjYmmUnUB4h0tk703cAKOrxQ8GFrOhoqfXRw4qeoGiBhMWVWIFSPQCxA0auq60ZSv1wsgKAJkBHr1plpzK0QobOFA54OjtkZKDjRtoLLssZhd8A4zmFr0AXJS6mPbARU6taAWLFUpFCSwuARU9QHGDiear6hAr9EDHbOAYhY3ZoIED0wobM0GjV9FY1s31pma0XKs3wDC31gKw6E2FHBEqepSKEZiYqU60QsTRAOLLh/d5HX/68Ga+PPdYsBVAZsJHDTxKqZUe6JilbOwBGxdRNG6gYbnk3ueNybLn5mV2BouZasUMpWJm6qMXKGbARAtEXAog3isomK+1SQteCkxaAGQGfJSg44jAMQs2DqVq3ECjdNXeACPbOpPBYi+1YhVUzFQoaqmOEaCwgsReELEIGv7006QXk02K6auL+fPPi8bb2AtGrPAxAh69KscshaMllbIaNnZXNW6gkboH3ADjrFV2BItZakUKKmb4KVqhIqdQlIBiNUysAolBcDgSILwO7ksOLu73Ct4GuhkGlFX7YgGPVdCRUzlagSOVTmmBjZxBtMUcehhV4wYa+nK8Acbm5vQdwkUqBbIaLEagAserpFIcECj2golVoGCI31MX2QtMhqDjEsAxAho4QjmF4yigkVI1bpAx9drau7AbYHCLHwAsWoybo4rFKFTspVTU0h2zYaLzSX4UIN4LGOx986ptbxRUugFkNnzUVI4e8Ojxb7QoG6OqxirYKKVOpvc8uakZN8CYCRcln0UqHTIKFbjDWlMhK6BiZupjT5hoAIkReLiBQw0BjjF/BESaIGQmeMyGjlbg2As2Zng1UumT3UDj+4aM7xQwdlIsch6LUbDYAypaVIqelMfqNEcDRHxoMEz+ScXEG0AcAxBW10IDyJ9bNtZiTJ0BHyXomKVyjKZTRpSNvVWNm6LRcrYnl/0OAWMSXLSqFWh+K1iMpEBGlIoUVORUilaoWAkUVphoAIk/WsscvgQ7C/j2Tse+yDXH48HGxIjq+ZcWQLCCR0uZqXabCRwt/g2runEp2OgZIXQabHxfisZ3BhjfGVy0mDWtcHEUsLACgBEqDgkU3xtEdLLWhwPChxk49oCN1aCRUjWskIFjHoOGtQfKiKJxg4zeq61pve8IMCbAxZ6qxeo0iBUocDq1QEVOqRgxZFpgwggS2J2LwcTBgOHthwnXhOF2c/frwZ7aLgQkZuhAm1rAY0ThyEFHSypltqoxU9Gw9j65GGgc7JowXMc9i3wHgDHpJpqDixHzZm8qpDcNYoWKSwLFRJjYFSR2gIe9gKDnRrLnOrsAy44QYgaPldCxCjh61Y1e2JitauTMoLeUiemSf8eAMQEsWhWL1R6LGCwsKZDZUDFboagBRUWZWA4RC8DhBgqme9P0hZaAyWIQqcJHDTp6VY5R4EipG7Ngw5JC2Qs0cJZOgY33qWi8Q8CYABY4aWYrFrPUiqNARU/KowQThhTHUpgYBIkbNEzngYsWOAwjC8GjCh21FEsPdKSAw5pOuVbYaOneOgUycODeF2i8M8C4wQXlbrfHNNULxJoCmaVWHBUsBqDiBhQXjf+7b3wIOBbBxu6gsYeqYTGH9phCexWNG2QMXWvvCDAmwMVs1QKHJlYuLOZNnQqZpViMQMVMteISKY9OkPgeIOLt69rurndPx+5eOnT39Ct3w8cC8KhCRy6lcmlVY0b6pCd1MmIGXerPeB9KxjsBjEG4mA0WPemQUX/FbKWiBSoGFIppaY/vACJWw8CMYLtHGdcELZeGj27gwIFsgY6VKZQVqsZVKBrXDxlXDhiDYJHzWvT2DGkFi1GoQP1jsLAoFTNSHzmo2EOhaICJI6sQN2DYA0coq31gFaUJQCYoHt3AMQobDCxvb9UjHvs1YmVjNmysBo0p3ozrBY0rBoxBuEipFr1De7emQXrAwhm33PGaBRWLVYpp6oTclWpg8Ue34NvXwXOjehcsL3AEcHh7vruqa/vuoyH4DB6X2uqXBJG7JxVE/lKp6QTQ0FvIQkeph8oocIzAhgUysIOtXo0YNmrpk5YxNIZB4zoh46puQqeLYjCAWOHC0u10NVzUTJu9ioUVLjqUimGwqIGEujteWqG4BExcGzzUArt1/iUg5JLQgXbZU+Eoqhuj3o3eFMqoorEaMnCQrEbQ7xAyrhAwBuDC6rU4Oli8N6gwAsXeMLESHr5XSLDCxOzlVsLJ3hBiho4BpePwsNGaOrkEaCwxgV6XknFFgDEAFrhbzVItVioWo2pFyluxQKnYQ6HYEyZmgsTFwOHz4PUxO6KPlvfbZW6kM0FkT/AwQccAcOBwJqHjEsrGiKqxGjRuasbmyr8SwBi4eV4CLFo9FiWw6FErLFDRmProhooDqRMzQGI5QLw3UBgFjdb1F4PJDADZAzxMwIG27YSOZt+GxbMRp1FafRorVY0Z/oyUojGUNrkMhLdcklcAGO8YLi6lWKTgItP749rBYhQqlgHFDSRa7lPjyy4Cj1HgWA0bhwKNHsjAka+Bxl6Kxg0ymq/DgwPGheFiVTpkBCx60yAHgIpVaY9RiMBVMxUkbvDQfCM6xAoLine truncated

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}.c{clip-path:url(#c);}.d{fill:url(#d);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593"/></linearGradient><linearGradient id="b" x1="0.473" x2="0.473" y2="0.95" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593" stop-opacity="0"/></linearGradient><clipPath id="c"><rect class="a" width="1920" height="64" transform="translate(-2 5)"/></clipPath><pattern id="d" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 1917 104"><image width="1917" height="104" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB30AAABoCAYAAAD1oOqOAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7svQnUbMdRJli3qv717U+bpSdZsi1LtiUbtVdsQ7Nz2LpZvNADPdimWYfumW4Yz4Dpw+FwGs2hB4YZppsxDeOF5TCNMYYeNwM+mIPBm7zbki3JkmxL1tPytLz1X6vq3onIyMiMjBt5q/7//ZL+/ylL+l/VzZtL5BdLZkbkzVt986eaqlc+BYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgILAnEahK0HdP8q0QXRAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQEHAIl6FsEoSBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgI7GEEStB3DzOvkF4QKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBErQt8hAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBDYwwiUoO8eZl4hvSBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIlKBvkYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgILCHEShB3z3MvEL6U4pA9ZS2bjfe7EKaCkkFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgsDsRKH7u3cmXQlVBYFsIlKDvtmArhQoCDoHdMiCWYG8RyIJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBDYDgLFz70d1EqZgsAuRKAEfXchUwpJewqB3TAglqDvnhKZQmxBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQENhVCBQ/965iRyGmILA9BErQd3u4lVJ7G4HdMIDtVgRLAHm3cqbQVRAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQEIgLFz52XhuLnLprytESgBH2flmx/2ne6DIZlMHzaK0EBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQENjTCBQ/d/Fz72kBLsTvPAIY9O3vfLWlxoJAQaAgUBAoCBQECgK7GoGy43NXs6cQVxAoCBQECgIFgYJAQaAgUBDYNgIlCLJt6ErBgkBBoCBQECgIFAT2MgIl6LuXuVdoLwgUBAoCBYGCQEFgJxEogeCdRLPUVRAoCBQECgIFgYJAQaAgUBB44hEoAd4nHuPSQkGgIFAQKAgUBAoCewSBEvTdI4wqZO4qBGZdUGDwYNa8XR3U9XRdl4DFrhKVQkxBoCBwgSGwE3a92OkLTChKdwoCBYGCQEGgIFAQKAgUBEwELH/IrPPpWfMV6AsCBYGCQEFg+whsx2/9RNtnWX/xn2yft6Xk0xiBEvR9GjO/dH1LCGxnENxSA09A5jIwPgGglioLAgWBgsAOIFDs8w6AWKooCBQECgIFgYJAQaAgUBDY1QjsRT/Krga0EFcQKAgUBHYIgb1on4sfZYeYX6q58BGobvqPHxwcfeWrm8c+9KGq8m/3xevH4bqH13UE4eirp6djWS6BdbgyH4FvrBO+sb5QD2bkNwpjO/C7gW9ndbht/91A4kWvinXo+4FKyB/akvzz9Ye+wT2ss8LGNE3cPt7zveFyeHmR7xNXL+8xhlgn16/pcX0U2CZ4+EoZN3fpaeffrl5t5sSbmRu4F+5LHI26knb4PnfM4IHDiz8ZXubkJ9Cde4u0qi+Li5fNRNZQtuDDbVtyJ+UZ70N2V+bsbbdWJAjIbw8sXuvfPu3AjS9ssIz7/vxt1YEbbgzcCOlYJ3wwj2sD8rlryIt5HI8EliGfpMXTw+0wfQ0UPOjrcZVIOgV7Qn98Q9w2lpE0n/G0jk+fcbQGmfC6H3TP6zPmcfYBP17fXJrXb66DSXnswx+qpO5i/TpN1hXsBbQRdBQzeHpMO6RlSuhMS+c9t1rb0ry8a52Q5YO98vQktGTSWN81z009Ufoq87AdYRvZ0l0PuLQ3rCPBvmtcgGdsL6S+ef1wNTq+Mv4GzhYGQQzVGMLpWsaS/Hwhxp4ue8/20cIqjF+SDm0TNd+kDsnxyac78eGxA/VFjG0sx5Imrk6Of0GHrDHW65EcnzQ+SZtCByXpgW8STzk+qnGF9VaO/S3eajngOoTtTngrxm83Jmk+qPqkjPM4G8ZLySdh7wOWyAs/R5G6zbLM8xGNvewjt896E7CQdHJfUXf8WJ7kF0zQY5g1prnsYkA/+qqvQ/Kb43/27urYD7ymOf7ud5sLsWOviffwN1Zz/5++uyJzLyYIrEdaF0WbjBd+X/na1/m63uXma/i58vU+7U8gzX8w7X5xbaVznlxertuqR8ryLG1JOmXZvfq7mUx6zXjcw+8av/FvNMLfVTMaw/cIrq1vyof5XTlRz3axYP5vt/xeLCdlUsqx0zND7qUsS3mV2HXJ+Sw6YtExTTem3Z+FN5b+W7qby3e+eafRqPs4zV7NypNZbNA02vR9i9YuWcvJm8XXLvy77PY0mnJlu2TW6rfuS64PjMesdE0bk7bKo5K/ILATuq/nP9a1bEfL/Sz6pccariP4omQDuKjQn2RNl96Xc0xdjO+5bz9XxTyPf/iDsUZoz5p/u3zsH/S+BL0eb/kJ9fpfEJRdK4n1icsu1/jKhyF9Gi4v+z/wt/AVJmtRkU+ue1prCuFP5XvW2kquP+R6xeGlfFzsi0j8i3KuL2jTflbpJ2NeJPV5bALvlA8g8YlABdx3XA8hVs7fI/BNZEfQmPjrpL9HrE0Tf+YUs8S+JfbrYvYgV9baU9aX8zdwHuU7QR+W5T/WfhS5Dmdfe/AhC/mUdOZ4mkuXGGm/EPLEtaf0jX13vMZl/5TVhpbZRC6kH8HLnKkLjGPOJ235psT6WvoFuX7te0l8zJKfks+WDRC2QdsJ9lU6H5qQdeZliFfgPY6d6PaU3OqYQLA3yucubUTwH2qfi9BN5gvSPHfwYDDm7of0FXt62JeNl+G3dExDo4lvmvvBdUm/uUiTvmbnn5Y+aPSFC591yOtp4Cacz5zzyXb9bzleBL+6b8c5NRWtmHLwxhfWLO/a9rCtcLYeMksZb/l35Jjp/UFYXvuLEhsv5LHlX9W+OIyFQX0XSd+ib7MVI5N6JfWP7a+/nx2TfBmuJrEfYsx1/RM+frzW/iwZ8+L6ZBxQxhOt8YbLI/uSeB9UJmNvLb1H8ZY4aFuOtPrYpPaLmv59gx8hTsjxTBUjlfMcppXHCEmvNT6YsQDsD/tr1Rwp6LmP23LfJI8sPyzed0HfhBFRVZLAayuQyIJiOMWTwIgSKFd9LkAjGOUUGgVfBjCR8XryZdWv29BOT1YeVXdCG5SRAadE0TJttiYXVsBD4mUFIgwFZrwCDWiRkuirZBr8tgRW0txFF1dlDOStwItBPwVECIng5Jfk6XotupR8uH5jZcgQvgeC0cD13KE4sCkUOi+9Z7w3OnNajA693twBqM8PXhhcJf+5xxvb5DSrdj3Q6Dz+vquu3w7WNnWTstUKRCt6zE4ag3sY8HNyg33bCoAi7/gsBIvl4sqyCZZcs55aQQnUfeS7L8f2wHXfTwRlEFXqp95UoQO1VgDRrYWRL9yeH/hde06a1SfXR5EuaTbxsXQtg0UreNllR1intJ3VfRCL2rAJxehXa7PGlMl0blGsF56JnWI1k0Io7H/CA22/jEWEmV/3LTcOSfy0LZXyoHmgy0mZl/oh5V7TnrOHurxVt2rfXOxKGVAT1xZ/JC1WOSmrmb63+KD7Z2FtjRE+n9bF7CJvlrFX9smYJEqdNbHU+DFfc7hJW6Z1ka9bu7liobWHHu400Sk2Pqulq76NUFlOn6VBZPoyFCS22Jie6A0vVvdnyZODraQXBJ4MBLr21z0Z7W+nDUuvcrq21XRNzzQ7gPl3Ws/PtLine truncated

After

Width:  |  Height:  |  Size: 98 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="30" viewBox="0 0 32 30"><defs><style>.a{fill:rgba(255,255,255,0.05);}.b{fill:url(#a);}</style><linearGradient id="a" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#a2e1f9"/><stop offset="1" stop-color="#45c3f3"/></linearGradient></defs><g transform="translate(1.411 -9.285)"><rect class="a" width="32" height="30" rx="4" transform="translate(-1.411 9.285)"/><path class="b" d="M4618.588,4844.6a1.257,1.257,0,1,1,1.258,1.256A1.258,1.258,0,0,1,4618.588,4844.6Zm4.609.838v-1.676h12.569v1.676Zm-4.609-5.867a1.257,1.257,0,1,1,1.258,1.257A1.257,1.257,0,0,1,4618.588,4839.569Zm4.609.838v-1.676h12.569v1.676Zm-4.609-5.865a1.257,1.257,0,1,1,1.258,1.257A1.257,1.257,0,0,1,4618.588,4834.542Zm4.609.838V4833.7h12.569v1.676Z" transform="translate(-4613 -4815)"/></g></svg>

After

Width:  |  Height:  |  Size: 892 B

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,20 @@
import React from 'react';
const Empty = ({
description = '暂无数据',
size = 'default'
}: {
description?: string;
size?: 'default' | 'small';
}) => {
return (
<div className={`empty-container ${size === 'small' ? 'empty-small' : ''}`}>
<div className="empty-image">
<img src="/icon/empty-list.png" alt="暂无数据" />
</div>
{description && <p className="empty-description">{description}</p>}
</div>
);
};
export default Empty;
@@ -0,0 +1,626 @@
"use client";
import {
useEffect,
useRef,
useState,
} from "react";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { authenticatedFetch } from "@/components/assistant-ui/document-upload-popup";
interface NetworkActorProps {
inputValue: string;
searchSpaceId: string;
onSuccess?: () => void;
}
interface platformItem {
title: string;
icon: string;
checked: boolean;
}
interface languageItem {
title: string;
checked: boolean;
}
interface PoliticsDirItem {
地区: string;
语言: string;
年龄段: string;
性别: string;
教育程度: string;
关注话题: { 话题: string; 倾向: string; 佐证: string; 风格: string }[];
类型: string;
对HZZLC: string;
对MZZLC: string;
对TZZLC: string[];
账号描述: string;
发文频率: string;
活跃时间: string;
}
interface 用户画像 {
地区: string;
教育程度: string;
关注话题: { 倾向: string; 话题: string; 佐证: string; 风格: string }[];
语言: string;
年龄段: string;
账号描述: string;
对TZZLC: string[];
对HZZLC: string;
对MZZLC: string[];
性别: string;
类型: string;
发文频率: string;
活跃时间: string;
}
interface hx_info {
语言: string;
用户画像: 用户画像;
}
interface UserDataItem {
comment_count: number;
country: string | null;
agent_task_id: string | null;
education: string | null;
groupId: string | null;
forward_count: number;
political_stand: string;
language: string;
type: string;
concern: string | null;
profile_image: string | null;
concern_topic: string | null;
screen_name: string;
school: string | null;
is_alive: number;
createdTime: string;
data_provider: string | null;
id: string;
text: string | null;
group: string | null;
read_count: number;
group_name: string;
area: string | null;
image: string | null;
updatedTime: string;
like_count: number;
m_politics_dir: string;
camp: string | null;
tags: string[];
army: string | null;
is_sync_a: number;
tweets_count: number;
groupName: string | null;
history_key: string;
user_id: string;
real_id: string | null;
h_politics_dir: string | null;
domain: string | null;
name: string | null;
position: string | null;
job: string | null;
age_round: string | null;
birthday: string | null;
is_virtual: number;
isMedia: number;
hx_info: hx_info;
gender: string | null;
agentInfo: PoliticsDirItem;
profile_url: string | null;
tagId: string[];
user_name: string;
a_update_time: string | null;
values: string | null;
follows_count: number;
title: string | null;
is_protected: number;
platform: string;
importType: string;
update_time: string;
key: string;
introduction: string | null;
brief: string | null;
creator: string;
history_title: string;
tweets_ratio: string | null;
is_verified: number;
t_politics_dir: string[];
eventMap: string | null;
fans_count: number;
a_create_time: string | null;
hobbies: string[];
location: string | null;
average_count: number;
ratio: string | null;
classification?: string | null;
groupDiscussTags?: string[];
checked?: boolean;
content?: string;
url?: string;
}
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
const [platforms, setPlatforms] = useState<platformItem[]>([
{ title: 'X', icon: 'x.png', checked: false },
{ title: 'Facebook', icon: 'facebook.png', checked: false },
{ title: 'Reddit', icon: 'reddit.png', checked: false },
{ title: 'PTT', icon: 'ptt.png', checked: false },
{ title: 'YouTube', icon: 'youtube.png', checked: false },
{ title: 'Dcard', icon: 'dcard.png', checked: false },
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
{ title: 'Threads', icon: 'threads.png', checked: false },
{ title: 'Instagram', icon: 'ins.png', checked: false },
]);
const platformList = useRef<string[]>([]);
const [languages, setLanguages] = useState<languageItem[]>([
{ title: '繁体中文', checked: false },
{ title: '简体中文', checked: false },
{ title: '英文', checked: false },
{ title: '日文', checked: false },
{ title: '其他', checked: false },
]);
const languageList = useRef<string[]>([]);
const [allPlatforms, setAllPlatforms] = useState<boolean>(true);
const [allLanguage, setAllLanguage] = useState<boolean>(true);
const [sortType, setSortType] = useState<string>('fans_count');
const sortTypeValue = useRef('fans_count');
const [fansSort, setFansSort] = useState<string>('desc');
const sortValue = useRef('desc');
const [relevanceSort, setRelevanceSort] = useState<string>('desc');
const [userList, setUserList] = useState<UserDataItem[]>([]);
const [searchText, setSearchText] = useState<string>(inputValue);
const [isSearching, setIsSearching] = useState<boolean>(false);
const [searchType, setSearchType] = useState<string>('network');
const searchValue = useRef<string>('network');
const [selNetCount, setSelNetCount] = useState<number>(0);
const [actorCount, setActorCount] = useState<number>(0);
const [directionType, setDirectionType] = useState<string>('TW');
const directionValue = useRef<string>('TW');
const consumerUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || 'https://ch1.b.uat.4.cn/consumer';
const backendUrl = typeof window !== 'undefined'
? (import.meta.env.VITE_BACKEND_BASE_URL || 'http://localhost:8001')
: 'http://localhost:8001';
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
const [isImporting, setIsImporting] = useState(false);
const searchChange = async () => {
setIsSearching(true);
if (searchValue.current === 'network') {
await agentUserSearch();
} else {
await agentGroupSearch();
}
};
const setDirectionChange = async (type: string) => {
setDirectionType(type);
directionValue.current = type;
await searchChange();
};
const searchTypeChange = async (value: string) => {
setSearchType(value);
searchValue.current = value;
await searchChange();
};
const allPlatformChange = async () => {
setAllPlatforms(true);
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
platformList.current = [];
await searchChange();
};
const handleTogglePlatform = async (index: number) => {
setPlatforms((prev) => {
const newPlatforms = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
platformList.current = checkedTitles;
setAllPlatforms(newPlatforms.every((item) => !item.checked));
return newPlatforms;
});
await searchChange();
};
const allLanguageChange = async () => {
setAllLanguage(true);
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
languageList.current = [];
await searchChange();
};
const handleToggleLanguage = async (index: number) => {
setLanguages((prev) => {
const newItems = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
languageList.current = checkedTitles;
setAllLanguage(newItems.every((item) => !item.checked));
return newItems;
});
await searchChange();
};
const sortTypeChange = async (type: string) => {
setSortType(type);
sortTypeValue.current = type;
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
if (type === 'fans_count') setFansSort(sortValue.current);
else if (type === '_score') setRelevanceSort(sortValue.current);
await searchChange();
};
const agentUserSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyTitle: '',
keyword: searchText,
language: languageList.current,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.list;
result.forEach((item) => {
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo['账号描述'];
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data.data.total);
setActorCount(data.data.zs_total);
}
} catch (error) {
console.error('Search error:', error);
}
};
const agentGroupSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyTitle: '',
keyword: searchText,
language: languageList.current,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.data;
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}`;
item.title = item.group_name || item.groupName;
if (item.hx_info?.['用户画像']) {
item.content = item.hx_info['用户画像']['账号描述'];
}
item.url = '';
});
setUserList(result);
setActorCount(data.data.total);
}
} catch (error) {
console.error('Search error:', error);
}
};
const listDataCheckedChange = (index: number, checked: boolean) => {
setUserList((prevSources) => {
const newSources = prevSources.map((item, aIndex) =>
aIndex === index ? { ...item, checked } : item
);
setSelectedSearchSources(newSources.filter((item) => item.checked));
return newSources;
});
};
const linkToB2 = (data: UserDataItem) => {
const url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${data.user_id}&platform=${data.platform}`;
window.open(url, '_blank');
};
const handleBatchDelete = () => {
setUserList((prev) => prev.map((item) => ({ ...item, checked: false })));
setSelectedSearchSources([]);
};
const handleImport = async () => {
if (selectedSearchSources.length === 0) {
toast.warning('没有可导入的来源');
return;
}
setIsImporting(true);
const dataList = selectedSearchSources.map((item) => ({
title: item.title,
content: item.content,
url: item.url,
}));
try {
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_space_id: Number(searchSpaceId),
source_list: dataList,
}),
}, true);
if (response.ok) {
const data = await response.json();
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
setSearchText('');
setUserList([]);
handleBatchDelete();
onSuccess?.();
} else {
const errorData = await response.json();
toast.error(errorData.detail || '导入失败,请重试');
}
} catch {
toast.error('导入失败,请重试');
} finally {
setIsImporting(false);
}
};
useEffect(() => {
void searchChange();
}, []);
return (
<div>
<div className="bg-white rounded-[5px] p-[10px]">
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">所属平台:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
onClick={() => void allPlatformChange()}
>全部</span>
{platforms.map((item, index) => (
<span
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleTogglePlatform(index)}
>
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
<span className="ml-[5px]">{item.title}</span>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">常用语言:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
onClick={() => void allLanguageChange()}
>全部</span>
{languages.map((item, index) => (
<span
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleToggleLanguage(index)}
>
<span>{item.title}</span>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">方向:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">类型:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
</span>
</span>
</div>
</div>
</div>
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="flex items-center ml-[10px]">
<div className="title-bar mr-[8px]"></div>
<div className="text-[#454859]">用户表格管理</div>
</div>
<div className="flex items-center">
<div
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('fans_count')}
>
<span className="leading-[16px]">粉丝数排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
<div
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('_score')}
>
<span className="leading-[16px]">相关度排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
{searchType === 'network' && (
<>
<div className="flex items-center ml-[35px]">
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
</div>
</div>
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
</>
)}
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
</div>
</div>
</div>
</div>
{selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除选中
</Button>
<Button
size="sm"
onClick={() => void handleImport()}
disabled={isImporting}
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
>
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
导入选中
</Button>
</div>
</div>
)}
<div className="p-[10px]">
{isSearching ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : null}
<div className={`grid grid-cols-4 gap-[10px] user-list${isSearching ? ' hidden' : ''}`}>
{userList.map((item, index) => (
<div key={index} className="user-item p-[10px] relative">
<div className="absolute top-[5px] right-[10px]">
<input
type="checkbox"
checked={item.checked ?? false}
onChange={() => listDataCheckedChange(index, !item.checked)}
className="w-4 h-4 cursor-pointer accent-primary"
/>
</div>
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
<div className="flex-1 min-w-0 pl-[10px]">
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
<div className="flex justify-between mb-[10px]">
<div className="flex items-center">
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
<span>粉丝数:{item.fans_count || '-'}</span>
</div>
<div className="flex items-center">
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
<span>{item.platform}</span>
</div>
</div>
<div className="flex items-center">
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
<span>实体人:{item.name}</span>
</div>
</div>
</div>
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>分类:</span>
</div>
<div className="flex-1 min-w-0">
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
</div>
</div>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>标签:</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-[5px]">
{(item.hobbies ?? []).map((hobbie) => (
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
))}
</div>
</div>
</div>
<div className="flex items-baseline mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>简介:</span>
</div>
<div className="flex-1 min-w-0">
<div className="line-clamp-3 text-ellipsis overflow-hidden">
{(item.agentInfo && item.agentInfo['账号描述']) || (item.hx_info?.['用户画像']?.['账号描述']) || ''}
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,956 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
type ReactNode,
} from "react";
import type {
GraphEdge,
GraphNode,
LayoutType,
SimEdge,
SimNode,
TooltipState,
Transform,
} from "./graphTypes";
import {
applyCircularLayout,
ForceSimulation,
getEdgeId,
getEdgeInfo,
getEdgeLabel,
getLinkDistance,
getNodeKind,
getNodeRadius,
getNodeTitle,
NODE_KIND_META,
seedPositions,
truncateLabel,
} from "./graphUtils";
const MIN_ZOOM = 0.15;
const MAX_ZOOM = 4;
/** Canvas-drawn colors per app theme (CSS variables don't reach the 2D context). */
const CANVAS_PALETTE = {
dark: {
exportBg: "#0b1020",
labelHalo: "rgba(2, 6, 23, 0.9)",
nodeRim: "rgba(2, 6, 23, 0.55)",
rootRim: "#e0f2fe",
expandedRim: "#7dd3fc",
rootRing: "rgba(125, 211, 252, 0.9)",
expandedDot: "#7dd3fc",
expandedDotRim: "rgba(2, 6, 23, 0.8)",
selectionRing: "#ffffff",
hoverRing: "rgba(255, 255, 255, 0.85)",
pin: "rgba(255, 255, 255, 0.8)",
labelActive: "#ffffff",
label: "#e2e8f0",
minimapViewport: "rgba(56, 189, 248, 0.8)",
},
light: {
exportBg: "#f8fafc",
labelHalo: "rgba(255, 255, 255, 0.92)",
nodeRim: "rgba(255, 255, 255, 0.9)",
rootRim: "#075985",
expandedRim: "#0284c7",
rootRing: "rgba(2, 132, 199, 0.85)",
expandedDot: "#0284c7",
expandedDotRim: "rgba(255, 255, 255, 0.9)",
selectionRing: "#0f172a",
hoverRing: "rgba(15, 23, 42, 0.7)",
pin: "rgba(15, 23, 42, 0.6)",
labelActive: "#0f172a",
label: "#334155",
minimapViewport: "rgba(2, 132, 199, 0.8)",
},
} as const;
export interface GraphCanvasHandle {
zoomIn: () => void;
zoomOut: () => void;
fitView: () => void;
relayout: () => void;
focusNode: (id: string) => void;
exportImage: () => void;
}
interface GraphCanvasProps {
nodes: GraphNode[];
edges: GraphEdge[];
/** Degrees computed on the FULL graph so node sizes stay stable under filters. */
degrees: Map<string, number>;
selectedNodeId: string | null;
/** null = no active search; otherwise the set of matching node ids. */
searchMatchIds: Set<string> | null;
layoutType: LayoutType;
showLabels: boolean;
/** App theme; drives the canvas-drawn colors (labels, rims, rings). */
theme: "light" | "dark";
onSelectNode: (id: string | null) => void;
onTooltip: (tooltip: TooltipState | null) => void;
onZoomChange: (k: number) => void;
/**
* Explore-mode rendering context; null/undefined renders the normal full view.
* preview = no root picked yet: nodes are shown faded as pickable起点 candidates.
*/
explore?: {
preview: boolean;
rootId: string | null;
nodeDepthMap: Map<string, number>;
expandedNodeIds: Set<string>;
} | null;
/** Floating pills rendered top-left over the canvas. */
toolbar?: ReactNode;
/** Small floating hint rendered bottom-center over the canvas. */
hint?: ReactNode;
/** Centered overlay (empty / loading states). */
overlay?: ReactNode;
}
interface DragState {
pointerId: number;
node: SimNode | null;
startX: number;
startY: number;
originX: number;
originY: number;
moved: boolean;
}
const easeCubicOut = (t: number) => 1 - Math.pow(1 - t, 3);
export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
function GraphCanvas(props, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const minimapRef = useRef<HTMLCanvasElement>(null);
const simRef = useRef<ForceSimulation | null>(null);
const nodeMapRef = useRef<Map<string, SimNode>>(new Map());
const adjacencyRef = useRef<Map<string, Set<string>>>(new Map());
const labelThresholdRef = useRef(3);
const transformRef = useRef<Transform>({ x: 0, y: 0, k: 0 });
const sizeRef = useRef({ w: 0, h: 0 });
const hoverRef = useRef<SimNode | null>(null);
const dragRef = useRef<DragState | null>(null);
const animRef = useRef<{
from: Transform;
to: Transform;
start: number;
duration: number;
} | null>(null);
const needsDrawRef = useRef(true);
const autoFitRef = useRef(false);
// While the initial layout is still settling we keep the whole graph in
// view every frame; any pan/zoom/drag from the user takes over.
const interactedRef = useRef(false);
const rafRef = useRef(0);
// Latest props readable from the rAF loop / event handlers.
const propsRef = useRef(props);
propsRef.current = props;
const requestDraw = useCallback(() => {
needsDrawRef.current = true;
}, []);
// ---------------------------------------------------------------- build
useEffect(() => {
const previous = nodeMapRef.current;
const simNodes: SimNode[] = props.nodes.map((n) => {
const degree = props.degrees.get(n.id) ?? 0;
const kind = getNodeKind(n);
const old = previous.get(n.id);
return {
id: n.id,
node: n,
kind,
degree,
radius: getNodeRadius(degree),
color: NODE_KIND_META[kind].color,
x: old?.x ?? Number.NaN,
y: old?.y ?? Number.NaN,
vx: 0,
vy: 0,
fx: old?.fx ?? null,
fy: old?.fy ?? null,
};
});
const fresh = simNodes.filter((n) => Number.isNaN(n.x));
if (fresh.length === simNodes.length) {
seedPositions(simNodes);
} else {
// Filters re-enabled some nodes: spiral the newcomers in near the center.
fresh.forEach((n, i) => {
const r = 60 + 16 * Math.sqrt(i + 1);
const angle = i * 2.3999632297;
n.x = r * Math.cos(angle);
n.y = r * Math.sin(angle);
});
}
const byId = new Map(simNodes.map((n) => [n.id, n]));
const simEdges: SimEdge[] = [];
const adjacency = new Map<string, Set<string>>();
for (const e of props.edges) {
const source = byId.get(e.source);
const target = byId.get(e.target);
if (!source || !target) continue;
const label = getEdgeLabel(e);
simEdges.push({
id: getEdgeId(e),
source,
target,
label,
info: getEdgeInfo(label),
weight: e.weight ?? 1,
distance: getLinkDistance(label) + source.radius + target.radius,
});
if (!adjacency.has(source.id)) adjacency.set(source.id, new Set());
if (!adjacency.has(target.id)) adjacency.set(target.id, new Set());
adjacency.get(source.id)!.add(target.id);
adjacency.get(target.id)!.add(source.id);
}
// Label budget: always label hubs; ~20 labels max by default.
const sortedDegrees = simNodes.map((n) => n.degree).sort((a, b) => b - a);
labelThresholdRef.current = Math.max(
2,
sortedDegrees[Math.min(19, sortedDegrees.length - 1)] ?? 2,
);
nodeMapRef.current = byId;
adjacencyRef.current = adjacency;
const sim = new ForceSimulation(simNodes, simEdges);
if (props.layoutType === "circular") {
applyCircularLayout(simNodes);
sim.alpha = 0;
} else {
sim.alpha = previous.size === 0 ? 1 : 0.5;
}
simRef.current = sim;
if (previous.size === 0) {
autoFitRef.current = false;
interactedRef.current = false;
// Pre-settle a fresh force layout synchronously (time-bounded) so the
// first paint is already roughly laid out instead of an exploding
// cloud that drifts out of the viewport.
if (props.layoutType === "force") {
const budget = simNodes.length > 350 ? 280 : 120;
const deadline = performance.now() + budget;
let guard = 0;
while (sim.alpha > 0.25 && guard < 600 && performance.now() < deadline) {
sim.tick();
guard += 1;
}
}
}
requestDraw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.nodes, props.edges, props.degrees]);
// Layout switching.
const layoutRef = useRef(props.layoutType);
useEffect(() => {
if (layoutRef.current === props.layoutType) return;
layoutRef.current = props.layoutType;
const sim = simRef.current;
if (!sim) return;
if (props.layoutType === "circular") {
applyCircularLayout(sim.nodes);
sim.alpha = 0;
} else {
for (const n of sim.nodes) {
n.fx = null;
n.fy = null;
}
sim.reheat(0.9);
}
autoFitRef.current = false;
interactedRef.current = false;
requestDraw();
}, [props.layoutType, requestDraw]);
// Redraw on highlight-affecting prop changes.
useEffect(() => {
requestDraw();
}, [
props.selectedNodeId,
props.searchMatchIds,
props.showLabels,
props.explore,
props.theme,
requestDraw,
]);
// ------------------------------------------------------------ transform
const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
// Notify React of zoom changes only when k actually moved — a state
// update per animation frame re-renders the whole page while settling.
const lastNotifiedZoomRef = useRef(0);
const setTransform = useCallback((t: Transform) => {
transformRef.current = t;
if (Math.abs(t.k - lastNotifiedZoomRef.current) > 0.004) {
lastNotifiedZoomRef.current = t.k;
propsRef.current.onZoomChange(t.k);
}
needsDrawRef.current = true;
}, []);
const animateTo = useCallback((to: Transform, duration = 350) => {
animRef.current = {
from: { ...transformRef.current },
to,
start: performance.now(),
duration,
};
needsDrawRef.current = true;
}, []);
const zoomBy = useCallback(
(factor: number, cx?: number, cy?: number) => {
interactedRef.current = true;
const t = transformRef.current;
const { w, h } = sizeRef.current;
const px = cx ?? w / 2;
const py = cy ?? h / 2;
const k = clampZoom(t.k * factor);
const wx = (px - t.x) / t.k;
const wy = (py - t.y) / t.k;
setTransform({ x: px - wx * k, y: py - wy * k, k });
},
[setTransform],
);
const computeFitTransform = useCallback((): Transform | null => {
const sim = simRef.current;
const { w, h } = sizeRef.current;
if (!sim || !sim.nodes.length || !w || !h) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x - n.radius);
minY = Math.min(minY, n.y - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
maxY = Math.max(maxY, n.y + n.radius);
}
// A single bad coordinate must not poison the viewport transform.
if (!Number.isFinite(minX + minY + maxX + maxY)) return null;
const pad = 70;
const bw = Math.max(maxX - minX, 1);
const bh = Math.max(maxY - minY, 1);
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
return { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
}, []);
const fitView = useCallback(
(animated = true) => {
const target = computeFitTransform();
if (!target) return;
if (animated) animateTo(target);
else setTransform(target);
},
[animateTo, computeFitTransform, setTransform],
);
const focusNode = useCallback(
(id: string) => {
const node = nodeMapRef.current.get(id);
const { w, h } = sizeRef.current;
if (!node || !w || !h) return;
interactedRef.current = true;
const k = clampZoom(Math.max(transformRef.current.k, 1.4));
animateTo({ x: w / 2 - node.x * k, y: h / 2 - node.y * k, k });
},
[animateTo],
);
const relayout = useCallback(() => {
const sim = simRef.current;
if (!sim) return;
if (layoutRef.current === "circular") {
applyCircularLayout(sim.nodes);
sim.alpha = 0;
fitView(true);
} else {
seedPositions(sim.nodes);
sim.alpha = 1;
autoFitRef.current = false;
interactedRef.current = false;
}
requestDraw();
}, [fitView, requestDraw]);
const exportImage = useCallback(() => {
const main = canvasRef.current;
if (!main) return;
const out = document.createElement("canvas");
out.width = main.width;
out.height = main.height;
const ctx = out.getContext("2d");
if (!ctx) return;
ctx.fillStyle = CANVAS_PALETTE[propsRef.current.theme].exportBg;
ctx.fillRect(0, 0, out.width, out.height);
ctx.drawImage(main, 0, 0);
out.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "knowledge-graph.png";
a.click();
URL.revokeObjectURL(url);
}, "image/png");
}, []);
useImperativeHandle(
ref,
() => ({
zoomIn: () => zoomBy(1.25),
zoomOut: () => zoomBy(1 / 1.25),
fitView: () => fitView(true),
relayout,
focusNode,
exportImage,
}),
[zoomBy, fitView, relayout, focusNode, exportImage],
);
// ----------------------------------------------------------------- draw
const draw = useCallback(() => {
const canvas = canvasRef.current;
const sim = simRef.current;
if (!canvas || !sim) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const { w, h } = sizeRef.current;
const t = transformRef.current;
const p = propsRef.current;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.translate(t.x, t.y);
ctx.scale(t.k, t.k);
const hover = hoverRef.current;
const activeId = hover?.id ?? p.selectedNodeId;
const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined;
const search = p.searchMatchIds;
const k = t.k || 1;
const explore = p.explore;
const palette = CANVAS_PALETTE[p.theme];
// ---- edges
for (const e of sim.edges) {
const touchesActive =
activeId !== null &&
(e.source.id === activeId || e.target.id === activeId);
let alpha: number;
if (activeId) alpha = touchesActive ? 0.95 : 0.07;
else if (search)
alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06;
else alpha = 0.38;
if (explore && !explore.preview) {
const sourceDepth = explore.nodeDepthMap.get(e.source.id) ?? 0;
const targetDepth = explore.nodeDepthMap.get(e.target.id) ?? 0;
const edgeDepth = Math.max(sourceDepth, targetDepth);
alpha *= Math.max(0.48, 1 - edgeDepth * 0.12);
}
ctx.globalAlpha = alpha;
ctx.strokeStyle = e.info.color;
ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k;
ctx.setLineDash(e.info.dashed ? [6 / k, 5 / k] : []);
ctx.beginPath();
ctx.moveTo(e.source.x, e.source.y);
ctx.lineTo(e.target.x, e.target.y);
ctx.stroke();
// Relation label at the midpoint while a node is focused.
if (touchesActive && k >= 0.55) {
const mx = (e.source.x + e.target.x) / 2;
const my = (e.source.y + e.target.y) / 2;
ctx.setLineDash([]);
ctx.font = `${10.5 / k}px "Noto Sans SC", system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineWidth = 3 / k;
ctx.strokeStyle = palette.labelHalo;
ctx.strokeText(e.info.zh, mx, my);
ctx.fillStyle = e.info.color;
ctx.fillText(e.info.zh, mx, my);
}
}
ctx.setLineDash([]);
// ---- nodes
const labelThreshold = labelThresholdRef.current;
for (const n of sim.nodes) {
const isActive = n.id === activeId;
const isSelected = n.id === p.selectedNodeId;
const isNeighbor = neighbors?.has(n.id) ?? false;
const depth = explore?.nodeDepthMap.get(n.id);
const isExploreRoot = explore?.rootId === n.id;
const isExpanded = explore?.expandedNodeIds.has(n.id) ?? false;
let alpha: number;
if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14;
else if (search) alpha = search.has(n.id) ? 1 : 0.14;
else alpha = 1;
let baseRadius = n.radius;
let labelByExplore = false;
let strokeWidth = 1.4 / k;
let strokeColor: string = palette.nodeRim;
let fillColor = n.color;
if (explore?.preview) {
alpha *= 0.42;
baseRadius = Math.max(8, n.radius * 0.86);
labelByExplore = isActive || isSelected;
} else if (explore && depth != null) {
baseRadius = depth === 0 ? 34 : Math.max(14, 28 - depth * 4);
alpha *= Math.max(0.45, 1 - depth * 0.12);
labelByExplore = depth <= 2 || isActive || isSelected;
strokeWidth = (depth === 0 ? 4 : isExpanded ? 3 : 1.5) / k;
strokeColor =
depth === 0
? palette.rootRim
: isExpanded
? palette.expandedRim
: palette.nodeRim;
if (isExploreRoot) fillColor = "#38bdf8";
}
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
ctx.globalAlpha = alpha;
// Glow for hubs and the active node. Canvas shadows are expensive,
// so on large graphs only the focused nodes keep theirs.
const glowEligible =
sim.nodes.length <= 450
? isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold
: isActive || isSelected || isExploreRoot;
if (alpha > 0.5 && glowEligible) {
ctx.shadowColor = fillColor;
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
} else {
ctx.shadowBlur = 0;
}
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = fillColor;
ctx.fill();
ctx.shadowBlur = 0;
// Rim.
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = strokeColor;
ctx.stroke();
if (isExploreRoot) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.4 / k;
ctx.strokeStyle = palette.rootRing;
ctx.stroke();
} else if (isExpanded) {
ctx.beginPath();
ctx.arc(n.x + r * 0.58, n.y - r * 0.58, Math.max(4 / k, 3), 0, Math.PI * 2);
ctx.fillStyle = palette.expandedDot;
ctx.fill();
ctx.lineWidth = 1 / k;
ctx.strokeStyle = palette.expandedDotRim;
ctx.stroke();
}
// Selection: double ring.
if (isSelected) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 4 / k, 0, Math.PI * 2);
ctx.lineWidth = 2 / k;
ctx.strokeStyle = palette.selectionRing;
ctx.stroke();
ctx.beginPath();
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.2 / k;
ctx.strokeStyle = n.color;
ctx.stroke();
} else if (n.id === hover?.id) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 3.5 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.6 / k;
ctx.strokeStyle = palette.hoverRing;
ctx.stroke();
}
// Pin marker for dragged-and-fixed nodes.
if (n.fx != null && !isSelected) {
ctx.beginPath();
ctx.arc(n.x, n.y, Math.max(r * 0.28, 2 / k), 0, Math.PI * 2);
ctx.fillStyle = palette.pin;
ctx.fill();
}
// Label.
const wantsLabel =
isActive ||
isSelected ||
isNeighbor ||
labelByExplore ||
(search?.has(n.id) ?? false) ||
(p.showLabels && n.degree >= labelThreshold && k >= 0.45);
if (wantsLabel && alpha > 0.5) {
const text = truncateLabel(getNodeTitle(n.node), isActive ? 26 : 14);
const fontSize = (isActive || isSelected ? 13 : 12) / k;
ctx.font = `600 ${fontSize}px "Noto Sans SC", system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.lineWidth = 3.5 / k;
ctx.strokeStyle = palette.labelHalo;
ctx.strokeText(text, n.x, n.y + r + 5 / k);
ctx.fillStyle = isActive || isSelected ? palette.labelActive : palette.label;
ctx.fillText(text, n.x, n.y + r + 5 / k);
}
}
ctx.globalAlpha = 1;
drawMinimap();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const drawMinimap = useCallback(() => {
const minimap = minimapRef.current;
const sim = simRef.current;
if (!minimap || !sim || !sim.nodes.length) return;
const ctx = minimap.getContext("2d");
if (!ctx) return;
const mw = minimap.width;
const mh = minimap.height;
ctx.clearRect(0, 0, mw, mh);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x);
maxY = Math.max(maxY, n.y);
}
const pad = 10;
const scale = Math.min(
(mw - pad * 2) / Math.max(maxX - minX, 1),
(mh - pad * 2) / Math.max(maxY - minY, 1),
);
const ox = (mw - (maxX - minX) * scale) / 2 - minX * scale;
const oy = (mh - (maxY - minY) * scale) / 2 - minY * scale;
for (const n of sim.nodes) {
ctx.beginPath();
ctx.arc(n.x * scale + ox, n.y * scale + oy, 1.6, 0, Math.PI * 2);
ctx.fillStyle = n.color;
ctx.globalAlpha = 0.9;
ctx.fill();
}
ctx.globalAlpha = 1;
// Viewport rectangle.
const t = transformRef.current;
const { w, h } = sizeRef.current;
if (t.k > 0 && w && h) {
const x0 = ((0 - t.x) / t.k) * scale + ox;
const y0 = ((0 - t.y) / t.k) * scale + oy;
const x1 = ((w - t.x) / t.k) * scale + ox;
const y1 = ((h - t.y) / t.k) * scale + oy;
ctx.strokeStyle = CANVAS_PALETTE[propsRef.current.theme].minimapViewport;
ctx.lineWidth = 1;
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
}
}, []);
// ----------------------------------------------------------------- loop
useEffect(() => {
const loop = () => {
const sim = simRef.current;
let active = false;
const anim = animRef.current;
if (anim) {
const progress = Math.min(
(performance.now() - anim.start) / anim.duration,
1,
);
const e = easeCubicOut(progress);
transformRef.current = {
x: anim.from.x + (anim.to.x - anim.from.x) * e,
y: anim.from.y + (anim.to.y - anim.from.y) * e,
k: anim.from.k + (anim.to.k - anim.from.k) * e,
};
if (progress >= 1) {
animRef.current = null;
lastNotifiedZoomRef.current = transformRef.current.k;
propsRef.current.onZoomChange(transformRef.current.k);
}
active = true;
}
if (sim && layoutRef.current === "force" && sim.tick()) {
active = true;
// Until the user pans/zooms/drags, gently track the settling layout
// so it stays in view without the viewport snapping every frame.
if (!interactedRef.current && sizeRef.current.w) {
const target = computeFitTransform();
if (target) {
const t = transformRef.current;
const ease = t.k === 0 ? 1 : 0.16;
setTransform({
x: t.x + (target.x - t.x) * ease,
y: t.y + (target.y - t.y) * ease,
k: t.k + (target.k - t.k) * ease,
});
}
autoFitRef.current = true;
} else if (!autoFitRef.current && sim.alpha < 0.3) {
autoFitRef.current = true;
fitView(true);
}
} else if (sim && !autoFitRef.current && sim.nodes.length && sizeRef.current.w) {
autoFitRef.current = true;
fitView(false);
}
if (active || needsDrawRef.current) {
needsDrawRef.current = false;
draw();
}
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [computeFitTransform, draw, fitView, setTransform]);
// --------------------------------------------------------------- resize
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
let timer: number | undefined;
const apply = () => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current = { w: rect.width, h: rect.height };
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
if (transformRef.current.k === 0) {
setTransform({ x: rect.width / 2, y: rect.height / 2, k: 0.9 });
}
requestDraw();
};
apply();
const observer = new ResizeObserver(() => {
window.clearTimeout(timer);
timer = window.setTimeout(apply, 120);
});
observer.observe(container);
return () => {
window.clearTimeout(timer);
observer.disconnect();
};
}, [requestDraw, setTransform]);
// --------------------------------------------------------------- events
const screenToWorld = (sx: number, sy: number) => {
const t = transformRef.current;
return { x: (sx - t.x) / t.k, y: (sy - t.y) / t.k };
};
const hitTest = (wx: number, wy: number): SimNode | null => {
const sim = simRef.current;
if (!sim) return null;
// Topmost (drawn last) first.
for (let i = sim.nodes.length - 1; i >= 0; i--) {
const n = sim.nodes[i];
const dx = wx - n.x;
const dy = wy - n.y;
const r = n.radius + 3 / (transformRef.current.k || 1);
if (dx * dx + dy * dy <= r * r) return n;
}
return null;
};
const localPoint = (e: { clientX: number; clientY: number }) => {
const rect = canvasRef.current!.getBoundingClientRect();
return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
};
const setHover = (node: SimNode | null, e?: React.PointerEvent) => {
if (hoverRef.current?.id !== node?.id) {
hoverRef.current = node;
requestDraw();
}
const canvas = canvasRef.current;
if (canvas) canvas.style.cursor = node ? "pointer" : "grab";
if (node && e) {
propsRef.current.onTooltip({
x: e.clientX,
y: e.clientY,
node: node.node,
kind: node.kind,
degree: node.degree,
});
} else {
propsRef.current.onTooltip(null);
}
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.button !== 0) return;
const { sx, sy } = localPoint(e);
const { x: wx, y: wy } = screenToWorld(sx, sy);
const node = hitTest(wx, wy);
animRef.current = null;
dragRef.current = {
pointerId: e.pointerId,
node,
startX: sx,
startY: sy,
originX: transformRef.current.x,
originY: transformRef.current.y,
moved: false,
};
if (node) {
node.fx = node.x;
node.fy = node.y;
}
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
const { sx, sy } = localPoint(e);
const drag = dragRef.current;
if (drag && drag.pointerId === e.pointerId) {
const dx = sx - drag.startX;
const dy = sy - drag.startY;
if (Math.abs(dx) + Math.abs(dy) > 3) {
drag.moved = true;
// Only a real pan/drag takes over the camera; a plain click keeps
// the auto-follow so the settling graph can't drift out of view.
interactedRef.current = true;
}
if (drag.node) {
const world = screenToWorld(sx, sy);
drag.node.fx = world.x;
drag.node.fy = world.y;
if (layoutRef.current === "force") simRef.current?.reheat(0.3);
propsRef.current.onTooltip(null);
} else {
transformRef.current = {
...transformRef.current,
x: drag.originX + dx,
y: drag.originY + dy,
};
}
requestDraw();
return;
}
const world = screenToWorld(sx, sy);
setHover(hitTest(world.x, world.y), e);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
dragRef.current = null;
if (!drag.moved) {
// Plain click: select node / clear selection on empty space.
if (drag.node) {
drag.node.fx = null;
drag.node.fy = null;
propsRef.current.onSelectNode(drag.node.id);
} else {
propsRef.current.onSelectNode(null);
}
requestDraw();
}
// A real drag leaves the node pinned at its drop position.
};
const handleDoubleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const { sx, sy } = localPoint(e);
const world = screenToWorld(sx, sy);
const node = hitTest(world.x, world.y);
if (node) {
propsRef.current.onSelectNode(node.id);
focusNode(node.id);
}
};
const handlePointerLeave = () => {
setHover(null);
};
// Wheel zoom needs a non-passive listener to preventDefault.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
interactedRef.current = true;
const rect = canvas.getBoundingClientRect();
zoomBy(
Math.exp(-e.deltaY * 0.0012),
e.clientX - rect.left,
e.clientY - rect.top,
);
};
canvas.addEventListener("wheel", onWheel, { passive: false });
return () => canvas.removeEventListener("wheel", onWheel);
}, [zoomBy]);
const minimapVisible = useMemo(() => props.nodes.length > 0, [props.nodes]);
return (
<div className="kg-canvas-wrap" ref={containerRef}>
<canvas
ref={canvasRef}
className="kg-canvas"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
onDoubleClick={handleDoubleClick}
/>
{props.toolbar ? (
<div className="kg-canvas-toolbar">{props.toolbar}</div>
) : null}
{props.hint}
{minimapVisible ? (
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
) : null}
{props.overlay}
</div>
);
},
);
@@ -0,0 +1,287 @@
import {
Ban,
Compass,
Crosshair,
EyeOff,
FileText,
Loader2,
MousePointerClick,
Waypoints,
X,
} from "lucide-react";
import type { KnowledgeNote } from "@/core/knowledge/types";
import type { GraphNode } from "./graphTypes";
import {
getEdgeInfo,
getNodeKind,
getNodeTitle,
NODE_KIND_META,
} from "./graphUtils";
const SOURCE_TYPE_ZH: Record<string, string> = {
thread: "对话沉淀",
search: "搜索沉淀",
tool: "工具结果",
file: "文件导入",
manual: "手动创建",
report: "报告",
};
const STATUS_ZH: Record<string, string> = {
draft: "草稿",
approved: "已审核",
archived: "已归档",
};
export interface NeighborItem {
node: GraphNode;
relation: string;
}
interface GraphDetailPanelProps {
node: GraphNode | null;
degree: number;
neighbors: NeighborItem[];
/** Note detail (only for note-type nodes; undefined while absent/loading). */
note: KnowledgeNote | undefined;
noteLoading: boolean;
/** Explore-mode context; null when in full mode. */
explore: {
/** Depth of this node in the explore graph; null = not yet visible. */
depth: number | null;
expanded: boolean;
isRoot: boolean;
} | null;
onViewSource: (noteId: string) => void;
onLocate: (id: string) => void;
onHide: (id: string) => void;
onBlacklist: (id: string) => void;
onPickNeighbor: (id: string) => void;
onExpandNode: (id: string) => void;
onSetExploreRoot: (id: string) => void;
onClose: () => void;
}
function formatTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
export function GraphDetailPanel(props: GraphDetailPanelProps) {
const { node } = props;
return (
<aside className="kg-panel kg-detail">
<div className="kg-panel-header">
<span className="kg-panel-title">节点详情</span>
<button
type="button"
className="kg-panel-close-btn"
title="收起详情面板"
onClick={props.onClose}
>
<X size={15} />
</button>
</div>
{!node ? (
<div className="kg-detail-empty">
<MousePointerClick size={28} />
<div className="kg-detail-empty-title">选择一个节点查看详情</div>
<div>
点击图谱中的文档、主题或实体节点,
<br />
查看摘要、标签、关联关系和相邻节点。
</div>
</div>
) : (
<DetailBody {...props} node={node} />
)}
</aside>
);
}
function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
const { node, note } = props;
const kind = getNodeKind(node);
const kindMeta = NODE_KIND_META[kind];
const isNote = (node.type || "").toLowerCase() === "note";
return (
<div className="kg-panel-content">
<h2 className="kg-detail-title">{getNodeTitle(node)}</h2>
<div>
<span
className="kg-detail-type"
style={{
background: `${kindMeta.color}26`,
color: kindMeta.color,
}}
>
<span className="kg-legend-dot" style={{ background: kindMeta.color }} />
{kindMeta.label}
{node.category && node.category !== kindMeta.label
? ` · ${node.category}`
: ""}
</span>
<span className="kg-detail-type kg-detail-type-neutral">
度数 {props.degree}
</span>
{props.explore && props.explore.depth !== null ? (
<>
<span className="kg-detail-type kg-detail-type-primary">
{props.explore.isRoot
? "探索起点"
: `第 ${props.explore.depth} 层`}
</span>
<span
className={`kg-detail-type ${
props.explore.expanded
? "kg-detail-type-success"
: "kg-detail-type-warn"
}`}
>
{props.explore.expanded ? "已展开" : "可展开"}
</span>
</>
) : null}
</div>
{props.noteLoading ? (
<div className="kg-detail-summary" style={{ display: "flex", gap: 8 }}>
<Loader2 size={14} className="animate-spin" /> 加载笔记详情...
</div>
) : null}
{note?.summary ? <p className="kg-detail-summary">{note.summary}</p> : null}
{note?.tags?.length ? (
<div className="kg-tag-list">
{note.tags.map((tag) => (
<span key={tag} className="kg-tag">
#{tag}
</span>
))}
</div>
) : null}
{note ? (
<div className="kg-detail-meta">
<span className="kg-detail-meta-label">来源</span>
<span className="kg-detail-meta-value">
{SOURCE_TYPE_ZH[note.source_type] ?? note.source_type}
</span>
<span className="kg-detail-meta-label">状态</span>
<span className="kg-detail-meta-value">
{STATUS_ZH[note.status] ?? note.status}
</span>
<span className="kg-detail-meta-label">创建时间</span>
<span className="kg-detail-meta-value">{formatTime(note.created_at)}</span>
<span className="kg-detail-meta-label">更新时间</span>
<span className="kg-detail-meta-value">{formatTime(note.updated_at)}</span>
{note.created_by_name ? (
<>
<span className="kg-detail-meta-label">创建人</span>
<span className="kg-detail-meta-value">{note.created_by_name}</span>
</>
) : null}
</div>
) : null}
<div className="kg-detail-actions">
{isNote ? (
<button
type="button"
className="kg-icon-button"
onClick={() => props.onViewSource(node.id)}
>
<FileText size={14} /> 查看原文
</button>
) : null}
<button
type="button"
className="kg-icon-button"
onClick={() => props.onLocate(node.id)}
>
<Crosshair size={14} /> 定位节点
</button>
<button
type="button"
className="kg-icon-button"
onClick={() => props.onHide(node.id)}
>
<EyeOff size={14} /> 隐藏节点
</button>
<button
type="button"
className="kg-icon-button"
title="加入黑名单后该节点将不再出现在图谱中,可在左侧「图谱黑名单」中移除"
onClick={() => props.onBlacklist(node.id)}
>
<Ban size={14} /> 加入黑名单
</button>
{props.explore ? (
<>
<button
type="button"
className="kg-icon-button"
disabled={props.explore.depth === null || props.explore.expanded}
title={
props.explore.expanded
? "该节点的一跳邻居已全部展开"
: "展开该节点的一跳邻居"
}
onClick={() => props.onExpandNode(node.id)}
>
<Waypoints size={14} /> 展开一跳邻居
</button>
<button
type="button"
className="kg-icon-button"
title="清空当前探索路径,以此节点为新起点"
onClick={() => props.onSetExploreRoot(node.id)}
>
<Compass size={14} /> 以此为新起点
</button>
</>
) : null}
</div>
<h3 className="kg-detail-section-title">
关联节点({props.neighbors.length})
</h3>
{props.neighbors.length === 0 ? (
<div className="kg-search-hint">该节点暂无可见的关联节点</div>
) : (
<div className="kg-neighbor-list">
{props.neighbors.map(({ node: neighbor, relation }) => {
const neighborKind = NODE_KIND_META[getNodeKind(neighbor)];
const relationInfo = getEdgeInfo(relation);
return (
<div
key={`${neighbor.id}:${relation}`}
className="kg-neighbor-item"
onClick={() => props.onPickNeighbor(neighbor.id)}
>
<span
className="kg-legend-dot"
style={{ background: neighborKind.color, flexShrink: 0 }}
/>
<span className="kg-neighbor-title">{getNodeTitle(neighbor)}</span>
<span
className="kg-neighbor-relation"
style={{ color: relationInfo.color }}
>
{relationInfo.zh}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
import type { NodeKind } from "./graphTypes";
import { getEdgeInfo, NODE_KIND_META, NODE_KIND_ORDER } from "./graphUtils";
interface GraphFooterProps {
nodeKindCounts: Map<NodeKind, number>;
edgeLabels: string[];
zoomLevel: number;
selectedTitle: string | null;
visibleNodes: number;
visibleEdges: number;
}
export function GraphFooter(props: GraphFooterProps) {
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
return (
<footer className="kg-footer">
<div className="kg-legend">
{kinds.map((kind) => (
<span key={kind} className="kg-legend-item">
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
{NODE_KIND_META[kind].label}
</span>
))}
{props.edgeLabels.slice(0, 6).map((label) => {
const info = getEdgeInfo(label);
return (
<span key={label} className="kg-legend-item">
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
{info.zh}
</span>
);
})}
</div>
<div className="kg-footer-status">
{props.selectedTitle ? (
<span className="kg-footer-selected" title={props.selectedTitle}>
选中:{props.selectedTitle}
</span>
) : null}
<span>
显示 {props.visibleNodes} 节点 · {props.visibleEdges} 关系
</span>
<span>缩放 {Math.round(props.zoomLevel * 100)}%</span>
</div>
</footer>
);
}
@@ -0,0 +1,124 @@
import {
ArrowLeft,
Download,
Loader2,
Maximize2,
RefreshCw,
Scan,
Shuffle,
ZoomIn,
ZoomOut,
} from "lucide-react";
interface GraphHeaderProps {
nodeCount: number;
edgeCount: number;
totalNodes: number;
totalEdges: number;
refreshing: boolean;
onBack: () => void;
onRelayout: () => void;
onFitView: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onExport: () => void;
onFullscreen: () => void;
onRefresh: () => void;
}
export function GraphHeader(props: GraphHeaderProps) {
const filteredNodes =
props.nodeCount === props.totalNodes
? `${props.totalNodes}`
: `${props.nodeCount}/${props.totalNodes}`;
const filteredEdges =
props.edgeCount === props.totalEdges
? `${props.totalEdges}`
: `${props.edgeCount}/${props.totalEdges}`;
return (
<header className="kg-header">
<div className="kg-header-left">
<button type="button" className="kg-back" onClick={props.onBack}>
<ArrowLeft size={15} />
<span className="kg-button-text">返回</span>
</button>
<div className="kg-title-block">
<h1 className="kg-title">知识图谱</h1>
<span className="kg-subtitle">知识关系可视化</span>
</div>
<div className="kg-stat-pills">
<span className="kg-stat-pill">{filteredNodes} 个节点</span>
<span className="kg-stat-pill kg-stat-pill-violet">
{filteredEdges} 条关系
</span>
</div>
</div>
<div className="kg-header-actions">
<button
type="button"
className="kg-icon-button"
title="重新布局"
onClick={props.onRelayout}
>
<Shuffle size={15} />
<span className="kg-button-text">重新布局</span>
</button>
<button
type="button"
className="kg-icon-button"
title="适应视图"
onClick={props.onFitView}
>
<Scan size={15} />
<span className="kg-button-text">适应视图</span>
</button>
<button
type="button"
className="kg-icon-button"
title="放大"
onClick={props.onZoomIn}
>
<ZoomIn size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="缩小"
onClick={props.onZoomOut}
>
<ZoomOut size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="导出图片"
onClick={props.onExport}
>
<Download size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="全屏"
onClick={props.onFullscreen}
>
<Maximize2 size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="刷新图谱数据"
onClick={props.onRefresh}
disabled={props.refreshing}
>
{props.refreshing ? (
<Loader2 size={15} className="animate-spin" />
) : (
<RefreshCw size={15} />
)}
</button>
</div>
</header>
);
}
@@ -0,0 +1,292 @@
import { ChevronDown, ChevronRight, Eraser, Search, X } from "lucide-react";
import { useState } from "react";
import type { GraphBlacklistItem } from "@/core/knowledge/api";
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
import {
getEdgeInfo,
getNodeTitle,
NODE_KIND_META,
NODE_KIND_ORDER,
} from "./graphUtils";
interface GraphSidebarProps {
searchKeyword: string;
searchMatchCount: number | null;
nodeKindCounts: Map<NodeKind, number>;
edgeLabelCounts: Map<string, number>;
disabledNodeKinds: Set<NodeKind>;
disabledEdgeLabels: Set<string>;
stats: GraphStats;
topNodes: Array<{ node: GraphNode; degree: number }>;
hiddenCount: number;
blacklistItems: GraphBlacklistItem[];
blacklistPending: boolean;
junkCandidateCount: number;
onSearchChange: (keyword: string) => void;
onSearchEnter: () => void;
onToggleNodeKind: (kind: NodeKind) => void;
onToggleEdgeLabel: (label: string) => void;
onRestoreHidden: () => void;
onRemoveBlacklist: (entryId: string) => void;
onCleanJunk: () => void;
onPickNode: (id: string) => void;
onClose: () => void;
}
type SectionKey =
| "nodeTypes"
| "edgeTypes"
| "graphStats"
| "coreNodes"
| "blacklist";
interface FilterSectionProps {
title: string;
extra?: string;
collapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function FilterSection(props: FilterSectionProps) {
return (
<section className="kg-filter-section">
<button
type="button"
className="kg-filter-section-header"
onClick={props.onToggle}
aria-expanded={!props.collapsed}
>
<span>{props.title}</span>
<span className="kg-filter-section-extra">
{props.extra}
{props.collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{!props.collapsed ? (
<div className="kg-filter-section-body">{props.children}</div>
) : null}
</section>
);
}
export function GraphSidebar(props: GraphSidebarProps) {
// 节点类型 / 关系类型默认收起,统计与核心节点默认展开。
const [collapsedSections, setCollapsedSections] = useState<
Record<SectionKey, boolean>
>({
nodeTypes: true,
edgeTypes: true,
graphStats: false,
coreNodes: false,
blacklist: true,
});
const toggleFilterSection = (key: SectionKey) => {
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
};
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
const edgeLabels = [...props.edgeLabelCounts.entries()].sort((a, b) => b[1] - a[1]);
return (
<aside className="kg-panel kg-sidebar">
<div className="kg-panel-header">
<span className="kg-panel-title">搜索与筛选</span>
<div className="kg-panel-header-actions">
{props.hiddenCount > 0 ? (
<button
type="button"
className="kg-text-button"
onClick={props.onRestoreHidden}
>
恢复 {props.hiddenCount} 个隐藏节点
</button>
) : null}
<button
type="button"
className="kg-panel-close-btn"
title="收起筛选面板"
onClick={props.onClose}
>
<X size={15} />
</button>
</div>
</div>
<div className="kg-panel-content">
<div className="kg-search-wrap">
<Search size={14} />
<input
className="kg-search"
placeholder="搜索节点标题..."
value={props.searchKeyword}
onChange={(e) => props.onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSearchEnter();
}}
/>
</div>
{props.searchMatchCount !== null ? (
<div
className={`kg-search-hint${props.searchMatchCount === 0 ? " kg-search-hint-warn" : ""}`}
>
{props.searchMatchCount === 0
? "没有匹配的节点,换个关键词试试"
: `匹配 ${props.searchMatchCount} 个节点,回车定位`}
</div>
) : null}
<FilterSection
title="节点类型"
extra={`${kinds.length} 类`}
collapsed={collapsedSections.nodeTypes}
onToggle={() => toggleFilterSection("nodeTypes")}
>
{kinds.map((kind) => (
<label key={kind} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledNodeKinds.has(kind)}
onChange={() => props.onToggleNodeKind(kind)}
/>
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
<span className="kg-check-label">{NODE_KIND_META[kind].label}</span>
</span>
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
</label>
))}
</FilterSection>
{edgeLabels.length > 0 ? (
<FilterSection
title="关系类型"
extra={`${edgeLabels.length} 类`}
collapsed={collapsedSections.edgeTypes}
onToggle={() => toggleFilterSection("edgeTypes")}
>
{edgeLabels.map(([label, count]) => {
const info = getEdgeInfo(label);
return (
<label key={label} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledEdgeLabels.has(label)}
onChange={() => props.onToggleEdgeLabel(label)}
/>
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
<span className="kg-check-label">{info.zh}</span>
</span>
<span className="kg-count">{count}</span>
</label>
);
})}
</FilterSection>
) : null}
<FilterSection
title="图谱统计"
collapsed={collapsedSections.graphStats}
onToggle={() => toggleFilterSection("graphStats")}
>
<div className="kg-stats-grid">
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.nodes}</div>
<div className="kg-stat-card-label">节点</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.edges}</div>
<div className="kg-stat-card-label">关系</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">
{props.stats.avgDegree.toFixed(2)}
</div>
<div className="kg-stat-card-label">平均度</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.largestComponent}</div>
<div className="kg-stat-card-label">最大连通分量</div>
</div>
</div>
</FilterSection>
{props.topNodes.length > 0 ? (
<FilterSection
title="核心节点"
extra={`${props.topNodes.length} 个`}
collapsed={collapsedSections.coreNodes}
onToggle={() => toggleFilterSection("coreNodes")}
>
{props.topNodes.map(({ node, degree }, i) => (
<div
key={node.id}
className="kg-rank-item"
onClick={() => props.onPickNode(node.id)}
>
<span className="kg-rank-index">{i + 1}</span>
<span className="kg-rank-title">{getNodeTitle(node)}</span>
<span className="kg-rank-degree">{degree} 度</span>
</div>
))}
</FilterSection>
) : null}
<FilterSection
title="图谱黑名单"
extra={`${props.blacklistItems.length} 个`}
collapsed={collapsedSections.blacklist}
onToggle={() => toggleFilterSection("blacklist")}
>
<div className="kg-search-hint">
黑名单中的主题 / 节点不会出现在图谱中。可在节点详情里加入黑名单。
</div>
<button
type="button"
className="kg-icon-button"
disabled={props.blacklistPending || props.junkCandidateCount === 0}
title="将单字符、纯数字符号、通用泛词等低价值主题批量加入黑名单"
onClick={props.onCleanJunk}
>
<Eraser size={13} /> 清理低价值主题
{props.junkCandidateCount > 0 ? `(${props.junkCandidateCount})` : ""}
</button>
{props.blacklistItems.length === 0 ? (
<div className="kg-search-hint">暂无黑名单条目。</div>
) : (
props.blacklistItems.map((item) => (
<div key={item.id} className="kg-check-row">
<span className="kg-check-row-main">
<span className="kg-check-label" title={item.label}>
{item.label}
</span>
</span>
<button
type="button"
className="kg-text-button"
disabled={props.blacklistPending}
title="移出黑名单,重新显示该节点"
onClick={() => props.onRemoveBlacklist(item.id)}
>
移除
</button>
</div>
))
)}
</FilterSection>
</div>
</aside>
);
}
@@ -0,0 +1,29 @@
import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
interface GraphTooltipProps {
tooltip: TooltipState | null;
}
export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport.
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
return (
<div className="kg-tooltip" style={{ left, top }}>
<div className="kg-tooltip-title">{getNodeTitle(tooltip.node)}</div>
<div className="kg-tooltip-desc">
<span className="kg-legend-item">
<span className="kg-legend-dot" style={{ background: meta.color }} />
{meta.label}
</span>
{tooltip.node.category ? <span>· {tooltip.node.category}</span> : null}
<span>· 度数 {tooltip.degree}</span>
</div>
<div className="kg-tooltip-hint">单击查看详情 · 双击聚焦 · 拖拽固定位置</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,111 @@
/** Shared types for the knowledge-graph workbench. */
/** Raw node as returned by `GET /api/knowledge/graph` (plus compat fields). */
export interface GraphNode {
id: string;
/** "note" | "entity" on the current backend; kept loose for compat. */
type?: string;
label?: string;
title?: string;
name?: string;
/** note: source_type (thread/file/manual/...); entity: entity_type. */
category?: string | null;
}
/** Raw edge as returned by `GET /api/knowledge/graph` (plus compat fields). */
export interface GraphEdge {
id?: string;
source: string;
target: string;
/** Relation type: "links" | "related" | LLM relation_type. */
label?: string;
type?: string;
relation?: string;
weight?: number;
directed?: boolean;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
/** Normalized visual bucket for a node. */
export type NodeKind =
| "document"
| "concept"
| "person"
| "org"
| "location"
| "event"
| "tag"
| "other";
export type LayoutType = "force" | "circular";
/** Graph display mode: full graph vs. click-to-expand exploration. */
export type GraphMode = "full" | "explore";
/** One incremental expansion step in explore mode (for undo / rebuild). */
export interface ExploreStep {
nodeId: string;
depth: number;
addedNodeIds: string[];
addedEdgeIds: string[];
timestamp: number;
}
/** Style metadata of an edge label. */
export interface EdgeInfo {
color: string;
zh: string;
dashed: boolean;
}
/** A node inside the force simulation (world coordinates). */
export interface SimNode {
id: string;
node: GraphNode;
kind: NodeKind;
degree: number;
radius: number;
color: string;
x: number;
y: number;
vx: number;
vy: number;
fx: number | null;
fy: number | null;
}
export interface SimEdge {
id: string;
source: SimNode;
target: SimNode;
label: string;
info: EdgeInfo;
weight: number;
distance: number;
}
export interface Transform {
x: number;
y: number;
k: number;
}
export interface TooltipState {
/** Viewport (client) coordinates. */
x: number;
y: number;
node: GraphNode;
kind: NodeKind;
degree: number;
}
export interface GraphStats {
nodes: number;
edges: number;
avgDegree: number;
largestComponent: number;
}
@@ -0,0 +1,434 @@
import type {
EdgeInfo,
GraphEdge,
GraphNode,
GraphStats,
NodeKind,
SimEdge,
SimNode,
} from "./graphTypes";
// ---------------------------------------------------------------------------
// Node helpers
// ---------------------------------------------------------------------------
export const getNodeTitle = (node: GraphNode): string =>
node.title || node.label || node.name || node.id;
const KIND_PATTERNS: Array<[NodeKind, RegExp]> = [
["person", /^(person|people|human|人物|人名|人员)$/i],
["org", /^(org|organization|organisation|company|institution|party|组织|机构|公司|团体|政党|国会)$/i],
["location", /^(location|place|country|city|region|gpe|地点|地区|国家|城市|地理位置)$/i],
["event", /^(event|事件|活动|会议|战争|选举|讲话)$/i],
["tag", /^(tag|keyword|标签|关键词)$/i],
["concept", /^(concept|topic|term|technology|theory|product|project|主题|概念|术语|技术|理论|产品|项目|政策)$/i],
];
/** Map a raw node (note source_type / entity entity_type) to a visual bucket. */
export function getNodeKind(node: GraphNode): NodeKind {
const type = (node.type || "").toLowerCase();
if (type === "note" || type === "document" || type === "file") return "document";
const category = (node.category || "").trim();
if (category) {
for (const [kind, re] of KIND_PATTERNS) {
if (re.test(category)) return kind;
}
}
// Unmatched LLM entity types still read better as "concept" than as a grey blob.
if (type === "entity") return category ? "other" : "concept";
return "other";
}
export const NODE_KIND_META: Record<NodeKind, { label: string; color: string }> = {
document: { label: "文档", color: "#38bdf8" },
concept: { label: "主题", color: "#a78bfa" },
person: { label: "人物", color: "#fbbf24" },
org: { label: "组织", color: "#2dd4bf" },
location: { label: "地点", color: "#34d399" },
event: { label: "事件", color: "#fb7185" },
tag: { label: "标签", color: "#a3e635" },
other: { label: "其他", color: "#94a3b8" },
};
/** Stable ordering for filters / legend. */
export const NODE_KIND_ORDER: NodeKind[] = [
"document",
"concept",
"person",
"org",
"location",
"event",
"tag",
"other",
];
export const getNodeRadius = (degree: number): number =>
Math.min(30, 9 + Math.sqrt(Math.max(degree, 0)) * 4);
export function truncateLabel(text: string, max = 14): string {
return text.length > max ? `${text.slice(0, max)}…` : text;
}
// ---------------------------------------------------------------------------
// Edge helpers
// ---------------------------------------------------------------------------
export const getEdgeLabel = (edge: GraphEdge): string =>
edge.label || edge.type || edge.relation || "related";
/** Stable edge identity shared by explore-mode visibility sets and the canvas. */
export const getEdgeId = (edge: GraphEdge): string =>
edge.id ?? `${edge.source}->${edge.target}:${getEdgeLabel(edge)}`;
const KNOWN_EDGE_INFO: Record<string, EdgeInfo> = {
related: { color: "#94a3b8", zh: "相关", dashed: false },
links: { color: "#a78bfa", zh: "引用", dashed: true },
references: { color: "#a78bfa", zh: "引用", dashed: true },
contains: { color: "#34d399", zh: "包含", dashed: false },
belongs_to: { color: "#2dd4bf", zh: "属于", dashed: false },
similar: { color: "#fbbf24", zh: "相似", dashed: true },
mentions: { color: "#38bdf8", zh: "提及", dashed: false },
};
const EDGE_PALETTE = [
"#38bdf8",
"#a78bfa",
"#34d399",
"#fbbf24",
"#fb7185",
"#2dd4bf",
"#fb923c",
"#e879f9",
];
function hashString(text: string): number {
let h = 0;
for (let i = 0; i < text.length; i++) {
h = (h * 31 + text.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
/** Color / display name / line style of an edge label (stable for unknown labels). */
export function getEdgeInfo(label: string): EdgeInfo {
const known = KNOWN_EDGE_INFO[label.toLowerCase()];
if (known) return known;
return {
color: EDGE_PALETTE[hashString(label) % EDGE_PALETTE.length],
zh: label,
dashed: false,
};
}
export function getLinkDistance(label: string): number {
switch (label.toLowerCase()) {
case "contains":
return 100;
case "links":
case "references":
return 130;
case "similar":
return 170;
default:
return 145;
}
}
// ---------------------------------------------------------------------------
// Graph statistics
// ---------------------------------------------------------------------------
export function computeDegrees(
nodes: GraphNode[],
edges: GraphEdge[],
): Map<string, number> {
const degrees = new Map<string, number>();
for (const n of nodes) degrees.set(n.id, 0);
for (const e of edges) {
if (degrees.has(e.source)) degrees.set(e.source, (degrees.get(e.source) ?? 0) + 1);
if (degrees.has(e.target)) degrees.set(e.target, (degrees.get(e.target) ?? 0) + 1);
}
return degrees;
}
export function computeGraphStats(
nodes: GraphNode[],
edges: GraphEdge[],
): GraphStats {
const adjacency = new Map<string, string[]>();
for (const n of nodes) adjacency.set(n.id, []);
let validEdges = 0;
for (const e of edges) {
if (!adjacency.has(e.source) || !adjacency.has(e.target)) continue;
validEdges += 1;
adjacency.get(e.source)!.push(e.target);
adjacency.get(e.target)!.push(e.source);
}
// Largest connected component via BFS.
const visited = new Set<string>();
let largest = 0;
for (const n of nodes) {
if (visited.has(n.id)) continue;
let size = 0;
const queue = [n.id];
visited.add(n.id);
while (queue.length) {
const cur = queue.pop()!;
size += 1;
for (const next of adjacency.get(cur) ?? []) {
if (!visited.has(next)) {
visited.add(next);
queue.push(next);
}
}
}
largest = Math.max(largest, size);
}
return {
nodes: nodes.length,
edges: validEdges,
avgDegree: nodes.length ? (validEdges * 2) / nodes.length : 0,
largestComponent: largest,
};
}
// ---------------------------------------------------------------------------
// Layouts
// ---------------------------------------------------------------------------
const GOLDEN_ANGLE = 2.3999632297286533;
/** Phyllotaxis seeding around the origin — hubs (high degree) near the center. */
export function seedPositions(nodes: SimNode[]): void {
const sorted = [...nodes].sort((a, b) => b.degree - a.degree);
sorted.forEach((n, i) => {
const r = 22 * Math.sqrt(i + 0.5);
const angle = i * GOLDEN_ANGLE;
n.x = r * Math.cos(angle);
n.y = r * Math.sin(angle);
n.vx = 0;
n.vy = 0;
n.fx = null;
n.fy = null;
});
}
/** Static circular layout, grouped by kind then degree. */
export function applyCircularLayout(nodes: SimNode[]): void {
const sorted = [...nodes].sort((a, b) => {
const ka = NODE_KIND_ORDER.indexOf(a.kind);
const kb = NODE_KIND_ORDER.indexOf(b.kind);
return ka !== kb ? ka - kb : b.degree - a.degree;
});
const radius = Math.max(180, (sorted.length * 56) / (2 * Math.PI));
sorted.forEach((n, i) => {
const angle = (i / sorted.length) * Math.PI * 2 - Math.PI / 2;
n.x = radius * Math.cos(angle);
n.y = radius * Math.sin(angle);
n.vx = 0;
n.vy = 0;
n.fx = null;
n.fy = null;
});
}
// ---------------------------------------------------------------------------
// Force simulation (d3-force-like, dependency-free, fine for <500 nodes)
// ---------------------------------------------------------------------------
export class ForceSimulation {
alpha = 1;
readonly alphaMin = 0.004;
private readonly alphaDecay: number;
private readonly velocityDecay = 0.38;
private readonly chargeStrength: number;
private readonly chargeCutoff: number;
private readonly chargeCutoff2: number;
/** Hard cap on per-tick movement so dense graphs can't oscillate or fly off. */
private readonly maxSpeed = 30;
constructor(
public nodes: SimNode[],
public edges: SimEdge[],
) {
// Scale forces down and cool faster as the graph grows — with the
// small-graph constants a few hundred nodes keep shoving each other
// around for minutes and the layout never settles.
const n = nodes.length;
if (n > 600) {
this.chargeStrength = -160;
this.chargeCutoff = 260;
this.alphaDecay = 0.05;
} else if (n > 300) {
this.chargeStrength = -240;
this.chargeCutoff = 340;
this.alphaDecay = 0.038;
} else if (n > 150) {
this.chargeStrength = -320;
this.chargeCutoff = 440;
this.alphaDecay = 0.03;
} else {
this.chargeStrength = -420;
this.chargeCutoff = 600;
this.alphaDecay = 0.0228;
}
this.chargeCutoff2 = this.chargeCutoff * this.chargeCutoff;
}
reheat(alpha = 0.9): void {
this.alpha = Math.max(this.alpha, alpha);
}
/** Advance one step; returns false when the layout has cooled down. */
tick(): boolean {
if (this.alpha < this.alphaMin) return false;
this.alpha += (0 - this.alpha) * this.alphaDecay;
const a = this.alpha;
const { nodes, edges } = this;
// Link spring force.
for (const e of edges) {
const s = e.source;
const t = e.target;
let dx = t.x - s.x;
let dy = t.y - s.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1e-3;
const f = ((dist - e.distance) / dist) * 0.45 * a;
dx *= f;
dy *= f;
t.vx -= dx * 0.5;
t.vy -= dy * 0.5;
s.vx += dx * 0.5;
s.vy += dy * 0.5;
}
// Repulsion + collision over nearby pairs only (uniform grid).
this.applyPairForces(a);
// Gentle pull toward the origin keeps disconnected clusters on screen.
for (const n of nodes) {
n.vx += (0 - n.x) * 0.05 * a;
n.vy += (0 - n.y) * 0.05 * a;
}
// Integrate velocities.
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
if (n.fx != null && n.fy != null) {
n.x = n.fx;
n.y = n.fy;
n.vx = 0;
n.vy = 0;
} else {
n.vx *= 1 - this.velocityDecay;
n.vy *= 1 - this.velocityDecay;
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
if (speed > this.maxSpeed) {
const s = this.maxSpeed / speed;
n.vx *= s;
n.vy *= s;
}
n.x += n.vx;
n.y += n.vy;
}
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) {
// A NaN/Infinity coordinate would blank the whole canvas (the
// bounding box and fit transform become NaN); park the node instead.
const r = 40 + 18 * Math.sqrt(i + 1);
n.x = r * Math.cos(i * GOLDEN_ANGLE);
n.y = r * Math.sin(i * GOLDEN_ANGLE);
n.vx = 0;
n.vy = 0;
}
}
return true;
}
/**
* Many-body repulsion + collision resolution, restricted to pairs within
* one grid cell (cell size = charge cutoff). Two nodes farther apart than
* the cutoff never interact, so binning by cell keeps the scan close to
* O(n) for spread-out layouts instead of O(n²) over all pairs.
*/
private applyPairForces(a: number): void {
const cellSize = this.chargeCutoff;
const grid = new Map<string, { cx: number; cy: number; items: SimNode[] }>();
for (const n of this.nodes) {
const cx = Math.floor(n.x / cellSize);
const cy = Math.floor(n.y / cellSize);
const key = `${cx},${cy}`;
let bucket = grid.get(key);
if (!bucket) grid.set(key, (bucket = { cx, cy, items: [] }));
bucket.items.push(n);
}
// Half of the 8-neighborhood so every cell pair is visited exactly once.
const offsets = [
[0, 0],
[1, 0],
[0, 1],
[1, 1],
[1, -1],
] as const;
for (const bucket of grid.values()) {
for (const [ox, oy] of offsets) {
const other =
ox === 0 && oy === 0
? bucket
: grid.get(`${bucket.cx + ox},${bucket.cy + oy}`);
if (!other) continue;
const left = bucket.items;
const right = other.items;
if (other === bucket) {
for (let i = 0; i < left.length; i++) {
for (let j = i + 1; j < left.length; j++) {
this.pairForce(left[i], left[j], i, j, a);
}
}
} else {
for (let i = 0; i < left.length; i++) {
for (let j = 0; j < right.length; j++) {
this.pairForce(left[i], right[j], i, j, a);
}
}
}
}
}
}
private pairForce(ni: SimNode, nj: SimNode, i: number, j: number, a: number): void {
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
let d2 = dx * dx + dy * dy;
if (d2 > this.chargeCutoff2) return;
if (d2 < 1) {
// Deterministic nudge for coincident nodes.
dx = ((i % 3) - 1) * 0.5 || 0.5;
dy = ((j % 3) - 1) * 0.5 || -0.5;
d2 = dx * dx + dy * dy;
}
const d = Math.sqrt(d2);
const f = (this.chargeStrength * a) / d2;
const fx = (dx / d) * f;
const fy = (dy / d) * f;
ni.vx += fx;
ni.vy += fy;
nj.vx -= fx;
nj.vy -= fy;
// Positional collision resolution (prevents node overlap).
const minDist = ni.radius + nj.radius + 8;
if (d < minDist) {
const push = ((minDist - d) / d) * 0.5;
const px = dx * push;
const py = dy * push;
if (ni.fx == null) {
ni.x -= px;
ni.y -= py;
}
if (nj.fx == null) {
nj.x += px;
nj.y += py;
}
}
}
}
+429 -6
View File
@@ -43,8 +43,8 @@
width: 100%;
height: 75px;
margin-top: 13px;
font-family: Noto Sans S Chinese;
font-weight: 500;
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-weight: 700;
color: #fff;
font-size: 20px;
display: flex;
@@ -111,6 +111,42 @@
word-break: break-all;
}
.sidebar-contnet--top-submenu .sidebar-contents {
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-contnet--top-submenu .item-nav {
height: auto;
min-height: 75px;
padding: 10px 2px;
font-size: 19px;
line-height: 1.35;
}
.sidebar-contnet--top-submenu .item-nav__label {
width: 3.1em;
max-width: 100%;
padding-left: 0;
letter-spacing: 0;
line-height: 1.35;
text-align: center;
word-break: normal;
overflow-wrap: normal;
white-space: normal;
}
.sidebar-contnet--top-submenu .item-nav__label--four-char {
width: 2.2em;
}
.sidebar-contnet--top-submenu .item-nav:hover::after,
.sidebar-contnet--top-submenu .item-nav-active::after,
.sidebar-contnet--top-submenu .item-nav-hovered::after {
top: auto;
bottom: 0;
}
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
@@ -119,7 +155,8 @@
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(2px);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
position: absolute;
left: 108px;
top: 0;
@@ -193,10 +230,10 @@
border: 0;
background: transparent;
padding: 3px 0;
font-family: Noto Sans S Chinese;
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-size: 18px;
line-height: 16px;
font-weight: 500;
font-weight: 700;
letter-spacing: 0;
color: rgba(255, 255, 255, 0.8);
text-align: left;
@@ -319,4 +356,390 @@
max-width: 64px;
opacity: 1;
transform: translateX(0);
}
}
.sidebar-contnet--top-submenu .item-nav:hover::before,
.sidebar-contnet--top-submenu .item-nav-active::before,
.sidebar-contnet--top-submenu .item-nav-hovered::before {
display: none;
}
.top-submenu-bar {
position: relative;
z-index: 35;
height: 64px;
min-width: 0;
flex: 1 1 auto;
align-self: stretch;
margin-left: 34px;
background: transparent;
border: 0;
box-shadow: none;
overflow-x: auto;
overflow-y: hidden;
}
/* 左右分组:不撑满、不裁切选中高光 */
.top-submenu-bar--group {
flex: 0 0 auto;
margin-left: 0;
overflow: visible;
}
.top-submenu-list {
min-width: max-content;
height: 64px;
display: flex;
align-items: stretch;
gap: 0;
padding: 0;
}
.top-submenu-item {
position: relative;
min-width: 122px;
height: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0 18px;
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-size: 18px;
line-height: 1;
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
overflow: visible;
transition: color 0.18s ease, text-shadow 0.18s ease;
}
.top-submenu-item:hover {
color: #9fe3ff;
text-shadow: 0 0 10px rgba(91, 210, 255, 0.4);
}
/* 选中态:设计稿提供的 nav-active.svg 发光高光 + 青色文字 */
.top-submenu-item--active {
color: #64cdf5;
text-shadow: 0 0 12px rgba(100, 205, 245, 0.5);
background-image: url("../assets/img/nav-active.svg");
background-repeat: no-repeat;
background-position: center calc(50% + 7px);
background-size: 152px 42px;
}
.third-submenu-bar {
position: relative;
z-index: 34;
height: 42px;
margin-left: 118px;
flex-shrink: 0;
background:
linear-gradient(180deg, rgba(4, 34, 78, 0.96) 0%, rgba(2, 24, 58, 0.96) 100%),
radial-gradient(ellipse at top left, rgba(45, 149, 255, 0.18) 0%, rgba(45, 149, 255, 0) 42%);
border-top: 1px solid rgba(79, 154, 255, 0.18);
border-bottom: 1px solid rgba(43, 143, 254, 0.26);
box-shadow: inset 0 1px 0 rgba(115, 194, 255, 0.08), inset 0 -10px 18px rgba(0, 26, 70, 0.22);
overflow-x: auto;
overflow-y: hidden;
}
.third-submenu-list {
min-width: max-content;
height: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 0 24px;
}
.third-submenu-item {
position: relative;
height: 28px;
min-width: 86px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(90, 169, 255, 0.2);
border-radius: 2px;
padding: 0 16px;
background: rgba(2, 28, 65, 0.36);
color: rgba(221, 242, 255, 0.78);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1;
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.third-submenu-item:hover,
.third-submenu-item--active {
color: #ffffff;
border-color: rgba(82, 205, 255, 0.55);
background: linear-gradient(180deg, rgba(14, 84, 165, 0.54) 0%, rgba(4, 45, 104, 0.58) 100%);
box-shadow: inset 0 0 12px rgba(0, 185, 255, 0.14), 0 0 10px rgba(35, 167, 255, 0.14);
text-shadow: 0 0 8px rgba(87, 215, 255, 0.34);
}
.third-submenu-item--active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: -1px;
height: 2px;
background: linear-gradient(90deg, rgba(62, 178, 255, 0) 0%, #4ee6ff 50%, rgba(62, 178, 255, 0) 100%);
box-shadow: 0 0 10px rgba(78, 230, 255, 0.72);
}
/* ══════════════════════════════════════════════════════════════════════
浅色主题导航(红/蓝两套,颜色经 --brand-* 变量自动切换)
逻辑同深色:一级导航在顶栏、二级导航在侧边栏、三级导航在顶栏下方横条。
但一级导航不做深色那种围绕基座的特殊处理,正常横排即可。
══════════════════════════════════════════════════════════════════════ */
/* ── 顶栏一级导航(白字落在 red/blue-header banner 上)──────────────────── */
.light-topnav {
display: flex;
align-items: center;
height: 64px;
min-width: 0;
gap: 6px;
/* 整体往右移(用百分比,随容器宽度自适应;约等于宽屏下的 200px 量级) */
margin-left: 10%;
overflow-x: auto;
overflow-y: hidden;
}
.light-topnav-item {
position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: 60px;
padding: 0 18px;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.85);
/* 复用全局字体栈(与「多智能体会商」等页面一致):Source Han Sans SC 在 Windows 上
常按字重分家族,放栈首会导致 font-weight 选不到粗体;用 --font-sans(Noto Sans SC
领先)才能正常加粗。 */
font-family: var(--font-sans);
font-size: 16px;
/* 普通项用 500(雅黑兜底下渲染为常规字面),选中项 700 才能看出粗体对比 */
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: color 0.18s ease;
}
.light-topnav-item:hover {
color: #ffffff;
}
/* 选中态:不改背景,文字加粗变亮 + 贴近导航栏底部一条粗白线 */
.light-topnav-item--active,
.light-topnav-item--active:hover {
color: #ffffff;
font-weight: 700;
}
.light-topnav-item--active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: 0;
height: 4px;
border-radius: 3px 3px 0 0;
background: #ffffff;
}
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
.light-sidebar {
position: fixed;
left: 0;
top: 4rem;
z-index: 40;
width: 96px;
height: calc(100vh - 4rem);
display: flex;
flex-direction: row;
background: #ffffff;
border-right: 1px solid var(--border);
box-sizing: border-box;
overflow: hidden;
/* 展开三级列时整体变宽的过渡动画 */
transition: width 0.3s ease;
}
/* 展开三级列:96(二级) + 140(三级) = 236px */
.light-sidebar--expanded {
width: 236px;
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
.light-sidebar__primary {
flex: 0 0 96px;
width: 96px;
height: 100%;
display: flex;
flex-direction: column;
/* 横向 padding 置 0:让选中态浅红背景能铺满与列宽一致 */
padding: 10px 0;
box-sizing: border-box;
}
/* 三级列:固定 140px,随侧边栏展开淡入 */
.light-sidebar__secondary {
flex: 0 0 140px;
width: 140px;
height: 100%;
border-left: 1px solid var(--border);
/* 横向 padding 置 0:让三级选中背景能铺满与列宽一致(文字内缩由 subitem 自身 padding 保证) */
padding: 12px 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
background: #ffffff;
opacity: 0;
transition: opacity 0.2s ease;
}
.light-sidebar--expanded .light-sidebar__secondary {
opacity: 1;
}
.light-sidebar__list {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
overflow-x: hidden;
}
.light-sidebar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px 4px;
border: 0;
border-radius: 0;
background: transparent;
color: #222222;
/* 复用全局字体栈,确保加粗生效(详见 .light-topnav-item 注释) */
font-family: var(--font-sans);
font-size: 16px;
font-weight: 500;
line-height: 1.3;
text-align: center;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.light-sidebar-item__icon {
flex-shrink: 0;
/* 图标统一用主题色(红主题红、蓝主题蓝) */
color: var(--brand-900);
transition: color 0.18s ease, transform 0.18s ease;
}
.light-sidebar-item__label {
display: block;
width: 100%;
word-break: break-word;
}
.light-sidebar-item:hover {
background: var(--brand-200);
}
.light-sidebar-item:hover .light-sidebar-item__icon {
color: var(--brand-900);
transform: scale(1.05);
}
.light-sidebar-item--active {
background: var(--brand-200);
color: var(--brand-900);
font-weight: 600;
/* 选中态:去圆角去边框,浅红底铺满父元素宽度,右侧一条粗主题色边(用 inset 阴影避免挤压内容) */
box-shadow: inset -4px 0 0 0 var(--brand-900);
}
.light-sidebar-item--active .light-sidebar-item__icon {
color: var(--brand-900);
}
/* 侧边栏底部:设置入口(浅色模式不支持收起,故无收起按钮) */
.light-sidebar__footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
/* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */
.light-sidebar-subnav {
display: flex;
flex-direction: column;
gap: 2px;
}
.light-sidebar-subitem {
position: relative;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px;
border: 0;
background: transparent;
color: #222222;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 500;
line-height: 1.3;
text-align: left;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.light-sidebar-subitem__icon {
flex-shrink: 0;
/* 图标用主题色(红主题红、蓝主题蓝) */
color: var(--brand-900);
}
.light-sidebar-subitem__label {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.light-sidebar-subitem:hover {
background: var(--brand-200);
}
.light-sidebar-subitem--active {
background: var(--brand-200);
color: var(--brand-900);
font-weight: 700;
box-shadow: inset -4px 0 0 0 var(--brand-900);
}
@@ -1,14 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { PanelLeftOpen, PanelLeftClose } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import {
defaultExpandedIds,
filterMenuItemsByRole,
sidebarMenuItems,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import './page-sdebar.css'
type PageSidebarDarkProps = {
@@ -19,10 +19,8 @@ type PageSidebarDarkProps = {
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useMemo(
() => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin),
[isAdmin],
);
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const inWujieEnv = isWujieEnv();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
const navigate = useNavigate();
@@ -56,7 +54,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
};
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
@@ -126,6 +124,13 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
};
const handleSecondItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
if (path) {
navigate(path);
}
};
const handleMenuItemClick = (item: MenuItem) => {
if (item.children?.length) {
toggleExpanded(item.id);
@@ -137,6 +142,35 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
};
const activeTopItem =
visibleMenuItems.find((item) => item.id === activeTopId) ??
visibleMenuItems.find(hasActiveChild) ??
visibleMenuItems[0];
const sidebarMenuItemsToRender = inWujieEnv
? visibleMenuItems
: activeTopItem?.children ?? [];
const isSidebarItemActive = (item: MenuItem) =>
inWujieEnv
? item.id === activeTopId
: item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item);
const handleSidebarItemClick = (item: MenuItem) => {
if (inWujieEnv) {
handleTopItemClick(item);
return;
}
handleSecondItemClick(item);
};
const getSidebarLabelClassName = (label: string) => {
if (!inWujieEnv && Array.from(label.trim()).length === 4) {
return "item-nav__label item-nav__label--four-char";
}
return "item-nav__label";
};
const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
{items.map((item) => {
@@ -163,6 +197,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
})}
</div>
);
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
if (collapsed) {
return (
@@ -180,16 +215,16 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
return (
<aside className="sidebar-contnet">
<aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
<div className="sidebar-contents">
{visibleMenuItems.map((item) => (
{sidebarMenuItemsToRender.map((item) => (
<div
key={item.id}
className="item-nav-group"
onMouseEnter={() => handleNavHoverEnter(item.id)}
onMouseLeave={scheduleNavPanelClose}
onMouseEnter={inWujieEnv ? () => handleNavHoverEnter(item.id) : undefined}
onMouseLeave={inWujieEnv ? scheduleNavPanelClose : undefined}
>
{item.children?.length ? (
{inWujieEnv && item.children?.length ? (
<div
className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`}
>
@@ -199,12 +234,12 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
<div className="item-nav-wrap">
<button
type="button"
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
onClick={() => handleTopItemClick(item)}
className={`item-nav ${isSidebarItemActive(item) ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
onClick={() => handleSidebarItemClick(item)}
>
<span className="item-nav__label">{item.label}</span>
<span className={getSidebarLabelClassName(item.label)}>{item.label}</span>
</button>
{item.children?.length && hoveredTopId === item.id ? (
{inWujieEnv && item.children?.length && hoveredTopId === item.id ? (
<span className="item-nav-hover-bridge" aria-hidden="true" />
) : null}
</div>
@@ -0,0 +1,303 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
Folder,
FolderOpen,
Settings,
User,
Wrench,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
defaultExpandedIds,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
type PageSidebarProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
export function PageSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
const iconButtonClass = isDark
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = isDark
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
: "ml-4 space-y-1 border-l border-red-100 pl-4";
const labelClass = (compact: boolean, small = false) =>
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
const menuButtonClass = (isActive: boolean, depth = 0) =>
`group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3"
} ${
isActive
? isDark
? "bg-blue-500/15 text-cyan-300 font-semibold"
: "bg-red-50 text-red-600 font-semibold"
: isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
}`;
const footerButtonClass = menuButtonClass(false, 0);
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const renderLeaf = (item: MenuItem, depth = 0) => {
const Icon = item.icon;
const isActive = !!item.path && pathname === item.path;
return (
<button
key={item.id}
type="button"
title={collapsed && depth === 0 ? item.label : undefined}
onClick={() => item.path && navigate(item.path)}
className={menuButtonClass(isActive, depth)}
>
{isActive && (
<span
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
isDark ? "bg-cyan-300" : "bg-red-600"
}`}
/>
)}
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
{(!collapsed || depth > 0) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
</button>
);
};
const renderBranch = (item: MenuItem, depth = 0) => {
const expanded = expandedIds.has(item.id);
const isTopLevel = depth === 0;
const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon;
return (
<div key={item.id} className="space-y-1">
<button
type="button"
onClick={() => {
if (collapsed && isTopLevel) {
onToggleCollapsed();
return;
}
toggleExpanded(item.id);
}}
title={collapsed && isTopLevel ? item.label : undefined}
className={menuButtonClass(false, depth)}
>
<Icon
className={`${isTopLevel ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
expanded && depth > 0 ? "scale-105" : ""
}`}
/>
{(!collapsed || !isTopLevel) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
{(!collapsed || !isTopLevel) && (
<ChevronRight
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
/>
)}
</button>
{(!collapsed || !isTopLevel) && item.children && (
<div
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
}`}
>
{item.children.map((child) =>
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
)}
</div>
)}
</div>
);
};
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
} ${collapsed ? "w-16" : "w-64"}`}
>
<button
type="button"
onClick={onToggleCollapsed}
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
isDark
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
}`}
>
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
</button>
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{visibleMenuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
</nav>
<div
className={`mt-auto shrink-0 theme-transition ${
isDark ? "border-t border-blue-800/30" : "border-t border-red-100"
} ${collapsed ? "p-2" : "p-4"}`}
>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={collapsed ? t.workspace.settingsAndMore : undefined}
className={footerButtonClass}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
<ChevronsUpDown className="h-4 w-4 flex-shrink-0" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={collapsed ? "start" : "end"}
side={collapsed ? "right" : "top"}
sideOffset={4}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span
className={`ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
isDark ? "bg-cyan-500" : "bg-red-500"
}`}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className={`${footerButtonClass} pointer-events-none`}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
)}
</button>
)}
</div>
<div
className={`pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full blur-3xl theme-transition ${
isDark ? "bg-blue-500/10" : "bg-red-500/5"
}`}
/>
<div
className={`pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full blur-3xl theme-transition ${
isDark ? "bg-cyan-400/5" : "bg-red-400/5"
}`}
/>
</aside>
</>
);
}
+214 -11
View File
@@ -22,14 +22,17 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
defaultExpandedIds,
filterMenuItemsByRole,
sidebarMenuItems,
getActiveThirdLevelItems,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import "@/components/page-sdebar.css";
type PageSidebarProps = {
collapsed: boolean;
@@ -38,14 +41,210 @@ type PageSidebarProps = {
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
export function PageSidebar({
const hasActiveChild = (item: MenuItem, pathname: string): boolean => {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
};
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) {
return null;
}
for (const child of items) {
if (child.path) {
return child.path;
}
const nested = findFirstRoutePath(child.children);
if (nested) {
return nested;
}
}
return null;
};
/**
* 浅色侧边栏入口。
*
* - 普通桌面环境:渲染「二级导航图标卡片」(一级在顶栏、二级在侧边栏,参考 导航-1.png)。
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
* (保证嵌入模式下所有层级仍可达,零回归)。
*/
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
if (isWujieEnv()) {
return (
<LegacyAccordionSidebar
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
/>
);
}
return <SecondaryNavSidebar />;
}
/**
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
*/
function SecondaryNavSidebar() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
// 当前激活的一级菜单(与顶栏一级导航联动);其 children 即侧边栏要渲染的二级导航。
const activeTopItem =
visibleMenuItems.find((item) => hasActiveChild(item, pathname)) ??
visibleMenuItems[0];
const secondaryItems = activeTopItem?.children ?? [];
const isItemActive = (item: MenuItem) =>
item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item, pathname);
const handleItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
if (path) {
navigate(path);
}
};
// 当前激活二级项的三级子项;非空时侧边栏在右侧展开「三级列」并整体变宽(见 css 过渡)。
const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
const expanded = thirdItems.length > 0;
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
{/* 一级列:二级图标卡片 + 底部设置 */}
<div className="light-sidebar__primary">
<nav className="light-sidebar__list" aria-label="二级菜单">
{secondaryItems.map((item) => {
const Icon = item.icon;
const active = isItemActive(item);
return (
<button
key={item.id}
type="button"
title={item.label}
onClick={() => handleItemClick(item)}
className={`light-sidebar-item ${active ? "light-sidebar-item--active" : ""}`}
>
<Icon className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">{item.label}</span>
</button>
);
})}
</nav>
<div className="light-sidebar__footer">
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={t.workspace.settingsAndMore}
className="light-sidebar-item"
>
<Settings className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">设置</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="right"
sideOffset={8}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--brand-900)] px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{/* 二级列:三级导航(激活二级项有三级子项时展开,整体动画见 css width transition) */}
<div className="light-sidebar__secondary" aria-hidden={!expanded}>
<nav className="light-sidebar-subnav" aria-label="三级菜单">
{thirdItems.map((child) => {
const ChildIcon = child.icon;
const childActive = child.path
? pathname === child.path || pathname.startsWith(child.path + "/")
: hasActiveChild(child, pathname);
return (
<button
key={child.id}
type="button"
title={child.label}
onClick={() => handleItemClick(child)}
className={`light-sidebar-subitem ${childActive ? "light-sidebar-subitem--active" : ""}`}
>
<ChildIcon className="light-sidebar-subitem__icon h-4 w-4" />
<span className="light-sidebar-subitem__label">{child.label}</span>
</button>
);
})}
</nav>
</div>
</aside>
</>
);
}
/**
* 旧版浅色整树手风琴侧边栏。仅用于 wujie 嵌入环境(顶栏被宿主隐藏时保证所有层级可达)。
*/
function LegacyAccordionSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(sidebarMenuItems, !!isAdmin);
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
@@ -67,9 +266,6 @@ export function PageSidebar({
setSettingsOpen(true);
};
const iconButtonClass = isDark
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = isDark
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
: "ml-4 space-y-1 border-l border-red-100 pl-4";
@@ -78,8 +274,8 @@ export function PageSidebar({
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
const menuButtonClass = (isActive: boolean, depth = 0) =>
`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
`group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3"
} ${
isActive
? isDark
@@ -115,7 +311,14 @@ export function PageSidebar({
onClick={() => item.path && navigate(item.path)}
className={menuButtonClass(isActive, depth)}
>
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
{isActive && (
<span
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
isDark ? "bg-cyan-300" : "bg-red-600"
}`}
/>
)}
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
{(!collapsed || depth > 0) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
@@ -145,7 +348,7 @@ export function PageSidebar({
className={menuButtonClass(false, depth)}
>
<Icon
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
className={`${isTopLevel ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
expanded && depth > 0 ? "scale-105" : ""
}`}
/>
@@ -5,7 +5,7 @@ import {
QueryClientProvider as TanStackQueryClientProvider,
} from "@tanstack/react-query";
const queryClient = new QueryClient();
export const queryClient = new QueryClient();
export function QueryClientProvider({
children,
@@ -0,0 +1,46 @@
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CheckboxProps
extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange" | "checked" | "type"
> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
/**
* 轻量级 Checkbox(不依赖 @radix-ui/react-checkbox,适配离线部署)。
* API 与 Radix/shadcn 对齐:受控 `checked` + `onCheckedChange(next)`。
*/
export const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
return (
<button
ref={ref}
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
data-state={checked ? "checked" : "unchecked"}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
"peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-sm outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
checked && "border-primary bg-primary text-primary-foreground",
className,
)}
{...props}
>
{checked && <Check className="size-3.5" strokeWidth={3} />}
</button>
);
},
);
Checkbox.displayName = "Checkbox";
+85 -75
View File
@@ -4,28 +4,38 @@ import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { isWujieEnv } from "@/core/embed/wujie";
import { cn } from "@/lib/utils";
function Dialog({
modal,
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
// 无界嵌入时 Radix modal 会给 body 注入异常 margin-right,导致右侧空白。
// 默认降为 non-modal;显式传 modal={true} 仍可强制 modal。
return (
<DialogPrimitive.Root
data-slot="dialog"
modal={modal ?? !isWujieEnv()}
{...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;
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
@@ -33,68 +43,68 @@ function DialogPortal({
return <DialogPrimitive.Portal data-slot="dialog-portal" {...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;
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} />;
});
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;
DialogClose.displayName = DialogPrimitive.Close.displayName;
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;
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;
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 (
@@ -0,0 +1,39 @@
import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
import { cn } from "@/lib/utils";
/** Inline, de-emphasized breadcrumb: folder path + current document title. */
export function Breadcrumb({
segments,
current,
className,
}: {
segments: string[];
current: string;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center gap-1 overflow-hidden text-xs text-slate-400 dark:text-slate-500",
className,
)}
>
{segments.map((segment, i) => (
<Fragment key={`${segment}-${i}`}>
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
{segment}
</span>
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
</Fragment>
))}
<span
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
title={current}
>
{current}
</span>
</div>
);
}
@@ -0,0 +1,120 @@
import { Download, Pencil, Save, Trash2, X } from "lucide-react";
import { cn } from "@/lib/utils";
function ToolbarButton({
icon,
label,
onClick,
disabled,
danger,
emphasized,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
emphasized?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-2 text-xs text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200",
danger &&
"text-red-500 hover:bg-red-50 hover:text-red-600 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300",
emphasized &&
"border border-slate-200 bg-white font-medium dark:border-slate-700 dark:bg-slate-900",
)}
>
{icon}
{label}
</button>
);
}
/** Compact inline toolbar: subtle meta (status / chars / updated) + actions. */
export function DocumentToolbar({
charCount,
updatedAt,
statusLabel,
editing,
saving,
archiving,
onEdit,
onCancelEdit,
onSave,
onExport,
onArchive,
}: {
charCount: number;
updatedAt: string;
statusLabel: string;
editing: boolean;
saving: boolean;
archiving: boolean;
onEdit: () => void;
onCancelEdit: () => void;
onSave: () => void;
onExport: () => void;
onArchive: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{editing ? (
<span className="inline-flex h-5 items-center rounded bg-amber-50 px-1.5 text-[11px] font-medium text-amber-600 dark:bg-amber-500/10 dark:text-amber-400">
编辑中
</span>
) : (
<span className="hidden h-5 items-center rounded bg-slate-100 px-1.5 text-[11px] text-slate-500 sm:inline-flex dark:bg-slate-800 dark:text-slate-400">
{statusLabel}
</span>
)}
<span className="hidden whitespace-nowrap text-xs text-slate-400 lg:inline dark:text-slate-500">
{charCount.toLocaleString()} 字符 · {updatedAt}
</span>
<div className="ml-1 flex items-center gap-1">
{editing ? (
<>
<ToolbarButton
icon={<X className="size-3.5" />}
label="取消"
onClick={onCancelEdit}
/>
<ToolbarButton
icon={<Save className="size-3.5" />}
label={saving ? "保存中..." : "保存"}
onClick={onSave}
disabled={saving}
emphasized
/>
</>
) : (
<>
<ToolbarButton
icon={<Pencil className="size-3.5" />}
label="编辑"
onClick={onEdit}
/>
<ToolbarButton
icon={<Download className="size-3.5" />}
label="导出"
onClick={onExport}
/>
<ToolbarButton
icon={<Trash2 className="size-3.5" />}
label="归档"
onClick={onArchive}
disabled={archiving}
danger
/>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,18 @@
/** Shown in the reading column when no document is selected. */
export function EmptyDocumentState() {
return (
<div className="flex h-full items-center justify-center bg-white px-6 text-center dark:bg-slate-900">
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-10 py-12 sm:px-12 dark:border-slate-700 dark:bg-slate-800/50">
<div className="mb-4 text-[40px] leading-none">📚</div>
<div className="mb-2 text-lg font-bold text-slate-700 dark:text-slate-200">
选择一篇文档开始阅读
</div>
<div className="text-sm leading-7 text-slate-400">
你可以从左侧目录树中选择知识文档,
<br />
也可以导入 Markdown、JSON、TXT 或文件夹来构建 LLM Wiki。
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { ListTree } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { TocItem } from "./wikiTypes";
/**
* Floating in-document outline: a small "目录" pill at the top-right of the
* reading area that opens a popover listing h2/h3 headings. Replaces the old
* always-visible right column. Hidden entirely when the document has too few
* headings to be worth navigating.
*/
export function FloatingToc({
items,
activeId,
onItemClick,
className,
}: {
items: TocItem[];
activeId: string | null;
onItemClick: (id: string) => void;
className?: string;
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Close when the document (and thus the heading list) changes.
useEffect(() => {
setOpen(false);
}, [items]);
// Close on click outside so the popover never lingers over the text.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
if (items.length < 2) return null;
return (
<div ref={rootRef} className={cn("flex flex-col items-end", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className={cn(
"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs shadow-sm backdrop-blur transition-colors",
open
? "border-slate-300 bg-white text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
: "border-slate-200 bg-white/90 text-slate-500 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-400 dark:hover:text-slate-200",
)}
>
<ListTree className="size-3.5" />
目录
</button>
{open ? (
<nav className="mt-2 max-h-[55vh] w-64 overflow-y-auto rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl dark:border-slate-700 dark:bg-slate-900">
{items.map((item) => (
<button
key={item.id}
type="button"
title={item.text}
onClick={() => {
onItemClick(item.id);
setOpen(false);
}}
className={cn(
"block w-full cursor-pointer truncate rounded-md px-2 py-1.5 text-left text-[13px] leading-snug transition-colors",
item.level >= 3 && "pl-5",
activeId === item.id
? "bg-slate-100 font-medium text-red-600 dark:bg-slate-800 dark:text-red-400"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800/60 dark:hover:text-slate-200",
)}
>
{item.text}
</button>
))}
</nav>
) : null}
</div>
);
}
@@ -0,0 +1,429 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, 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 { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
useExtractTemplateActions,
useExtractTemplates,
useFolderActions,
useKnowledgeFolders,
} from "@/core/knowledge/hooks";
import type {
ExtractTemplateScope,
KnowledgeExtractTemplate,
} from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
const NONE = "__none__";
/** A dropdown of managed directories (+ an "unfiled" option). */
export function FolderSelect({
value,
onChange,
disabled,
triggerClassName,
allowNone = true,
}: {
value: string | null;
onChange: (v: string | null) => void;
disabled?: boolean;
triggerClassName?: string;
allowNone?: boolean;
}) {
const { data: folders = [] } = useKnowledgeFolders();
return (
<Select
value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? null : v)}
disabled={disabled}
>
<SelectTrigger className={cn("h-9", triggerClassName)}>
<SelectValue placeholder="选择目录" />
</SelectTrigger>
<SelectContent>
{allowNone ? <SelectItem value={NONE}>(不归类)</SelectItem> : null}
{folders.map((f) => (
<SelectItem key={f.id} value={f.path}>
{f.path}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** A dropdown of enabled extraction templates applicable to a scope. */
export function TemplateSelect({
value,
onChange,
scope,
disabled,
triggerClassName,
}: {
value: string | null;
onChange: (v: string | null) => void;
scope: ExtractTemplateScope;
disabled?: boolean;
triggerClassName?: string;
}) {
const { data: templates = [] } = useExtractTemplates(scope, true);
return (
<Select
value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? null : v)}
disabled={disabled}
>
<SelectTrigger className={cn("h-9", triggerClassName)}>
<SelectValue placeholder="默认模板" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>默认模板</SelectItem>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
{t.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** Create / rename / delete knowledge directories. */
export function FolderManageDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data: folders = [], isLoading } = useKnowledgeFolders();
const { create, rename, remove } = useFolderActions();
const [newPath, setNewPath] = useState("");
const handleCreate = async () => {
const path = newPath.trim();
if (!path) {
toast.error("请输入目录路径");
return;
}
try {
await create.mutateAsync(path);
toast.success("目录已创建");
setNewPath("");
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建失败");
}
};
const handleRename = async (folderId: string, current: string) => {
const next = window.prompt("重命名 / 移动目录(输入新的完整路径,斜杠分隔)", current);
if (next === null) return;
try {
await rename.mutateAsync({ folderId, path: next });
toast.success("已更新");
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新失败");
}
};
const handleDelete = async (folderId: string, path: string) => {
if (!window.confirm(`删除目录「${path}」?其中的知识将变为未归类(不会被删除)。`)) return;
try {
await remove.mutateAsync({ folderId });
toast.success("目录已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>目录管理</DialogTitle>
<DialogDescription>
自建知识目录,用斜杠分隔多级(如「投研/行业」)。删除目录只会让其中的知识变为未归类。
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Input
placeholder="新目录路径,如 投研/行业"
value={newPath}
onChange={(e) => setNewPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
/>
<Button onClick={() => void handleCreate()} disabled={create.isPending}>
<Plus className="size-4" /> 新建
</Button>
</div>
<ScrollArea className="max-h-[50vh]">
{isLoading ? (
<div className="text-muted-foreground p-3 text-sm">加载中...</div>
) : folders.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
还没有目录,先在上方新建一个。
</div>
) : (
<ul className="flex flex-col gap-1 p-1">
{folders.map((f) => (
<li
key={f.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm dark:border-slate-700"
>
<span className="truncate font-mono text-[13px]">{f.path}</span>
<div className="flex shrink-0 items-center gap-1">
<Button size="sm" variant="ghost" onClick={() => void handleRename(f.id, f.path)}>
重命名
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700"
onClick={() => void handleDelete(f.id, f.path)}
>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const SCOPE_LABELS: Record<ExtractTemplateScope, string> = {
thread: "对话",
document: "文档",
both: "通用",
};
const EMPTY_DRAFT = {
name: "",
scope: "both" as ExtractTemplateScope,
description: "",
system_prompt: "",
is_default: false,
enabled: true,
};
/** Create / edit / delete the editable extraction-prompt templates. */
export function ExtractTemplateManageDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data: templates = [], isLoading } = useExtractTemplates();
const { create, update, remove } = useExtractTemplateActions();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState({ ...EMPTY_DRAFT });
const selected = useMemo(
() => templates.find((t) => t.id === selectedId) ?? null,
[templates, selectedId],
);
// Load the selected template into the editable draft (or reset for "new").
useEffect(() => {
if (selected) {
setDraft({
name: selected.name,
scope: selected.scope,
description: selected.description ?? "",
system_prompt: selected.system_prompt,
is_default: selected.is_default,
enabled: selected.enabled,
});
} else {
setDraft({ ...EMPTY_DRAFT });
}
}, [selected]);
const startNew = () => setSelectedId(null);
const handleSave = async () => {
if (!draft.name.trim() || !draft.system_prompt.trim()) {
toast.error("请填写名称与提示词正文");
return;
}
try {
if (selectedId) {
await update.mutateAsync({ templateId: selectedId, data: draft });
toast.success("已保存");
} else {
const created = await create.mutateAsync(draft);
setSelectedId(created.id);
toast.success("模板已创建");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleDelete = async (t: KnowledgeExtractTemplate) => {
if (!window.confirm(`删除模板「${t.name}」?`)) return;
try {
await remove.mutateAsync(t.id);
if (selectedId === t.id) setSelectedId(null);
toast.success("已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[95vw] sm:max-w-[1800px]">
<DialogHeader>
<DialogTitle>抽取提示词模板</DialogTitle>
<DialogDescription>
编辑驱动「知识沉淀」的提炼提示词,可新建多套抽取逻辑。沉淀对话/导入文件时可选用对应模板;未选则用该类型的默认模板。
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-[240px_minmax(0,1fr)] gap-4">
{/* template list */}
<div className="flex min-h-0 flex-col gap-2">
<Button size="sm" variant={selectedId === null ? "default" : "outline"} onClick={startNew}>
<Plus className="size-4" /> 新建模板
</Button>
<ScrollArea className="max-h-[50vh] rounded-md border dark:border-slate-700">
{isLoading ? (
<div className="text-muted-foreground p-3 text-sm">加载中...</div>
) : (
<ul className="flex flex-col">
{templates.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => setSelectedId(t.id)}
className={cn(
"flex w-full items-center justify-between gap-2 border-b px-3 py-2 text-left text-sm last:border-b-0 dark:border-slate-800",
selectedId === t.id ? "bg-slate-100 dark:bg-slate-800" : "hover:bg-slate-50 dark:hover:bg-slate-800/50",
)}
>
<span className="min-w-0">
<span className="block truncate">{t.name}</span>
<span className="text-muted-foreground text-[11px]">
{SCOPE_LABELS[t.scope]}
{t.is_default ? " · 默认" : ""}
{t.enabled ? "" : " · 已停用"}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</ScrollArea>
</div>
{/* editor */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
placeholder="模板名称"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
/>
<Select
value={draft.scope}
onValueChange={(v) => setDraft((d) => ({ ...d, scope: v as ExtractTemplateScope }))}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">通用</SelectItem>
<SelectItem value="thread">对话</SelectItem>
<SelectItem value="document">文档</SelectItem>
</SelectContent>
</Select>
</div>
<Input
placeholder="简介(可选)"
value={draft.description}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
/>
<Textarea
rows={16}
placeholder="提示词正文(system prompt)。这里直接控制提炼粒度——想保留更多信息,就在此放宽要求、要求结构化更详细的输出。"
value={draft.system_prompt}
onChange={(e) => setDraft((d) => ({ ...d, system_prompt: e.target.value }))}
className="h-[50vh] max-h-[50vh] resize-none overflow-y-auto font-mono text-xs"
/>
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={draft.is_default}
onChange={(e) => setDraft((d) => ({ ...d, is_default: e.target.checked }))}
className="size-4"
/>
设为该类型默认
</label>
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft((d) => ({ ...d, enabled: e.target.checked }))}
className="size-4"
/>
启用
</label>
<div className="ml-auto flex items-center gap-2">
{selected ? (
<Button
variant="ghost"
className="text-red-600 hover:text-red-700"
onClick={() => void handleDelete(selected)}
>
<Trash2 className="size-4" /> 删除
</Button>
) : null}
<Button onClick={() => void handleSave()} disabled={create.isPending || update.isPending}>
{selectedId ? "保存" : "创建"}
</Button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,189 @@
import {
Loader2,
PanelLeftClose,
PanelLeftOpen,
Search,
SlidersHorizontal,
} from "lucide-react";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import { KnowledgeTree } from "./KnowledgeTree";
import type { TreeNode } from "./wikiTypes";
import { SOURCE_TYPE_LABELS } from "./wikiUtils";
interface KnowledgeSidebarProps {
keyword: string;
onKeywordChange: (v: string) => void;
sourceType: string;
onSourceTypeChange: (v: string) => void;
status: string;
onStatusChange: (v: string) => void;
tree: TreeNode[];
expandedIds: Set<string>;
onToggleFolder: (id: string) => void;
selectedId?: string;
onSelect: (note: KnowledgeNote) => void;
onExportNote: (note: KnowledgeNote) => void;
total: number;
isLoading: boolean;
/** Collapsed to a narrow rail (desktop only; the mobile drawer always expands). */
collapsed?: boolean;
onToggleCollapse?: () => void;
className?: string;
}
/** Left column: search + filters + directory tree, collapsible to a narrow rail. */
export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const activeFilters =
(props.sourceType !== "all" ? 1 : 0) + (props.status !== "all" ? 1 : 0);
const searching = props.keyword.trim().length > 0;
if (props.collapsed) {
return (
<aside className="hidden min-h-0 flex-col items-center gap-1 border-r border-slate-100 bg-white py-2.5 md:flex dark:border-slate-800 dark:bg-slate-900">
<button
type="button"
onClick={props.onToggleCollapse}
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
aria-label="展开目录"
title="展开目录"
>
<PanelLeftOpen className="size-4" />
</button>
<button
type="button"
onClick={props.onToggleCollapse}
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
aria-label="搜索文档"
title="搜索文档"
>
<Search className="size-4" />
</button>
</aside>
);
}
return (
<aside
className={cn(
"flex min-h-0 flex-col border-r border-slate-100 bg-white dark:border-slate-800 dark:bg-slate-900",
props.className,
)}
>
<div className="border-b border-slate-100 p-2.5 dark:border-slate-800">
<div className="flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
<input
value={props.keyword}
onChange={(e) => props.onKeywordChange(e.target.value)}
placeholder="搜索标题或内容..."
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50 pl-8 pr-2.5 text-[13px] text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-400 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-slate-500 dark:focus:bg-slate-900"
/>
</div>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={cn(
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors",
filtersOpen || activeFilters > 0
? "bg-red-50 text-red-500 dark:bg-red-500/10 dark:text-red-400"
: "text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300",
)}
aria-label="高级筛选"
title="高级筛选"
>
<SlidersHorizontal className="size-4" />
{activeFilters > 0 ? (
<span className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white">
{activeFilters}
</span>
) : null}
</button>
{props.onToggleCollapse ? (
<button
type="button"
onClick={props.onToggleCollapse}
className="inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 max-md:hidden dark:hover:bg-slate-800 dark:hover:text-slate-300"
aria-label="收起目录"
title="收起目录"
>
<PanelLeftClose className="size-4" />
</button>
) : null}
</div>
{filtersOpen ? (
<div className="mt-2 grid grid-cols-2 gap-1.5">
<Select value={props.sourceType} onValueChange={props.onSourceTypeChange}>
<SelectTrigger className="h-8 w-full text-[13px]">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={props.status} onValueChange={props.onStatusChange}>
<SelectTrigger className="h-8 w-full text-[13px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
<div className="px-2 pb-1.5 pt-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
文档目录
</div>
{props.isLoading ? (
<div className="flex items-center gap-2 p-4 text-sm text-slate-400">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : props.tree.length === 0 ? (
<div className="mx-2 rounded-lg border border-dashed border-slate-200 p-5 text-center text-sm text-slate-400 dark:border-slate-700">
{searching || activeFilters > 0
? "没有匹配的文档"
: "暂无知识。点击「新建知识」或在对话页「保存到知识库」。"}
</div>
) : (
<KnowledgeTree
nodes={props.tree}
selectedId={props.selectedId}
expandedIds={props.expandedIds}
forceExpand={searching}
onToggleFolder={props.onToggleFolder}
onSelect={props.onSelect}
onExportNote={props.onExportNote}
/>
)}
</div>
<div className="flex h-8 shrink-0 items-center justify-center border-t border-slate-100 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500">
共 {props.total} 篇文档
</div>
</aside>
);
}
@@ -0,0 +1,132 @@
import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import type { TreeNode } from "./wikiTypes";
interface KnowledgeTreeProps {
nodes: TreeNode[];
selectedId?: string;
expandedIds: Set<string>;
/** When searching, render every folder open regardless of expandedIds. */
forceExpand?: boolean;
onToggleFolder: (id: string) => void;
onSelect: (note: KnowledgeNote) => void;
onExportNote: (note: KnowledgeNote) => void;
}
function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number }) {
const { node, depth, selectedId, expandedIds, forceExpand } = props;
const indent = { paddingLeft: `${6 + depth * 10}px` };
if (node.type === "folder") {
const open = forceExpand || expandedIds.has(node.id);
return (
<div>
<div
role="button"
tabIndex={0}
onClick={() => props.onToggleFolder(node.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") props.onToggleFolder(node.id);
}}
style={indent}
title={node.name}
className="group flex h-7 cursor-pointer select-none items-center gap-1 rounded-md pr-2 text-[13px] font-medium text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70"
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-slate-400 transition-transform",
open && "rotate-90",
)}
/>
{open ? (
<FolderOpen className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
) : (
<Folder className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
)}
<span className="flex-1 truncate">{node.name}</span>
<span className="text-[11px] font-normal text-slate-300 opacity-0 group-hover:opacity-100 dark:text-slate-600">
{node.children?.length ?? 0}
</span>
</div>
{open && node.children ? (
<div>
{node.children.map((child) => (
<TreeRow key={child.id} {...props} node={child} depth={depth + 1} />
))}
</div>
) : null}
</div>
);
}
const active = node.id === selectedId;
return (
<div
role="button"
tabIndex={0}
onClick={() => node.item && props.onSelect(node.item)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && node.item) props.onSelect(node.item);
}}
style={indent}
className={cn(
"group flex h-7 cursor-pointer select-none items-center gap-1.5 rounded-md border-l-2 border-transparent pr-1 text-[13px]",
active
? "border-red-500 bg-slate-100 font-medium text-slate-900 dark:bg-slate-800 dark:text-slate-100"
: "text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70",
)}
>
<FileText
className={cn(
"size-3.5 shrink-0",
active ? "text-red-500 dark:text-red-400" : "text-slate-400 dark:text-slate-500",
)}
/>
<span className="flex-1 truncate" title={node.name}>
{node.name}
</span>
{node.item ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-slate-400 opacity-0 hover:bg-slate-200/60 group-hover:opacity-100 data-[state=open]:opacity-100 dark:hover:bg-slate-700"
aria-label="更多操作"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={() => props.onSelect(node.item!)}>
打开文档
</DropdownMenuItem>
<DropdownMenuItem onClick={() => props.onExportNote(node.item!)}>
导出 Markdown
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
}
/** Recursive folder/document tree for the wiki sidebar. */
export function KnowledgeTree(props: KnowledgeTreeProps) {
return (
<div className="flex flex-col gap-px">
{props.nodes.map((node) => (
<TreeRow key={node.id} {...props} node={node} depth={0} />
))}
</div>
);
}
@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
import MarkdownRenderer, {
type WikiLinkMap,
type WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
import type { TocItem } from "./wikiTypes";
import "./wiki.css";
/**
* Renders the note body via the shared MarkdownRenderer and mounts the toc ids
* onto the rendered h1–h4 elements so outline clicks / scroll-spy can target them.
* extractToc and this component walk headings in the same document order, so a
* positional mapping is sufficient.
*/
export function MarkdownViewer({
content,
toc,
wikiLinks,
onWikiLinkClick,
}: {
content: string;
toc: TocItem[];
wikiLinks?: WikiLinkMap;
onWikiLinkClick?: (link: WikiLinkTarget) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const headings = root.querySelectorAll<HTMLElement>("h1, h2, h3, h4");
headings.forEach((el, index) => {
const item = toc[index];
if (item) el.id = item.id;
});
}, [content, toc]);
return (
<div ref={rootRef} className="wiki-markdown">
<MarkdownRenderer
content={content}
wikiLinks={wikiLinks}
onWikiLinkClick={onWikiLinkClick}
/>
</div>
);
}
@@ -0,0 +1,120 @@
import { FileText, Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
WikiLinkMap,
WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
import type { RelatedLink } from "./wikiUtils";
/**
* Compact "相关文档" module rendered from the note's `## Related` wikilinks.
* Each entry is resolved through the backend wikilink map so the visible
* title always matches the actual navigation target; duplicates (same note)
* are collapsed and unresolved targets are shown muted and non-clickable.
*/
export function RelatedDocs({
links,
wikiLinks,
onOpen,
}: {
links: RelatedLink[];
wikiLinks?: WikiLinkMap;
onOpen: (link: WikiLinkTarget) => void;
}) {
if (links.length === 0) return null;
// Resolving is still in flight — render nothing rather than dead links.
const resolving = !wikiLinks;
const seen = new Set<string>();
const entries: { link: RelatedLink; resolved?: WikiLinkTarget }[] = [];
for (const link of links) {
const resolved = wikiLinks?.[link.target];
const key =
resolved?.note_id ?? resolved?.vault_path ?? link.target.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
entries.push({ link, resolved });
}
if (entries.length === 0) return null;
return (
<section className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-slate-600 dark:text-slate-300">
<Link2 className="size-3.5 text-slate-400" />
相关文档
<span className="text-xs font-normal text-slate-400">
{entries.length}
</span>
</h2>
<div className="grid gap-2 sm:grid-cols-2">
{entries.map(({ link, resolved }) => {
const label = link.alias || resolved?.title || link.target;
const clickable = !!resolved?.exists;
const body = (
<>
<FileText
className={cn(
"size-4 shrink-0",
clickable
? "text-slate-400 transition-colors group-hover:text-red-500"
: "text-slate-300 dark:text-slate-600",
)}
/>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-medium",
clickable
? "text-slate-700 dark:text-slate-200"
: "text-slate-400 dark:text-slate-500",
)}
>
{label}
</span>
{resolved?.vault_path ? (
<span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
{resolved.vault_path}
</span>
) : null}
</span>
</>
);
if (clickable) {
return (
<button
key={link.target}
type="button"
title={label}
onClick={() => resolved && onOpen(resolved)}
className="group flex cursor-pointer items-center gap-2.5 rounded-lg border border-slate-200 px-3 py-2 text-left transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700/80 dark:hover:border-slate-600 dark:hover:bg-slate-800/60"
>
{body}
</button>
);
}
return (
<div
key={link.target}
title={resolving ? label : `${label}(未收录)`}
className={cn(
"flex items-center gap-2.5 rounded-lg border border-dashed border-slate-200 px-3 py-2 dark:border-slate-700/80",
!resolving && "opacity-70",
)}
>
{body}
{!resolving ? (
<span className="shrink-0 text-[11px] text-slate-300 dark:text-slate-600">
未收录
</span>
) : null}
</div>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,186 @@
import {
ChevronLeft,
ChevronRight,
FileText,
Hash,
Loader2,
Search,
Sparkles,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useKnowledgeNotes } from "@/core/knowledge/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { SOURCE_TYPE_LABELS, formatDateTime } from "./wikiUtils";
/** What was clicked: a tag (exact filter) or an entity (relation-graph match). */
export type RelatedNotesFilter =
| { kind: "tag"; value: string }
| { kind: "entity"; value: string };
const PAGE_SIZE = 20;
function useDebounced<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(id);
}, [value, delay]);
return debounced;
}
/**
* Click a tag / entity chip → list every other note that shares it.
* Server-side keyword search + pagination so huge graphs stay usable:
* tags use the backend's exact `tag` filter, entities the exact `entity`
* filter (note→entity relations), both combinable with `keyword`.
*/
export function RelatedNotesDialog({
filter,
currentNoteId,
onOpenChange,
onSelect,
}: {
filter: RelatedNotesFilter | null;
currentNoteId?: string;
onOpenChange: (open: boolean) => void;
onSelect: (noteId: string) => void;
}) {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search.trim());
const [page, setPage] = useState(0);
// New chip or new search term → back to the first page.
useEffect(() => {
setPage(0);
}, [filter?.kind, filter?.value, debouncedSearch]);
// Reset the search box whenever a different chip is opened.
useEffect(() => {
setSearch("");
}, [filter?.kind, filter?.value]);
const params = {
...(filter?.kind === "tag" ? { tag: filter.value } : {}),
...(filter?.kind === "entity" ? { entity: filter.value } : {}),
keyword: debouncedSearch || undefined,
limit: filter ? PAGE_SIZE : 0,
offset: page * PAGE_SIZE,
};
const { data, isLoading, isFetching } = useKnowledgeNotes(params);
const items = (data?.items ?? []).filter(
(n: KnowledgeNote) => n.id !== currentNoteId,
);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<Dialog open={!!filter} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{filter?.kind === "entity" ? (
<Sparkles className="size-4 text-violet-500" />
) : (
<Hash className="size-4 text-rose-500" />
)}
<span className="truncate">{filter?.value}</span>
</DialogTitle>
<DialogDescription>
{filter?.kind === "entity"
? "提到该实体的关联知识"
: "使用该标签的关联知识"}
{total > 0 ? ` · 共 ${total} 条` : ""}
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="在关联知识中搜索标题/内容..."
className="h-9 pl-8"
/>
</div>
<ScrollArea className="max-h-[50vh]">
{isLoading || (isFetching && items.length === 0) ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
{debouncedSearch ? "没有匹配的关联知识。" : "暂无其他关联知识。"}
</div>
) : (
<ul className="flex flex-col gap-1 pr-2">
{items.map((note) => (
<li key={note.id}>
<button
type="button"
onClick={() => onSelect(note.id)}
className="flex w-full flex-col gap-0.5 rounded-md border border-transparent px-3 py-2 text-left transition-colors hover:border-slate-200 hover:bg-slate-50 dark:hover:border-slate-700 dark:hover:bg-slate-800/60"
>
<span className="flex items-center gap-2 text-sm font-medium text-slate-800 dark:text-slate-100">
<FileText className="size-3.5 shrink-0 text-slate-400" />
<span className="truncate">{note.title}</span>
</span>
{note.summary ? (
<span className="line-clamp-2 pl-[1.375rem] text-xs text-slate-500 dark:text-slate-400">
{note.summary}
</span>
) : null}
<span className="text-muted-foreground pl-[1.375rem] text-[11px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
{note.updated_at
? ` · ${formatDateTime(note.updated_at).slice(0, 10)}`
: ""}
</span>
</button>
</li>
))}
</ul>
)}
</ScrollArea>
{pageCount > 1 ? (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-muted-foreground text-xs">
第 {page + 1} / {pageCount} 页
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page === 0 || isFetching}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft className="size-4" /> 上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= pageCount - 1 || isFetching}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
下一页 <ChevronRight className="size-4" />
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,429 @@
import { ExternalLink, Sparkles } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import type {
WikiLinkMap,
WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
import { Breadcrumb } from "./Breadcrumb";
import { DocumentToolbar } from "./DocumentToolbar";
import { FolderSelect } from "./KnowledgeManage";
import { FloatingToc } from "./FloatingToc";
import { MarkdownViewer } from "./MarkdownViewer";
import { RelatedDocs } from "./RelatedDocs";
import {
RelatedNotesDialog,
type RelatedNotesFilter,
} from "./RelatedNotesDialog";
import {
SOURCE_TYPE_LABELS,
STATUS_LABELS,
downloadMarkdown,
extractToc,
formatDateTime,
localizeHeadings,
noteBreadcrumbSegments,
splitRelatedSection,
} from "./wikiUtils";
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
};
function entityMeta(type?: string | null) {
return (
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
label: "其他",
className:
"bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
}
);
}
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
function actorLabel(name?: string | null, id?: string | null): string {
if (name && name.trim()) return name.trim();
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
return "—";
}
function MetaTag({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex h-6 items-center rounded-md bg-slate-100 px-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{children}
</span>
);
}
/** Middle column: compact header + markdown reading area (or inline editor). */
export function WikiContent({
note,
wikiLinks,
onWikiLinkClick,
}: {
note: KnowledgeNote;
wikiLinks?: WikiLinkMap;
onWikiLinkClick: (link: WikiLinkTarget) => void;
}) {
const navigate = useNavigate();
const { update, remove } = useKnowledgeActions();
const scrollRef = useRef<HTMLDivElement>(null);
const tickingRef = useRef(false);
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState(note.title);
const [draftTags, setDraftTags] = useState(note.tags.join(", "));
const [draftContent, setDraftContent] = useState(note.content_md);
const [draftStatus, setDraftStatus] = useState<string>(note.status);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
// Tag / entity chip clicked → show notes that share it.
const [relatedFilter, setRelatedFilter] = useState<RelatedNotesFilter | null>(
null,
);
// Pull the backend-injected `## Related` wikilinks out of the body so they
// render as a structured module; editing still works on the full source.
// After extraction, localize the English section headings (Context / Finding
// / Reasoning / …) to Chinese for display — `extractToc` and the viewer share
// this transformed text so heading anchors stay consistent.
const { body: rawBody, related } = useMemo(
() => splitRelatedSection(note.content_md),
[note.content_md],
);
const body = useMemo(() => localizeHeadings(rawBody), [rawBody]);
const toc = useMemo(() => extractToc(body), [body]);
// The floating outline only shows h2/h3 — deeper levels are noise.
const outlineItems = useMemo(
() => toc.filter((item) => item.level === 2 || item.level === 3),
[toc],
);
useEffect(() => {
setEditing(false);
setDraftTitle(note.title);
setDraftTags(note.tags.join(", "));
setDraftContent(note.content_md);
setDraftStatus(note.status);
scrollRef.current?.scrollTo({ top: 0 });
setActiveHeadingId(null);
// Re-sync drafts whenever a different note (or fresh server copy) arrives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id, note.updated_at]);
const handleScroll = () => {
if (tickingRef.current) return;
tickingRef.current = true;
window.requestAnimationFrame(() => {
tickingRef.current = false;
const container = scrollRef.current;
if (!container) return;
const headings = container.querySelectorAll<HTMLElement>(
".wiki-markdown h1[id], .wiki-markdown h2[id], .wiki-markdown h3[id], .wiki-markdown h4[id]",
);
if (headings.length === 0) return;
const containerTop = container.getBoundingClientRect().top;
let current: string | null = headings[0].id;
for (const el of headings) {
if (el.getBoundingClientRect().top - containerTop <= 96) {
current = el.id;
} else {
break;
}
}
setActiveHeadingId(current);
});
};
const handleTocClick = (id: string) => {
setActiveHeadingId(id);
document
.getElementById(id)
?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const handleSave = async () => {
try {
await update.mutateAsync({
noteId: note.id,
data: {
title: draftTitle.trim() || note.title,
content_md: draftContent,
tags: draftTags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status: draftStatus,
},
});
toast.success("已保存");
setEditing(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleMoveFolder = async (folder: string | null) => {
try {
await update.mutateAsync({ noteId: note.id, data: { folder } });
toast.success(folder ? `已移动到「${folder}」` : "已移出目录");
} catch (error) {
toast.error(error instanceof Error ? error.message : "移动失败");
}
};
const handleArchive = async () => {
try {
await remove.mutateAsync({ noteId: note.id });
toast.success("已归档");
navigate("/page/workspace/knowledge");
} catch (error) {
toast.error(error instanceof Error ? error.message : "归档失败");
}
};
return (
<div className="flex min-h-0 min-w-0 flex-col bg-white dark:bg-slate-900">
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
<Breadcrumb
segments={noteBreadcrumbSegments(note)}
current={note.title}
className="min-w-0 flex-1"
/>
<DocumentToolbar
charCount={note.content_md.length}
updatedAt={formatDateTime(note.updated_at)}
statusLabel={STATUS_LABELS[note.status] ?? note.status}
editing={editing}
saving={update.isPending}
archiving={remove.isPending}
onEdit={() => setEditing(true)}
onCancelEdit={() => setEditing(false)}
onSave={() => void handleSave()}
onExport={() => downloadMarkdown(note)}
onArchive={() => void handleArchive()}
/>
</header>
<div className="relative min-h-0 flex-1">
{!editing ? (
<FloatingToc
items={outlineItems}
activeId={activeHeadingId}
onItemClick={handleTocClick}
className="absolute right-4 top-3 z-10 sm:right-6"
/>
) : null}
<div
ref={scrollRef}
onScroll={editing ? undefined : handleScroll}
className="h-full overflow-y-auto"
>
<div className="mx-auto w-full max-w-[980px] px-5 pb-28 pt-8 sm:px-8 lg:px-10">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/>
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={24}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-2 text-2xl font-bold leading-snug text-slate-900 dark:text-slate-100">
{note.title}
</h1>
{note.summary ? (
<div className="my-4 rounded-lg border-l-2 border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-7 text-slate-600 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-300">
{note.summary}
</div>
) : null}
<div className="mb-7 mt-3 flex flex-wrap items-center gap-1.5">
<MetaTag>
来源:{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</MetaTag>
<MetaTag>状态:{STATUS_LABELS[note.status] ?? note.status}</MetaTag>
{note.created_at ? (
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null}
<span className="inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
目录
<FolderSelect
value={note.folder ?? null}
onChange={(v) => void handleMoveFolder(v)}
disabled={update.isPending}
triggerClassName="h-6 w-[10rem] text-xs"
/>
</span>
{note.tags.map((tag) => (
<button
key={tag}
type="button"
title={`查看「${tag}」的关联知识`}
onClick={() => setRelatedFilter({ kind: "tag", value: tag })}
className="inline-flex h-6 cursor-pointer items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 transition-colors hover:bg-rose-100 hover:text-rose-600 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20"
>
#{tag}
</button>
))}
</div>
{note.entities && note.entities.length > 0 ? (
<div className="mb-7 flex flex-wrap items-center gap-1.5">
<span className="mr-0.5 inline-flex items-center gap-1 text-xs text-slate-400">
<Sparkles className="size-3" /> 实体
</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<button
key={`${ent.name}-${ent.entity_type ?? ""}`}
type="button"
title={`查看与「${ent.name}」(${meta.label})相关的知识`}
onClick={() =>
setRelatedFilter({ kind: "entity", value: ent.name })
}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium transition-opacity hover:opacity-80",
meta.className,
)}
>
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
<span>{ent.name}</span>
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</button>
);
})}
</div>
) : null}
<MarkdownViewer
content={body}
toc={toc}
wikiLinks={wikiLinks}
onWikiLinkClick={onWikiLinkClick}
/>
<RelatedDocs
links={related}
wikiLinks={wikiLinks}
onOpen={onWikiLinkClick}
/>
{note.sources && note.sources.length > 0 ? (
<div className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
<h2 className="mb-3 text-sm font-semibold text-slate-600 dark:text-slate-300">
来源
</h2>
<ul className="flex flex-col gap-1.5">
{note.sources.map((src) => (
<li key={src.id} className="text-[13px]">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span className="text-slate-600 dark:text-slate-300">
{src.title || src.tool_name || "来源"}
</span>
)}
{src.snippet ? (
<p className="mt-0.5 line-clamp-2 text-xs text-slate-400">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="mt-12 border-t border-slate-100 pt-3 text-[12px] text-slate-400/90 dark:border-slate-800 dark:text-slate-500">
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
{formatDateTime(note.updated_at)}
</div>
</>
)}
</div>
</div>
</div>
<RelatedNotesDialog
filter={relatedFilter}
currentNoteId={note.id}
onOpenChange={(open) => {
if (!open) setRelatedFilter(null);
}}
onSelect={(id) => {
setRelatedFilter(null);
navigate(`/page/workspace/knowledge/${id}`);
}}
/>
</div>
);
}
@@ -0,0 +1,121 @@
import {
Copy,
FolderTree,
Menu,
Network,
Plus,
RefreshCw,
SlidersHorizontal,
Upload,
} from "lucide-react";
import { cn } from "@/lib/utils";
function HeaderButton({
icon,
label,
onClick,
variant = "outline",
className,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
variant?: "outline" | "primary";
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-sm transition-colors",
variant === "primary"
? "border-red-600 bg-red-600 text-white hover:bg-red-700"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800",
className,
)}
>
{icon}
<span className="hidden sm:inline">{label}</span>
</button>
);
}
/** Light 56px wiki header: plain page title on the left, action entries on the right. */
export function WikiHeader({
onToggleSidebar,
onImportFile,
onBatchImport,
onDedup,
onGraph,
onCreate,
onManageFolders,
onManageTemplates,
}: {
onToggleSidebar: () => void;
onImportFile: () => void;
onBatchImport: () => void;
onDedup: () => void;
onGraph: () => void;
onCreate: () => void;
onManageFolders: () => void;
onManageTemplates: () => void;
}) {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
className="inline-flex size-9 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 md:hidden dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="打开目录"
>
<Menu className="size-5" />
</button>
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
问答梳理
</div>
</div>
<div className="flex items-center gap-2">
<HeaderButton
icon={<Upload className="size-4" />}
label="导入文件"
onClick={onImportFile}
/>
<HeaderButton
icon={<RefreshCw className="size-4" />}
label="批量导入"
onClick={onBatchImport}
/>
<HeaderButton
icon={<FolderTree className="size-4" />}
label="目录管理"
onClick={onManageFolders}
/>
<HeaderButton
icon={<SlidersHorizontal className="size-4" />}
label="抽取模板"
onClick={onManageTemplates}
/>
<HeaderButton
icon={<Copy className="size-4" />}
label="查重"
onClick={onDedup}
/>
<HeaderButton
icon={<Network className="size-4" />}
label="图谱"
onClick={onGraph}
/>
<HeaderButton
icon={<Plus className="size-4" />}
label="新建知识"
onClick={onCreate}
variant="primary"
/>
</div>
</header>
);
}
+168
View File
@@ -0,0 +1,168 @@
/* Wiki reading-area typography: overrides the generic MarkdownRenderer classes
inside .wiki-markdown to get a document-site reading experience. */
.wiki-markdown h1 {
font-size: 26px;
line-height: 1.4;
font-weight: 700;
color: #111827;
margin: 0 0 20px;
}
.wiki-markdown h2 {
font-size: 20px;
font-weight: 700;
line-height: 1.4;
margin: 36px 0 14px;
padding-bottom: 8px;
border-bottom: 1px solid #eef1f5;
color: #111827;
}
.wiki-markdown h3 {
font-size: 17px;
font-weight: 600;
margin: 26px 0 10px;
color: #1f2937;
}
.wiki-markdown h4 {
font-size: 15.5px;
font-weight: 600;
margin: 22px 0 8px;
color: #334155;
}
.wiki-markdown p {
font-size: 15.5px;
line-height: 1.75;
color: #334155;
margin-bottom: 14px;
}
.wiki-markdown ul,
.wiki-markdown ol {
padding-left: 22px;
margin-bottom: 14px;
}
.wiki-markdown li {
margin: 5px 0;
line-height: 1.75;
color: #334155;
font-size: 15.5px;
}
.wiki-markdown li > ul,
.wiki-markdown li > ol {
margin-top: 5px;
margin-bottom: 5px;
}
.wiki-markdown strong {
color: #111827;
font-weight: 600;
}
.wiki-markdown a {
color: #2563eb;
text-decoration: none;
}
.wiki-markdown a:hover {
text-decoration: underline;
}
.wiki-markdown blockquote {
border-left: 3px solid #e2e8f0;
background: #f8fafc;
padding: 10px 16px;
color: #475569;
margin: 16px 0;
border-radius: 0 8px 8px 0;
font-style: normal;
}
.wiki-markdown blockquote p {
margin-bottom: 0;
}
.wiki-markdown table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 14px;
}
.wiki-markdown th,
.wiki-markdown td {
border: 1px solid #e8ecf1;
padding: 8px 12px;
}
.wiki-markdown th {
background: #f8fafc;
font-weight: 600;
color: #334155;
}
.wiki-markdown hr {
margin: 28px 0;
border-color: #eef1f5;
}
/* Anchor target offset so headings clear the floating "目录" pill. */
.wiki-markdown h1,
.wiki-markdown h2,
.wiki-markdown h3,
.wiki-markdown h4 {
scroll-margin-top: 24px;
}
/* Dark theme adjustments (app toggles the .dark class on a root element). */
.dark .wiki-markdown h1,
.dark .wiki-markdown h2 {
color: #f1f5f9;
}
.dark .wiki-markdown h2 {
border-bottom-color: #1f2937;
}
.dark .wiki-markdown h3,
.dark .wiki-markdown h4 {
color: #e2e8f0;
}
.dark .wiki-markdown p,
.dark .wiki-markdown li {
color: #cbd5e1;
}
.dark .wiki-markdown strong {
color: #f8fafc;
}
.dark .wiki-markdown a {
color: #60a5fa;
}
.dark .wiki-markdown blockquote {
background: #1e293b;
border-left-color: #475569;
color: #cbd5e1;
}
.dark .wiki-markdown th {
background: #1e293b;
color: #e2e8f0;
}
.dark .wiki-markdown th,
.dark .wiki-markdown td {
border-color: #334155;
}
.dark .wiki-markdown hr {
border-color: #334155;
}
@@ -0,0 +1,19 @@
import type { KnowledgeNote } from "@/core/knowledge/types";
/** One entry of the in-document outline, parsed from markdown headings (h1–h4). */
export interface TocItem {
id: string;
text: string;
level: number;
}
/** A node of the left-hand knowledge directory tree. */
export interface TreeNode {
id: string;
name: string;
type: "folder" | "file";
children?: TreeNode[];
item?: KnowledgeNote;
/** Full slash-joined directory path (folder nodes only). */
folderPath?: string;
}
@@ -0,0 +1,316 @@
import type { KnowledgeNote } from "@/core/knowledge/types";
import type { TocItem, TreeNode } from "./wikiTypes";
export const SOURCE_TYPE_LABELS: Record<string, string> = {
thread: "对话沉淀",
search: "检索沉淀",
tool: "工具沉淀",
file: "文件导入",
manual: "手动创建",
report: "研究报告",
};
export const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
approved: "已批准",
archived: "已归档",
};
const FALLBACK_FOLDER = "未分类";
/**
* Display labels for known English directory segments (the wiki-capture
* categories that historically drove the vault path). Keyed by the lowercased
* segment. User-created folders are arbitrary (usually Chinese) and simply don't
* match, so they pass through unchanged.
*/
const FOLDER_LABELS: Record<string, string> = {
synthesis: "分析综合",
concepts: "概念框架",
concept: "概念框架",
references: "外部来源",
reference: "外部来源",
decision: "决策记录",
journal: "会话纪要",
session: "会话纪要",
};
/** Map a known English directory segment to its Chinese display label (else unchanged). */
export function localizeFolderName(name: string): string {
return FOLDER_LABELS[name.trim().toLowerCase()] ?? name;
}
/**
* Canonical English wiki-capture section headings → Chinese display labels.
* Keyed by the lowercased, symbol-stripped heading text.
*/
const HEADING_LABELS: Record<string, string> = {
context: "背景",
"finding / decision": "结论 / 决策",
"finding/decision": "结论 / 决策",
finding: "结论",
decision: "决策",
reasoning: "推理依据",
implications: "影响 / 后续",
sources: "来源",
related: "相关知识",
merged: "已合并",
};
/**
* Replace the canonical English wiki-capture section headings (`## Context`,
* `## Finding / Decision`, …) with Chinese labels at render time. Operates on
* heading lines only (fenced code blocks are skipped) and matches the heading
* text case-insensitively, so **all historical notes are localized with no data
* migration**. Heading levels are preserved; because `extractToc` and the
* markdown viewer both receive this same transformed text, the slug/anchor ids
* stay consistent.
*/
export function localizeHeadings(markdown: string): string {
let inFence = false;
return markdown
.split("\n")
.map((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
const match = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
if (!match) return line;
const key = match[2].replace(/[#`*_~]/g, "").trim().toLowerCase();
const zh = HEADING_LABELS[key];
return zh ? `${match[1]} ${zh}` : line;
})
.join("\n");
}
/** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
export function slugify(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[#`*_~[\]()]/g, "")
.replace(/\s+/g, "-")
.replace(/[^\w一-龥-]/g, "");
}
/**
* Parse h1–h4 headings out of raw markdown (skipping fenced code blocks) and
* assign each a stable id; duplicate titles get a numeric suffix. The same id
* sequence is mounted onto the rendered headings by MarkdownViewer.
*/
export function extractToc(markdown: string): TocItem[] {
const usedIds = new Map<string, number>();
const items: TocItem[] = [];
let inFence = false;
for (const line of markdown.split("\n")) {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = /^(#{1,4})\s+(.+)$/.exec(line);
if (!match) continue;
const level = match[1].length;
const text = match[2].replace(/[#`*_~]/g, "").trim();
const baseId = slugify(text) || "heading";
const count = usedIds.get(baseId) ?? 0;
usedIds.set(baseId, count + 1);
items.push({ id: count === 0 ? baseId : `${baseId}-${count + 1}`, text, level });
}
return items;
}
/** One entry parsed from the note's trailing `## Related` wikilink list. */
export interface RelatedLink {
target: string;
alias?: string;
}
/**
* Split the backend-injected `## Related` section (a plain list of
* `- [[Title]]` bullets, see knowledge/service.py `_inject_related_notes`)
* out of the markdown body so it can be rendered as a structured module
* instead of a loose bullet list. If the section contains anything other
* than wikilink bullets, the body is returned untouched (no data loss).
*/
export function splitRelatedSection(markdown: string): {
body: string;
related: RelatedLink[];
} {
const lines = markdown.split("\n");
let inFence = false;
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*(```|~~~)/.test(lines[i])) {
inFence = !inFence;
continue;
}
if (inFence) continue;
if (/^##\s+(Related|相关文档|相关)\s*$/i.test(lines[i].trim())) {
start = i;
break;
}
}
if (start === -1) return { body: markdown, related: [] };
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^#{1,2}\s+/.test(lines[i])) {
end = i;
break;
}
}
const related: RelatedLink[] = [];
for (const raw of lines.slice(start + 1, end)) {
const line = raw.trim();
if (!line) continue;
const match = /^[-*]\s*\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]\s*$/.exec(line);
if (!match) return { body: markdown, related: [] };
const target = match[1].trim();
if (!related.some((r) => r.target === target)) {
related.push({ target, alias: match[2]?.trim() || undefined });
}
}
if (related.length === 0) return { body: markdown, related: [] };
const body = [...lines.slice(0, start), ...lines.slice(end)]
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trimEnd();
return { body, related };
}
/**
* Folder path segments for a note. The user-assigned ``folder`` (a slash-joined
* path) is authoritative; unfiled notes fall back to a source-type group so the
* tree is never empty. (The old behavior keyed off ``vault_path`` — the fixed
* ``projects/<project>/<category>`` scaffold — which the editable directory now
* replaces.)
*/
export function noteFolderSegments(note: KnowledgeNote): string[] {
const folder = (note.folder || "").replace(/^\/+|\/+$/g, "").trim();
if (folder) {
const segments = folder.split("/").map((s) => s.trim()).filter(Boolean);
if (segments.length > 0) return segments;
}
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
}
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
}
function sortTree(nodes: TreeNode[]): void {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name, "zh-CN");
});
for (const node of nodes) {
if (node.children) sortTree(node.children);
}
}
/**
* Build the directory tree from the flat note list. Notes with a vault_path
* become nested folders mirroring that path; others are grouped by source type.
*/
export function buildKnowledgeTree(
notes: KnowledgeNote[],
folderPaths: string[] = [],
): TreeNode[] {
const root: TreeNode[] = [];
const folderMap = new Map<string, TreeNode>();
const ensureFolder = (segments: string[]): TreeNode[] => {
let children = root;
let path = "";
for (const segment of segments) {
path = path ? `${path}/${segment}` : segment;
let node = folderMap.get(path);
if (!node) {
// Display name is localized (known English category → 中文); the id /
// folderPath keep the raw segment so filtering/matching is unaffected.
node = { id: `folder:${path}`, name: localizeFolderName(segment), type: "folder", children: [], folderPath: path };
folderMap.set(path, node);
children.push(node);
}
children = node.children!;
}
return children;
};
// Pre-create managed (possibly empty) directories so a freshly-made folder
// shows up in the tree before any note is filed into it.
for (const path of folderPaths) {
const segments = path.split("/").map((s) => s.trim()).filter(Boolean);
if (segments.length) ensureFolder(segments);
}
for (const note of notes) {
ensureFolder(noteFolderSegments(note)).push({
id: note.id,
name: note.title || "未命名文档",
type: "file",
item: note,
});
}
sortTree(root);
return root;
}
/** Ids of every folder in the tree (used by "expand all" while searching). */
export function collectFolderIds(nodes: TreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: TreeNode[]) => {
for (const node of list) {
if (node.type === "folder") {
ids.push(node.id);
if (node.children) walk(node.children);
}
}
};
walk(nodes);
return ids;
}
/** Folder ids on the path from the root down to the given note, or null if absent. */
export function findAncestorFolderIds(
nodes: TreeNode[],
noteId: string,
): string[] | null {
for (const node of nodes) {
if (node.type === "file" && node.id === noteId) return [];
if (node.type === "folder" && node.children) {
const sub = findAncestorFolderIds(node.children, noteId);
if (sub) return [node.id, ...sub];
}
}
return null;
}
export function formatDateTime(value?: string | null): string {
return value ? value.slice(0, 19).replace("T", " ") : "—";
}
export function formatDate(value?: string | null): string {
return value ? value.slice(0, 10) : "—";
}
/** Trigger a client-side download of the note body as a .md file. */
export function downloadMarkdown(note: KnowledgeNote): void {
const blob = new Blob([note.content_md], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${(note.title || "knowledge").replace(/[\\/:*?"<>|]/g, "_")}.md`;
anchor.click();
URL.revokeObjectURL(url);
}
@@ -0,0 +1,479 @@
"use client";
import {
CheckIcon,
Loader2,
PencilIcon,
PlusIcon,
RefreshCwIcon,
Trash2Icon,
UserPlusIcon,
UsersIcon,
} from "lucide-react";
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 { Textarea } from "@/components/ui/textarea";
import { resolveTagColor } from "@/components/workspace/tags/tag-colors";
import {
POSITION_RESOURCE_HINT,
POSITION_RESOURCE_LABELS,
POSITION_RESOURCE_TYPES,
resourceTypeToTagType,
useAddMembers,
useCreatePosition,
useDeletePosition,
usePositionMembers,
usePositionTagBindings,
usePositionUsers,
usePositions,
useRemoveMembers,
useResyncPosition,
useSetTagBindings,
useUpdatePosition,
type Position,
type PositionResourceType,
type TagBindings,
} from "@/core/positions";
import { useTags } from "@/core/tags";
import { cn } from "@/lib/utils";
function errMsg(e: unknown): string {
return String(e instanceof Error ? e.message : e);
}
/** Inline multi-select chip row for the tags of one resource type. */
function TagMultiSelect({
resourceType,
value,
onChange,
}: {
resourceType: PositionResourceType;
value: string[];
onChange: (next: string[]) => void;
}) {
const { data: tags, isLoading } = useTags(resourceTypeToTagType(resourceType));
const toggle = (id: string) =>
onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
if (isLoading) {
return <Loader2 className="text-muted-foreground size-4 animate-spin" />;
}
if (!tags || tags.length === 0) {
return (
<span className="text-muted-foreground text-xs">
暂无{POSITION_RESOURCE_LABELS[resourceType]}标签,请先到「标签管理」创建。
</span>
);
}
return (
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => {
const checked = value.includes(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 rounded-full border px-3 py-1 text-xs font-medium transition-all",
color.chip,
checked ? "ring-foreground/40 ring-2 ring-offset-1" : "opacity-45 hover:opacity-90",
)}
>
{checked && <CheckIcon className="size-3" />}
{tag.name}
</button>
);
})}
</div>
);
}
function PositionFormDialog({
open,
onOpenChange,
editing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Position | null;
}) {
const createPosition = useCreatePosition();
const updatePosition = useUpdatePosition();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isDefault, setIsDefault] = useState(false);
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${editing?.id ?? "new"}`;
if (open && syncedFor !== syncKey) {
setName(editing?.name ?? "");
setDescription(editing?.description ?? "");
setIsDefault(editing?.is_default ?? false);
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
const pending = createPosition.isPending || updatePosition.isPending;
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
try {
if (editing) {
await updatePosition.mutateAsync({ id: editing.id, input: { name: trimmed, description, is_default: isDefault } });
} else {
await createPosition.mutateAsync({ name: trimmed, description, is_default: isDefault });
}
onOpenChange(false);
} catch (e) {
toast.error(errMsg(e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "编辑岗位" : "新增岗位"}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">岗位名称</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:理赔专员" maxLength={191} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">描述</label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} maxLength={1024} />
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
设为默认岗位(未分配岗位的用户继承此岗位的配置)
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={pending || !name.trim()}>
{pending && <Loader2 className="size-4 animate-spin" />}保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function AddMembersDialog({
open,
onOpenChange,
positionId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
positionId: string;
}) {
const { data: users, isLoading } = usePositionUsers();
const addMembers = useAddMembers();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
useEffect(() => {
if (open) setSelected(new Set());
}, [open]);
const filtered = useMemo(() => {
const list = users ?? [];
const q = search.trim().toLowerCase();
return q ? list.filter((u) => u.email.toLowerCase().includes(q)) : list;
}, [users, search]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSave = async () => {
if (selected.size === 0) return;
try {
await addMembers.mutateAsync({ positionId, userIds: [...selected] });
toast.success(`已为 ${selected.size} 名用户配置岗位`);
onOpenChange(false);
} catch (e) {
toast.error(errMsg(e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>批量添加成员</DialogTitle>
</DialogHeader>
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索邮箱…" />
<div className="max-h-72 overflow-y-auto rounded-md border">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-6">
<Loader2 className="size-4 animate-spin" />加载用户…
</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="text-muted-foreground py-6 text-center text-sm">无匹配用户</div>
)}
{filtered.map((u) => {
const checked = selected.has(u.id);
return (
<button
key={u.id}
type="button"
onClick={() => toggle(u.id)}
className={cn(
"flex w-full items-center justify-between gap-2 border-b px-3 py-2 text-left text-sm last:border-b-0 hover:bg-muted/50",
checked && "bg-muted/40",
)}
>
<span className="truncate">
{u.email}
{u.system_role === "admin" && <span className="text-muted-foreground ml-2 text-xs">(管理员)</span>}
</span>
<span className="flex items-center gap-2">
{u.position_id && u.position_id !== positionId && (
<span className="text-muted-foreground text-xs">已属其他岗位</span>
)}
<span className={cn("flex size-4 items-center justify-center rounded border", checked ? "bg-foreground text-background border-foreground" : "border-muted-foreground/40")}>
{checked && <CheckIcon className="size-3" />}
</span>
</span>
</button>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={addMembers.isPending || selected.size === 0}>
{addMembers.isPending && <Loader2 className="size-4 animate-spin" />}添加 {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function PositionDetail({ position }: { position: Position }) {
const { data: serverBindings } = usePositionTagBindings(position.id);
const { data: members } = usePositionMembers(position.id);
const setBindings = useSetTagBindings();
const removeMembers = useRemoveMembers();
const resync = useResyncPosition();
const [draft, setDraft] = useState<TagBindings>({});
const [addOpen, setAddOpen] = useState(false);
// Reload the draft whenever the selected position or its server bindings change.
const [loadedFor, setLoadedFor] = useState<string | null>(null);
const loadKey = `${position.id}:${serverBindings ? "ready" : "loading"}`;
if (serverBindings && loadedFor !== loadKey) {
setDraft({ ...serverBindings });
setLoadedFor(loadKey);
}
const setType = (rt: PositionResourceType, ids: string[]) =>
setDraft((prev) => ({ ...prev, [rt]: ids }));
const handleSaveBindings = async () => {
try {
await setBindings.mutateAsync({ positionId: position.id, bindings: draft });
toast.success("标签配置已保存,已同步岗位成员");
} catch (e) {
toast.error(errMsg(e));
}
};
const handleResync = async () => {
try {
const n = await resync.mutateAsync(position.id);
toast.success(`已重新同步 ${n} 名成员`);
} catch (e) {
toast.error(errMsg(e));
}
};
const handleRemove = async (userId: string) => {
try {
await removeMembers.mutateAsync({ positionId: position.id, userIds: [userId] });
} catch (e) {
toast.error(errMsg(e));
}
};
return (
<div className="flex flex-col gap-6">
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">默认资源标签配置</h3>
<Button size="sm" onClick={handleSaveBindings} disabled={setBindings.isPending}>
{setBindings.isPending && <Loader2 className="size-4 animate-spin" />}保存标签配置
</Button>
</div>
<div className="flex flex-col divide-y">
{POSITION_RESOURCE_TYPES.map((rt) => (
<div key={rt} className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium">{POSITION_RESOURCE_LABELS[rt]}</span>
<span className="text-muted-foreground text-xs">{POSITION_RESOURCE_HINT[rt]}</span>
</div>
<TagMultiSelect resourceType={rt} value={draft[rt] ?? []} onChange={(ids) => setType(rt, ids)} />
</div>
))}
</div>
</div>
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-1.5 text-sm font-semibold">
<UsersIcon className="size-4" />岗位成员 ({members?.length ?? 0})
</h3>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleResync} disabled={resync.isPending}>
{resync.isPending ? <Loader2 className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}重新同步
</Button>
<Button size="sm" onClick={() => setAddOpen(true)}>
<UserPlusIcon className="size-4" />添加成员
</Button>
</div>
</div>
{members && members.length > 0 ? (
<div className="flex flex-col divide-y">
{members.map((m) => (
<div key={m.id} className="flex items-center justify-between py-2 text-sm">
<span className="truncate">{m.email}</span>
<Button variant="ghost" size="icon" onClick={() => handleRemove(m.id)} aria-label="移除">
<Trash2Icon className="text-destructive size-4" />
</Button>
</div>
))}
</div>
) : (
<div className="text-muted-foreground py-6 text-center text-sm">暂无成员</div>
)}
</div>
<AddMembersDialog open={addOpen} onOpenChange={setAddOpen} positionId={position.id} />
</div>
);
}
export function PositionManagementPage() {
const { data: positions, isLoading, error } = usePositions();
const deletePosition = useDeletePosition();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Position | null>(null);
const selected = useMemo(
() => positions?.find((p) => p.id === selectedId) ?? null,
[positions, selectedId],
);
// Auto-select the first position once loaded.
useEffect(() => {
if (!selectedId && positions && positions.length > 0) setSelectedId(positions[0].id);
}, [positions, selectedId]);
const handleDelete = async (p: Position) => {
if (!window.confirm(`确认删除岗位「${p.name}」?其成员将回退到默认岗位。`)) return;
try {
await deletePosition.mutateAsync(p.id);
if (selectedId === p.id) setSelectedId(null);
} catch (e) {
toast.error(errMsg(e));
}
};
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">岗位管理</h1>
<p className="text-muted-foreground text-sm">
为岗位配置默认智能体/技能/定时任务/推荐问题的标签,并批量分配给用户。
</p>
</div>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<PlusIcon className="size-4" />新增岗位
</Button>
</div>
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12">
<Loader2 className="size-4 animate-spin" />加载中…
</div>
)}
{error && <div className="text-destructive text-sm">{errMsg(error)}</div>}
{!isLoading && !error && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
<div className="flex flex-col gap-2">
{positions && positions.length === 0 && (
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
还没有岗位,点击「新增岗位」创建。
</div>
)}
{positions?.map((p) => (
<div
key={p.id}
onClick={() => setSelectedId(p.id)}
className={cn(
"group cursor-pointer rounded-lg border p-3 transition-colors",
selectedId === p.id ? "border-foreground bg-muted/40" : "hover:bg-muted/30",
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{p.name}</span>
{p.is_default && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-950 dark:text-blue-300">默认</span>
)}
</div>
<div className="flex opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); setEditing(p); setDialogOpen(true); }} aria-label="编辑">
<PencilIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); void handleDelete(p); }} aria-label="删除">
<Trash2Icon className="text-destructive size-3.5" />
</Button>
</div>
</div>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<UsersIcon className="size-3" />{p.member_count} 名成员
</div>
</div>
))}
</div>
<div>
{selected ? (
<PositionDetail key={selected.id} position={selected} />
) : (
<div className="text-muted-foreground rounded-lg border border-dashed py-16 text-center text-sm">
选择左侧一个岗位以配置标签与成员
</div>
)}
</div>
</div>
)}
<PositionFormDialog open={dialogOpen} onOpenChange={setDialogOpen} editing={editing} />
</div>
);
}
@@ -27,6 +27,7 @@ import {
type Tag,
type TagType,
} from "@/core/tags";
import { TAG_TYPES, TAG_TYPE_LABELS } from "@/core/tags/types";
import { cn } from "@/lib/utils";
function TagFormDialog({
@@ -191,11 +192,11 @@ export function TagManagementPage() {
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>
{TAG_TYPES.map((tt) => (
<TabsTrigger key={tt} value={tt}>
{TAG_TYPE_LABELS[tt]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Button size="sm" onClick={openCreate}>
@@ -1,39 +1,54 @@
"use client";
import { BotIcon } from "lucide-react";
import { type Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
export function AgentWelcome({
className,
agent,
agentName,
}: {
className?: string;
agent: Agent | null | undefined;
agentName: string;
}) {
const displayName = desensitize(agent?.name ?? agentName);
const description = agent?.description
? desensitize(agent.description)
: undefined;
return (
<div
className={cn(
"mx-auto flex w-full flex-col items-center justify-center gap-2 px-8 py-4 text-center",
className,
)}
>
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
<BotIcon className="text-primary h-6 w-6" />
</div>
<div className="text-2xl font-bold">{displayName}</div>
{description && (
<p className="text-muted-foreground max-w-sm text-sm">{description}</p>
)}
</div>
);
}
"use client";
import { BotIcon } from "lucide-react";
import { type Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Landing (home) hero shown above the input box before the first message of an
* agent conversation: centered avatar, a "Hi,我是{name}" greeting and the
* agent's description as a slogan. The description is clamped to two lines
* (full text on hover) so a long blurb can never push the input box below the
* fold. The agent's 常用问题 are rendered by the page as a list beside the
* input box, not here.
*/
export function AgentWelcome({
className,
agent,
agentName,
}: {
className?: string;
agent: Agent | null | undefined;
agentName: string;
}) {
const displayName = desensitize(agent?.name ?? agentName);
const description = agent?.description
? desensitize(agent.description)
: undefined;
return (
<div
className={cn(
"mx-auto flex w-full max-w-2xl flex-col items-center justify-center gap-3 px-6 text-center",
className,
)}
>
<div className="bg-primary/10 ring-primary/15 mb-2 flex h-[72px] w-[72px] items-center justify-center rounded-3xl ring-1">
<BotIcon className="text-primary h-9 w-9" />
</div>
<div className="text-3xl font-bold tracking-tight">
Hi,我是{displayName}
</div>
{description && (
<p
className="text-muted-foreground line-clamp-2 max-w-xl text-base leading-relaxed"
title={description}
>
{description}
</p>
)}
</div>
);
}
@@ -2,9 +2,10 @@
import {
BotIcon,
ClockIcon,
CopyIcon,
EyeOffIcon,
GlobeIcon,
InfoIcon,
LockIcon,
MessageSquareIcon,
PinIcon,
@@ -14,6 +15,7 @@ import {
StarIcon,
TagsIcon,
Trash2Icon,
UserIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import {
@@ -45,12 +47,24 @@ import {
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 { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
normalizeAgentQuestions,
useAgent,
useCreateAgent,
useDeleteAgent,
@@ -61,10 +75,11 @@ import {
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents";
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import { formatDateTime } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -73,6 +88,9 @@ interface AgentCardProps {
/** "card" (default) renders the full gallery card; "list" renders a compact
* single-row layout for the list view. All dialogs/actions are shared. */
layout?: "card" | "list";
/** Click the 发布者 line to filter the gallery to that publisher. Receives the
* owner user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
}
interface BadgeItem {
@@ -184,7 +202,7 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
);
}
export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCardProps) {
const isList = layout === "list";
const { t } = useI18n();
const router = useRouter();
@@ -202,8 +220,18 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
// Common questions (常用问题) — primary editing now lives in the agent chat
// page's right-hand AgentDetailPanel; this dialog stays as a backup entry.
// Each row has a short title (shown on the chip) and the full prompt sent
// when clicked.
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
() => normalizeAgentQuestions(agent.common_questions),
);
const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares();
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
);
@@ -243,12 +271,16 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
setEditDescription(agent.description ?? "");
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setEditSquareId(agent.square_id ?? "");
setSelectedSkills(agent.skills ?? []);
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
}, [
agent.common_questions,
agent.description,
agent.is_private,
agent.name,
agent.published,
agent.square_id,
agent.skills,
editOpen,
]);
@@ -262,6 +294,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
// include `soul`) keep working.
if (agentDetail) {
setEditSoul(agentDetail.soul ?? "");
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
} else {
setEditSoul(agent.soul ?? "");
}
@@ -326,20 +359,40 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
toast.warning("私密广场与公开发布互斥,请只勾选其一");
return;
}
// Private agents cannot also be published — force the value the
// backend will see, regardless of any stale state in editPublished.
const nextPublished = editIsPrivate ? false : editPublished;
const nextQuestions = editCommonQuestions
.map((q) => ({
title: q.title.trim() || q.prompt.trim(),
prompt: q.prompt.trim() || q.title.trim(),
}))
.filter((q) => q.prompt.length > 0);
// Only send the fields that actually changed: the backend merges partial
// updates (model_fields_set), so e.g. a 常用问题 edit no longer rewrites
// skills/SOUL and saving stays fast.
const baselineQuestions = normalizeAgentQuestions(
agentDetail?.common_questions ?? agent.common_questions,
);
const request: UpdateAgentRequest = {};
if (trimmedName !== agent.name) request.name = trimmedName;
if (editDescription !== (agent.description ?? "")) request.description = editDescription;
if (editSoul !== (agentDetail?.soul ?? agent.soul ?? "")) request.soul = editSoul;
if (nextPublished !== agent.published) request.published = nextPublished;
if (editIsPrivate !== agent.is_private) request.is_private = editIsPrivate;
if ((editSquareId || "") !== (agent.square_id ?? "")) request.square_id = editSquareId;
if (JSON.stringify(selectedSkills) !== JSON.stringify(agent.skills ?? []))
request.skills = selectedSkills;
if (JSON.stringify(nextQuestions) !== JSON.stringify(baselineQuestions))
request.common_questions = nextQuestions;
if (Object.keys(request).length === 0) {
setEditOpen(false);
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
request: {
name: trimmedName,
description: editDescription,
soul: editSoul,
// Private agents cannot also be published — force the value the
// backend will see, regardless of any stale state in editPublished.
published: editIsPrivate ? false : editPublished,
is_private: editIsPrivate,
skills: selectedSkills,
},
});
await updateAgent.mutateAsync({ id: agent.id, request });
toast.success("智能体已更新");
setEditOpen(false);
} catch (err) {
@@ -355,6 +408,12 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
// Publishing: prompt for which square to publish into (when any squares are
// configured). Unpublishing: just flip it off.
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
@@ -366,6 +425,21 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
}
}
async function handlePublishToSquare(squareId: string) {
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: true, square_id: squareId },
});
const sqName =
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePrivate() {
const turningOn = !agent.is_private;
try {
@@ -427,16 +501,10 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
}
}
function toggleSkill(skillName: string) {
setSelectedSkills((prev) => {
if (prev.includes(skillName)) {
return prev.filter((name) => name !== skillName);
}
return [...prev, skillName];
});
}
const enabledSkills = skills.filter((skill) => skill.enabled);
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row.
@@ -464,7 +532,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
来自他人
</Badge>
)}
{isOwned && agent.published && !agent.is_private && (
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
@@ -474,6 +542,16 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
已发布
</Badge>
)}
{(isOwned || isAdmin) && !isBuiltin && !agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-500 dark:text-zinc-400 text-xs"
title="该智能体尚未发布,仅所有者可见"
>
<EyeOffIcon className="size-3" />
未发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
@@ -497,6 +575,58 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</>
);
// 发布者(仅自定义/已发布的智能体显示;内置无 owner 不显示)。点击可按发布者筛选。
const publisherLabel = agent.owner_username
? desensitize(agent.owner_username)
: "";
const canFilterByPublisher =
!isBuiltin && !!agent.owner_username && !!agent.user_id && !!onPublisherClick;
const publisherNode =
!isBuiltin && agent.owner_username ? (
<button
type="button"
disabled={!canFilterByPublisher}
onClick={(e) => {
if (!canFilterByPublisher) return;
e.stopPropagation();
onPublisherClick!({ id: agent.user_id!, label: publisherLabel });
}}
className={cn(
"text-muted-foreground flex max-w-full items-center gap-1 text-sm font-medium",
canFilterByPublisher &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
canFilterByPublisher
? `点击筛选发布者:${publisherLabel}`
: `发布者:${publisherLabel}`
}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{publisherLabel}</span>
</button>
) : null;
const createdAtText = formatDateTime(agent.created_at);
const createdAtNode = createdAtText ? (
<div
className="text-muted-foreground flex max-w-full items-center gap-1 text-xs"
title={`创建时间:${createdAtText}`}
>
<ClockIcon className="size-3.5 shrink-0" />
<span className="truncate">创建时间:{createdAtText}</span>
</div>
) : null;
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
const skillNameZhMap = useMemo(() => {
const map = new Map<string, string>();
for (const s of skills) {
if (s.name_zh?.trim()) map.set(s.name, s.name_zh.trim());
}
return map;
}, [skills]);
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
@@ -519,7 +649,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
setDetailSkillName(skill);
}}
>
{desensitize(skill)}
{skillNameZhMap.get(skill) ?? desensitize(skill)}
</Badge>
),
})),
@@ -585,15 +715,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<SparklesIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
<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"
@@ -702,6 +832,8 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)}
</CardDescription>
)}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
@@ -747,6 +879,8 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)}
</p>
)}
{publisherNode && <div className="mt-0.5">{publisherNode}</div>}
{createdAtNode && <div className="mt-0.5">{createdAtNode}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons}
@@ -764,7 +898,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<DialogHeader>
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
<DialogDescription>
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
可以修改名称、描述、技能白名单、SOUL 内容、常用问题与发布状态。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
@@ -792,48 +926,13 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
已选 {selectedSkills.length} 个
</span>
</div>
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能
</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
key={skill.name}
className="flex w-full min-w-0 items-center"
>
<Button
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={desensitize(skill.description ?? "")}
className="h-8 min-w-0 flex-1 rounded-r-none"
>
<span className="truncate">{desensitize(skill.name)}</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
<SkillTransferSelector
skills={enabledSkills}
selected={selectedSkills}
onChange={setSelectedSkills}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
/>
<p className="text-muted-foreground text-xs">
不选任何技能会保存为无技能(skills = [])。
</p>
@@ -848,7 +947,17 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="min-h-40 font-mono text-xs"
className="max-h-[45vh] min-h-48 resize-y font-mono text-xs"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">常用问题</label>
<p className="text-muted-foreground text-xs">
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
</p>
<AgentQuestionEditor
value={editCommonQuestions}
onChange={setEditCommonQuestions}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
@@ -872,6 +981,32 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
}}
/>
</div>
{editPublished && !editIsPrivate && (publishSquares?.squares.length ?? 0) > 0 && (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">发布广场</p>
<p className="text-muted-foreground text-xs">
选择发布到哪个广场;不选则归入默认广场。每个智能体只能归属一个广场。
</p>
</div>
<Select
value={editSquareId || publishSquares?.default_square_id || ""}
onValueChange={setEditSquareId}
>
<SelectTrigger className="h-9 w-44 text-sm">
<SelectValue placeholder="默认广场" />
</SelectTrigger>
<SelectContent position="popper">
{publishSquares?.squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
{sq.id === publishSquares?.default_square_id ? "(默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">加入私密广场</p>
@@ -934,13 +1069,13 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</DialogContent>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<SkillDetailDialog
@@ -950,6 +1085,16 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
if (!o) setDetailSkillName(null);
}}
/>
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={publishSquares?.squares ?? []}
defaultSquareId={agent.square_id || publishSquares?.default_square_id}
loading={updateAgent.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
/>
</>
);
}
@@ -0,0 +1,904 @@
"use client";
import {
BotIcon,
CheckIcon,
GlobeIcon,
InfoIcon,
Loader2Icon,
LockIcon,
MessageCircleQuestionIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
PlusIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
Trash2Icon,
UploadCloudIcon,
UserRoundIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import {
normalizeAgentQuestions,
useDeleteAgent,
useSetAgentFeatured,
useUpdateAgent,
type Agent,
type AgentQuestion,
type UpdateAgentRequest,
} from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { isAgentSkillCandidate } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** Local edit-mode draft — committed in one `updateAgent` call on 保存. */
interface AgentDraft {
name: string;
description: string;
soul: string;
questions: AgentQuestion[];
skills: string[];
published: boolean;
featured: boolean;
templateName: string;
}
function draftFromAgent(agent: Agent): AgentDraft {
return {
name: agent.name,
description: agent.description ?? "",
soul: agent.soul ?? "",
questions: normalizeAgentQuestions(agent.common_questions),
skills: agent.skills ?? [],
published: agent.published,
featured: agent.featured_order != null,
templateName: agent.template_name ?? "",
};
}
function Section({
icon,
title,
extra,
children,
}: {
icon?: ReactNode;
title: string;
extra?: ReactNode;
children: ReactNode;
}) {
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
{icon}
{title}
</h4>
{extra}
</div>
{children}
</section>
);
}
/**
* 智能体详情面板 — the right-hand agent configuration center on the agent chat
* page. View mode shows the full profile (avatar, name, description, SOUL,
* 常用问题, enabled skills, publish/featured state); 管理员或发布者 can flip
* into an inline edit mode (no dialog) that edits everything locally and
* commits in a single 保存. 添加技能 opens a secondary drawer (SkillPickerSheet).
* Everyone else gets a read-only profile. On narrow screens (<lg) the panel
* collapses into a rail whose button opens the same content in a Sheet, so it
* never overlaps the chat input.
*/
export function AgentDetailPanel({
agent,
agentName,
onAskQuestion,
disabled,
}: {
agent: Agent | null | undefined;
agentName: string;
onAskQuestion: (text: string) => void;
disabled?: boolean;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<AgentDraft | null>(null);
const [editingQuestionIndex, setEditingQuestionIndex] = useState<
number | null
>(null);
const [questionDraft, setQuestionDraft] = useState<AgentQuestion>({
title: "",
prompt: "",
});
const [pickerOpen, setPickerOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const updateAgent = useUpdateAgent();
const deleteAgent = useDeleteAgent();
const setFeatured = useSetAgentFeatured();
const { data: publishSquares } = usePublishSquares();
const { skills, isLoading: isSkillsLoading } = useSkills();
// Permission model mirrors agent-card: admins manage the full catalog,
// owners manage their own agents, everyone else is read-only.
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」,
// 其他用户未发布的自定义技能不可选(管理员例外)。
const enabledPool = useMemo(
() =>
skills.filter((skill) =>
isAgentSkillCandidate(skill, currentUserId, isAdmin),
),
[skills, currentUserId, isAdmin],
);
const isOwned =
!!agent &&
!agent.builtin &&
agent.user_id !== null &&
agent.user_id === currentUserId;
const canEdit = !!agent && (isOwned || isAdmin);
// Built-ins are inherently visible to everyone, so publish toggle is moot.
const canTogglePublish = !!agent && (isOwned || (isAdmin && !agent.builtin));
const isSaving = updateAgent.isPending || setFeatured.isPending;
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
// 信息面板给预览让位;切换到新文件时同样收起。编辑中不打断用户,用户随时
// 可手动重新展开。
//
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
// 该文件要等生成结束、artifact 入库后才会进 thread.values.artifacts。若按
// 数量判定就只能等生成结束才收起——正是之前「生成完才收起」的 bug 根因。
const artifactsCtx = useArtifactsOptional();
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
const artifactPanelVisible =
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0);
const prevArtifactPanelVisible = useRef(false);
const prevSelectedArtifact = useRef<string | null>(null);
useEffect(() => {
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
const switched =
artifactPanelVisible && selectedArtifact !== prevSelectedArtifact.current;
prevArtifactPanelVisible.current = artifactPanelVisible;
prevSelectedArtifact.current = selectedArtifact;
if ((opened || switched) && !editing) {
setCollapsed(true);
}
}, [artifactPanelVisible, selectedArtifact, editing]);
const displayName = desensitize(agent?.name ?? agentName);
const questions = useMemo(
() => normalizeAgentQuestions(agent?.common_questions),
[agent?.common_questions],
);
function beginEdit() {
if (!agent || !canEdit) return;
cancelQuestionEdit();
setDraft(draftFromAgent(agent));
setEditing(true);
}
function cancelEdit() {
setEditing(false);
setDraft(null);
setPickerOpen(false);
}
function beginQuestionEdit(index: number, question: AgentQuestion) {
if (!canEdit) return;
setEditingQuestionIndex(index);
setQuestionDraft(question);
}
function cancelQuestionEdit() {
setEditingQuestionIndex(null);
setQuestionDraft({ title: "", prompt: "" });
}
async function saveQuestionEdit(index: number) {
if (!agent) return;
const nextQuestions = questions.map((question, questionIndex) =>
questionIndex === index
? {
title: questionDraft.title.trim() || questionDraft.prompt.trim(),
prompt: questionDraft.prompt.trim() || questionDraft.title.trim(),
}
: question,
);
const cleanedQuestions = nextQuestions.filter(
(question) => question.prompt.trim().length > 0,
);
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { common_questions: cleanedQuestions },
});
toast.success("Updated");
cancelQuestionEdit();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function saveEdit() {
if (!agent || !draft) return;
const trimmedName = draft.name.trim();
if (!trimmedName) {
toast.error("名称不能为空");
return;
}
// 私密广场与公开发布互斥 — force what the backend will accept.
const nextPublished = agent.is_private ? false : draft.published;
const nextQuestions = draft.questions
.map((q) => ({
title: q.title.trim() || q.prompt.trim(),
prompt: q.prompt.trim() || q.title.trim(),
}))
.filter((q) => q.prompt.length > 0);
// Only send the fields that actually changed: the backend merges partial
// updates (model_fields_set), so e.g. editing 常用问题 alone no longer
// re-saves skills/SOUL/发布状态 and the save returns quickly.
const request: UpdateAgentRequest = {};
if (trimmedName !== agent.name) request.name = trimmedName;
if (draft.description !== (agent.description ?? ""))
request.description = draft.description;
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
if (draft.templateName.trim() !== (agent.template_name ?? ""))
request.template_name = draft.templateName.trim();
if (nextPublished !== agent.published) request.published = nextPublished;
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills;
if (
JSON.stringify(nextQuestions) !==
JSON.stringify(normalizeAgentQuestions(agent.common_questions))
)
request.common_questions = nextQuestions;
try {
if (Object.keys(request).length > 0) {
await updateAgent.mutateAsync({ id: agent.id, request });
}
// 首页推荐 rides a dedicated endpoint — only call it when toggled.
const wasFeatured = agent.featured_order != null;
if (isAdmin && draft.featured !== wasFeatured) {
if (draft.featured && !nextPublished) {
toast.warning("只有已发布的智能体可以加入首页推荐,已跳过该项");
} else {
await setFeatured.mutateAsync({
id: agent.id,
order: draft.featured ? Date.now() : null,
});
}
}
toast.success("智能体已更新");
setEditing(false);
setDraft(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePublished() {
if (!agent) return;
if (!agent.published && agent.is_private) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
// Publishing: pick a target square when any are configured.
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: !agent.published },
});
toast.success(agent.published ? "已下架" : "已发布");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handlePublishToSquare(squareId: string) {
if (!agent) return;
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: true, square_id: squareId },
});
const sqName =
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
if (!agent) return;
try {
await deleteAgent.mutateAsync(agent.id);
toast.success("智能体已删除");
setDeleteOpen(false);
router.push("/page/workspace/agents");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
const statusBadges = agent && (
<>
{agent.builtin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
>
<GlobeIcon className="size-3" />
已发布
</Badge>
)}
{!agent.published && !agent.builtin && (
<Badge variant="outline" className="text-muted-foreground text-xs">
未发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
>
<ShieldIcon className="size-3" />
私密
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
>
<SparklesIcon className="size-3" />
首页推荐
</Badge>
)}
</>
);
const headerActions = canEdit ? (
editing ? (
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={() => void saveEdit()} disabled={isSaving}>
{isSaving && <Loader2Icon className="size-3.5 animate-spin" />}
保存
</Button>
<Button
size="sm"
variant="outline"
onClick={cancelEdit}
disabled={isSaving}
>
取消
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
{canTogglePublish && (
<Tooltip content={agent?.published ? "下架" : "发布"}>
<Button
size="icon-sm"
variant="ghost"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
aria-label={agent?.published ? "下架" : "发布"}
>
{agent?.published ? (
<LockIcon className="size-3.5" />
) : (
<GlobeIcon className="size-3.5" />
)}
</Button>
</Tooltip>
)}
<Tooltip content="删除智能体">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
aria-label="删除智能体"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
</div>
)
) : null;
const skillNamesShown = editing && draft ? draft.skills : (agent?.skills ?? []);
const body = !agent ? (
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
智能体信息加载中…
</div>
) : (
<div className="space-y-4 px-4 py-4">
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start gap-3">
<div className="bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
<BotIcon className="text-primary size-6" />
</div>
<div className="min-w-0 flex-1 space-y-1.5">
{editing && draft ? (
<Input
value={draft.name}
onChange={(e) =>
setDraft({ ...draft, name: e.currentTarget.value })
}
placeholder="智能体名称"
className="h-8 text-sm font-semibold"
/>
) : (
<div
className="truncate text-base font-bold"
title={displayName}
>
{displayName}
</div>
)}
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
{!editing && agent.owner_username && !agent.builtin && (
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<UserRoundIcon className="size-3 shrink-0" />
发布者:{desensitize(agent.owner_username)}
</div>
)}
</div>
</div>
<div className="mt-3">
{editing && draft ? (
<Textarea
value={draft.description}
onChange={(e) =>
setDraft({ ...draft, description: e.currentTarget.value })
}
placeholder="智能体描述"
className="min-h-16 text-sm"
/>
) : agent.description ? (
<p className="text-muted-foreground text-sm leading-relaxed">
{desensitize(agent.description)}
</p>
) : (
<p className="text-muted-foreground/60 text-sm">暂无描述</p>
)}
</div>
{/* 入库模板名 — view mode shows a hint row when configured. */}
{!editing && agent.template_name?.trim() && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<UploadCloudIcon className="size-3 shrink-0" />
入库模板:{desensitize(agent.template_name)}
</div>
)}
{/* Publish / featured toggles — only meaningful in edit mode. */}
{editing && draft && (
<div className="mt-3 space-y-2">
<div className="bg-background/60 space-y-1 rounded-lg border px-3 py-2">
<p className="text-sm font-medium">入库模板名</p>
<Input
value={draft.templateName}
onChange={(e) =>
setDraft({ ...draft, templateName: e.currentTarget.value })
}
placeholder="例如:临时策略库(留空表示不支持入库)"
className="h-8 text-sm"
/>
<p className="text-muted-foreground text-xs">
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
</p>
</div>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs">
{agent.is_private
? "已加入私密广场,不可发布"
: "打开后对所有用户可见"}
</p>
</div>
<Switch
checked={draft.published && !agent.is_private}
disabled={agent.is_private}
onCheckedChange={(v) => setDraft({ ...draft, published: v })}
/>
</div>
{isAdmin && (
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">首页推荐</p>
<p className="text-muted-foreground text-xs">
加入首页选择器(需已发布)
</p>
</div>
<Switch
checked={draft.featured}
onCheckedChange={(v) => setDraft({ ...draft, featured: v })}
/>
</div>
)}
</div>
)}
</section>
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(editing || questions.length > 0 || canEdit) && (
<Section
icon={<MessageCircleQuestionIcon className="size-3.5" />}
title="常用问题"
extra={
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs tabular-nums">
{(editing && draft ? draft.questions : questions).length} 个
</span>
{!editing && canEdit && (
<Tooltip content="管理常用问题(新增 / 删除 / 排序)">
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={beginEdit}
aria-label="管理常用问题"
>
<SquarePenIcon className="size-3.5" />
</Button>
</Tooltip>
)}
</div>
}
>
{editing && draft ? (
<AgentQuestionEditor
value={draft.questions}
onChange={(next) => setDraft({ ...draft, questions: next })}
disabled={isSaving}
/>
) : questions.length === 0 ? (
<button
type="button"
onClick={beginEdit}
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
>
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
添加常用问题
</button>
) : (
<div className="flex flex-col gap-2">
{questions.map((question, index) => {
const isEditingThis = editingQuestionIndex === index;
return (
<div
key={index}
className="border-border/60 bg-background/60 rounded-xl border p-2"
>
{isEditingThis ? (
<div className="space-y-2">
<Input
value={questionDraft.title}
disabled={isSaving}
placeholder="Title"
onChange={(e) =>
setQuestionDraft({
...questionDraft,
title: e.currentTarget.value,
})
}
className="h-8 text-sm font-medium"
/>
<Textarea
value={questionDraft.prompt}
disabled={isSaving}
placeholder="Prompt"
onChange={(e) =>
setQuestionDraft({
...questionDraft,
prompt: e.currentTarget.value,
})
}
className="min-h-16 resize-none text-sm"
/>
<div className="flex justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
onClick={cancelQuestionEdit}
disabled={isSaving}
>
<XIcon className="size-3.5" />
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={() => void saveQuestionEdit(index)}
disabled={isSaving}
>
{isSaving ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<CheckIcon className="size-3.5" />
)}
Save
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<button
type="button"
disabled={disabled}
onClick={() => onAskQuestion(question.prompt)}
title={desensitize(question.prompt)}
className={cn(
"hover:bg-accent min-w-0 flex-1 rounded-lg px-2 py-1.5 text-left text-sm leading-relaxed transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="block truncate">
{desensitize(question.title)}
</span>
</button>
{canEdit && (
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={() => beginQuestionEdit(index, question)}
disabled={isSaving}
aria-label="Edit question"
>
<SquarePenIcon className="size-3.5" />
</Button>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Section>
)}
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section
icon={<SparklesIcon className="size-3.5" />}
title={`已启用技能 ${skillNamesShown.length} 个`}
extra={
editing ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setPickerOpen(true)}
>
<PlusIcon className="size-3.5" />
添加技能
</Button>
) : undefined
}
>
<EnabledSkillList
skillNames={skillNamesShown}
skills={skills}
isLoading={isSkillsLoading}
editing={editing}
onRemove={
editing && draft
? (name) =>
setDraft({
...draft,
skills: draft.skills.filter((n) => n !== name),
})
: undefined
}
onViewDetail={setDetailSkillName}
/>
</Section>
</div>
);
const panelChrome = (collapseButton: ReactNode) => (
<>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<span className="text-sm font-semibold">智能体信息</span>
<div className="flex items-center gap-1">
{headerActions}
{collapseButton}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</>
);
return (
<>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{collapsed ? (
<div className="hidden shrink-0 flex-col items-center border-l pt-3 lg:flex">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(false)}
aria-label="展开智能体信息"
>
<PanelRightOpenIcon className="size-4" />
</Button>
</Tooltip>
</div>
) : (
<aside className="bg-card/40 hidden h-full w-[360px] shrink-0 flex-col border-l lg:flex">
{panelChrome(
<Tooltip content="收起">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(true)}
aria-label="收起智能体信息"
>
<PanelRightCloseIcon className="size-4" />
</Button>
</Tooltip>,
)}
</aside>
)}
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMobileOpen(true)}
aria-label="打开智能体信息"
>
<InfoIcon className="size-4" />
</Button>
</Tooltip>
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 pb-3 pt-4">
<SheetTitle className="flex items-center justify-between text-sm">
智能体信息
<span className="mr-6">{headerActions}</span>
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
<SkillPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
skills={enabledPool}
isLoading={isSkillsLoading}
selected={editing && draft ? draft.skills : []}
onChange={(next) => {
if (editing && draft) setDraft({ ...draft, skills: next });
}}
onViewDetail={setDetailSkillName}
/>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>删除智能体</DialogTitle>
<DialogDescription>
确定要删除「{displayName}」吗?该操作不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteAgent.isPending}
>
取消
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteAgent.isPending}
>
{deleteAgent.isPending ? "删除中…" : "删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={publishSquares?.squares ?? []}
defaultSquareId={agent?.square_id || publishSquares?.default_square_id}
loading={updateAgent.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
/>
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -12,10 +12,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import { AgentCard } from "./agent-card";
@@ -29,17 +30,24 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
"mine",
);
const [activeTab, setActiveTab] = useState<string>("mine");
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc");
// Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 },
const { data: publishSquares } = usePublishSquares();
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
// Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
// Per-tab 1-based page index (keyed by tab value), preserved across switches.
const [pages, setPages] = useState<Record<string, number>>({});
// Search + tag filtering are both server-side now. The backend mirrors the
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
@@ -47,8 +55,27 @@ export function AgentGallery() {
// filters change, every tab returns to its first page.
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey, sort]);
setPages({});
}, [search, tagKey, sort, publisher?.id]);
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab.
useEffect(() => {
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
if (activeTab === "builtin" && !isAdmin) {
setActiveTab("mine");
return;
}
if (
activeTab !== "mine" &&
activeTab !== "private" &&
activeTab !== "builtin" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === activeTab)
) {
setActiveTab("mine");
}
}, [activeTab, squareTabs, isAdmin]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -62,13 +89,22 @@ export function AgentGallery() {
const [passwordInput, setPasswordInput] = useState("");
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
const mainScope = activeTab === "mine" ? "mine" : "square";
const mainPage = activeTab === "mine" ? pages.mine : pages.all;
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
// "内置" to "builtin" (admin-only, user_id IS NULL).
const isSquareTab =
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
const mainScope =
activeTab === "mine"
? "mine"
: activeTab === "builtin"
? "builtin"
: "square";
const mainPage = pages[activeTab] ?? 1;
const {
agents: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
} = useAgentsPaginated({
scope: mainScope,
search,
@@ -77,20 +113,25 @@ export function AgentGallery() {
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
owner: publisher?.id,
// Each square tab fetches only that square's agents.
square: isSquareTab ? activeTab : undefined,
});
const {
agents: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
isFetching: isPrivateFetching,
error: privateError,
} = usePrivateAgentsPaginated(privatePassword, {
search,
tagIds,
sort,
page: pages.private,
page: pages.private ?? 1,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
owner: publisher?.id,
});
// If the server reports the stored password no longer works (likely the
@@ -103,8 +144,14 @@ export function AgentGallery() {
}
}, [privateError, privatePassword]);
// The private tab is only "unlocked" once the feature is usable and a
// password has been verified. Until then we show the gate (or the lock
// prompt) and hide the keyword/tag search bar — there is no list to filter.
const privateUnlocked = privateFeatureUsable && !!privatePassword;
const showSearchBar = activeTab !== "private" || privateUnlocked;
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
const setTabPage = (tab: string, page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
const handleNewAgent = () => {
@@ -127,17 +174,29 @@ export function AgentGallery() {
setPrivatePassword(null);
};
const handlePublisherClick = (p: { id: string; label: string }) =>
setPublisher(p);
const renderGrid = (list: Agent[]) =>
viewMode === "list" ? (
<div className="flex flex-col gap-2">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} layout="list" />
<AgentCard
key={agent.id}
agent={agent}
layout="list"
onPublisherClick={handlePublisherClick}
/>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
<AgentCard
key={agent.id}
agent={agent}
onPublisherClick={handlePublisherClick}
/>
))}
</div>
);
@@ -147,16 +206,21 @@ export function AgentGallery() {
list: Agent[];
total: number;
isLoading: boolean;
tab: "all" | "mine" | "private";
isFetching: boolean;
tab: string;
emptyDescription: string;
showNewButton?: boolean;
}) => {
const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts;
const { list, total, isLoading, isFetching, tab, emptyDescription, showNewButton } =
opts;
// keepPreviousData keeps the old page visible while a new page loads, so
// only show the spinner on the very first load (no rows yet).
if (isLoading && list.length === 0) {
// this full-height spinner only covers the very first load (no rows yet),
// or a filter change while the previous result was already empty (where
// isLoading stays false but there is nothing to keep on screen).
if ((isLoading || isFetching) && list.length === 0) {
return (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
);
@@ -165,10 +229,31 @@ export function AgentGallery() {
return renderEmpty(emptyDescription, showNewButton);
}
return (
<div className="flex flex-col gap-4">
{renderGrid(list)}
<div className="relative flex flex-col gap-4">
{/* While a server-side search/tag/page query is in flight the stale
keepPreviousData rows stay mounted — dim them and float a spinner
on top so a slow backend query never looks like the filter was
ignored, and block clicks on results that are about to change. */}
<div
className={
isFetching
? "pointer-events-none opacity-40 transition-opacity duration-200"
: "transition-opacity duration-200"
}
aria-busy={isFetching}
>
{renderGrid(list)}
</div>
{isFetching && (
<div className="absolute inset-0 z-10 flex items-start justify-center pt-24">
<div className="bg-background/90 text-muted-foreground flex items-center gap-2 rounded-full border px-4 py-2 text-sm shadow-sm">
<Loader2 className="size-4 animate-spin" />
正在查询…
</div>
</div>
)}
<Pagination
page={pages[tab]}
page={pages[tab] ?? 1}
pageSize={PAGE_SIZE}
total={total}
onPageChange={(p) => setTabPage(tab, p)}
@@ -272,6 +357,7 @@ export function AgentGallery() {
list: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
isFetching: isPrivateFetching,
tab: "private",
emptyDescription: "私密广场暂时还没有智能体。",
});
@@ -296,22 +382,39 @@ export function AgentGallery() {
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={(v) =>
setActiveTab(v as "all" | "mine" | "private")
}
onValueChange={(v) => {
setActiveTab(v);
// Each tab starts from a clean slate: switching tabs clears the
// keyword / tag / sort / publisher filters instead of carrying them
// over. The TagSearchBar's own input box is reset via its key below.
setSearch("");
setTagIds([]);
setSort("time_desc");
setPublisher(null);
}}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="mx-6 mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<TabsList className="w-auto self-start">
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
{privateFeatureAvailable && (
<TabsTrigger value="private">
<LockIcon className="mr-1 h-3.5 w-3.5" />
私密广场
</TabsTrigger>
)}
{isAdmin && (
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
<BotIcon className="mr-1 h-3.5 w-3.5" />
内置智能体
</TabsTrigger>
)}
</TabsList>
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm whitespace-nowrap">
@@ -331,54 +434,97 @@ export function AgentGallery() {
)}
</div>
</div>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
{showSearchBar && (
<TagSearchBar
// Remount per tab so the internal keyword input / 展开 state also
// resets when the parent-owned filters are cleared on tab switch.
key={activeTab}
targetType="agent"
searching={
activeTab === "private" ? isPrivateFetching : mainFetching
}
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
)}
{publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
<button
type="button"
onClick={() => setPublisher(null)}
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
title="点击清除发布者筛选"
>
{publisher.label}
<XIcon className="size-3.5" />
</button>
</div>
)}
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
tab: "all",
emptyDescription: t.agents.emptyDescription,
})}
</TabsContent>
{squareTabs.map((sq) => (
<TabsContent
key={sq.id}
value={sq.id}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: sq.id,
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
})}
</TabsContent>
))}
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: "mine",
emptyDescription: t.agents.emptyDescription,
showNewButton: true,
})}
</TabsContent>
{isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: "builtin",
emptyDescription: "暂时没有内置智能体。",
})}
</TabsContent>
)}
{privateFeatureAvailable && (
<TabsContent
value="private"
@@ -0,0 +1,174 @@
"use client";
import { Bot, Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Select } from "tdesign-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import {
useAgentModuleConfig,
useUpdateAgentModuleConfig,
} from "@/core/system-settings/hooks";
import { useTags } from "@/core/tags";
import { desensitize } from "@/lib/desensitize";
/**
* 模块(zzJC / zzXD)类型配置弹窗(需求 0616 #3/#4,admin)。
*
* 单选一个「类型」(agent 标签,TDesign 下拉框)→ 下方实时预览该类型下的智能体(名称+描述)→
* 确认后写入后端(``/api/system-settings/agent-modules/{key}``,单选即存单元素列表),
* 该模块左栏据此只展示选中类型的智能体。
*/
export function AgentModuleSettingsDialog({
open,
onOpenChange,
moduleKey,
moduleTitle,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
moduleKey: string;
moduleTitle: string;
}) {
const { data: tags = [] } = useTags("agent");
const { agents } = useAgents();
const { data: config } = useAgentModuleConfig(moduleKey);
const { mutate: save, isPending } = useUpdateAgentModuleConfig(moduleKey);
const [selectedTagId, setSelectedTagId] = useState<string | undefined>();
// Radix Dialog(modal) 把 body 设为 pointer-events:none,TDesign 浮层默认挂到 body 上
// 会点不动 —— 用 attach 把下拉浮层挂到弹窗内部容器(pointer-events 正常)。
const selectWrapRef = useRef<HTMLDivElement>(null);
// 每次打开同步当前已保存配置(取首个,单选语义)。
useEffect(() => {
if (open) setSelectedTagId(config?.tag_ids?.[0]);
}, [open, config?.tag_ids]);
const tagOptions = useMemo(
() => tags.map((t) => ({ label: t.name, value: t.id })),
[tags],
);
// 选中类型下的智能体(含名称 + 描述)。
const previewAgents = useMemo<Agent[]>(() => {
if (!selectedTagId) return [];
return agents.filter((agent) =>
(agent.tags ?? []).some((t) => t.id === selectedTagId),
);
}, [agents, selectedTagId]);
const handleConfirm = () => {
save(selectedTagId ? [selectedTagId] : [], {
onSuccess: () => {
toast.success(`已更新「${moduleTitle}」展示的类型`);
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "保存失败,请重试"),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[78vh] max-h-[88vh] min-h-[620px] flex-col gap-0 p-0 sm:max-w-[760px]">
<DialogHeader className="border-b border-border/60 px-6 py-4">
<DialogTitle>{moduleTitle} · 类型配置</DialogTitle>
<DialogDescription>
选择该模块左栏展示的智能体类型(标签);下方预览该类型下的智能体。
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-hidden px-6 py-5">
<div className="shrink-0">
<div className="text-muted-foreground/80 mb-2 text-sm font-semibold">
选择类型
</div>
{tags.length === 0 ? (
<p className="text-muted-foreground py-1 text-sm">
暂无智能体标签,请先到「标签管理」创建。
</p>
) : (
<div ref={selectWrapRef} className="relative">
<Select
className="w-full"
value={selectedTagId}
options={tagOptions}
filterable
clearable
placeholder="搜索并选择一个类型"
popupProps={{
attach: () => selectWrapRef.current ?? document.body,
}}
onChange={(v) => setSelectedTagId((v as string) || undefined)}
/>
</div>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div className="text-muted-foreground/80 mb-2 shrink-0 text-sm font-semibold">
该类型下的智能体({previewAgents.length})
</div>
{!selectedTagId ? (
<p className="text-muted-foreground py-1 text-sm">
未选择类型时,左栏展示全部可见智能体。
</p>
) : previewAgents.length === 0 ? (
<p className="text-muted-foreground py-1 text-sm">
该类型下暂无智能体。
</p>
) : (
<ScrollArea className="min-h-0 flex-1 pr-3">
<div className="flex flex-col gap-2">
{previewAgents.map((agent) => (
<div
key={agent.id}
className="flex items-start gap-3 rounded-xl border border-border/60 bg-card p-3"
>
<div className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg">
<Bot className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">
{desensitize(agent.name)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{agent.description?.trim()
? desensitize(agent.description)
: "暂无描述"}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</div>
</div>
<DialogFooter className="border-t border-border/60 px-6 py-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleConfirm} disabled={isPending}>
{isPending && <Loader2 className="size-4 animate-spin" />}
确认
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,278 @@
"use client";
import { Bot, ChevronDown, MessageSquare, Settings } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { useAgentModuleConfig } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { useThreads } from "@/core/threads/hooks";
import type { AgentThread } from "@/core/threads/types";
import {
pathOfThread,
sortThreadsPinnedFirst,
titleOfThread,
} from "@/core/threads/utils";
import type { Tag } from "@/core/tags";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** 单个「按类型聚合」模块配置(需求 0616 #3 zzJC / #4 zzXD)。 */
export interface AgentModuleConfig {
/** 模块标识(路由 /page/strategy/qa/{key} 的 key)。 */
key: string;
/** 模块标题。 */
title: string;
/** 未配置类型时的默认落地智能体 id。 */
fallbackAgentId: string;
}
/**
* 已知「按类型聚合」模块 —— 按 **moduleKey** 索引(路由 /page/strategy/qa/{key})。
* zzJC / zzXD 布局完全一致,仅默认落地智能体不同。
*/
export const AGENT_MODULES: Record<string, AgentModuleConfig> = {
zzjc: { key: "zzjc", title: "zzJC支持", fallbackAgentId: "6bf" },
zzxd: {
key: "zzxd",
title: "zzXD应用",
fallbackAgentId: "10d69686718349688e58a8790cb61024",
},
};
const UNTAGGED_KEY = "__untagged__";
interface AgentGroup {
/** 分组键(tag id 或未分类常量)。 */
key: string;
/** 分组显示名。 */
label: string;
agents: Agent[];
}
/**
* 按 agent 标签把可见智能体分组(多标签智能体会出现在多个组)。
*
* - ``allowedTagIds`` 非空(admin 已配置该模块的类型):仅保留这些标签、按其配置顺序,
* 空组(无智能体)不展示;不含「未分类」。
* - ``allowedTagIds`` 为空(未配置):回退展示全部标签 + 「未分类」。
*/
function groupAgentsByTag(agents: Agent[], allowedTagIds: string[]): AgentGroup[] {
if (allowedTagIds.length > 0) {
const allowed = new Set(allowedTagIds);
const byTag = new Map<string, { tag: Tag; agents: Agent[] }>();
for (const agent of agents) {
for (const tag of agent.tags ?? []) {
if (!allowed.has(tag.id)) continue;
const entry = byTag.get(tag.id) ?? { tag, agents: [] };
entry.agents.push(agent);
byTag.set(tag.id, entry);
}
}
const groups: AgentGroup[] = [];
for (const tagId of allowedTagIds) {
const entry = byTag.get(tagId);
if (entry && entry.agents.length > 0) {
groups.push({ key: tagId, label: entry.tag.name, agents: entry.agents });
}
}
return groups;
}
const byTag = new Map<string, { tag: Tag; agents: Agent[] }>();
const untagged: Agent[] = [];
for (const agent of agents) {
const tags = agent.tags ?? [];
if (tags.length === 0) {
untagged.push(agent);
continue;
}
for (const tag of tags) {
const entry = byTag.get(tag.id) ?? { tag, agents: [] };
entry.agents.push(agent);
byTag.set(tag.id, entry);
}
}
const groups: AgentGroup[] = [...byTag.values()]
.map((e) => ({ key: e.tag.id, label: e.tag.name, agents: e.agents }))
.sort((a, b) => a.label.localeCompare(b.label, "zh"));
if (untagged.length > 0) {
groups.push({ key: UNTAGGED_KEY, label: "未分类", agents: untagged });
}
return groups;
}
/**
* 「按类型聚合智能体 + 聊天记录」左栏(需求 0616 #3 zzJC / #4 zzXD)。
*
* - 上半:可见智能体按标签(类型)分组、可折叠;点击进入该智能体对话(保持当前路由
* 命名空间,全量模式下为 /page/strategy)。
* - 下半:聊天记录 —— 仅与本模块智能体(即左栏列出的智能体)的会话,按 metadata.agent_id 过滤。
* - 宽度与通用问答左栏一致(``--sidebar-width``)。
*/
export function AgentModuleSidebar({
moduleKey,
activeAgentId,
onOpenSettings,
}: {
moduleKey: string;
activeAgentId?: string;
/** 传入则在左栏顶部显示设置齿轮(admin)。 */
onOpenSettings?: () => void;
}) {
const routeBase = useRouteBase();
const pathname = usePathname();
const { agents } = useAgents();
const { data: threads = [] } = useThreads();
const { data: moduleConfig } = useAgentModuleConfig(moduleKey);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const allowedTagIds = useMemo(
() => moduleConfig?.tag_ids ?? [],
[moduleConfig?.tag_ids],
);
const groups = useMemo(
() => groupAgentsByTag(agents, allowedTagIds),
[agents, allowedTagIds],
);
// 本模块智能体集合 = 左栏实际展示的智能体(聊天记录据此过滤,仅本模块相关会话)。
const moduleAgentIds = useMemo(
() => new Set(groups.flatMap((g) => g.agents.map((a) => a.id))),
[groups],
);
// 聊天记录:仅与本模块智能体的会话(按 metadata.agent_id 过滤),置顶优先。
const moduleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter((thread: AgentThread) => {
const agentId = thread.metadata?.agent_id;
return typeof agentId === "string" && moduleAgentIds.has(agentId);
}),
),
[threads, moduleAgentIds],
);
const toggleGroup = (key: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
return (
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
{onOpenSettings && (
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-xs font-semibold text-foreground/80">智能体</span>
<button
type="button"
onClick={onOpenSettings}
title="模块设置"
aria-label="模块设置"
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors hover:bg-sidebar-accent/60"
>
<Settings className="size-4" />
</button>
</div>
)}
{/* 上半:按类型分组的智能体 */}
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
智能体类型
</h2>
{groups.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p>
) : (
<div className="flex flex-col gap-0.5">
{groups.map((group) => {
const isCollapsed = collapsed.has(group.key);
return (
<div key={group.key}>
<button
type="button"
onClick={() => toggleGroup(group.key)}
className="flex w-full items-center justify-between gap-1 rounded-lg px-2 py-1.5 text-xs font-semibold text-foreground/80 hover:bg-sidebar-accent/60"
>
<span className="flex items-center gap-1.5 truncate">
<span className="truncate">{group.label}</span>
<span className="text-muted-foreground/60 text-[10px] font-normal tabular-nums">
{group.agents.length}
</span>
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
/>
</button>
{!isCollapsed && (
<div className="mt-0.5 flex flex-col gap-0.5 pl-1">
{group.agents.map((agent) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
return (
<Link
key={`${group.key}:${agent.id}`}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
isActive &&
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
)}
>
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{desensitize(agent.name)}</span>
</Link>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* 下半:聊天记录(仅本模块智能体会话) */}
<div className="flex min-h-0 flex-[2] flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
聊天记录
</h2>
{moduleThreads.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无聊天记录</p>
) : (
<div className="flex flex-col gap-0.5">
{moduleThreads.map((thread) => {
const href = pathOfThread(thread, undefined, routeBase);
const isActive = href === pathname;
return (
<Link
key={thread.thread_id}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
isActive &&
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
)}
>
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">
{desensitize(titleOfThread(thread, "未命名对话"))}
</span>
</Link>
);
})}
</div>
)}
</div>
</aside>
);
}
@@ -0,0 +1,78 @@
"use client";
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useState } from "react";
import type { AgentQuestion } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** Chips shown before the rest folds behind the 更多问题 toggle. */
const VISIBLE_COUNT = 4;
/**
* 常用问题 prompt chips on the agent chat landing screen — light-gray
* borderless pills stacked left-aligned between the welcome hero and the
* input box. Clicking a chip sends the question's full prompt through the
* page's existing submit flow. Lists longer than {@link VISIBLE_COUNT} fold
* behind a 更多问题 toggle; an empty list renders nothing.
*/
export function AgentQuestionChips({
questions,
onAsk,
disabled,
className,
}: {
questions: AgentQuestion[];
onAsk: (prompt: string) => void;
disabled?: boolean;
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
if (questions.length === 0) return null;
const visible = expanded ? questions : questions.slice(0, VISIBLE_COUNT);
const foldedCount = questions.length - VISIBLE_COUNT;
return (
<div className={cn("flex flex-col items-start gap-2 px-1", className)}>
{visible.map((question, index) => (
<button
key={index}
type="button"
disabled={disabled}
onClick={() => onAsk(question.prompt)}
title={desensitize(question.prompt)}
className={cn(
"group bg-muted text-foreground/90 flex max-w-full items-center gap-2 rounded-full px-4 py-2.5 text-left text-sm leading-5 transition",
"hover:bg-accent active:scale-[0.98]",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="min-w-0 truncate">{desensitize(question.title)}</span>
<ArrowRightIcon className="text-muted-foreground size-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
</button>
))}
{foldedCount > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded-full px-4 py-1.5 text-xs transition-colors"
>
{expanded ? (
<>
收起
<ChevronUpIcon className="size-3.5" />
</>
) : (
<>
更多问题({foldedCount})
<ChevronDownIcon className="size-3.5" />
</>
)}
</button>
)}
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import {
ChevronDownIcon,
ChevronUpIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { AgentQuestion } from "@/core/agents";
/** Soft cap on the chip label length — beyond this the landing chip truncates. */
const TITLE_SOFT_LIMIT = 80;
/** The text a question row would actually persist as (panel save falls back
* title↔prompt), used for duplicate detection. */
function effectivePrompt(q: AgentQuestion): string {
return (q.prompt.trim() || q.title.trim()).toLowerCase();
}
/**
* Editable list of an agent's 常用问题 (recommended questions). Each row has a
* short `title` (the chip label) and the full `prompt` sent on click. Supports
* add / remove / inline edit / reorder (move up & down), with non-blocking
* hints for over-long titles and duplicate questions. Pure controlled
* component — persistence stays with the caller (edit dialog or detail panel).
*/
export function AgentQuestionEditor({
value,
onChange,
disabled,
}: {
value: AgentQuestion[];
onChange: (next: AgentQuestion[]) => void;
disabled?: boolean;
}) {
function update(index: number, patch: Partial<AgentQuestion>) {
const next = [...value];
next[index] = { ...next[index]!, ...patch };
onChange(next);
}
function remove(index: number) {
onChange(value.filter((_, i) => i !== index));
}
function move(index: number, delta: -1 | 1) {
const target = index + delta;
if (target < 0 || target >= value.length) return;
const next = [...value];
[next[index], next[target]] = [next[target]!, next[index]!];
onChange(next);
}
return (
<div className="space-y-3">
{value.length === 0 && (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
尚未配置常用问题,点击下方按钮添加。
</p>
)}
{value.map((item, index) => {
const titleLength = item.title.trim().length;
const prompt = effectivePrompt(item);
const duplicateOf = prompt
? value.findIndex((other, i) => i < index && effectivePrompt(other) === prompt)
: -1;
return (
<div key={index} className="bg-background/60 space-y-1.5 rounded-lg border p-2.5">
<div className="flex items-center gap-1.5">
<Input
value={item.title}
disabled={disabled}
placeholder="标题(如:能蒸馏哪些人)"
onChange={(e) => update(index, { title: e.currentTarget.value })}
className="h-8 text-sm font-medium"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label="上移"
title="上移"
>
<ChevronUpIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled || index === value.length - 1}
onClick={() => move(index, 1)}
aria-label="下移"
title="下移"
>
<ChevronDownIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled}
onClick={() => remove(index)}
aria-label="删除问题"
title="删除问题"
>
<XIcon className="size-4" />
</Button>
</div>
<Textarea
value={item.prompt}
disabled={disabled}
placeholder="点击后实际发送的完整问题内容…"
onChange={(e) => update(index, { prompt: e.currentTarget.value })}
className="min-h-16 resize-none text-sm"
/>
{titleLength > TITLE_SOFT_LIMIT && (
<p className="text-destructive text-xs">
标题建议不超过 {TITLE_SOFT_LIMIT} 字(当前 {titleLength} 字),过长会在聊天区被截断。
</p>
)}
{duplicateOf >= 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400">
与第 {duplicateOf + 1} 条问题内容重复。
</p>
)}
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={disabled}
onClick={() => onChange([...value, { title: "", prompt: "" }])}
>
<PlusIcon className="size-3.5" /> 添加问题
</Button>
</div>
);
}
@@ -20,15 +20,18 @@ import {
} from "@/components/ui/dialog";
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
import { useAgentSelector } from "@/core/agents";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
interface AgentSelectorProps {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
disabled?: boolean;
/** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */
compact?: boolean;
}
/**
@@ -44,16 +47,26 @@ export function AgentSelector({
selectedAgentId,
onAgentChange,
disabled,
compact = false,
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const { agents, isLoading } = useAgentSelector();
// 弹窗列表只展示管理弹窗里配置的 agent(agents);但「已选中 agent」的名称解析要更宽——
// 侧边栏「常用智能体」可以选中未配置进管理弹窗的 agent(如用户自己的智能体),
// 这些 agent 也要能在胶囊上显示名称,故额外用「我的 / 广场」列表兜底解析(缓存与侧边栏共享)。
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null;
return agents.find((a) => a.id === selectedAgentId) ?? null;
}, [agents, selectedAgentId]);
return (
[...agents, ...mineAgents, ...squareAgents].find(
(a) => a.id === selectedAgentId,
) ?? null
);
}, [agents, mineAgents, squareAgents, selectedAgentId]);
const handleSelect = (agent: Agent) => {
onAgentChange(agent.id);
@@ -65,7 +78,7 @@ export function AgentSelector({
onAgentChange(null);
};
const triggerLabel = selectedAgent?.name ?? "选择 Agent";
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
return (
<>
@@ -76,12 +89,17 @@ export function AgentSelector({
disabled={disabled}
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
compact
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
: selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<BotIcon className="size-3" />
<BotIcon
className={cn("size-3", compact && "text-[var(--primary)]")}
/>
<span className="max-w-[140px] truncate text-xs font-normal">
{triggerLabel}
</span>
@@ -117,7 +135,7 @@ export function AgentSelector({
{agents.map((agent) => (
<CommandItem
key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`}
value={`${desensitize(agent.name)} ${desensitize(agent.description ?? "")}`}
onSelect={() => handleSelect(agent)}
className={cn(
"flex w-full min-w-0 items-start gap-2 overflow-hidden",
@@ -126,10 +144,10 @@ export function AgentSelector({
>
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
<span className="truncate text-sm">{desensitize(agent.name)}</span>
{agent.description && (
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
{agent.description}
{desensitize(agent.description)}
</span>
)}
</div>
@@ -0,0 +1,153 @@
"use client";
import { InboxIcon, InfoIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Skill } from "@/core/skills";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** Chinese display label with English-name fallback. */
export function skillDisplayLabel(skill: Skill) {
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
}
/** First character of the display label — the letter avatar on skill cards. */
export function skillInitial(skill: Skill) {
const label = skill.name_zh?.trim() || skill.name;
return label.trim().charAt(0).toUpperCase() || "?";
}
/** Human label for the skill's provenance (内置 / 用户上传 / 对话沉淀). */
export function skillSourceLabel(skill: Skill): string | null {
if (skill.builtin) return "内置";
if (skill.source === "upload") return "用户上传";
if (skill.source === "agent") return "对话沉淀";
return null;
}
/**
* The agent's enabled-skill list shown in the detail panel: one card per
* whitelisted skill name with a letter avatar, Chinese name, category/source
* badges and the short description. In editing mode each card gains a remove
* button. Skill names with no metadata in the pool (e.g. a skill that was
* deleted after being whitelisted) still render as a degraded card so the
* whitelist stays visible and removable.
*/
export function EnabledSkillList({
skillNames,
skills,
editing,
onRemove,
onViewDetail,
isLoading,
}: {
/** The agent's persisted whitelist — `agent.skills` (English names). */
skillNames: string[];
/** Full skill pool used to resolve metadata for each name. */
skills: Skill[];
editing?: boolean;
onRemove?: (name: string) => void;
onViewDetail?: (name: string) => void;
isLoading?: boolean;
}) {
const byName = useMemo(
() => new Map(skills.map((s) => [s.name, s])),
[skills],
);
if (isLoading && skillNames.length > 0) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
技能加载中…
</div>
);
}
if (skillNames.length === 0) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-1.5 rounded-lg border border-dashed px-3 py-5 text-xs">
<InboxIcon className="size-5 opacity-50" />
<span>尚未启用任何技能</span>
</div>
);
}
return (
<div className="space-y-2">
{skillNames.map((name) => {
const skill = byName.get(name);
return (
<div
key={name}
className={cn(
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
)}
>
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
{skill ? skillInitial(skill) : "?"}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span
className="min-w-0 truncate text-sm font-medium"
title={skill ? skillDisplayLabel(skill) : desensitize(name)}
>
{skill ? skillDisplayLabel(skill) : desensitize(name)}
</span>
{skill?.category?.trim() && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
{skill.category}
</Badge>
)}
{skill && skillSourceLabel(skill) && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{skillSourceLabel(skill)}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{skill
? skill.description?.trim()
? desensitize(skill.description)
: "暂无描述"
: "该技能已不存在或不可用,可将其移除。"}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{skill && onViewDetail && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground size-7"
onClick={() => onViewDetail(name)}
title="查看技能详情"
aria-label="查看技能详情"
>
<InfoIcon className="size-3.5" />
</Button>
)}
{editing && onRemove && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive size-7"
onClick={() => onRemove(name)}
title="移除技能"
aria-label="移除技能"
>
<XIcon className="size-3.5" />
</Button>
)}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,436 @@
"use client";
import {
CheckIcon,
InfoIcon,
Loader2Icon,
PlusIcon,
SearchIcon,
UploadIcon,
XIcon,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import {
skillDisplayLabel,
skillInitial,
skillSourceLabel,
} from "@/components/workspace/agents/enabled-skill-list";
import type { Skill } from "@/core/skills";
import {
useInstallSkillUpload,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const ALL_CATEGORIES = "__all__";
type SourceFilter = "all" | "builtin" | "upload" | "agent";
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: "all", label: "全部来源" },
{ value: "builtin", label: "内置技能" },
{ value: "upload", label: "用户上传" },
{ value: "agent", label: "对话沉淀" },
];
function matchesSource(skill: Skill, source: SourceFilter) {
switch (source) {
case "all":
return true;
case "builtin":
return !!skill.builtin;
case "upload":
return skill.source === "upload";
case "agent":
return skill.source === "agent";
}
}
function matchesQuery(skill: Skill, q: string) {
if (!q) return true;
return (
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q)
);
}
/**
* 技能选择器 — a secondary right-hand drawer opened from the agent detail
* panel's "添加技能" button. Search (中英文名称 + 描述), category tabs, source
* filter (内置 / 用户上传 / 对话沉淀), skill-pack upload, card grid with
* enable/disable switches, bulk add-filtered / clear-selected, and a footer
* showing the live selected count. Selection is controlled by the caller (the
* panel's local draft) — nothing here persists until the panel saves.
*/
export function SkillPickerSheet({
open,
onOpenChange,
skills,
isLoading,
selected,
onChange,
onViewDetail,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Selectable pool (caller pre-filters by `enabled`). */
skills: Skill[];
isLoading?: boolean;
/** Currently selected skill names — the draft `skills: string[]`. */
selected: string[];
onChange: (next: string[]) => void;
onViewDetail?: (name: string) => void;
}) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
const [source, setSource] = useState<SourceFilter>("all");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const validateUpload = useValidateSkillUpload();
const installUpload = useInstallSkillUpload();
const isUploading = validateUpload.isPending || installUpload.isPending;
const selectedSet = useMemo(() => new Set(selected), [selected]);
const categories = useMemo(() => {
const set = new Set<string>();
for (const skill of skills) {
if (skill.category?.trim()) set.add(skill.category.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [skills]);
const query = search.trim().toLowerCase();
const filtered = useMemo(
() =>
skills.filter(
(skill) =>
matchesQuery(skill, query) &&
matchesSource(skill, source) &&
(category === ALL_CATEGORIES || skill.category?.trim() === category),
),
[skills, query, source, category],
);
const filteredUnselected = useMemo(
() => filtered.filter((skill) => !selectedSet.has(skill.name)),
[filtered, selectedSet],
);
function toggle(name: string) {
if (selectedSet.has(name)) {
onChange(selected.filter((n) => n !== name));
} else {
onChange([...selected, name]);
}
}
function addAllFiltered() {
if (filteredUnselected.length === 0) return;
onChange([...selected, ...filteredUnselected.map((s) => s.name)]);
}
function clearSelected() {
if (selected.length === 0) return;
onChange([]);
}
async function handleUploadFile(file: File) {
const lower = file.name.toLowerCase();
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error("仅支持 .zip / .skill 技能包");
return;
}
try {
const validation = await validateUpload.mutateAsync(file);
if (validation.conflict) {
toast.warning(
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
);
return;
}
const result = await installUpload.mutateAsync({
file,
targetCategory: "custom",
});
// Auto-select the freshly installed skill so the user doesn't have to
// hunt for it in the refreshed pool.
if (!selectedSet.has(result.skill_name)) {
onChange([...selected, result.skill_name]);
}
toast.success(`技能「${result.skill_name}」已安装并加入已选`);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
>
<SheetHeader className="border-b px-5 pb-3 pt-4">
<SheetTitle>添加技能</SheetTitle>
<SheetDescription>
为该智能体启用技能。修改后需在右侧面板点击「保存」才会生效。
</SheetDescription>
</SheetHeader>
{/* Toolbar: search / source / upload */}
<div className="space-y-2.5 border-b px-5 py-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能(中英文名称、描述)…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={source}
onValueChange={(v) => setSource(v as SourceFilter)}
>
<SelectTrigger className="h-8 w-28 shrink-0 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 shrink-0 text-xs"
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
title="上传 .zip / .skill 技能包"
>
{isUploading ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<UploadIcon className="size-3.5" />
)}
上传技能
</Button>
<input
ref={fileInputRef}
type="file"
accept=".zip,.skill"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleUploadFile(file);
}}
/>
</div>
{/* Category tabs */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{[ALL_CATEGORIES, ...categories].map((c) => (
<button
key={c}
type="button"
onClick={() => setCategory(c)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs transition-colors",
category === c
? "border-primary/50 bg-primary/10 text-primary font-medium"
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{c === ALL_CATEGORIES ? "全部" : c}
</button>
))}
</div>
)}
{/* Bulk actions */}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length} 个匹配技能
</span>
<div className="flex items-center gap-1.5">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={filteredUnselected.length === 0}
onClick={addAllFiltered}
>
<PlusIcon className="size-3.5" />
添加当前筛选结果
{filteredUnselected.length > 0
? `(${filteredUnselected.length})`
: ""}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground h-7 text-xs"
disabled={selected.length === 0}
onClick={clearSelected}
>
<XIcon className="size-3.5" />
清空已选
</Button>
</div>
</div>
</div>
{/* Skill card grid */}
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-3">
{isLoading ? (
<div className="text-muted-foreground flex h-32 items-center justify-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
技能加载中…
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center gap-1.5 text-sm">
<span>没有匹配的技能</span>
{(query || category !== ALL_CATEGORIES || source !== "all") && (
<span className="text-xs">试试调整搜索词或筛选条件</span>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{filtered.map((skill) => {
const isSelected = selectedSet.has(skill.name);
return (
<div
key={skill.name}
role="button"
tabIndex={0}
onClick={() => toggle(skill.name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle(skill.name);
}
}}
title={isSelected ? "点击禁用" : "点击启用"}
className={cn(
"group/card flex cursor-pointer flex-col gap-2 rounded-xl border p-3 transition-colors",
isSelected
? "border-primary/50 bg-primary/5 ring-primary/15 ring-1"
: "bg-background/60 hover:border-primary/40 hover:bg-accent/40",
)}
>
<div className="flex items-start gap-2.5">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{skillInitial(skill)}
</div>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-medium"
title={skillDisplayLabel(skill)}
>
{skillDisplayLabel(skill)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{skill.description?.trim()
? desensitize(skill.description)
: "暂无描述"}
</p>
</div>
<Switch
checked={isSelected}
onCheckedChange={() => toggle(skill.name)}
onClick={(e) => e.stopPropagation()}
aria-label={isSelected ? "禁用技能" : "启用技能"}
className="shrink-0"
/>
</div>
<div className="flex items-center gap-1.5">
{skill.category?.trim() && (
<Badge variant="secondary" className="text-[10px]">
{skill.category}
</Badge>
)}
{skillSourceLabel(skill) && (
<Badge variant="outline" className="text-[10px]">
{skillSourceLabel(skill)}
</Badge>
)}
{isSelected && (
<Badge className="bg-primary/15 text-primary hover:bg-primary/15 text-[10px]">
<CheckIcon className="size-2.5" />
已启用
</Badge>
)}
{onViewDetail && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewDetail(skill.name);
}}
title="查看技能详情"
className="text-muted-foreground hover:text-foreground ml-auto opacity-60 transition-opacity group-hover/card:opacity-100"
>
<InfoIcon className="size-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer: selected count + done */}
<div className="flex shrink-0 items-center justify-between border-t px-5 py-3">
<span className="text-sm">
已选 <span className="text-primary font-semibold tabular-nums">{selected.length}</span> 个技能
</span>
<Button size="sm" onClick={() => onOpenChange(false)}>
完成
</Button>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,429 @@
"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
InboxIcon,
InfoIcon,
PlusIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Skill } from "@/core/skills";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 5;
const ALL_CATEGORIES = "__all__";
interface SkillTransferSelectorProps {
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
skills: Skill[];
/** Currently selected skill names — the persisted `skills: string[]`. */
selected: string[];
/** Emits the next selection (always a `string[]`, `[]` when empty). */
onChange: (next: string[]) => void;
isLoading?: boolean;
/** Open the skill detail dialog for a given skill name. */
onViewDetail?: (name: string) => void;
}
function skillLabel(skill: Skill) {
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
}
function matchesQuery(skill: Skill, q: string) {
if (!q) return true;
return (
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q)
);
}
export function SkillTransferSelector({
skills,
selected,
onChange,
isLoading,
onViewDetail,
}: SkillTransferSelectorProps) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
const [availablePage, setAvailablePage] = useState(0);
const [selectedPage, setSelectedPage] = useState(0);
const selectedSet = useMemo(() => new Set(selected), [selected]);
// Distinct categories for the filter dropdown (skip blank ones).
const categories = useMemo(() => {
const set = new Set<string>();
for (const skill of skills) {
if (skill.category?.trim()) set.add(skill.category.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [skills]);
const query = search.trim().toLowerCase();
// Left pane: unselected skills, filtered by search + category.
const availableSkills = useMemo(() => {
return skills.filter(
(skill) =>
!selectedSet.has(skill.name) &&
matchesQuery(skill, query) &&
(category === ALL_CATEGORIES || skill.category?.trim() === category),
);
}, [skills, selectedSet, query, category]);
// Right pane: selected skills, filtered by search only (category filtering
// the chosen set would just hide active selections, which is confusing).
const selectedSkills = useMemo(() => {
const byName = new Map(skills.map((s) => [s.name, s]));
return selected
.map((name) => byName.get(name))
.filter((s): s is Skill => Boolean(s) && matchesQuery(s!, query));
}, [skills, selected, query]);
// Clamp pagination whenever the underlying lists shrink (search/category
// changes, items moved between panes).
const availablePages = Math.max(1, Math.ceil(availableSkills.length / PAGE_SIZE));
const selectedPages = Math.max(1, Math.ceil(selectedSkills.length / PAGE_SIZE));
useEffect(() => {
if (availablePage > availablePages - 1) setAvailablePage(availablePages - 1);
}, [availablePage, availablePages]);
useEffect(() => {
if (selectedPage > selectedPages - 1) setSelectedPage(selectedPages - 1);
}, [selectedPage, selectedPages]);
const availableView = availableSkills.slice(
availablePage * PAGE_SIZE,
availablePage * PAGE_SIZE + PAGE_SIZE,
);
const selectedView = selectedSkills.slice(
selectedPage * PAGE_SIZE,
selectedPage * PAGE_SIZE + PAGE_SIZE,
);
function add(name: string) {
if (selectedSet.has(name)) return;
onChange([...selected, name]);
}
function remove(name: string) {
onChange(selected.filter((n) => n !== name));
}
function addAllFiltered() {
if (availableSkills.length === 0) return;
const merged = [...selected];
for (const skill of availableSkills) {
if (!selectedSet.has(skill.name)) merged.push(skill.name);
}
onChange(merged);
}
function clearSelected() {
if (selected.length === 0) return;
onChange([]);
}
if (isLoading) {
return (
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
技能加载中...
</div>
);
}
if (skills.length === 0) {
return (
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
当前没有可用技能。
</div>
);
}
return (
<div className="space-y-2.5">
{/* Toolbar: search + category filter */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索全部技能(中英文名称、描述)…"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setAvailablePage(0);
setSelectedPage(0);
}}
className="h-8 pl-7 text-xs"
/>
</div>
{categories.length > 0 && (
<Select
value={category}
onValueChange={(v) => {
setCategory(v);
setAvailablePage(0);
}}
>
<SelectTrigger
className={cn(
"border-input bg-background h-8 w-32 px-2 text-xs shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
"[&_svg]:dark:text-[#AACEE2]",
)}
>
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent
position="popper"
className="dark:border-[#1B477E] dark:bg-[#022248]"
>
<SelectItem
value={ALL_CATEGORIES}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
全部分类
</SelectItem>
{categories.map((c) => (
<SelectItem
key={c}
value={c}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Bulk actions */}
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={availableSkills.length === 0}
onClick={addAllFiltered}
>
<PlusIcon className="size-3.5" />
添加当前搜索结果{availableSkills.length > 0 ? `(${availableSkills.length})` : ""}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground h-7 text-xs"
disabled={selected.length === 0}
onClick={clearSelected}
>
<XIcon className="size-3.5" />
清空已选
</Button>
</div>
{/* Two-column transfer panes */}
<div className="grid grid-cols-2 gap-3">
<SkillPane
title="可用技能"
count={availableSkills.length}
items={availableView}
variant="available"
page={availablePage}
totalPages={availablePages}
onPageChange={setAvailablePage}
onPick={add}
onViewDetail={onViewDetail}
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
/>
<SkillPane
title="已启用技能"
count={selectedSkills.length}
items={selectedView}
variant="selected"
page={selectedPage}
totalPages={selectedPages}
onPageChange={setSelectedPage}
onPick={remove}
onViewDetail={onViewDetail}
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
/>
</div>
</div>
);
}
interface SkillPaneProps {
title: string;
count: number;
items: Skill[];
variant: "available" | "selected";
page: number;
totalPages: number;
onPageChange: (page: number) => void;
onPick: (name: string) => void;
onViewDetail?: (name: string) => void;
emptyHint: string;
}
function SkillPane({
title,
count,
items,
variant,
page,
totalPages,
onPageChange,
onPick,
onViewDetail,
emptyHint,
}: SkillPaneProps) {
const isSelected = variant === "selected";
return (
<div className="flex flex-col overflow-hidden rounded-md border dark:border-[#1B477E] dark:bg-[#022248]">
<div className="bg-muted/40 flex items-center justify-between border-b px-2.5 py-1.5 text-xs font-medium dark:border-[#1B477E] dark:bg-[#02305f]">
<span>{title}</span>
<span className="text-muted-foreground tabular-nums">{count}</span>
</div>
<div className="h-[240px] space-y-1.5 overflow-y-auto p-2">
{items.length === 0 ? (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-1.5 text-xs">
<InboxIcon className="size-5 opacity-50" />
<span>{emptyHint}</span>
</div>
) : (
items.map((skill) => (
<SkillItemCard
key={skill.name}
skill={skill}
isSelected={isSelected}
onPick={() => onPick(skill.name)}
onViewDetail={
onViewDetail ? () => onViewDetail(skill.name) : undefined
}
/>
))
)}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t px-2 py-1 text-xs dark:border-[#1B477E]">
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-6"
disabled={page <= 0}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeftIcon className="size-3.5" />
</Button>
<span className="text-muted-foreground tabular-nums">
{page + 1}/{totalPages}
</span>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-6"
disabled={page >= totalPages - 1}
onClick={() => onPageChange(page + 1)}
>
<ChevronRightIcon className="size-3.5" />
</Button>
</div>
)}
</div>
);
}
interface SkillItemCardProps {
skill: Skill;
isSelected: boolean;
onPick: () => void;
onViewDetail?: () => void;
}
function SkillItemCard({
skill,
isSelected,
onPick,
onViewDetail,
}: SkillItemCardProps) {
return (
<div
role="button"
tabIndex={0}
onClick={onPick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPick();
}
}}
title={isSelected ? "点击移除" : "点击添加"}
className={cn(
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
isSelected
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
)}
>
<div className="flex items-center gap-1.5">
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full text-[10px]",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground group-hover/item:bg-primary/15 group-hover/item:text-primary",
)}
>
{isSelected ? (
<XIcon className="size-3" />
) : (
<PlusIcon className="size-3" />
)}
</span>
<div className="min-w-0 flex-1">
<div
className="truncate text-xs font-medium"
title={skillLabel(skill)}
>
{skillLabel(skill)}
</div>
{skill.description?.trim() && (
<div className="text-muted-foreground truncate text-[11px]">
{desensitize(skill.description)}
</div>
)}
</div>
</div>
{onViewDetail && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewDetail();
}}
title="查看技能详情"
className="text-muted-foreground hover:text-foreground absolute right-1.5 top-1.5 opacity-60 transition-opacity group-hover/item:opacity-100"
>
<InfoIcon className="size-3.5" />
</button>
)}
</div>
);
}
@@ -5,14 +5,16 @@ import {
Code2Icon,
CopyIcon,
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
@@ -35,10 +37,11 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import {
useArtifactContent,
@@ -76,6 +79,7 @@ import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
import { SpreadsheetPreview } from "./spreadsheet-preview";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
@@ -85,6 +89,13 @@ function extractTextFromChildren(children: ReactNode): string {
return "";
}
/** 取 md 标题用于入库:优先文中第一个标题行,否则退回文件名(去扩展名)。 */
function extractMarkdownTitle(content: string, filepath: string): string {
const heading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/m.exec(content);
if (heading?.[1]) return heading[1].trim();
return getFileName(filepath).replace(/\.(md|markdown)$/i, "");
}
function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) {
return null;
@@ -110,12 +121,26 @@ export function ArtifactFileDetail({
filepath: filepathFromProps,
threadId,
onClose,
allowVirtualMarkdownEdit = false,
disableDesensitize = false,
}: {
className?: string;
filepath: string;
threadId: string;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
/**
* 圆桌沙箱(Step2/Step3)用:流式结束后,把 `write-file:` 虚拟 md 预览切回真实
* artifacts API(可加载 + PATCH 保存),从而支持用户编辑(对齐主聊天页 md 可编辑)。
* 默认 false —— 主聊天页等其它调用方行为不变。
*/
allowVirtualMarkdownEdit?: boolean;
/**
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
* 默认 false,沿用全局脱敏。
*/
disableDesensitize?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
@@ -130,13 +155,32 @@ export function ArtifactFileDetail({
}
return filepathFromProps;
}, [filepathFromProps, isWriteFileVirtual]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath);
}, [filepath, isWriteFileVirtual]);
const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill");
}, [filepath]);
// .xlsx is a ZIP archive — the generic binary iframe preview renders its
// raw bytes as mojibake, so it gets a dedicated table preview instead.
const isSpreadsheetFile = useMemo(() => {
return /\.xlsx$/i.test(filepath);
}, [filepath]);
// 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实
// artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅
// allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。
const editVirtualMarkdown = useMemo(() => {
return (
allowVirtualMarkdownEdit &&
isWriteFileVirtual &&
!isImageFile(filepath) &&
!isMock &&
!thread.isLoading &&
checkCodeFile(filepath).language === "markdown"
);
}, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language;
@@ -157,7 +201,10 @@ export function ArtifactFileDetail({
}, [language]);
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
// 不会被旧的内存 stub 值覆盖回退。
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
enabled: isCodeFile && !useWriteFileStreaming,
});
@@ -174,13 +221,14 @@ export function ArtifactFileDetail({
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (isSpreadsheetFile) return "XLSX";
if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
if (language === "html") return "HTML";
if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL";
if (language) return language.toUpperCase();
return "FILE";
}, [filepath, isSkillFile, language]);
}, [filepath, isSkillFile, isSpreadsheetFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
@@ -191,6 +239,9 @@ export function ArtifactFileDetail({
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
@@ -310,6 +361,7 @@ export function ArtifactFileDetail({
useEffect(() => {
if (isCodeFile) {
setSpreadsheetData(null);
setBinaryPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
@@ -325,6 +377,13 @@ export function ArtifactFileDetail({
urlOfArtifact({ filepath, threadId, isMock, publicShareCode }),
);
if (cancelled || !res.ok) return;
if (isSpreadsheetFile) {
const buffer = await res.arrayBuffer();
if (!cancelled) {
setSpreadsheetData(buffer);
}
return;
}
const blob = await res.blob();
if (cancelled) return;
const u = URL.createObjectURL(blob);
@@ -342,12 +401,13 @@ export function ArtifactFileDetail({
return () => {
cancelled = true;
setSpreadsheetData(null);
setBinaryPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
};
}, [filepath, threadId, isMock, isCodeFile, publicShareCode]);
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, publicShareCode]);
const handlePauseAutoScroll = useCallback(() => {
if (!thread.isLoading || !autoScrollEnabled) {
@@ -420,6 +480,50 @@ export function ArtifactFileDetail({
updateArtifact,
]);
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
const { agent: routeAgent } = useAgent(routeAgentId ?? null);
const ingestTemplateName = routeAgent?.template_name?.trim() ?? "";
const canPublishToKnowledge =
!!routeAgentId &&
!!ingestTemplateName &&
language === "markdown" &&
!isSkillFile &&
!isMock;
const [isPublishingToKnowledge, setIsPublishingToKnowledge] = useState(false);
const handlePublishToKnowledge = useCallback(async () => {
if (!routeAgentId || isPublishingToKnowledge) {
return;
}
const source = (displayContent ?? "").trim();
if (!source) {
toast.error("Markdown 内容为空,无法发布入库");
return;
}
setIsPublishingToKnowledge(true);
try {
const result = await publishArtifactToKnowledge({
agent_id: routeAgentId,
title: extractMarkdownTitle(source, filepath),
content: source,
});
if (result.success) {
toast.success(`已发布入库:${result.node_name}`);
} else {
const last = result.attempts[result.attempts.length - 1];
toast.error(
`发布入库失败:${last?.error ?? last?.response_text ?? "接口返回失败"}`,
);
}
} catch (error) {
console.error("Failed to publish artifact to knowledge base:", error);
toast.error(error instanceof Error ? error.message : "发布入库失败");
} finally {
setIsPublishingToKnowledge(false);
}
}, [displayContent, filepath, isPublishingToKnowledge, routeAgentId]);
const [isExportingWord, setIsExportingWord] = useState(false);
const handleDownloadAsWord = useCallback(async () => {
const source = (displayContent ?? "").trim();
@@ -440,30 +544,20 @@ export function ArtifactFileDetail({
}
}, [displayContent, filepath]);
const handleOpenWriteFileInNewTab = useCallback(() => {
/** HTML 产物在新窗口打开(结果绘制汇总/大屏多页都需要全屏看)。
* blob: URL 带 charset=utf-8(与预览/下载一致,防中文乱码);URL 延迟回收,
* 给新窗口足够的加载时间。 */
const handleOpenInNewWindow = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法打开");
toast.error("文件内容为空,暂时无法在新窗口打开");
return;
}
if (language === "mermaid") {
void openMermaidSvgInNewTab(source).catch((error) => {
console.error("Failed to open mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Failed to open artifact");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const blob = new Blob([source], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [displayContent, language]);
}, [displayContent]);
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
@@ -497,7 +591,7 @@ export function ArtifactFileDetail({
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
@@ -527,7 +621,7 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
</div>
<div className="flex items-center justify-center">
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
{isSupportPreview && (
<div className="flex items-center gap-2">
<ToggleGroup
@@ -570,8 +664,17 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
{canPublishToKnowledge && (
<ArtifactAction
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
label="发布入库"
tooltip={`发布到知识库(模板:${ingestTemplateName})`}
disabled={isPublishingToKnowledge || !displayContent}
onClick={() => void handlePublishToKnowledge()}
/>
)}
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
@@ -595,45 +698,6 @@ export function ArtifactFileDetail({
/>
</Tooltip>
)}
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab();
return;
}
if (language === "mermaid") {
try {
await openMermaidSvgInNewTab(displayContent ?? "");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
return;
}
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
}}
/>
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
@@ -660,6 +724,15 @@ export function ArtifactFileDetail({
onClick={handleDownloadAsWord}
/>
)}
{language === "html" && (
<ArtifactAction
icon={ExternalLinkIcon}
label="新窗口打开"
tooltip="在新窗口全屏打开 HTML"
disabled={!displayContent?.trim()}
onClick={handleOpenInNewWindow}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
@@ -736,7 +809,7 @@ export function ArtifactFileDetail({
language === "html" ||
language === "mermaid") && (
<ArtifactFilePreview
content={desensitize(displayContent ?? "")}
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
@@ -749,7 +822,7 @@ export function ArtifactFileDetail({
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={
canEditArtifact
canEditArtifact || disableDesensitize
? (displayContent ?? "")
: desensitize(displayContent ?? "")
}
@@ -766,7 +839,18 @@ export function ArtifactFileDetail({
/>
</div>
)}
{!isCodeFile && isSpreadsheetFile && (
spreadsheetData ? (
<SpreadsheetPreview data={spreadsheetData} />
) : (
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
<LoaderIcon className="size-5 animate-spin" />
{t.common.loading}
</div>
)
)}
{!isCodeFile &&
!isSpreadsheetFile &&
(binaryPreviewUrl ? (
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
) : (
Loaded 100 of 601 files, more files were not shown because too many files have changed in this diff. Show more