Compare commits

...
47 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
吉鑫升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
吉鑫升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
吉鑫升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 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
吉鑫升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
165 changed files with 9860 additions and 361 deletions

No files matched your search

+1
View File
@@ -84,6 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **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/` - **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) - **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 ### Configuration
+4 -1
View File
@@ -4,6 +4,9 @@
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 # 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
@@ -27,7 +30,7 @@ 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_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://localhost:8003 VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL) # 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间前端地址(用于 auth/callback 等跳转) # 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn # VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
+5
View File
@@ -33,6 +33,11 @@
# 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。 # 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。
# VITE_USE_REAL_DATA=true # 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) # MaxKey 鉴权根地址(无界 onlineTicket 换 token)
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn # VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer # VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+3
View File
@@ -4,6 +4,9 @@
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 # 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001 VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
+3
View File
@@ -4,6 +4,9 @@
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 # 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
+3
View File
@@ -4,6 +4,9 @@
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。 # 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
+11 -1
View File
@@ -86,12 +86,22 @@ 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() { export function App() {
return ( return (
<HashRouter> <HashRouter>
<AppProviders> <AppProviders>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/login" replace />} /> <Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} /> <Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} /> <Route path="/landing" element={<LandingPage />} />
@@ -826,7 +826,13 @@ const DocumentUploadPopupContent: FC<{
</Button> </Button>
</div> </div>
)} )}
{listData.length === 0 && <Empty />} {isSearching ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
listData.length === 0 && <Empty />
)}
{searchType === "human" && ( {searchType === "human" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]"> <div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item: listDataItem, index: number) => { {listData.map((item: listDataItem, index: number) => {
@@ -545,7 +545,12 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
</div> </div>
)} )}
<div className="p-[10px]"> <div className="p-[10px]">
<div className="grid grid-cols-4 gap-[10px] user-list"> {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) => ( {userList.map((item, index) => (
<div key={index} className="user-item p-[10px] relative"> <div key={index} className="user-item p-[10px] relative">
<div className="absolute top-[5px] right-[10px]"> <div className="absolute top-[5px] right-[10px]">
@@ -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>
);
}
@@ -22,6 +22,7 @@ import type {
import { Breadcrumb } from "./Breadcrumb"; import { Breadcrumb } from "./Breadcrumb";
import { DocumentToolbar } from "./DocumentToolbar"; import { DocumentToolbar } from "./DocumentToolbar";
import { FolderSelect } from "./KnowledgeManage";
import { FloatingToc } from "./FloatingToc"; import { FloatingToc } from "./FloatingToc";
import { MarkdownViewer } from "./MarkdownViewer"; import { MarkdownViewer } from "./MarkdownViewer";
import { RelatedDocs } from "./RelatedDocs"; import { RelatedDocs } from "./RelatedDocs";
@@ -35,6 +36,7 @@ import {
downloadMarkdown, downloadMarkdown,
extractToc, extractToc,
formatDateTime, formatDateTime,
localizeHeadings,
noteBreadcrumbSegments, noteBreadcrumbSegments,
splitRelatedSection, splitRelatedSection,
} from "./wikiUtils"; } from "./wikiUtils";
@@ -102,10 +104,14 @@ export function WikiContent({
// Pull the backend-injected `## Related` wikilinks out of the body so they // Pull the backend-injected `## Related` wikilinks out of the body so they
// render as a structured module; editing still works on the full source. // render as a structured module; editing still works on the full source.
const { body, related } = useMemo( // 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), () => splitRelatedSection(note.content_md),
[note.content_md], [note.content_md],
); );
const body = useMemo(() => localizeHeadings(rawBody), [rawBody]);
const toc = useMemo(() => extractToc(body), [body]); const toc = useMemo(() => extractToc(body), [body]);
// The floating outline only shows h2/h3 — deeper levels are noise. // The floating outline only shows h2/h3 — deeper levels are noise.
const outlineItems = useMemo( const outlineItems = useMemo(
@@ -177,6 +183,15 @@ export function WikiContent({
} }
}; };
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 () => { const handleArchive = async () => {
try { try {
await remove.mutateAsync({ noteId: note.id }); await remove.mutateAsync({ noteId: note.id });
@@ -277,6 +292,15 @@ export function WikiContent({
{note.created_at ? ( {note.created_at ? (
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag> <MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null} ) : 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) => ( {note.tags.map((tag) => (
<button <button
key={tag} key={tag}
@@ -1,9 +1,11 @@
import { import {
Copy, Copy,
FolderTree,
Menu, Menu,
Network, Network,
Plus, Plus,
RefreshCw, RefreshCw,
SlidersHorizontal,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
@@ -48,6 +50,8 @@ export function WikiHeader({
onDedup, onDedup,
onGraph, onGraph,
onCreate, onCreate,
onManageFolders,
onManageTemplates,
}: { }: {
onToggleSidebar: () => void; onToggleSidebar: () => void;
onImportFile: () => void; onImportFile: () => void;
@@ -55,6 +59,8 @@ export function WikiHeader({
onDedup: () => void; onDedup: () => void;
onGraph: () => void; onGraph: () => void;
onCreate: () => void; onCreate: () => void;
onManageFolders: () => void;
onManageTemplates: () => void;
}) { }) {
return ( 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"> <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">
@@ -83,6 +89,16 @@ export function WikiHeader({
label="批量导入" label="批量导入"
onClick={onBatchImport} onClick={onBatchImport}
/> />
<HeaderButton
icon={<FolderTree className="size-4" />}
label="目录管理"
onClick={onManageFolders}
/>
<HeaderButton
icon={<SlidersHorizontal className="size-4" />}
label="抽取模板"
onClick={onManageTemplates}
/>
<HeaderButton <HeaderButton
icon={<Copy className="size-4" />} icon={<Copy className="size-4" />}
label="查重" label="查重"
@@ -14,4 +14,6 @@ export interface TreeNode {
type: "folder" | "file"; type: "folder" | "file";
children?: TreeNode[]; children?: TreeNode[];
item?: KnowledgeNote; item?: KnowledgeNote;
/** Full slash-joined directory path (folder nodes only). */
folderPath?: string;
} }
+92 -12
View File
@@ -19,6 +19,73 @@ export const STATUS_LABELS: Record<string, string> = {
const FALLBACK_FOLDER = "未分类"; 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. */ /** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
export function slugify(text: string): string { export function slugify(text: string): string {
return text return text
@@ -119,16 +186,17 @@ export function splitRelatedSection(markdown: string): {
return { body, related }; return { body, related };
} }
/** Folder path segments for a note: vault_path directories, else its source-type group. */ /**
* 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[] { export function noteFolderSegments(note: KnowledgeNote): string[] {
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, ""); const folder = (note.folder || "").replace(/^\/+|\/+$/g, "").trim();
if (rawPath && rawPath.includes("/")) { if (folder) {
let segments = rawPath.split("/").slice(0, -1).filter(Boolean); const segments = folder.split("/").map((s) => s.trim()).filter(Boolean);
// Vault notes live under projects/<project>/<category>/ — the first two
// levels are constant scaffolding, so hide them from the directory tree.
if (segments[0] === "projects" || segments[0] === "project") {
segments = segments.slice(2);
}
if (segments.length > 0) return segments; if (segments.length > 0) return segments;
} }
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER]; return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
@@ -136,7 +204,7 @@ export function noteFolderSegments(note: KnowledgeNote): string[] {
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */ /** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] { export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
return ["问答梳理", ...noteFolderSegments(note)]; return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
} }
function sortTree(nodes: TreeNode[]): void { function sortTree(nodes: TreeNode[]): void {
@@ -153,7 +221,10 @@ function sortTree(nodes: TreeNode[]): void {
* Build the directory tree from the flat note list. Notes with a vault_path * 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. * become nested folders mirroring that path; others are grouped by source type.
*/ */
export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] { export function buildKnowledgeTree(
notes: KnowledgeNote[],
folderPaths: string[] = [],
): TreeNode[] {
const root: TreeNode[] = []; const root: TreeNode[] = [];
const folderMap = new Map<string, TreeNode>(); const folderMap = new Map<string, TreeNode>();
@@ -164,7 +235,9 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
path = path ? `${path}/${segment}` : segment; path = path ? `${path}/${segment}` : segment;
let node = folderMap.get(path); let node = folderMap.get(path);
if (!node) { if (!node) {
node = { id: `folder:${path}`, name: segment, type: "folder", children: [] }; // 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); folderMap.set(path, node);
children.push(node); children.push(node);
} }
@@ -173,6 +246,13 @@ export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
return 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) { for (const note of notes) {
ensureFolder(noteFolderSegments(note)).push({ ensureFolder(noteFolderSegments(note)).push({
id: note.id, id: note.id,
@@ -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 Tag,
type TagType, type TagType,
} from "@/core/tags"; } from "@/core/tags";
import { TAG_TYPES, TAG_TYPE_LABELS } from "@/core/tags/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function TagFormDialog({ function TagFormDialog({
@@ -191,11 +192,11 @@ export function TagManagementPage() {
onValueChange={(v) => setActiveType(v as TagType)} onValueChange={(v) => setActiveType(v as TagType)}
> >
<TabsList variant="line"> <TabsList variant="line">
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger> {TAG_TYPES.map((tt) => (
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger> <TabsTrigger key={tt} value={tt}>
<TabsTrigger value="scheduled_task"> {TAG_TYPE_LABELS[tt]}
{t.adminTags.typeScheduledTask} </TabsTrigger>
</TabsTrigger> ))}
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button size="sm" onClick={openCreate}> <Button size="sm" onClick={openCreate}>
@@ -2,6 +2,7 @@
import { import {
BotIcon, BotIcon,
ClockIcon,
CopyIcon, CopyIcon,
EyeOffIcon, EyeOffIcon,
GlobeIcon, GlobeIcon,
@@ -78,6 +79,7 @@ import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { formatDateTime } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -605,6 +607,17 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</button> </button>
) : null; ) : 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 只存英文名)。 // 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
const skillNameZhMap = useMemo(() => { const skillNameZhMap = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -820,6 +833,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</CardDescription> </CardDescription>
)} )}
{publisherNode && <div className="mt-2">{publisherNode}</div>} {publisherNode && <div className="mt-2">{publisherNode}</div>}
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
</CardHeader> </CardHeader>
{(agent.tags?.length ?? 0) > 0 && ( {(agent.tags?.length ?? 0) > 0 && (
@@ -866,6 +880,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</p> </p>
)} )}
{publisherNode && <div className="mt-0.5">{publisherNode}</div>} {publisherNode && <div className="mt-0.5">{publisherNode}</div>}
{createdAtNode && <div className="mt-0.5">{createdAtNode}</div>}
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons} {showActionRow && actionButtons}
@@ -187,23 +187,30 @@ export function AgentDetailPanel({
const isSaving = updateAgent.isPending || setFeatured.isPending; const isSaving = updateAgent.isPending || setFeatured.isPending;
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体 // 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
// 信息面板给预览让位;新文件落地时同样收起。编辑中不打断用户,用户随时 // 信息面板给预览让位;切换到新文件时同样收起。编辑中不打断用户,用户随时
// 可手动重新展开。 // 可手动重新展开。
//
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
// 该文件要等生成结束、artifact 入库后才会进 thread.values.artifacts。若按
// 数量判定就只能等生成结束才收起——正是之前「生成完才收起」的 bug 根因。
const artifactsCtx = useArtifactsOptional(); const artifactsCtx = useArtifactsOptional();
const artifactCount = artifactsCtx?.artifacts.length ?? 0; const artifactCount = artifactsCtx?.artifacts.length ?? 0;
const artifactPanelVisible = !!artifactsCtx?.open && artifactCount > 0; const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
const artifactPanelVisible =
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0);
const prevArtifactPanelVisible = useRef(false); const prevArtifactPanelVisible = useRef(false);
const prevArtifactCount = useRef(0); const prevSelectedArtifact = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current; const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
const grew = const switched =
artifactPanelVisible && artifactCount > prevArtifactCount.current; artifactPanelVisible && selectedArtifact !== prevSelectedArtifact.current;
prevArtifactPanelVisible.current = artifactPanelVisible; prevArtifactPanelVisible.current = artifactPanelVisible;
prevArtifactCount.current = artifactCount; prevSelectedArtifact.current = selectedArtifact;
if ((opened || grew) && !editing) { if ((opened || switched) && !editing) {
setCollapsed(true); setCollapsed(true);
} }
}, [artifactPanelVisible, artifactCount, editing]); }, [artifactPanelVisible, selectedArtifact, editing]);
const displayName = desensitize(agent?.name ?? agentName); const displayName = desensitize(agent?.name ?? agentName);
const questions = useMemo( const questions = useMemo(
@@ -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>
);
}
@@ -20,9 +20,10 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { PromptInputButton } from "@/components/ai-elements/prompt-input"; import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog"; 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 type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface AgentSelectorProps { interface AgentSelectorProps {
@@ -51,12 +52,21 @@ export function AgentSelector({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false);
const { agents, isLoading } = useAgentSelector(); 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 isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => { const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null; if (!selectedAgentId) return null;
return agents.find((a) => a.id === selectedAgentId) ?? null; return (
}, [agents, selectedAgentId]); [...agents, ...mineAgents, ...squareAgents].find(
(a) => a.id === selectedAgentId,
) ?? null
);
}, [agents, mineAgents, squareAgents, selectedAgentId]);
const handleSelect = (agent: Agent) => { const handleSelect = (agent: Agent) => {
onAgentChange(agent.id); onAgentChange(agent.id);
@@ -68,7 +78,7 @@ export function AgentSelector({
onAgentChange(null); onAgentChange(null);
}; };
const triggerLabel = selectedAgent?.name ?? "选择 Agent"; const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
return ( return (
<> <>
@@ -125,7 +135,7 @@ export function AgentSelector({
{agents.map((agent) => ( {agents.map((agent) => (
<CommandItem <CommandItem
key={agent.id} key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`} value={`${desensitize(agent.name)} ${desensitize(agent.description ?? "")}`}
onSelect={() => handleSelect(agent)} onSelect={() => handleSelect(agent)}
className={cn( className={cn(
"flex w-full min-w-0 items-start gap-2 overflow-hidden", "flex w-full min-w-0 items-start gap-2 overflow-hidden",
@@ -134,10 +144,10 @@ export function AgentSelector({
> >
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" /> <BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col"> <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 && ( {agent.description && (
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground"> <span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
{agent.description} {desensitize(agent.description)}
</span> </span>
)} )}
</div> </div>
@@ -122,6 +122,7 @@ export function ArtifactFileDetail({
threadId, threadId,
onClose, onClose,
allowVirtualMarkdownEdit = false, allowVirtualMarkdownEdit = false,
disableDesensitize = false,
}: { }: {
className?: string; className?: string;
filepath: string; filepath: string;
@@ -134,6 +135,12 @@ export function ArtifactFileDetail({
* 默认 false —— 主聊天页等其它调用方行为不变。 * 默认 false —— 主聊天页等其它调用方行为不变。
*/ */
allowVirtualMarkdownEdit?: boolean; allowVirtualMarkdownEdit?: boolean;
/**
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
* 默认 false,沿用全局脱敏。
*/
disableDesensitize?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
@@ -802,7 +809,7 @@ export function ArtifactFileDetail({
language === "html" || language === "html" ||
language === "mermaid") && ( language === "mermaid") && (
<ArtifactFilePreview <ArtifactFilePreview
content={desensitize(displayContent ?? "")} content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
language={language ?? "text"} language={language ?? "text"}
threadId={threadId} threadId={threadId}
publicShareCode={publicShareCode} publicShareCode={publicShareCode}
@@ -815,7 +822,7 @@ export function ArtifactFileDetail({
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent" className="size-full resize-none rounded-none border-none bg-transparent"
value={ value={
canEditArtifact canEditArtifact || disableDesensitize
? (displayContent ?? "") ? (displayContent ?? "")
: desensitize(displayContent ?? "") : desensitize(displayContent ?? "")
} }
@@ -0,0 +1,603 @@
"use client";
/**
* 通用问答(全量版 / full layout)新建对话左侧栏 —— 按「样式修改.png」参考图重画。
*
* 这是**全量版专属**的独立组件:因为全量版是新功能、新需求,若继续往
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
*
* 三块结构(自上而下):
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
* 重命名/置顶/收藏/分享/导出/删除)、「更多最近对话」入口,底部品牌信息。
*/
import {
Bot,
ChevronDown,
ChevronRight,
Download,
FileJson,
FileText,
MessageSquare,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Plus,
Search,
Share2,
Star,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
exportThreadAsJSON,
exportThreadAsMarkdown,
} from "@/core/threads/export";
import {
useDeleteThread,
useRenameThread,
useThreads,
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isThreadFavorite,
isThreadPinned,
pathOfThread,
sortThreadsPinnedFirst,
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { SidebarCollapseButton } from "./workspace-header";
const NEW_CHAT_PATH = "/page/workspace/chats/new";
/** 「更多」常用智能体跳转目标:智能体管理页。 */
const MORE_AGENTS_HREF = "/page/workspace/agents";
/** 「更多最近对话」跳转目标。 */
const MORE_CHATS_HREF = "/page/workspace/chats";
/** 常用智能体最多展示个数。 */
const MAX_FREQUENT = 4;
// 智能体图标统一用 Bot 机器人图标;底色按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
// 浅色模式沿用原高彩度彩色(不改);深蓝主题改用《深蓝 UI 规范》§1.1.7/§1.1.8 的蓝-青系
// (docs/ui规范-深蓝,克制、不刺眼),仅深蓝主题下生效,互不影响。
const AGENT_COLORS_LIGHT = [
{ from: "#fb7185", to: "#ef4444" },
{ from: "#60a5fa", to: "#3b82f6" },
{ from: "#fbbf24", to: "#f59e0b" },
{ from: "#34d399", to: "#10b981" },
{ from: "#a78bfa", to: "#8b5cf6" },
] as const;
const AGENT_COLORS_DARK = [
{ from: "#4096FF", to: "#1677FF" }, // 蓝(链接蓝 → 主色)
{ from: "#36CFC9", to: "#13C2C2" }, // 青(辅色)
{ from: "#6388FF", to: "#0958D9" }, // 紫蓝(装饰紫 → 主色深一档)
{ from: "#5CDBD3", to: "#08979C" }, // 浅青 → 深青
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
] as const;
function agentColor(id: string, isDark: boolean) {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
return palette[hash % palette.length]!;
}
/** 折叠区块标题(带 chevron 与可选计数徽标)。 */
function SectionLabel({
label,
count,
collapsed,
onToggle,
}: {
label: string;
count?: number;
collapsed: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5">
<span>{label}</span>
{count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count}
</span>
)}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
collapsed && "-rotate-90",
)}
/>
</button>
);
}
export function GeneralQaSidebar() {
const { t } = useI18n();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
const pathname = usePathname();
const router = useRouter();
const navigate = useNavigate();
// ── 区块折叠状态(持久化,键独立于旧组件,避免互相影响)──
const [agentsCollapsed, setAgentsCollapsed] = useState(
() => localStorage.getItem("gqa-sidebar:agents-collapsed") === "true",
);
const [chatsCollapsed, setChatsCollapsed] = useState(
() => localStorage.getItem("gqa-sidebar:chats-collapsed") === "true",
);
const toggleAgents = () =>
setAgentsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
return !v;
});
const toggleChats = () =>
setChatsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
return !v;
});
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const frequent = useMemo<Agent[]>(() => {
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
seen.add(agent.id);
out.push(agent);
};
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
mine.forEach(add);
square.forEach(add);
return out;
}, [mine, square]);
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
const searchParams = useSearchParams();
const activeAgentId = searchParams.get("agent");
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback(
(agentId: string) => {
navigate(`/page/workspace/chats/new?agent=${encodeURIComponent(agentId)}`);
},
[navigate],
);
// ── 最近的对话取数 + 搜索 ──
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
thread_id: string;
agent_id?: string;
}>();
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [query, setQuery] = useState("");
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
const visibleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
),
),
[threads],
);
const filteredThreads = useMemo(() => {
const q = query.trim().toLowerCase();
const list = q
? visibleThreads.filter((thread) =>
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
)
: visibleThreads;
// 未搜索时与旧版一致:最多展示 10 条
return q ? list : list.slice(0, 10);
}, [visibleThreads, query, t.pages.newChat]);
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
event.preventDefault();
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
};
const handleDelete = useCallback(
(threadId: string) => {
deleteThread({ threadId });
if (threadId === threadIdFromPath) {
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) {
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
}
}
void router.push(nextThreadPath);
}
},
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
);
const handleRenameClick = useCallback((threadId: string, currentTitle: string) => {
setRenameThreadId(threadId);
setRenameValue(currentTitle);
setRenameDialogOpen(true);
}, []);
const handleRenameSubmit = useCallback(() => {
if (renameThreadId && renameValue.trim()) {
renameThread({ threadId: renameThreadId, title: renameValue.trim() });
setRenameDialogOpen(false);
setRenameThreadId(null);
setRenameValue("");
}
}, [renameThread, renameThreadId, renameValue]);
const handleExport = useCallback(
async (thread: AgentThread, format: "markdown" | "json") => {
try {
const apiClient = getAPIClient();
const state = await apiClient.threads.getState<AgentThreadState>(
thread.thread_id,
);
const messages = state.values?.messages ?? [];
if (messages.length === 0) {
toast.error(t.conversation.noMessages);
return;
}
if (format === "markdown") exportThreadAsMarkdown(thread, messages);
else exportThreadAsJSON(thread, messages);
toast.success(t.common.exportSuccess);
} catch {
toast.error("Failed to export conversation");
}
},
[t],
);
const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
return (
// 背景走 sidebar token(浅主题=白、深蓝主题=深蓝),不要写死 bg-white,否则全量版深蓝主题下侧栏仍是白底。
<div className="flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground">
{/* ── 顶部:新对话胶囊 + 收起按钮 ── */}
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
{/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走
inline style,避免被全局 border-color(--border)覆盖;无阴影。主色描边 + 5px 圆角。
底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应
(不要混 white,否则深蓝主题下会变成浅色块)。 */}
<Link
href={NEW_CHAT_PATH}
onClick={handleNewChatClick}
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
className={cn(
"flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
"border border-solid",
"bg-[color-mix(in_srgb,var(--primary)_14%,transparent)] hover:bg-[color-mix(in_srgb,var(--primary)_24%,transparent)]",
)}
>
<Plus className="size-4 stroke-[2.5]" />
<span>{t.sidebar.newChat}</span>
</Link>
<SidebarCollapseButton className="size-9 shrink-0 rounded-full border border-border bg-card text-muted-foreground shadow-sm hover:bg-accent" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{/* ── 常用智能体 ── */}
{frequent.length > 0 && (
<div className="pt-2">
<SectionLabel
label="常用智能体"
collapsed={agentsCollapsed}
onToggle={toggleAgents}
/>
{!agentsCollapsed && (
<div className="mt-1 flex flex-col gap-1">
{frequent.map((agent) => {
const isActive = activeAgentId === agent.id;
const { from, to } = agentColor(agent.id, isDark);
return (
<button
key={agent.id}
type="button"
onClick={() => handleSelectAgent(agent.id)}
className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 text-left transition-colors",
isActive
? "border-primary/40 bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50",
)}
>
<span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
{agent.description ? desensitize(agent.description) : "智能体助手"}
</span>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</button>
);
})}
<Link
href={MORE_AGENTS_HREF}
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</div>
)}
</div>
)}
{/* ── 最近的对话 ── */}
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
<SectionLabel
label={t.sidebar.recentChats}
count={visibleThreads.length}
collapsed={chatsCollapsed}
onToggle={toggleChats}
/>
{!chatsCollapsed && (
<div className="mt-1.5">
{/* 历史会话搜索框 */}
<div className="relative mb-1.5 px-1">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索历史会话…"
className="h-8 rounded-lg border-border bg-card pl-8 text-[12px]"
/>
</div>
<div className="flex flex-col gap-0.5">
{filteredThreads.length === 0 ? (
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
{query ? "没有匹配的会话" : t.chats.noChats}
</div>
) : (
filteredThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<div
key={thread.thread_id}
className="group/item relative flex items-center"
>
<Link
href={pathOfThread(thread)}
className={cn(
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
isActive
? "border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<MessageSquare
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
{!isStatic && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="absolute right-1 flex size-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-background group-hover/item:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="size-4" />
<span className="sr-only">{t.common.more}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48 rounded-lg" side="right" align="start">
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread, t.pages.newChat),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => togglePin(thread.thread_id, !pinned)}>
{pinned ? (
<PinOff className="text-muted-foreground" />
) : (
<Pin className="text-muted-foreground" />
)}
<span>{pinned ? t.common.unpin : t.common.pin}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star
className={
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground"
}
/>
<span>{favorited ? t.common.unfavorite : t.common.favorite}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShareThreadId(thread.thread_id)}>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => handleExport(thread, "markdown")}>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleExport(thread, "json")}>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => handleDelete(thread.thread_id)}>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
})
)}
{!query && (
<Link
href={MORE_CHATS_HREF}
className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>更多最近对话</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
)}
</div>
</div>
)}
</div>
</div>
{/* 重命名弹窗 */}
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t.common.rename}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder={t.common.rename}
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
{t.common.cancel}
</Button>
<Button onClick={handleRenameSubmit}>{t.common.save}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分享弹窗 */}
<ShareConversationDialog
threadId={shareThreadId}
open={shareThreadId !== null}
onOpenChange={(o) => {
if (!o) setShareThreadId(null);
}}
/>
</div>
);
}
@@ -10,6 +10,7 @@ import {
LightbulbIcon, LightbulbIcon,
PaperclipIcon, PaperclipIcon,
PlusIcon, PlusIcon,
ShrinkIcon,
SparklesIcon, SparklesIcon,
RocketIcon, RocketIcon,
XIcon, XIcon,
@@ -329,6 +330,14 @@ export function InputBox({
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]); }, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
const handleContextCompressionToggle = useCallback(() => {
setLocalSettings("display", {
contextCompressionEnabled: !contextCompressionEnabled,
});
}, [contextCompressionEnabled, setLocalSettings]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (status === "streaming") { if (status === "streaming") {
@@ -526,24 +535,12 @@ export function InputBox({
)} )}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( {isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<div className={cn(compact && "-mt-2 flex flex-col gap-3")}>
{compact && (
<div className="text-muted-foreground flex items-center justify-center gap-1.5 text-xs font-medium">
<SparklesIcon className="size-3 text-[var(--primary)]" />
<span>试试这些高频业务场景</span>
</div>
)}
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]"> <div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => ( {recommendedQuestions.map((q) => (
<button <button
key={q.id} key={q.id}
type="button" type="button"
className={cn( className="rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
"rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
// 紧凑模式(CM 参考图,F12 精确还原):白底灰字灰边 + 主色 hover
compact &&
"px-4 py-2 text-xs font-medium text-gray-700 border-gray-100 shadow-xs transition-all duration-200 hover:bg-[color-mix(in_srgb,var(--primary)_3%,white)] hover:text-[var(--primary)] hover:border-[color-mix(in_srgb,var(--primary)_22%,transparent)] dark:text-slate-300 dark:border-slate-600 dark:hover:text-[var(--primary)]",
)}
onClick={() => { onClick={() => {
textInput.setInput(q.content); textInput.setInput(q.content);
setFollowupsHidden(true); setFollowupsHidden(true);
@@ -554,7 +551,6 @@ export function InputBox({
</button> </button>
))} ))}
</div> </div>
</div>
)} )}
{showFollowups && ( {showFollowups && (
@@ -748,6 +744,35 @@ export function InputBox({
<span className="text-xs font-normal">参考文献</span> <span className="text-xs font-normal">参考文献</span>
</PromptInputButton> </PromptInputButton>
</Tooltip> </Tooltip>
<Tooltip
content={
contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
: "上下文压缩已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={contextCompressionEnabled}
disabled={disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
contextCompressionEnabled
? "border border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleContextCompressionToggle}
>
{contextCompressionEnabled && (
<CheckIcon className="size-3 text-violet-700 dark:text-violet-200" />
)}
<ShrinkIcon className="size-3" />
<span className="text-xs font-normal">上下文压缩</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu> <PromptInputActionMenu>
<ModeHoverGuide <ModeHoverGuide
mode={ mode={
@@ -7,6 +7,7 @@ import { useStickToBottomContext } from "use-stick-to-bottom";
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -94,6 +95,7 @@ export function MessageList({
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false, tokenUsageEnabled = false,
extraRehypePlugins, extraRehypePlugins,
scrollButtonClassName,
onClarificationSubmit, onClarificationSubmit,
onEditResubmit, onEditResubmit,
onSetupAgentApprove, onSetupAgentApprove,
@@ -108,6 +110,8 @@ export function MessageList({
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
paddingBottom?: number; paddingBottom?: number;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>; extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>; onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void; onEditResubmit?: (text: string) => void;
@@ -577,6 +581,16 @@ export function MessageList({
<div style={{ height: `${paddingBottom}px` }} /> <div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider> </MessageMetricsProvider>
</ConversationContent> </ConversationContent>
{/* ChatGPT 式「回到最新」悬浮钮:仅在向上滚离底部时出现,点击平滑回到
最新问答。默认贴底,调用方可用 scrollButtonClassName 上移避开输入框。 */}
<ConversationScrollButton
className={cn(
"pointer-events-auto z-20 size-9 shadow-md",
scrollButtonClassName,
)}
aria-label="回到最新"
title="回到最新"
/>
</Conversation> </Conversation>
); );
} }
@@ -35,7 +35,7 @@ import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage"; import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage"; import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
@@ -457,14 +457,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
{isAdmin && ( {isAdmin && (
<> <>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator /> <Separator />
<PromptPrefixSettingsPage embedded /> <PromptPrefixSettingsPage embedded />
<Separator /> <Separator />
<PrivateSquareSettingsPage embedded /> <PrivateSquareSettingsPage embedded />
<Separator /> <Separator />
<PublishSquaresSettingsPage embedded /> <PublishSquaresSettingsPage embedded />
<Separator />
<SkillCompressionSettingsPage embedded />
</> </>
)} )}
</div> </div>
@@ -2,6 +2,7 @@
import { import {
AlertCircle, AlertCircle,
Anchor,
ArchiveIcon, ArchiveIcon,
ArrowDownUpIcon, ArrowDownUpIcon,
BookmarkIcon, BookmarkIcon,
@@ -77,6 +78,7 @@ import {
useGenerateSkillNameZh, useGenerateSkillNameZh,
useInstallSkillUpload, useInstallSkillUpload,
useReconcileCustomSkills, useReconcileCustomSkills,
useSetSkillAlwaysOn,
useSetSkillFeatured, useSetSkillFeatured,
useSetSkillNameZh, useSetSkillNameZh,
useSetSkillPinned, useSetSkillPinned,
@@ -84,8 +86,13 @@ import {
useSkills, useSkills,
useValidateSkillUpload, useValidateSkillUpload,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import { usePublishSquares, useViewMode } from "@/core/system-settings/hooks"; import {
usePublishSquares,
useSkillCompressionSettings,
useViewMode,
} from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { formatDateTime } from "@/core/utils/datetime";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -175,13 +182,22 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const fillNamesMutation = useGenerateMissingSkillNameZh(); const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default). // Optional model override for the admin "一键补充中文名" action (null = default).
const [fillModel, setFillModel] = useState<string | null>(null); const [fillModel, setFillModel] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
const storedAuth = useMemo(() => getStoredAuth(), []); const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin"; const isAdmin = storedAuth?.system_role === "admin";
// Name + tag filtering is resolved server-side via the /api/skills query.
// A 岗位 (position) no longer *restricts* the list — it grants skills into the
// member's 我的技能 (each such skill comes back with ``favorited: true``), so
// the 广场 always shows every visible skill and the 我的 tab includes the
// position-granted ones. ``scope`` is kept for API compatibility only.
const { skills, isLoading, error } = useSkills({
search,
tagIds,
sort,
scope: isAdmin ? undefined : "mine",
});
// 把可见技能分类成 UI 关心的几个桶。 // 把可见技能分类成 UI 关心的几个桶。
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。 // "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。 // "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
@@ -209,7 +225,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
if (isBuiltin || !!skill.published) { if (isBuiltin || !!skill.published) {
all.push(skill); all.push(skill);
} }
if (isOwnedByMe) { // "我的" = skills I own OR skills granted into my collection by my 岗位
// (position) — mirroring how favorited agents land in 我的智能体.
if (isOwnedByMe || !!skill.favorited) {
mine.push(skill); mine.push(skill);
} }
} }
@@ -593,6 +611,8 @@ export function SkillRow({
const { data: cardPublishSquares } = usePublishSquares(); const { data: cardPublishSquares } = usePublishSquares();
const setPinned = useSetSkillPinned(); const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured(); const setFeatured = useSetSkillFeatured();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const deleteSkill = useDeleteCustomSkill(); const deleteSkill = useDeleteCustomSkill();
const duplicateSkillMutation = useDuplicateSkill(); const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@@ -612,6 +632,17 @@ export function SkillRow({
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own. // Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin); const canManage = isCustom && (isOwned || isAdmin);
const canTogglePublish = canManage && !isBuiltin; const canTogglePublish = canManage && !isBuiltin;
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
const canSetAlwaysOn = isAdmin || isOwned;
async function handleToggleAlwaysOn(next: boolean) {
try {
await setAlwaysOn.mutateAsync({ skillName: skill.name, alwaysOn: next });
toast.success(next ? "已设为常驻(免压缩)" : "已取消常驻");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePublished(next: boolean) { async function handleTogglePublished(next: boolean) {
// Publishing: prompt for which square to publish into (when configured). // Publishing: prompt for which square to publish into (when configured).
@@ -806,6 +837,15 @@ export function SkillRow({
{t.settings.skills.badgePinned} {t.settings.skills.badgePinned}
</Badge> </Badge>
)} )}
{skill.always_on && (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title="常驻(免压缩):开启技能压缩后,本技能仍全文注入系统提示词并被正常检索"
>
常驻
</Badge>
)}
</div> </div>
); );
@@ -848,6 +888,14 @@ export function SkillRow({
: t.settings.skills.useCountInlineZero} : t.settings.skills.useCountInlineZero}
</div> </div>
)} )}
{formatDateTime(skill.created_at) && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<ClockIcon className="size-3.5 shrink-0" />
<span className="truncate">
创建时间:{formatDateTime(skill.created_at)}
</span>
</div>
)}
{(skill.tags?.length ?? 0) > 0 && ( {(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" /> <TagChipList tags={skill.tags!} className="mt-2" />
)} )}
@@ -907,6 +955,27 @@ export function SkillRow({
/> />
</Button> </Button>
)} )}
{compression?.enabled && canSetAlwaysOn && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.always_on && "text-amber-600 hover:text-amber-700",
)}
disabled={setAlwaysOn.isPending}
onClick={() => void handleToggleAlwaysOn(!skill.always_on)}
title={
skill.always_on
? "取消常驻(将被压缩,改由 search_skills 检索)"
: "设为常驻(免压缩,始终全文注入提示词)"
}
>
<Anchor
className={cn("size-3.5", skill.always_on && "fill-current")}
/>
</Button>
)}
{canTogglePublish && ( {canTogglePublish && (
<Button <Button
size="icon" size="icon"
@@ -26,6 +26,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
@@ -37,12 +38,14 @@ import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
useGenerateSkillNameZh, useGenerateSkillNameZh,
useSetSkillAlwaysOn,
useSetSkillDetail, useSetSkillDetail,
useSetSkillNameZh, useSetSkillNameZh,
useSkill, useSkill,
useSkillContent, useSkillContent,
useSkillFiles, useSkillFiles,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
@@ -105,6 +108,8 @@ export function SkillDetailDialog({
const setDetail = useSetSkillDetail(); const setDetail = useSetSkillDetail();
const setNameZh = useSetSkillNameZh(); const setNameZh = useSetSkillNameZh();
const generateNameZh = useGenerateSkillNameZh(); const generateNameZh = useGenerateSkillNameZh();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@@ -327,6 +332,15 @@ export function SkillDetailDialog({
{tx.badgePublished} {tx.badgePublished}
</Badge> </Badge>
)} )}
{skill?.always_on && compression?.enabled && (
<Badge
variant="outline"
className="border-amber-500/30 text-xs text-amber-700 dark:text-amber-300"
title="常驻(免压缩):本技能始终全文注入系统提示词"
>
常驻
</Badge>
)}
</div> </div>
)} )}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed"> <DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
@@ -372,6 +386,31 @@ export function SkillDetailDialog({
</TabsList> </TabsList>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{canManage && compression?.enabled && skill && (
<div
className="mr-1 flex items-center gap-1.5"
title="开启后,即使启用了技能压缩,本技能仍会全文注入系统提示词并被正常检索(免压缩)"
>
<span className="text-muted-foreground text-xs">常驻(免压缩)</span>
<Switch
checked={!!skill.always_on}
disabled={setAlwaysOn.isPending}
onCheckedChange={(next) =>
setAlwaysOn.mutate(
{ skillName: skill.name, alwaysOn: next },
{
onSuccess: () =>
toast.success(next ? "已设为常驻(免压缩)" : "已取消常驻"),
onError: (e) =>
toast.error(
e instanceof Error ? e.message : "更新常驻状态失败",
),
},
)
}
/>
</div>
)}
{selectedTab === "detail" && canManage && !editing && ( {selectedTab === "detail" && canManage && !editing && (
<Button <Button
size="sm" size="sm"
@@ -0,0 +1,98 @@
"use client";
import { CheckIcon, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { resolveTagColor } from "@/components/workspace/tags/tag-colors";
import { useResourceTags, useSetResourceTags, useTags, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
/** Generic admin dialog to assign a resource's tags (multi-select chips). */
export function ResourceTagDialog({
open,
onOpenChange,
targetType,
targetId,
title = "打标签",
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
targetType: TagType;
targetId: string;
title?: string;
}) {
const { data: tags } = useTags(targetType);
const { data: current } = useResourceTags(targetType, targetId, open);
const setTags = useSetResourceTags();
const [selected, setSelected] = useState<string[]>([]);
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${targetId}:${current ? "ready" : "loading"}`;
if (open && current && syncedFor !== syncKey) {
setSelected(current.map((t) => t.id));
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
const toggle = (id: string) =>
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
const handleSave = async () => {
try {
await setTags.mutateAsync({ targetType, targetId, tagIds: selected });
onOpenChange(false);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{!tags || tags.length === 0 ? (
<p className="text-muted-foreground text-sm">暂无可用标签,请先到「标签管理」创建对应类型的标签。</p>
) : (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => {
const checked = selected.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>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={setTags.isPending}>
{setTags.isPending && <Loader2 className="size-4 animate-spin" />}保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -4,11 +4,13 @@ import {
BellIcon, BellIcon,
BotIcon, BotIcon,
BrainIcon, BrainIcon,
BriefcaseIcon,
CalendarClockIcon, CalendarClockIcon,
ChevronsUpDown, ChevronsUpDown,
DatabaseZapIcon, DatabaseZapIcon,
GaugeIcon, GaugeIcon,
HistoryIcon, HistoryIcon,
MessageCircleQuestionIcon,
ScrollTextIcon, ScrollTextIcon,
Settings2Icon, Settings2Icon,
SettingsIcon, SettingsIcon,
@@ -216,6 +218,26 @@ export function WorkspaceNavMenu() {
<TagsIcon /> <TagsIcon />
{t.adminTags.title} {t.adminTags.title}
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")}
>
<BriefcaseIcon />
岗位管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate(
"/page/workspace/settings/recommended-questions",
)
}
>
<MessageCircleQuestionIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && ( {isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -12,7 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { FrequentAgentsList } from "./frequent-agents-list"; import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list"; import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list"; import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list"; import { RecentWritingList } from "./recent-writing-list";
@@ -30,7 +30,8 @@ export function WorkspaceSidebar({
const pathname = usePathname(); const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light"; const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。 // 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = pathname.startsWith("/page/workspace/chats"); const isGeneralQa = pathname.startsWith("/page/workspace/chats");
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 // 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
@@ -58,13 +59,16 @@ export function WorkspaceSidebar({
{...props} {...props}
> >
<SidebarContent> <SidebarContent>
<WorkspaceNavChatList showCollapseTrigger compact /> {isGeneralQa ? (
{isGeneralQa && <FrequentAgentsList />} // 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
<RecentChatList // GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
compact <GeneralQaSidebar />
limit={isGeneralQa ? 10 : undefined} ) : (
moreHref={isGeneralQa ? "/page/workspace/chats" : undefined} <>
/> <WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent> </SidebarContent>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
+175
View File
@@ -0,0 +1,175 @@
/**
* 外部系统跳转登录时的「任务深链」:登录 URL 带 ``taskId`` + ``goPath`` 时,
* 拉取任务详情并据 goPath 把用户直接送到对应的智能体问答 / 多智能体会商页。
*
* 流程:
* 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=``,
* 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。
* 3. 按 goPath 解析出业务码 + 模式:
* - ``3qfx`` / ``rwfx`` / ``xdfx`` → 单智能体问答(用「配置管理」里该业务配置的智能体)
* - ``3qfx-A`` / ``rwfx-A`` / ``xdfx-A`` → 多智能体会商(用该业务配置的业务链条)
* 携带 taskName / taskContent 作为开场信息。
*
* 业务码映射与「配置管理」(`/api/business-mapping`) 一致:
* 3Q=三情分析(3QFX)、6BF=任务分析(任务FX)、7BF=行动分析(XDFX)。
*/
import { requestCrudService } from "@/service/request-axios-service";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
/** consumer 接口返回的任务详情(data 字段)。 */
export interface CopTaskDetail {
id: string | number | null;
overview: string;
taskDirection: string;
taskName: string;
taskContent: string;
}
/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */
export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail";
/** goPath 基础码 → 业务码(配置管理 businessCode)。 */
const GOPATH_BUSINESS_CODE: Record<string, string> = {
"3qfx": "3Q", // 三情分析
rwfx: "6BF", // 任务分析(任务FX)
xdfx: "7BF", // 行动分析(XDFX)
};
/**
* 业务码 → 「任务管理」布局下的路由前缀(与 sidebar-menu.ts 的 COH 中任务菜单路径一致)。
* 会商 / 智能体问答都挂在此前缀下,保证侧边栏停留在 任务管理 → COH中任务 → 对应业务 高亮。
*/
const BUSINESS_TASK_BASE: Record<string, string> = {
"3Q": "/page/strategy/task-management/plan-task-sentiment",
"6BF": "/page/strategy/task-management/plan-task/plan-task-fx",
"7BF": "/page/strategy/task-management/plan-task/plan-task-action",
};
export interface ParsedGoPath {
businessCode: string;
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
roundtable: boolean;
}
/** 解析 goPath(大小写不敏感);``-A`` 后缀表示多智能体会商。无法识别返回 null。 */
export function parseGoPath(goPath: string): ParsedGoPath | null {
const raw = goPath.trim().toLowerCase();
if (!raw) return null;
const roundtable = raw.endsWith("-a");
const base = roundtable ? raw.slice(0, -2) : raw;
const businessCode = GOPATH_BUSINESS_CODE[base];
if (!businessCode) return null;
return { businessCode, roundtable };
}
function consumerBaseUrl(): string {
const value = import.meta.env.VITE_AIPI_CONSUMER_URL;
return typeof value === "string" ? value.replace(/\/+$/, "") : "";
}
/** 拉取任务详情;缺少 consumer 地址 / 返回不合法时返回 null。 */
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
const base = consumerBaseUrl();
if (!base || !taskId) return null;
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
}
/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */
export function persistCopTaskDetail(detail: CopTaskDetail): void {
try {
sessionStorage.setItem(COP_TASK_DETAIL_STORAGE_KEY, JSON.stringify(detail));
} catch {
/* noop */
}
}
/** 读取暂存的任务详情。 */
export function readCopTaskDetail(): CopTaskDetail | null {
try {
const raw = sessionStorage.getItem(COP_TASK_DETAIL_STORAGE_KEY);
return raw ? (JSON.parse(raw) as CopTaskDetail) : null;
} catch {
return null;
}
}
export interface TaskDeeplinkTarget {
path: string;
/** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */
state?: Record<string, unknown>;
}
/** 用 taskName / taskContent 拼出开场信息文本。 */
function buildOpeningText(detail: CopTaskDetail): string {
return [
detail.taskName ? `任务名称:${detail.taskName}` : "",
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
]
.filter(Boolean)
.join("\n");
}
/**
* 据 goPath + 任务详情解析跳转目标。
* - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条)的 state。
* - 单智能体问答:解析「配置管理」里该业务配置的智能体;把开场信息写入
* ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。
* 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。
*/
export async function resolveTaskDeeplinkTarget(
goPath: string,
detail: CopTaskDetail,
): Promise<TaskDeeplinkTarget | null> {
const parsed = parseGoPath(goPath);
if (!parsed) return null;
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
if (!taskBase) return null;
if (parsed.roundtable) {
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。
return {
path: `${taskBase}/roundtable`,
state: {
roundtablePrefill: {
taskTitle: detail.taskName,
taskGoal: detail.taskContent,
businessCode: parsed.businessCode,
autoStart: true,
},
},
};
}
let agentId: string | null = null;
try {
const mappings = await listBusinessMappings();
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
} catch {
agentId = null;
}
if (!agentId) return null;
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId, text: buildOpeningText(detail) }),
);
} catch {
/* noop */
}
// 单智能体问答:同样嵌进对应业务的「任务管理」布局。
return { path: `${taskBase}/agent/${agentId}/chats/new` };
}
+111 -1
View File
@@ -4,8 +4,11 @@ import { getBackendBaseURL } from "@/core/config";
import type { import type {
BatchIngestPayload, BatchIngestPayload,
BatchIngestResult, BatchIngestResult,
ExtractTemplateScope,
GraphExportResult, GraphExportResult,
IngestThreadPayload, IngestThreadPayload,
KnowledgeExtractTemplate,
KnowledgeFolder,
KnowledgeListParams, KnowledgeListParams,
KnowledgeListResponse, KnowledgeListResponse,
KnowledgeNote, KnowledgeNote,
@@ -35,6 +38,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
if (params.entity) qs.set("entity", params.entity); if (params.entity) qs.set("entity", params.entity);
if (params.source_type) qs.set("source_type", params.source_type); if (params.source_type) qs.set("source_type", params.source_type);
if (params.status) qs.set("status", params.status); if (params.status) qs.set("status", params.status);
if (params.folder !== undefined) qs.set("folder", params.folder);
qs.set("limit", String(params.limit ?? 20)); qs.set("limit", String(params.limit ?? 20));
qs.set("offset", String(params.offset ?? 0)); qs.set("offset", String(params.offset ?? 0));
return parseResponse<KnowledgeListResponse>( return parseResponse<KnowledgeListResponse>(
@@ -54,6 +58,7 @@ export async function createKnowledgeNote(data: {
summary?: string | null; summary?: string | null;
tags?: string[]; tags?: string[];
status?: string; status?: string;
folder?: string | null;
}) { }) {
const res = await parseResponse<{ note: KnowledgeNote }>( const res = await parseResponse<{ note: KnowledgeNote }>(
await apiFetch(`${baseUrl()}/notes`, { await apiFetch(`${baseUrl()}/notes`, {
@@ -73,6 +78,7 @@ export async function updateKnowledgeNote(
summary: string | null; summary: string | null;
tags: string[]; tags: string[];
status: string; status: string;
folder: string | null;
}>, }>,
) { ) {
return parseResponse<KnowledgeNote>( return parseResponse<KnowledgeNote>(
@@ -138,7 +144,14 @@ export interface IngestFileResult {
/** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */ /** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */
export async function ingestKnowledgeFile( export async function ingestKnowledgeFile(
file: File, file: File,
opts: { title?: string; tags?: string; status?: string; model_name?: string | null } = {}, opts: {
title?: string;
tags?: string;
status?: string;
model_name?: string | null;
template_id?: string | null;
folder?: string | null;
} = {},
) { ) {
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
@@ -146,6 +159,8 @@ export async function ingestKnowledgeFile(
if (opts.tags) form.append("tags", opts.tags); if (opts.tags) form.append("tags", opts.tags);
if (opts.status) form.append("status", opts.status); if (opts.status) form.append("status", opts.status);
if (opts.model_name) form.append("model_name", opts.model_name); if (opts.model_name) form.append("model_name", opts.model_name);
if (opts.template_id) form.append("template_id", opts.template_id);
if (opts.folder) form.append("folder", opts.folder);
// No Content-Type header — the browser sets the multipart boundary itself. // No Content-Type header — the browser sets the multipart boundary itself.
return parseResponse<IngestFileResult>( return parseResponse<IngestFileResult>(
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }), await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
@@ -284,3 +299,98 @@ export async function removeGraphBlacklist(entryId: string) {
}), }),
); );
} }
// --- folders (user-managed directories) ---
export async function listKnowledgeFolders() {
const res = await parseResponse<{ items: KnowledgeFolder[] }>(
await apiFetch(`${baseUrl()}/folders`),
);
return res.items;
}
export async function createKnowledgeFolder(path: string) {
const res = await parseResponse<{ folder: KnowledgeFolder }>(
await apiFetch(`${baseUrl()}/folders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
}),
);
return res.folder;
}
export async function renameKnowledgeFolder(folderId: string, path: string) {
const res = await parseResponse<{ folder: KnowledgeFolder }>(
await apiFetch(`${baseUrl()}/folders/${encodeURIComponent(folderId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
}),
);
return res.folder;
}
export async function deleteKnowledgeFolder(folderId: string, reassignTo?: string) {
const qs = reassignTo ? `?reassign_to=${encodeURIComponent(reassignTo)}` : "";
return parseResponse<{ ok: boolean }>(
await apiFetch(`${baseUrl()}/folders/${encodeURIComponent(folderId)}${qs}`, {
method: "DELETE",
}),
);
}
// --- extraction prompt templates ---
export async function listExtractTemplates(scope?: ExtractTemplateScope, enabledOnly = false) {
const qs = new URLSearchParams();
if (scope) qs.set("scope", scope);
if (enabledOnly) qs.set("enabled_only", "true");
const q = qs.toString();
const res = await parseResponse<{ items: KnowledgeExtractTemplate[] }>(
await apiFetch(`${baseUrl()}/extract-templates${q ? `?${q}` : ""}`),
);
return res.items;
}
export type ExtractTemplateInput = {
name: string;
system_prompt: string;
scope?: ExtractTemplateScope;
description?: string | null;
is_default?: boolean;
enabled?: boolean;
};
export async function createExtractTemplate(data: ExtractTemplateInput) {
const res = await parseResponse<{ template: KnowledgeExtractTemplate }>(
await apiFetch(`${baseUrl()}/extract-templates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
return res.template;
}
export async function updateExtractTemplate(
templateId: string,
data: Partial<ExtractTemplateInput>,
) {
const res = await parseResponse<{ template: KnowledgeExtractTemplate }>(
await apiFetch(`${baseUrl()}/extract-templates/${encodeURIComponent(templateId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
return res.template;
}
export async function deleteExtractTemplate(templateId: string) {
return parseResponse<{ ok: boolean }>(
await apiFetch(`${baseUrl()}/extract-templates/${encodeURIComponent(templateId)}`, {
method: "DELETE",
}),
);
}
+68 -1
View File
@@ -7,7 +7,11 @@ import {
import { import {
addGraphBlacklist, addGraphBlacklist,
createExtractTemplate,
createKnowledgeFolder,
createKnowledgeNote, createKnowledgeNote,
deleteExtractTemplate,
deleteKnowledgeFolder,
deleteKnowledgeNote, deleteKnowledgeNote,
exportKnowledgeGraph, exportKnowledgeGraph,
fetchVaultPage, fetchVaultPage,
@@ -18,14 +22,18 @@ import {
ingestThread, ingestThread,
ingestThreadsBatch, ingestThreadsBatch,
listDuplicates, listDuplicates,
listExtractTemplates,
listKnowledgeFolders,
listKnowledgeNotes, listKnowledgeNotes,
mergeKnowledgeNotes, mergeKnowledgeNotes,
removeGraphBlacklist, removeGraphBlacklist,
renameKnowledgeFolder,
resolveNoteWikilinks, resolveNoteWikilinks,
searchKnowledge, searchKnowledge,
updateExtractTemplate,
updateKnowledgeNote, updateKnowledgeNote,
} from "./api"; } from "./api";
import type { KnowledgeListParams } from "./types"; import type { ExtractTemplateScope, KnowledgeListParams } from "./types";
const ROOT_KEY = ["knowledge"] as const; const ROOT_KEY = ["knowledge"] as const;
@@ -172,3 +180,62 @@ export function useKnowledgeDuplicates(enabled: boolean) {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
} }
// --- folders (user-managed directories) ---
export function useKnowledgeFolders() {
return useQuery({
queryKey: [...ROOT_KEY, "folders"],
queryFn: listKnowledgeFolders,
refetchOnWindowFocus: false,
});
}
export function useFolderActions() {
const queryClient = useQueryClient();
// Folder changes can move notes around → invalidate the whole KB tree.
const invalidate = () => queryClient.invalidateQueries({ queryKey: ROOT_KEY });
return {
create: useMutation({ mutationFn: createKnowledgeFolder, onSuccess: invalidate }),
rename: useMutation({
mutationFn: ({ folderId, path }: { folderId: string; path: string }) =>
renameKnowledgeFolder(folderId, path),
onSuccess: invalidate,
}),
remove: useMutation({
mutationFn: ({ folderId, reassignTo }: { folderId: string; reassignTo?: string }) =>
deleteKnowledgeFolder(folderId, reassignTo),
onSuccess: invalidate,
}),
};
}
// --- extraction prompt templates ---
export function useExtractTemplates(scope?: ExtractTemplateScope, enabledOnly = false) {
return useQuery({
queryKey: [...ROOT_KEY, "extract-templates", scope ?? "all", enabledOnly],
queryFn: () => listExtractTemplates(scope, enabledOnly),
refetchOnWindowFocus: false,
});
}
export function useExtractTemplateActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [...ROOT_KEY, "extract-templates"] });
return {
create: useMutation({ mutationFn: createExtractTemplate, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({
templateId,
data,
}: {
templateId: string;
data: Parameters<typeof updateExtractTemplate>[1];
}) => updateExtractTemplate(templateId, data),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteExtractTemplate, onSuccess: invalidate }),
};
}
+46
View File
@@ -31,6 +31,8 @@ export interface KnowledgeNote {
summary?: string | null; summary?: string | null;
content_md: string; content_md: string;
vault_path?: string | null; vault_path?: string | null;
/** User-assigned directory (slash-joined path); null/undefined = unfiled. */
folder?: string | null;
source_type: string; source_type: string;
source_id?: string | null; source_id?: string | null;
status: KnowledgeStatus; status: KnowledgeStatus;
@@ -58,10 +60,37 @@ export interface KnowledgeListParams {
entity?: string; entity?: string;
source_type?: string; source_type?: string;
status?: string; status?: string;
/** Filter to a directory (and its sub-folders); "" = unfiled only. */
folder?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
} }
/** A user-managed knowledge directory. */
export interface KnowledgeFolder {
id: string;
path: string;
name: string;
sort_order?: number;
}
export type ExtractTemplateScope = "thread" | "document" | "both";
/** An editable knowledge-extraction prompt template. */
export interface KnowledgeExtractTemplate {
id: string;
name: string;
description?: string | null;
scope: ExtractTemplateScope;
system_prompt: string;
is_default: boolean;
enabled: boolean;
created_by?: string | null;
updated_by?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export type WikilinkKind = "note" | "vault" | "missing"; export type WikilinkKind = "note" | "vault" | "missing";
export interface WikilinkResolution { export interface WikilinkResolution {
@@ -105,6 +134,10 @@ export interface IngestThreadPayload {
background?: boolean; background?: boolean;
/** Optional model override for knowledge extraction (null = configured default). */ /** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null; model_name?: string | null;
/** Optional extraction-prompt template id (null = scope default). */
template_id?: string | null;
/** Optional directory to file the result under (slash-joined path). */
folder?: string | null;
} }
export interface BatchIngestPayload { export interface BatchIngestPayload {
@@ -114,12 +147,25 @@ export interface BatchIngestPayload {
include_sources?: boolean; include_sources?: boolean;
skip_existing?: boolean; skip_existing?: boolean;
mode?: "summary" | "full"; mode?: "summary" | "full";
/**
* When true (default), the backend runs the per-thread extraction loop in a
* background task and returns immediately (`queued:true`) instead of blocking
* the request until every thread is processed — which otherwise outlives the
* nginx gateway timeout and surfaces as a generic "request failed".
*/
background?: boolean;
/** Optional model override for knowledge extraction (null = configured default). */ /** Optional model override for knowledge extraction (null = configured default). */
model_name?: string | null; model_name?: string | null;
/** Optional extraction-prompt template id (null = scope default). */
template_id?: string | null;
/** Optional directory to file results under (slash-joined path). */
folder?: string | null;
} }
export interface BatchIngestResult { export interface BatchIngestResult {
ok: boolean; ok: boolean;
/** True when the backend queued the batch as a background task (see payload). */
queued?: boolean;
processed: number; processed: number;
created: number; created: number;
skipped: number; skipped: number;
@@ -13,6 +13,7 @@ import {
BookOpen, BookOpen,
Bot, Bot,
Brain, Brain,
Briefcase,
CalendarClock, CalendarClock,
CircleHelp, CircleHelp,
ClipboardList, ClipboardList,
@@ -52,7 +53,7 @@ export type MenuItem = {
export const sidebarMenuItems: MenuItem[] = [ export const sidebarMenuItems: MenuItem[] = [
{ {
id: "qa-center", id: "qa-center",
label: "问答中心", label: "问答管理",
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
@@ -65,11 +66,11 @@ export const sidebarMenuItems: MenuItem[] = [
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" }, // { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{ {
id: "qa-zzjc", id: "qa-zzjc",
label: "zzJC支持", label: "JC支持",
icon: Bot, icon: Bot,
path: "/page/strategy/agents/6bf/chats/new", path: "/page/strategy/qa/zzjc",
}, },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", }, { id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
], ],
}, },
@@ -102,24 +103,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "COH中任务", label: "COH中任务",
icon: Activity, icon: Activity,
children: [ children: [
{ // {
id: "coh-task-3qfx", // id: "coh-task-3qfx",
label: "3QFX", // label: "3QFX",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment", // path: "/page/strategy/task-management/plan-task-sentiment",
}, // },
{ // {
id: "coh-task-fx", // id: "coh-task-fx",
label: "任务FX", // label: "任务FX",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx", // path: "/page/strategy/task-management/plan-task/plan-task-fx",
}, // },
{ // {
id: "coh-task-xdfx", // id: "coh-task-xdfx",
label: "XDFX", // label: "XDFX",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action", // path: "/page/strategy/task-management/plan-task/plan-task-action",
}, // },
], ],
}, },
{ {
@@ -241,6 +242,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/users", path: "/page/strategy/admin/users",
adminOnly: true, adminOnly: true,
}, },
{
id: "mgmt-positions",
label: "岗位管理",
icon: Briefcase,
path: "/page/strategy/admin/positions",
adminOnly: true,
},
{ {
id: "mgmt-admin-scheduled-tasks", id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理", label: "定时任务管理",
+130
View File
@@ -0,0 +1,130 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
Position,
PositionCreateInput,
PositionListResponse,
PositionMember,
PositionUpdateInput,
PositionUser,
TagBindings,
TagBindingsResponse,
} from "./types";
async function parseDetail(res: Response, fallback: string): Promise<Error> {
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
}
const base = () => `${getBackendBaseURL()}/api/positions`;
export async function listPositions(): Promise<Position[]> {
const res = await apiFetch(base());
if (!res.ok) throw await parseDetail(res, "Failed to list positions");
const data = (await res.json()) as PositionListResponse;
return data.positions;
}
export async function createPosition(input: PositionCreateInput): Promise<Position> {
const res = await apiFetch(base(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to create position");
return (await res.json()) as Position;
}
export async function updatePosition(id: string, input: PositionUpdateInput): Promise<Position> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to update position");
return (await res.json()) as Position;
}
export async function deletePosition(id: string): Promise<void> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok) throw await parseDetail(res, "Failed to delete position");
}
export async function getTagBindings(positionId: string): Promise<TagBindings> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/tag-bindings`);
if (!res.ok) throw await parseDetail(res, "Failed to load tag bindings");
const data = (await res.json()) as TagBindingsResponse;
return data.bindings;
}
export async function setTagBindings(positionId: string, bindings: TagBindings): Promise<TagBindings> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/tag-bindings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bindings }),
});
if (!res.ok) throw await parseDetail(res, "Failed to save tag bindings");
const data = (await res.json()) as TagBindingsResponse;
return data.bindings;
}
export async function listMembers(positionId: string): Promise<PositionMember[]> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`);
if (!res.ok) throw await parseDetail(res, "Failed to list members");
const data = (await res.json()) as { members: PositionMember[] };
return data.members;
}
export async function addMembers(positionId: string, userIds: string[]): Promise<PositionMember[]> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_ids: userIds }),
});
if (!res.ok) throw await parseDetail(res, "Failed to add members");
const data = (await res.json()) as { members: PositionMember[] };
return data.members;
}
export async function removeMembers(positionId: string, userIds: string[]): Promise<PositionMember[]> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_ids: userIds }),
});
if (!res.ok) throw await parseDetail(res, "Failed to remove members");
const data = (await res.json()) as { members: PositionMember[] };
return data.members;
}
export async function resyncPosition(positionId: string): Promise<number> {
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/resync`, { method: "POST" });
if (!res.ok) throw await parseDetail(res, "Failed to resync position");
const data = (await res.json()) as { synced: number };
return data.synced;
}
export async function getMyPosition(): Promise<Position | null> {
const res = await apiFetch(`${base()}/me`);
if (!res.ok) throw await parseDetail(res, "Failed to load my position");
const data = (await res.json()) as { position: Position | null };
return data.position ?? null;
}
export async function listPositionUsers(): Promise<PositionUser[]> {
const res = await apiFetch(`${base()}/users`);
if (!res.ok) throw await parseDetail(res, "Failed to list users");
const data = (await res.json()) as { users: PositionUser[] };
return data.users;
}
export async function setUserPosition(userId: string, positionId: string | null): Promise<void> {
const res = await apiFetch(`${base()}/users/${encodeURIComponent(userId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ position_id: positionId }),
});
if (!res.ok) throw await parseDetail(res, "Failed to set user position");
}
+131
View File
@@ -0,0 +1,131 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
addMembers,
createPosition,
deletePosition,
getMyPosition,
getTagBindings,
listMembers,
listPositionUsers,
listPositions,
removeMembers,
resyncPosition,
setTagBindings,
setUserPosition,
updatePosition,
} from "./api";
import type {
PositionCreateInput,
PositionUpdateInput,
TagBindings,
} from "./types";
const positionKeys = {
root: ["positions"] as const,
list: ["positions", "list"] as const,
bindings: (id: string) => ["positions", "bindings", id] as const,
members: (id: string) => ["positions", "members", id] as const,
users: ["positions", "users"] as const,
me: ["positions", "me"] as const,
};
export function usePositions() {
return useQuery({ queryKey: positionKeys.list, queryFn: listPositions });
}
/** The caller's own effective position (explicit, else default). */
export function useMyPosition() {
return useQuery({ queryKey: positionKeys.me, queryFn: getMyPosition });
}
export function usePositionUsers() {
return useQuery({ queryKey: positionKeys.users, queryFn: listPositionUsers });
}
export function usePositionTagBindings(positionId: string | null) {
return useQuery({
queryKey: positionKeys.bindings(positionId ?? ""),
queryFn: () => getTagBindings(positionId as string),
enabled: !!positionId,
});
}
export function usePositionMembers(positionId: string | null) {
return useQuery({
queryKey: positionKeys.members(positionId ?? ""),
queryFn: () => listMembers(positionId as string),
enabled: !!positionId,
});
}
function useInvalidate() {
const qc = useQueryClient();
return () => void qc.invalidateQueries({ queryKey: positionKeys.root });
}
export function useCreatePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (input: PositionCreateInput) => createPosition(input),
onSuccess: invalidate,
});
}
export function useUpdatePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: PositionUpdateInput }) => updatePosition(id, input),
onSuccess: invalidate,
});
}
export function useDeletePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => deletePosition(id),
onSuccess: invalidate,
});
}
export function useSetTagBindings() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ positionId, bindings }: { positionId: string; bindings: TagBindings }) =>
setTagBindings(positionId, bindings),
onSuccess: (_data, { positionId }) => {
void qc.invalidateQueries({ queryKey: positionKeys.bindings(positionId) });
},
});
}
export function useAddMembers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ positionId, userIds }: { positionId: string; userIds: string[] }) =>
addMembers(positionId, userIds),
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
});
}
export function useRemoveMembers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ positionId, userIds }: { positionId: string; userIds: string[] }) =>
removeMembers(positionId, userIds),
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
});
}
export function useResyncPosition() {
return useMutation({ mutationFn: (positionId: string) => resyncPosition(positionId) });
}
export function useSetUserPosition() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, positionId }: { userId: string; positionId: string | null }) =>
setUserPosition(userId, positionId),
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./types";
export * from "./api";
export * from "./hooks";
+82
View File
@@ -0,0 +1,82 @@
import type { TagType } from "@/core/tags/types";
/** Resource types a 岗位 (position) can grant, by tag. */
export type PositionResourceType =
| "agent"
| "skill"
| "scheduled_task"
| "recommended_question";
export const POSITION_RESOURCE_TYPES: PositionResourceType[] = [
"agent",
"skill",
"scheduled_task",
"recommended_question",
];
// A position's resource types are exactly the tag types — reuse the labels.
export const POSITION_RESOURCE_LABELS: Record<PositionResourceType, string> = {
agent: "智能体",
skill: "技能",
scheduled_task: "定时任务",
recommended_question: "推荐问题",
};
export const POSITION_RESOURCE_HINT: Record<PositionResourceType, string> = {
agent: "不配置标签 = 默认加载全部内置智能体;配置后只加载所选标签下的智能体。",
skill: "不配置标签 = 不向「我的技能」添加任何技能;配置后将所选标签下的技能加入成员的「我的技能」(技能广场仍展示全部技能)。",
scheduled_task: "不配置标签 = 不订阅任何定时任务;配置后订阅所选标签下的定时任务。",
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题(聊天框上方的推荐问题)。",
};
export interface Position {
id: string;
name: string;
description: string;
is_default: boolean;
member_count: number;
created_at: string | null;
updated_at: string | null;
}
export interface PositionListResponse {
positions: Position[];
resource_types: PositionResourceType[];
}
export interface PositionCreateInput {
name: string;
description?: string;
is_default?: boolean;
}
export interface PositionUpdateInput {
name?: string;
description?: string;
is_default?: boolean;
}
/** resource_type -> [tag_id] */
export type TagBindings = Partial<Record<PositionResourceType, string[]>>;
export interface TagBindingsResponse {
position_id: string;
bindings: TagBindings;
}
export interface PositionMember {
id: string;
email: string;
}
export interface PositionUser {
id: string;
email: string;
system_role: string;
position_id: string | null;
}
// The resource types double as tag types for the binding pickers.
export function resourceTypeToTagType(rt: PositionResourceType): TagType {
return rt as TagType;
}
@@ -9,8 +9,10 @@ import type {
const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`; const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`;
export async function listRecommendedQuestions(): Promise<RecommendedQuestion[]> { export async function listRecommendedQuestions(all = false): Promise<RecommendedQuestion[]> {
const res = await apiFetch(BASE()); // ``all=true`` is the admin management view: every question, unfiltered by the
// caller's 岗位 (position). The chat input uses the default (filtered) view.
const res = await apiFetch(`${BASE()}${all ? "?all=1" : ""}`);
if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`); if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`);
const data = (await res.json()) as { questions: RecommendedQuestion[] }; const data = (await res.json()) as { questions: RecommendedQuestion[] };
return data.questions; return data.questions;
@@ -10,11 +10,21 @@ import {
import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index"; import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index";
const QK = ["recommended-questions"] as const; const QK = ["recommended-questions"] as const;
const ADMIN_QK = ["recommended-questions", "admin"] as const;
export function useRecommendedQuestions() { export function useRecommendedQuestions() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: QK, queryKey: QK,
queryFn: listRecommendedQuestions, queryFn: () => listRecommendedQuestions(false),
});
return { questions: data ?? [], isLoading, error };
}
/** Admin management view — every question, unfiltered by 岗位. */
export function useAdminRecommendedQuestions() {
const { data, isLoading, error } = useQuery({
queryKey: ADMIN_QK,
queryFn: () => listRecommendedQuestions(true),
}); });
return { questions: data ?? [], isLoading, error }; return { questions: data ?? [], isLoading, error };
} }
@@ -1,8 +1,12 @@
import type { Tag } from "@/core/tags";
export interface RecommendedQuestion { export interface RecommendedQuestion {
id: string; id: string;
title: string; title: string;
content: string; content: string;
sort_order: number; sort_order: number;
/** Tags assigned to this question (按岗位分发标签). */
tags?: Tag[];
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
+7
View File
@@ -7,6 +7,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
display: { display: {
streamSpeedMeterEnabled: true, streamSpeedMeterEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
contextCompressionEnabled: false,
citationCleanupWords: [], citationCleanupWords: [],
fontScale: 1, fontScale: 1,
}, },
@@ -43,6 +44,12 @@ export interface LocalSettings {
* messages render tool results with the legacy generic search UI only. * messages render tool results with the legacy generic search UI only.
*/ */
ragModeEnabled: boolean; ragModeEnabled: boolean;
/**
* Per-user switch for context compression (上下文压缩). When enabled, long
* conversations are summarized transiently before the model call to save
* tokens. Default off; toggled from the chat input box (below 参考文献).
*/
contextCompressionEnabled: boolean;
/** /**
* Admin-maintained words or short phrases removed from reference snippets * Admin-maintained words or short phrases removed from reference snippets
* and dialogs, one entry per line in the settings UI. * and dialogs, one entry per line in the settings UI.
+23
View File
@@ -9,6 +9,8 @@ export interface ListSkillsParams {
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc"; sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
/** Keep only skills filed under this publish square (发布广场). */ /** Keep only skills filed under this publish square (发布广场). */
square?: string; square?: string;
/** ``"mine"`` restricts to the skills the caller's 岗位 (position) makes visible. */
scope?: "mine";
} }
export async function loadSkills(params: ListSkillsParams = {}) { export async function loadSkills(params: ListSkillsParams = {}) {
@@ -17,6 +19,7 @@ export async function loadSkills(params: ListSkillsParams = {}) {
for (const id of params.tagIds ?? []) qs.append("tag_ids", id); for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort); if (params.sort) qs.set("sort", params.sort);
if (params.square) qs.set("square", params.square); if (params.square) qs.set("square", params.square);
if (params.scope) qs.set("scope", params.scope);
const query = qs.toString(); const query = qs.toString();
const skills = await apiFetch( const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`, `${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
@@ -62,6 +65,26 @@ export async function enableSkill(skillName: string, enabled: boolean) {
return response.json(); return response.json();
} }
export async function setSkillAlwaysOn(
skillName: string,
alwaysOn: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/always-on`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ always_on: alwaysOn }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill 常驻 flag");
}
return response.json() as Promise<Skill>;
}
export async function setSkillDisplay( export async function setSkillDisplay(
skillName: string, skillName: string,
display: SkillDisplayConfig | null, display: SkillDisplayConfig | null,
+22 -2
View File
@@ -18,6 +18,7 @@ import {
installSkillUpload, installSkillUpload,
reconcileCustomSkills, reconcileCustomSkills,
saveSkillFile, saveSkillFile,
setSkillAlwaysOn,
setSkillDetail, setSkillDetail,
setSkillNameZh, setSkillNameZh,
setSkillDisplay, setSkillDisplay,
@@ -42,9 +43,10 @@ export function useSkills(params: ListSkillsParams = {}) {
const search = params.search ?? ""; const search = params.search ?? "";
const tagIds = params.tagIds ?? []; const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc"; const sort = params.sort ?? "time_desc";
const scope = params.scope;
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort], queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
queryFn: () => loadSkills({ search, tagIds, sort }), queryFn: () => loadSkills({ search, tagIds, sort, scope }),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
return { skills: data ?? EMPTY_SKILLS, isLoading, error }; return { skills: data ?? EMPTY_SKILLS, isLoading, error };
@@ -170,6 +172,24 @@ export function useEnableSkill() {
}); });
} }
export function useSetSkillAlwaysOn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, alwaysOn }: { skillName: string; alwaysOn: boolean }) =>
setSkillAlwaysOn(skillName, alwaysOn),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill", skillName], data);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useValidateSkillUpload() { export function useValidateSkillUpload() {
return useMutation({ return useMutation({
mutationFn: (file: File) => validateSkillUpload(file), mutationFn: (file: File) => validateSkillUpload(file),
+6
View File
@@ -48,6 +48,9 @@ export interface Skill {
category: string; category: string;
license: string; license: string;
enabled: boolean; enabled: boolean;
// 常驻(免压缩) —— 开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,
// 而非被压缩成索引 + search_skills 检索。
always_on?: boolean;
// Multitenancy fields — owner_user_id is null for built-in / public skills. // Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side. // ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null; owner_user_id?: string | null;
@@ -58,6 +61,9 @@ export interface Skill {
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */ /** Publish square (发布广场) the skill is filed under once published. "" = default square. */
square_id?: string; square_id?: string;
builtin?: boolean; builtin?: boolean;
// True when this skill is in the caller's 我的技能 collection — e.g. granted by
// their 岗位 (position). Owned skills are detected separately via owner_user_id.
favorited?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin. // Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown. // Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null; detail?: string | null;
@@ -247,6 +247,89 @@ export async function updateCitationDisplaySettings(
return (await response.json()) as CitationDisplaySettings; return (await response.json()) as CitationDisplaySettings;
} }
export interface SkillCompressionSettings {
enabled: boolean;
search_top_n: number;
keep_index_in_prompt: boolean;
}
export interface SkillCompressionUpdate {
enabled?: boolean;
search_top_n?: number;
keep_index_in_prompt?: boolean;
}
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
);
if (!response.ok) {
throw new Error(
`Failed to load skill compression settings: ${response.status}`,
);
}
return (await response.json()) as SkillCompressionSettings;
}
export async function updateSkillCompressionSettings(
body: SkillCompressionUpdate,
): Promise<SkillCompressionSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update skill compression" }));
throw new Error(detail.detail ?? "Failed to update skill compression");
}
return (await response.json()) as SkillCompressionSettings;
}
/** 「按类型聚合」模块(zzJC / zzXD)选中的智能体标签配置(需求 0616 #3/#4)。 */
export interface AgentModuleConfig {
key: string;
tag_ids: string[];
}
export async function loadAgentModuleConfig(
key: string,
): Promise<AgentModuleConfig> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/agent-modules/${encodeURIComponent(key)}`,
);
if (!response.ok) {
throw new Error(`Failed to load agent module config: ${response.status}`);
}
return (await response.json()) as AgentModuleConfig;
}
export async function updateAgentModuleConfig(
key: string,
tagIds: string[],
): Promise<AgentModuleConfig> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/agent-modules/${encodeURIComponent(key)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_ids: tagIds }),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update agent module config" }));
throw new Error(detail.detail ?? "Failed to update agent module config");
}
return (await response.json()) as AgentModuleConfig;
}
export interface LogSystemSettings { export interface LogSystemSettings {
enabled: boolean; enabled: boolean;
url: string; url: string;
@@ -3,24 +3,29 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { import {
loadAgentModuleConfig,
loadAppearanceSettings, loadAppearanceSettings,
loadCitationDisplaySettings, loadCitationDisplaySettings,
loadLeaderboardSettings, loadLeaderboardSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
loadPublishSquares, loadPublishSquares,
loadSkillCompressionSettings,
updateAgentModuleConfig,
updateAppearanceSettings, updateAppearanceSettings,
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
updatePublishSquares, updatePublishSquares,
updateSkillCompressionSettings,
type AppearanceUpdate, type AppearanceUpdate,
type CitationDisplayUpdate, type CitationDisplayUpdate,
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
type PublishSquaresUpdate, type PublishSquaresUpdate,
type SkillCompressionUpdate,
type LayoutMode, type LayoutMode,
type ViewMode, type ViewMode,
} from "./api"; } from "./api";
@@ -31,6 +36,7 @@ const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const; const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const; const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
export function usePromptPrefixSettings() { export function usePromptPrefixSettings() {
return useQuery({ return useQuery({
@@ -165,6 +171,29 @@ export function useLayoutMode(): [
return [mode, setMode]; return [mode, setMode];
} }
/** 「按类型聚合」模块(zzJC / zzXD)选中标签配置(需求 0616 #3/#4)。 */
const agentModuleKey = (key: string) =>
["systemSettings", "agentModule", key] as const;
export function useAgentModuleConfig(key: string) {
return useQuery({
queryKey: agentModuleKey(key),
queryFn: () => loadAgentModuleConfig(key),
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateAgentModuleConfig(key: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tagIds: string[]) => updateAgentModuleConfig(key, tagIds),
onSuccess: (data) => {
queryClient.setQueryData(agentModuleKey(key), data);
},
});
}
export function useCitationDisplaySettings() { export function useCitationDisplaySettings() {
return useQuery({ return useQuery({
queryKey: CITATION_DISPLAY_KEY, queryKey: CITATION_DISPLAY_KEY,
@@ -186,6 +215,29 @@ export function useUpdateCitationDisplaySettings() {
}); });
} }
/** Skill compression (技能压缩) settings. Readable by any logged-in user — the
* skill detail page reads it to decide whether to show the 常驻(免压缩) toggle. */
export function useSkillCompressionSettings() {
return useQuery({
queryKey: SKILL_COMPRESSION_KEY,
queryFn: () => loadSkillCompressionSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateSkillCompressionSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: SkillCompressionUpdate) =>
updateSkillCompressionSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(SKILL_COMPRESSION_KEY, data);
},
});
}
export function useLeaderboardSettings() { export function useLeaderboardSettings() {
return useQuery({ return useQuery({
queryKey: LEADERBOARD_KEY, queryKey: LEADERBOARD_KEY,
+1
View File
@@ -83,6 +83,7 @@ export function useSetResourceTags() {
void queryClient.invalidateQueries({ queryKey: ["skills"] }); void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["agents"] }); void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] }); void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
void queryClient.invalidateQueries({ queryKey: ["recommended-questions"] });
}, },
}); });
} }
+19 -2
View File
@@ -1,6 +1,23 @@
export type TagType = "agent" | "skill" | "scheduled_task"; export type TagType =
| "agent"
| "skill"
| "scheduled_task"
| "recommended_question";
export const TAG_TYPES: TagType[] = ["agent", "skill", "scheduled_task"]; export const TAG_TYPES: TagType[] = [
"agent",
"skill",
"scheduled_task",
"recommended_question",
];
// Human labels for the tag types (used by the tag management + 岗位 pages).
export const TAG_TYPE_LABELS: Record<TagType, string> = {
agent: "智能体",
skill: "技能",
scheduled_task: "定时任务",
recommended_question: "推荐问题",
};
export interface Tag { export interface Tag {
id: string; id: string;
+21
View File
@@ -343,6 +343,23 @@ export function useThreadStream({
) { ) {
const e = event as { type: "llm_retry"; message: string }; const e = event as { type: "llm_retry"; message: string };
toast(e.message); toast(e.message);
return;
}
// Context compression about to run a (blocking) summarization LLM call —
// surface a transient hint so the wait before the answer streams isn't a
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "context_compacting" &&
"message" in event &&
typeof event.message === "string" &&
event.message.trim()
) {
const e = event as { type: "context_compacting"; message: string };
toast(e.message);
} }
}, },
onError(error) { onError(error) {
@@ -647,6 +664,9 @@ export function useThreadStream({
lockThreadRagMode(threadId); lockThreadRagMode(threadId);
} }
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
const mergedContext = { const mergedContext = {
...context, ...context,
...extraContext, ...extraContext,
@@ -698,6 +718,7 @@ export function useThreadStream({
memory_injection_enabled: memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true, mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled, rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled,
thread_id: threadId, thread_id: threadId,
}, },
}, },
+2
View File
@@ -20,6 +20,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
writing_artifact_path?: string; writing_artifact_path?: string;
memory_injection_enabled?: boolean; memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean; rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */ /** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean; knowledge_rag_enabled?: boolean;
agent_id?: string; agent_id?: string;
+15
View File
@@ -25,3 +25,18 @@ export function formatTimeAgo(date: Date | string | number, locale?: Locale) {
locale: getDateFnsLocale(effectiveLocale), locale: getDateFnsLocale(effectiveLocale),
}); });
} }
/**
* Format a created/updated timestamp as `YYYY-MM-DD HH:mm`.
* Returns an empty string for missing or unparseable values.
*/
export function formatDateTime(value?: Date | string | number | null): string {
if (value === null || value === undefined || value === "") return "";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}`
);
}
+34 -13
View File
@@ -100,9 +100,20 @@ function candidateTextFromHref(href: string): string {
/** /**
* Injects a small runtime script into generated HTML so that clicking an * Injects a small runtime script into generated HTML so that clicking an
* in-page anchor (`<a href="#section">`) scrolls within the iframe instead of * in-page anchor (`<a href="#section">`) works inside the preview iframe.
* navigating it. The preview iframe loads from a sandboxed `blob:` URL, where a *
* fragment navigation blanks the frame; intercepting the click avoids that. * The preview loads from a sandboxed `blob:` URL (opaque origin). Such a frame
* is FORBIDDEN from navigating to its own `blob:` URL, so changing the hash
* throws "Not allowed to load local resource: blob:…#page-x". And because CSS
* `:target` only updates on a real fragment navigation, the html-multipage
* skill's `:target`-based sub-page switching can never fire here.
*
* So we never touch the hash. Instead the handler emulates the skill's
* show/hide in JS: a "page" is a `.page` inside a `.pages` container; clicking
* a link to one shows it and hides its siblings (mirroring the
* `.page:target` + `~ #page-home` rules via inline `display`, which outranks
* the skill's class-level CSS). Plain in-page anchors (TOC headings, etc.) just
* smooth-scroll as before.
*/ */
export function injectInPageAnchorHandler(html: string): string { export function injectInPageAnchorHandler(html: string): string {
const script = `<script>(function(){ const script = `<script>(function(){
@@ -112,6 +123,22 @@ export function injectInPageAnchorHandler(html: string): string {
var esc=window.CSS&&CSS.escape?CSS.escape(id):id.replace(/["\\\\]/g,"\\\\$&"); var esc=window.CSS&&CSS.escape?CSS.escape(id):id.replace(/["\\\\]/g,"\\\\$&");
try{return document.querySelector("#"+esc+",[name=\\""+esc+"\\"]");}catch(e){return null;} try{return document.querySelector("#"+esc+",[name=\\""+esc+"\\"]");}catch(e){return null;}
} }
function find(raw){
var id=decode(raw);
return document.getElementById(id)||document.getElementById(raw)||
document.getElementsByName(id)[0]||document.getElementsByName(raw)[0]||
bySelector(id)||bySelector(raw);
}
function switchPage(target){
var container=target.parentElement;
while(container&&!(container.classList&&container.classList.contains("pages"))){container=container.parentElement;}
var pages=(container||document).querySelectorAll(".page");
if(!pages.length)return false;
for(var i=0;i<pages.length;i++){pages[i].style.display="none";}
target.style.display="block";
if(window.scrollTo){window.scrollTo(0,0);}
return true;
}
document.addEventListener("click",function(e){ document.addEventListener("click",function(e){
var el=e.target; var el=e.target;
while(el&&el.tagName!=="A"){el=el.parentElement;} while(el&&el.tagName!=="A"){el=el.parentElement;}
@@ -119,16 +146,10 @@ export function injectInPageAnchorHandler(html: string): string {
var href=el.getAttribute("href"); var href=el.getAttribute("href");
if(!href||href.charAt(0)!=="#"||href.length<2)return; if(!href||href.charAt(0)!=="#"||href.length<2)return;
e.preventDefault(); e.preventDefault();
var id=decode(href.slice(1)); var target=find(href.slice(1));
var raw=href.slice(1); if(!target)return;
var target= if(target.classList&&target.classList.contains("page")&&switchPage(target))return;
document.getElementById(id)|| target.scrollIntoView({behavior:"smooth",block:"start"});
document.getElementById(raw)||
document.getElementsByName(id)[0]||
document.getElementsByName(raw)[0]||
bySelector(id)||
bySelector(raw);
if(target){target.scrollIntoView({behavior:"smooth",block:"start"});}
},true); },true);
})();</script>`; })();</script>`;
if (/<\/body>/i.test(html)) { if (/<\/body>/i.test(html)) {
+23
View File
@@ -4,6 +4,9 @@
* value: 脱敏后替换词 * value: 脱敏后替换词
*/ */
export const DESENSITIZE_MAP: Record<string, string> = { export const DESENSITIZE_MAP: Record<string, string> = {
方案: "FA",
设计: "SJ",
任务: "RW",
态势: "TS", 态势: "TS",
滨海作战体系: "第一张网", 滨海作战体系: "第一张网",
滨海作战部队: "第一张网", 滨海作战部队: "第一张网",
@@ -72,6 +75,26 @@ export function desensitize(text: string): string {
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched); return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched);
} }
/**
* 深度脱敏:递归遍历对象 / 数组,对其中所有字符串值做 {@link desensitize},
* 其余类型原样保留。返回**新值**(不修改入参),用于对结构化数据(如大屏 report-json
* 解析出的对象)整体脱敏后再渲染。
*
* 注意:仅用于「展示」路径——不要用它改写要回传后端 / 落库的数据。
*/
export function desensitizeDeep<T>(value: T): T {
if (typeof value === "string") return desensitize(value) as unknown as T;
if (Array.isArray(value)) return value.map((v) => desensitizeDeep(v)) as unknown as T;
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = desensitizeDeep(v);
}
return out as T;
}
return value;
}
/** /**
* 运行时动态追加脱敏词,追加后自动重编译正则。 * 运行时动态追加脱敏词,追加后自动重编译正则。
* *
@@ -0,0 +1,75 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
/** 课题研究模板:一组写作表单预设(需求 0616 #5)。 */
export interface ResearchTemplate {
id: string;
name: string;
description: string | null;
/** 引用的文章类型 key(对应 ArticleType.key);null = 不设。 */
article_type_key: string | null;
outline: string | null;
material_source: 'general' | 'notebook';
word_count: number;
audience: string | null;
strict_mode: boolean;
topic: string | null;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface CreateResearchTemplateRequest {
name: string;
description?: string | null;
article_type_key?: string | null;
outline?: string | null;
material_source?: 'general' | 'notebook';
word_count?: number;
audience?: string | null;
strict_mode?: boolean;
topic?: string | null;
sort_order?: number;
}
export type UpdateResearchTemplateRequest = Partial<CreateResearchTemplateRequest>;
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/research-templates`;
export async function listResearchTemplates(): Promise<ResearchTemplate[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取研究模板失败: ${resp.status}`);
return resp.json();
}
export async function createResearchTemplate(
data: CreateResearchTemplateRequest,
): Promise<ResearchTemplate> {
const resp = await apiFetch(BASE(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!resp.ok) throw new Error(`创建研究模板失败: ${resp.status}`);
return resp.json();
}
export async function updateResearchTemplate(
id: string,
data: UpdateResearchTemplateRequest,
): Promise<ResearchTemplate> {
const resp = await apiFetch(`${BASE()}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 404) throw new Error(`研究模板不存在`);
if (!resp.ok) throw new Error(`更新研究模板失败: ${resp.status}`);
return resp.json();
}
export async function deleteResearchTemplate(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (resp.status === 404) throw new Error(`研究模板不存在`);
if (!resp.ok) throw new Error(`删除研究模板失败: ${resp.status}`);
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo } from 'react';
import { PenLine, SlidersHorizontal } from 'lucide-react'; import { PenLine, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -55,8 +55,6 @@ export function AIWritingForm({
onSubmit, onSubmit,
disabled, disabled,
hideAdvancedFields = false, hideAdvancedFields = false,
initialNotebookId,
initialDocumentIds,
studioToken, studioToken,
}: AIWritingFormProps) { }: AIWritingFormProps) {
const { const {
@@ -68,13 +66,12 @@ export function AIWritingForm({
wordCountInput, setWordCountInput, wordCountInput, setWordCountInput,
strictMode, setStrictMode, strictMode, setStrictMode,
materialSource, setMaterialSource, materialSource, setMaterialSource,
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
selectedNotebookId, setSelectedNotebookId,
selectedDocumentIds, setSelectedDocumentIds,
modelName, setModelName, modelName, setModelName,
} = useWritingForm(); } = useWritingForm();
// 笔记来源:选中的 search_space_id 和多个 document_id
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(
initialNotebookId ?? null,
);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
const { models, isLoading: modelsLoading } = useModels(); const { models, isLoading: modelsLoading } = useModels();
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken); const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
@@ -146,8 +143,10 @@ export function AIWritingForm({
} }
function toggleDocument(docId: number) { function toggleDocument(docId: number) {
setSelectedDocumentIds((prev) => setSelectedDocumentIds(
prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId], selectedDocumentIds.includes(docId)
? selectedDocumentIds.filter((id) => id !== docId)
: [...selectedDocumentIds, docId],
); );
} }
@@ -0,0 +1,283 @@
"use client";
import { BookOpen, FileText, LayoutGrid, Loader2, Plus, Settings2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
import { createSearchSpace } from "@/core/studio/api";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { cn } from "@/lib/utils";
import { useResearchTemplates } from "../../hooks/useResearchTemplates";
import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
import { useWritingForm } from "../../contexts/WritingFormContext";
import { useAIWriting } from "../../contexts/AIWritingContext";
/**
* 课题研究左栏(需求 0616 #5)。
* - 上半「研究模板」= 研究模板实体(``useResearchTemplates``);点击一键预填整张写作表单
* (文章类型 / 大纲 / 搜索来源 / 字数 / 受众 / 严格模式 / 主题)并展开配置。顶部「配置」
* 入口进入模板配置页维护这些模板。
* - 下半「笔记本」= 我的空间(``useStudioNotebooks``);点击在右侧内嵌打开该笔记本页。
*/
export function ResearchSidebar({
viewingNotebookId,
onOpenNotebook,
onOpenAllSpaces,
}: {
/** 当前在右侧查看的笔记本 id(高亮用)。 */
viewingNotebookId?: number | null;
/** 点击笔记本 → 在右侧内嵌打开该笔记本页(替换右侧内容)。 */
onOpenNotebook: (notebookId: number) => void;
/** 点击「所有空间」→ 在右侧内嵌打开空间列表页。 */
onOpenAllSpaces: () => void;
}) {
const navigate = useNavigate();
const { templates, refresh } = useResearchTemplates();
const { startWriting } = useAIWriting();
const {
notebooks,
isLoading: notebooksLoading,
isError: notebooksError,
} = useStudioNotebooks();
const {
setIntent,
setArticleType,
setOutlineText,
setShowOutline,
setMaterialSource,
setWordCountInput,
setAudience,
setStrictMode,
setFormExpanded,
} = useWritingForm();
const queryClient = useQueryClient();
// 新建空间弹窗(与 workspace-nav-studio 同款)。
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
useEffect(() => {
void refresh();
}, [refresh]);
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) return;
setCreating(true);
setCreateError("");
try {
const email = getStoredAuth()?.email ?? "";
const token = queryClient.getQueryData<string>(["studio", "token", email]);
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
await createSearchSpace(trimmed, token);
setCreateOpen(false);
setName("");
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setCreateError(e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
};
// 点击研究模板 → ①回填整张写作表单(保持写作态左栏显示与运行配置一致)
// ②直接以模板预设进入 AI 写作流程(startWriting)。
const handleTemplateClick = (tpl: ResearchTemplate) => {
const intent = tpl.topic?.trim() ?? "";
const articleType = tpl.article_type_key || "analysis";
const outline = tpl.outline?.trim() || "";
const audience = tpl.audience?.trim() || "普通读者";
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
const writingMode = tpl.strict_mode ? "strict" : "loose";
// 回填表单上下文(写作态左栏据此展示)。
if (intent) setIntent(intent);
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
if (outline) {
setOutlineText(outline);
setShowOutline(true);
}
setMaterialSource(tpl.material_source);
setWordCountInput(String(wordCount));
if (tpl.audience?.trim()) setAudience(audience);
setStrictMode(tpl.strict_mode);
setFormExpanded(true);
// 无主题/意图无法发起写作 → 仅回填表单,由用户补充后手动开始。
if (!intent) return;
void startWriting({
userIntent: intent,
userOutline: outline || undefined,
articleType,
targetAudience: audience,
wordCountTarget: wordCount,
keywordCount: 1,
writingMode,
materialSource: tpl.material_source,
});
};
return (
<aside className="flex h-full w-64 shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
{/* 上半:研究模板 */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
<div className="px-2 mb-1.5 flex items-center justify-between">
<h2 className="text-muted-foreground/70 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
研究模板
</h2>
<button
type="button"
onClick={() => navigate("/page/canvas/research-templates")}
title="配置研究模板"
aria-label="配置研究模板"
className="text-muted-foreground/70 hover:text-foreground rounded p-0.5 transition-colors"
>
<Settings2 className="size-3.5" />
</button>
</div>
{templates.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">
暂无研究模板,点右上角配置
</p>
) : (
<div className="flex flex-col gap-1">
{templates.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => handleTemplateClick(tpl)}
title={tpl.description ?? tpl.name}
className="flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-left text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
>
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium">{tpl.name}</span>
{tpl.description && (
<span className="text-muted-foreground/70 line-clamp-1 block text-[11px]">
{tpl.description}
</span>
)}
</span>
</button>
))}
</div>
)}
</div>
{/* 下半:笔记本(我的空间) */}
<div className="flex min-h-0 flex-1 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>
<div className="flex flex-col gap-0.5">
{/* 新建空间 */}
<button
type="button"
onClick={() => {
setName("");
setCreateError("");
setCreateOpen(true);
}}
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
>
<Plus className="size-3.5 shrink-0 text-muted-foreground/70" />
<span>新建空间</span>
</button>
{notebooksLoading ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">加载中…</p>
) : notebooksError ? (
<p className="px-2 py-1.5 text-xs text-amber-600">
笔记本服务暂不可用(检查空间服务地址 / 账号)
</p>
) : notebooks.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无笔记本</p>
) : (
notebooks.map((nb) => {
const active = viewingNotebookId === nb.id;
return (
<button
key={nb.id}
type="button"
onClick={() => onOpenNotebook(nb.id)}
title={nb.description || nb.name}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
)}
>
<BookOpen className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{nb.name}</span>
</button>
);
})
)}
{/* 所有空间 */}
<button
type="button"
onClick={onOpenAllSpaces}
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
>
<LayoutGrid className="size-3.5 shrink-0 text-muted-foreground/70" />
<span>所有空间</span>
</button>
</div>
</div>
{/* 新建空间弹窗 */}
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
placeholder="空间名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
{createError && <p className="text-xs text-destructive">{createError}</p>}
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={creating}
onClick={() => setCreateOpen(false)}
>
取消
</Button>
<Button
size="sm"
disabled={!name.trim() || creating}
onClick={() => void handleCreate()}
>
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</aside>
);
}
@@ -28,7 +28,7 @@ import { uuid } from '@/core/utils/uuid';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes'; import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext'; import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { WritingWelcome } from './WritingWelcome'; import { WritingWelcome } from './WritingWelcome';
/** /**
@@ -42,13 +42,25 @@ import { WritingWelcome } from './WritingWelcome';
* *
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。 * 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
*/ */
export function WritingSetupChat() { export function WritingSetupChat({
onApprove: onApproveProp,
title = '写作助手',
hint = '描述你想写什么,我来帮你填好右侧配置',
}: {
/** 审批卡「应用」回调;缺省回填写作表单(需 WritingFormProvider)。传入则脱离表单上下文,
* 用于复用到「新建研究模板」等场景,把建议字段写进调用方的表单。 */
onApprove?: (payload: SetupWritingApprovalPayload) => void;
title?: string;
hint?: string;
} = {}) {
const threadId = useMemo(() => uuid(), []); const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false); const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState(''); const [chatModel, setChatModel] = useState('');
const { models, isLoading: modelsLoading } = useModels(); const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes(); const { articleTypes } = useArticleTypes();
const { applyProposal } = useWritingForm(); // 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
const formCtx = useWritingFormOptional();
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
// 首个模型可用时默认选中 // 首个模型可用时默认选中
useEffect(() => { useEffect(() => {
@@ -98,8 +110,8 @@ export function WritingSetupChat() {
const handleApprove = useCallback( const handleApprove = useCallback(
async (payload: SetupWritingApprovalPayload) => { async (payload: SetupWritingApprovalPayload) => {
applyProposal(payload); applyProposal?.(payload);
toast.success('已应用到左侧表单,可继续编辑或开始写作'); toast.success('已应用到表单,可继续编辑');
}, },
[applyProposal], [applyProposal],
); );
@@ -119,8 +131,8 @@ export function WritingSetupChat() {
{!showWelcome && ( {!showWelcome && (
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3"> <header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" /> <Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">写作助手</h2> <h2 className="text-sm font-semibold">{title}</h2>
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好右侧配置</span> <span className="text-muted-foreground text-xs">{hint}</span>
</header> </header>
)} )}
@@ -39,7 +39,7 @@ export function WritingWelcome() {
<div className="flex flex-1 flex-col justify-center gap-6"> <div className="flex flex-1 flex-col justify-center gap-6">
<div> <div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white"> <h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
欢迎使用「妙笔 AI」智力打字协作工坊 欢迎使用「ai写作」智力打字协作工坊
</h2> </h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60"> <p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
这是一个专业的自动化写作系统。你只需在下方对话框中 这是一个专业的自动化写作系统。你只需在下方对话框中
@@ -36,6 +36,12 @@ export interface WritingFormValue {
setStrictMode: (v: boolean) => void; setStrictMode: (v: boolean) => void;
materialSource: 'general' | 'notebook'; materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void; setMaterialSource: (v: 'general' | 'notebook') => void;
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
selectedNotebookId: number | null;
setSelectedNotebookId: (v: number | null) => void;
/** 选中笔记本下的文档 id 列表(缩小检索范围)。 */
selectedDocumentIds: number[];
setSelectedDocumentIds: (v: number[]) => void;
modelName: string; modelName: string;
setModelName: (v: string) => void; setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */ /** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
@@ -56,7 +62,17 @@ export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext); return useContext(WritingFormContext);
} }
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'general' | 'notebook' }) { export function WritingFormProvider({
children,
initialMaterialSource,
initialNotebookId,
initialDocumentIds,
}: {
children: ReactNode;
initialMaterialSource?: 'general' | 'notebook';
initialNotebookId?: number | null;
initialDocumentIds?: number[];
}) {
const [intent, setIntent] = useState(''); const [intent, setIntent] = useState('');
const [formExpanded, setFormExpanded] = useState(false); const [formExpanded, setFormExpanded] = useState(false);
const [showOutline, setShowOutline] = useState(true); const [showOutline, setShowOutline] = useState(true);
@@ -66,6 +82,8 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false); const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general'); const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => { const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
@@ -100,10 +118,12 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
audience, setAudience, audience, setAudience,
strictMode, setStrictMode, strictMode, setStrictMode,
materialSource, setMaterialSource, materialSource, setMaterialSource,
selectedNotebookId, setSelectedNotebookId,
selectedDocumentIds, setSelectedDocumentIds,
modelName, setModelName, modelName, setModelName,
applyProposal, applyProposal,
}), }),
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal], [intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
); );
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>; return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
@@ -0,0 +1,60 @@
import { useCallback, useState } from 'react';
import {
createResearchTemplate,
deleteResearchTemplate,
listResearchTemplates,
updateResearchTemplate,
} from '../api/ai-writing-research-templates';
import type {
CreateResearchTemplateRequest,
ResearchTemplate,
UpdateResearchTemplateRequest,
} from '../api/ai-writing-research-templates';
export function useResearchTemplates() {
const [templates, setTemplates] = useState<ResearchTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
setTemplates(await listResearchTemplates());
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
const create = useCallback(
async (data: CreateResearchTemplateRequest): Promise<ResearchTemplate> => {
const created = await createResearchTemplate(data);
setTemplates((prev) =>
[...prev, created].sort((a, b) => a.sort_order - b.sort_order),
);
return created;
},
[],
);
const update = useCallback(
async (id: string, data: UpdateResearchTemplateRequest): Promise<ResearchTemplate> => {
const updated = await updateResearchTemplate(id, data);
setTemplates((prev) =>
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
);
return updated;
},
[],
);
const remove = useCallback(async (id: string): Promise<void> => {
await deleteResearchTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
}, []);
return { templates, loading, error, refresh, create, update, remove };
}
@@ -81,8 +81,8 @@
* -d '{"retention_days": 0}' * -d '{"retention_days": 0}'
* ────────────────────────────────────────────────────────────────────────── * ──────────────────────────────────────────────────────────────────────────
*/ */
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { PanelRightOpen } from 'lucide-react'; import { ArrowLeft, PanelRightOpen } from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { GroupImperativeHandle } from 'react-resizable-panels'; import type { GroupImperativeHandle } from 'react-resizable-panels';
@@ -93,6 +93,13 @@ import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel'; import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel'; import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat'; import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { ResearchSidebar } from '../components/ai-writing/ResearchSidebar';
import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage';
import StudioNotebooksPage from '@/pages/StudioNotebooksPage';
import { ChatRuntime } from '@/pages/WorkspaceRoutes';
/** 右侧内嵌视图:笔记本详情 / 所有空间列表 / null(写作) */
type RightView = { kind: 'notebook'; id: number } | { kind: 'allSpaces' } | null;
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext'; import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext'; import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext'; import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
@@ -210,6 +217,81 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
); );
} }
/**
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
*/
function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
// 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容
// (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。
const [rightView, setRightView] = useState<RightView>(null);
const overlayActive = rightView != null;
const viewingNotebookId = rightView?.kind === 'notebook' ? rightView.id : null;
// 进入写作(开始生成 / 查看历史)后收起研究模板侧栏;嵌入态(studioToken)始终不展示。
const showSidebar = !studioToken && state.status === 'idle' && !isHistoryMode;
return (
<div className="flex-1 min-h-0 overflow-hidden flex">
{/* 需求 0616 #5:课题研究左栏(研究模板 + 笔记本)。进入写作后收起。 */}
{showSidebar && (
<ResearchSidebar
viewingNotebookId={viewingNotebookId}
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })}
/>
)}
{/* 右侧:写作布局常驻(仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程),
查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
<div className="relative flex-1 min-h-0 overflow-hidden">
<div className={cn('absolute inset-0', overlayActive && 'hidden')}>
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div>
{overlayActive && (
<div className="absolute inset-0 flex flex-col bg-background">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-2">
<button
type="button"
onClick={() => setRightView(null)}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
返回写作
</button>
{rightView.kind === 'notebook' && (
<button
type="button"
onClick={() => setRightView({ kind: 'allSpaces' })}
className="ml-1 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
所有空间
</button>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{rightView.kind === 'notebook' ? (
// 笔记本详情页内部用 ChatBox(useArtifacts) → 需 ChatRuntime 提供 Provider。
<ChatRuntime>
<StudioNotebookDetailPage key={rightView.id} notebookId={rightView.id} />
</ChatRuntime>
) : (
<div className="h-full overflow-y-auto">
<StudioNotebooksPage
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
/>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
export default function AIWritingPage({ export default function AIWritingPage({
hideAdvancedFields = false, hideAdvancedFields = false,
initialNotebookId, initialNotebookId,
@@ -227,12 +309,19 @@ export default function AIWritingPage({
<CanvasUIProvider> <CanvasUIProvider>
<AIWritingHistoryProvider> <AIWritingHistoryProvider>
<AIWritingProvider> <AIWritingProvider>
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}> <WritingFormProvider
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg"> <div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<div className="flex-1 min-h-0 overflow-hidden"> <AIWritingBody
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} /> hideAdvancedFields={hideAdvancedFields}
</div> initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
/>
</div> </div>
</TooltipProvider> </TooltipProvider>
</WritingFormProvider> </WritingFormProvider>
@@ -0,0 +1,300 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { SetupWritingApprovalPayload } from '@/components/workspace/messages';
import { useResearchTemplates } from '../hooks/useResearchTemplates';
import { useArticleTypes } from '../hooks/useArticleTypes';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import type {
CreateResearchTemplateRequest,
ResearchTemplate,
} from '../api/ai-writing-research-templates';
const LIST_PATH = '/page/canvas/research-templates';
const INPUT_CLASS =
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50';
const TEXTAREA_CLASS =
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none';
const LABEL_CLASS = 'mb-1 block text-xs font-medium text-muted-foreground';
interface FormState {
name: string;
description: string;
article_type_key: string;
outline: string;
material_source: 'general' | 'notebook';
word_count: string;
audience: string;
strict_mode: boolean;
topic: string;
sort_order: string;
}
const EMPTY_FORM: FormState = {
name: '',
description: '',
article_type_key: '',
outline: '',
material_source: 'general',
word_count: '800',
audience: '',
strict_mode: false,
topic: '',
sort_order: '0',
};
function toForm(t: ResearchTemplate): FormState {
return {
name: t.name,
description: t.description ?? '',
article_type_key: t.article_type_key ?? '',
outline: t.outline ?? '',
material_source: t.material_source,
word_count: String(t.word_count),
audience: t.audience ?? '',
strict_mode: t.strict_mode,
topic: t.topic ?? '',
sort_order: String(t.sort_order),
};
}
function toPayload(form: FormState): CreateResearchTemplateRequest {
return {
name: form.name.trim(),
description: form.description.trim() || null,
article_type_key: form.article_type_key || null,
outline: form.outline.trim() || null,
material_source: form.material_source,
word_count: Math.max(100, parseInt(form.word_count, 10) || 800),
audience: form.audience.trim() || null,
strict_mode: form.strict_mode,
topic: form.topic.trim() || null,
sort_order: parseInt(form.sort_order, 10) || 0,
};
}
export default function ResearchTemplateEditorPage() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const editing = !!id;
const { templates, refresh, create, update } = useResearchTemplates();
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
const [form, setForm] = useState<FormState>(EMPTY_FORM);
// 新建即就绪;编辑需等列表加载后回填一次。
const [initialized, setInitialized] = useState(!editing);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
void refresh();
void refreshTypes();
}, [refresh, refreshTypes]);
useEffect(() => {
if (!editing || initialized) return;
const t = templates.find((x) => x.id === id);
if (t) {
setForm(toForm(t));
setInitialized(true);
}
}, [editing, initialized, templates, id]);
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
// 问答助手「应用」→ 把 setup_writing 建议映射进模板表单字段(与 AI 写作同款机制)。
const applyProposalToForm = (p: SetupWritingApprovalPayload) =>
setForm((f) => ({
...f,
name: f.name || (p.user_intent?.trim() ? p.user_intent.trim().slice(0, 20) : f.name),
topic: p.user_intent?.trim() || f.topic,
article_type_key: p.article_type?.trim() || f.article_type_key,
outline: p.outline?.trim() || f.outline,
word_count:
typeof p.word_count_target === 'number' && p.word_count_target > 0
? String(p.word_count_target)
: f.word_count,
audience: p.target_audience?.trim() || f.audience,
strict_mode:
p.writing_mode === 'strict' ? true : p.writing_mode === 'loose' ? false : f.strict_mode,
material_source: p.material_source
? p.material_source === 'notebook'
? 'notebook'
: 'general'
: f.material_source,
}));
const handleSave = async () => {
if (!form.name.trim()) {
setFormError('请填写模板名称');
return;
}
setSaving(true);
setFormError(null);
try {
const payload = toPayload(form);
if (editing && id) await update(id, payload);
else await create(payload);
navigate(LIST_PATH);
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
setSaving(false);
}
};
return (
<div className="flex h-full flex-col bg-background">
<header className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(LIST_PATH)}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-base font-semibold">
{editing ? '编辑研究模板' : '新建研究模板'}
</h1>
<p className="text-xs text-muted-foreground">
左侧用问答描述需求,自动填好右侧字段;也可手动编辑。
</p>
</div>
</div>
<div className="flex items-center gap-2">
{formError && <span className="text-sm text-destructive">{formError}</span>}
<Button variant="outline" size="sm" onClick={() => navigate(LIST_PATH)} disabled={saving}>
取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
{saving && <Loader2 className="size-4 animate-spin" />}
保存
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
{/* 左:模板助手问答(同 AI 写作 setup 机制)→ 应用到右侧表单。问答区更宽(约 7:3)。 */}
<div className="min-w-0 flex-[7] border-r border-border">
<WritingSetupChat
onApprove={applyProposalToForm}
title="模板助手"
hint="描述你想要的研究模板,我来帮你填好右侧字段"
/>
</div>
{/* 右:模板表单 */}
<div className="grid min-w-0 flex-[3] grid-cols-2 content-start gap-4 overflow-y-auto p-6">
<div className="col-span-2">
<label className={LABEL_CLASS}>模板名称 *</label>
<input
className={INPUT_CLASS}
value={form.name}
onChange={(e) => set('name', e.target.value)}
placeholder="如:深度舆情分析"
/>
</div>
<div className="col-span-2">
<label className={LABEL_CLASS}>描述</label>
<input
className={INPUT_CLASS}
value={form.description}
onChange={(e) => set('description', e.target.value)}
placeholder="模板用途简述"
/>
</div>
<div>
<label className={LABEL_CLASS}>文章类型</label>
<select
className={INPUT_CLASS}
value={form.article_type_key}
onChange={(e) => set('article_type_key', e.target.value)}
>
<option value="">不设</option>
{articleTypes.map((t) => (
<option key={t.key} value={t.key}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS}>搜索来源</label>
<select
className={INPUT_CLASS}
value={form.material_source}
onChange={(e) => set('material_source', e.target.value as 'general' | 'notebook')}
>
<option value="general">通用检索</option>
<option value="notebook">我的空间(笔记本)</option>
</select>
</div>
<div>
<label className={LABEL_CLASS}>目标字数</label>
<input
type="number"
className={INPUT_CLASS}
value={form.word_count}
onChange={(e) => set('word_count', e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS}>目标受众</label>
<input
className={INPUT_CLASS}
value={form.audience}
onChange={(e) => set('audience', e.target.value)}
placeholder="如:决策者"
/>
</div>
<div className="col-span-2">
<label className={LABEL_CLASS}>主题 / 意图</label>
<textarea
className={TEXTAREA_CLASS}
rows={2}
value={form.topic}
onChange={(e) => set('topic', e.target.value)}
placeholder="点击模板时预填到写作意图输入框"
/>
</div>
<div className="col-span-2">
<label className={LABEL_CLASS}>默认大纲</label>
<textarea
className={TEXTAREA_CLASS}
rows={6}
value={form.outline}
onChange={(e) => set('outline', e.target.value)}
placeholder={'一、…\n二、…\n三、…'}
/>
</div>
<div className="flex items-center gap-2">
<input
id="rt-strict"
type="checkbox"
checked={form.strict_mode}
onChange={(e) => set('strict_mode', e.target.checked)}
className="size-4"
/>
<label htmlFor="rt-strict" className="text-sm">
严格写作模式
</label>
</div>
<div>
<label className={LABEL_CLASS}>排序(小在前)</label>
<input
type="number"
className={INPUT_CLASS}
value={form.sort_order}
onChange={(e) => set('sort_order', e.target.value)}
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, FileText, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useResearchTemplates } from '../hooks/useResearchTemplates';
import { useArticleTypes } from '../hooks/useArticleTypes';
const NEW_PATH = '/page/canvas/research-templates/new';
const editPath = (id: string) => `/page/canvas/research-templates/edit/${id}`;
export default function ResearchTemplatesConfigPage() {
const navigate = useNavigate();
const { templates, loading, error, refresh, remove } = useResearchTemplates();
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
void refresh();
void refreshTypes();
}, [refresh, refreshTypes]);
const handleDelete = async () => {
if (!deleteTargetId) return;
setDeleting(true);
try {
await remove(deleteTargetId);
setDeleteTargetId(null);
} finally {
setDeleting(false);
}
};
const articleTypeLabel = (key: string | null) =>
key ? articleTypes.find((t) => t.key === key)?.label ?? key : '—';
return (
<div className="mx-auto h-full w-full max-w-4xl overflow-y-auto p-6">
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-lg font-semibold">研究模板配置</h1>
<p className="text-xs text-muted-foreground">
配置课题研究左栏的研究模板;点击模板时一键预填写作表单。
</p>
</div>
</div>
<Button size="sm" onClick={() => navigate(NEW_PATH)}>
<Plus className="size-4" />
新建模板
</Button>
</div>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
{loading ? (
<div className="flex justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : templates.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">
暂无研究模板,点击右上角「新建模板」创建。
</p>
) : (
<div className="flex flex-col gap-2">
{templates.map((t) => (
<div
key={t.id}
className="flex items-start gap-3 rounded-xl border border-border bg-card p-4"
>
<div className="bg-primary/10 text-primary mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg">
<FileText className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{t.name}</span>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{articleTypeLabel(t.article_type_key)}
</span>
</div>
{t.description && (
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{t.description}</p>
)}
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground/80">
<span>来源:{t.material_source === 'notebook' ? '我的空间' : '通用检索'}</span>
<span>字数:{t.word_count}</span>
{t.audience && <span>受众:{t.audience}</span>}
<span>{t.strict_mode ? '严格模式' : '宽松模式'}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate(editPath(t.id))}
title="编辑"
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDeleteTargetId(t.id)}
title="删除"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 删除确认 */}
<Dialog open={!!deleteTargetId} onOpenChange={(v) => !deleting && !v && setDeleteTargetId(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>删除研究模板</DialogTitle>
</DialogHeader>
<p className="py-2 text-sm text-muted-foreground">确定删除该研究模板吗?此操作不可恢复。</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTargetId(null)} disabled={deleting}>
取消
</Button>
<Button variant="destructive" onClick={() => void handleDelete()} disabled={deleting}>
{deleting && <Loader2 className="size-4 animate-spin" />}
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -4,6 +4,8 @@ import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage"; import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage"; import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage"; import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
import ResearchTemplateEditorPage from "../pages/ResearchTemplateEditorPage";
export default function OpenCanvasRoutes() { export default function OpenCanvasRoutes() {
return ( return (
@@ -11,6 +13,9 @@ export default function OpenCanvasRoutes() {
<Route index element={<CanvasHomePage />} /> <Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} /> <Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} /> <Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
<Route path="research-templates/edit/:id" element={<ResearchTemplateEditorPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} /> <Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes> </Routes>
); );
+26 -4
View File
@@ -31,15 +31,31 @@ import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
export default function AgentChatPage() { export default function AgentChatPage({
embeddedAgentId,
chatBasePath,
}: {
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
embeddedAgentId?: string;
/** 智能体对话所在的路由前缀(到 `/chats` 为止,不含 thread_id)。
* 默认 `${routeBase}/agents/${agent_id}/chats`;任务深链把对话嵌进「任务管理」布局时,
* 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`,
* 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */
chatBasePath?: string;
} = {}) {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter(); const router = useRouter();
const { agent_id } = useParams<{ const { agent_id: agentIdFromPath } = useParams<{
agent_id: string; agent_id: string;
}>(); }>();
const agent_id = embeddedAgentId ?? agentIdFromPath;
const isEmbedded = !!embeddedAgentId;
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`;
// If ChatPage stashed an opening message for this agent, skip the landing // If ChatPage stashed an opening message for this agent, skip the landing
// screen on first render so the user doesn't see a flash of the welcome UI // screen on first render so the user doesn't see a flash of the welcome UI
@@ -92,7 +108,7 @@ export default function AgentChatPage() {
history.replaceState( history.replaceState(
null, null,
"", "",
`${window.location.pathname}${window.location.search}#${routeBase}/agents/${agent_id}/chats/${createdThreadId}`, `${window.location.pathname}${window.location.search}#${agentChatsBase}/${createdThreadId}`,
); );
}, },
onFinish: (state) => { onFinish: (state) => {
@@ -208,8 +224,12 @@ export default function AgentChatPage() {
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push( router.push(
`/page/workspace/agents/${agent_id}/chats/new`, isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
); );
}} }}
> >
@@ -285,6 +305,8 @@ export default function AgentChatPage() {
thread={thread} thread={thread}
paddingBottom={showFollowups ? 96 : 24} paddingBottom={showFollowups ? 96 : 24}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
// 输入框在流内(MessageList 下方),按钮贴对话区底部即可。
scrollButtonClassName="bottom-2"
onClarificationSubmit={handleClarificationSubmit} onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => handleSubmit({ text, files: [] })} onEditResubmit={(text) => handleSubmit({ text, files: [] })}
/> />
@@ -0,0 +1,99 @@
"use client";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import {
AgentModuleSidebar,
type AgentModuleConfig,
} from "@/components/workspace/agents/agent-module-sidebar";
import { AgentModuleSettingsDialog } from "@/components/workspace/agents/agent-module-settings-dialog";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base";
import { useAgentModuleConfig } from "@/core/system-settings/hooks";
import AgentChatPage from "./AgentChatPage";
import { ChatRuntime } from "./WorkspaceRoutes";
/**
* 「按类型聚合」模块页(需求 0616 #3 zzJC / #4 zzXD,重做)。
*
* 一个**常驻外壳**(左栏:按类型分组智能体 + 聊天记录 + admin 设置)包裹右侧对话;
* 点击智能体只在模块内切换对话(左栏不卸载,无整页跳转)。模块自带独立路由
* ``/page/strategy/qa/{moduleKey}``,并以该路径作为 RouteBase,使内嵌的 AgentChatPage
* 所有跳转(新建对话 / onStart 改写 hash)都留在模块子树内。
*/
export function AgentModuleRoutes({ module }: { module: AgentModuleConfig }) {
const base = `/page/strategy/qa/${module.key}`;
return (
<RouteBaseProvider value={base}>
<Routes>
<Route index element={<ModuleDefaultRedirect module={module} />} />
<Route
path="agents/:agent_id/chats/:thread_id"
element={<ModuleChatShell module={module} />}
/>
</Routes>
</RouteBaseProvider>
);
}
/** 进入模块默认落地:配置类型下的第一个智能体;无配置回退到模块默认体。 */
function ModuleDefaultRedirect({ module }: { module: AgentModuleConfig }) {
const { agents, isLoading: agentsLoading } = useAgents();
const { data: config, isLoading: configLoading } = useAgentModuleConfig(
module.key,
);
if (agentsLoading || configLoading) {
return (
<div className="flex size-full items-center justify-center">
<Loader2 className="text-muted-foreground size-5 animate-spin" />
</div>
);
}
const tagIds = config?.tag_ids ?? [];
let targetId = module.fallbackAgentId;
if (tagIds.length > 0) {
const allowed = new Set(tagIds);
const first = agents.find((a) =>
(a.tags ?? []).some((t) => allowed.has(t.id)),
);
if (first) targetId = first.id;
}
return <Navigate to={`agents/${targetId}/chats/new`} replace />;
}
/** 模块外壳:常驻左栏 + 内嵌当前智能体对话(按 agent_id 重建对话区,左栏保持挂载)。 */
function ModuleChatShell({ module }: { module: AgentModuleConfig }) {
const { agent_id } = useParams<{ agent_id: string }>();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="flex size-full min-h-0 overflow-hidden">
<AgentModuleSidebar
moduleKey={module.key}
activeAgentId={agent_id}
onOpenSettings={isAdmin ? () => setSettingsOpen(true) : undefined}
/>
<div className="min-h-0 min-w-0 flex-1">
<ChatRuntime>
<AgentChatPage key={agent_id} />
</ChatRuntime>
</div>
{isAdmin && (
<AgentModuleSettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
moduleKey={module.key}
moduleTitle={module.title}
/>
)}
</div>
);
}
@@ -31,10 +31,13 @@ import {
type BusinessMapping, type BusinessMapping,
} from "@/strategy-components/api/business-mapping"; } from "@/strategy-components/api/business-mapping";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
export default function BusinessChainMappingPage() { export default function BusinessChainMappingPage() {
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
// 整片脱敏:业务链条映射页展示文本(链条名/描述/席位名/标题)。输入框 Hook 自动跳过。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
const [rows, setRows] = useState<BusinessMapping[]>([]); const [rows, setRows] = useState<BusinessMapping[]>([]);
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
@@ -160,7 +163,7 @@ export default function BusinessChainMappingPage() {
]; ];
return ( return (
<div className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}> <div ref={dzRef} className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
<div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8"> <div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8">
{/* ── 页头 banner ── */} {/* ── 页头 banner ── */}
+14 -4
View File
@@ -34,7 +34,7 @@ import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-rout
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PanelLeftOpen } from "lucide-react"; import { PanelLeftOpen } from "lucide-react";
import { useAgentSelector } from "@/core/agents"; import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
@@ -130,6 +130,10 @@ export default function ChatPage() {
const browserContextAutoSubmitRef = useRef<string | null>(null); const browserContextAutoSubmitRef = useRef<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } = const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector(); useAgentSelector();
// 已选 agent 的存在性校验需要更宽的来源:侧边栏可选未配置进管理弹窗的 agent
// (用户自己的智能体),这些也算有效,故把「我的 / 广场」一并纳入校验集(缓存共享)。
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const { selectedArtifact } = useArtifacts(); const { selectedArtifact } = useArtifacts();
useSpecificChatMode(); useSpecificChatMode();
@@ -142,7 +146,7 @@ export default function ChatPage() {
setHasStartedConversation(false); setHasStartedConversation(false);
setPromptPrefixOn(false); setPromptPrefixOn(false);
// Pre-select the agent the user last started a conversation with so a // Pre-select the agent the user last started a conversation with so a
// fresh chat defaults to it. Validated against the selector list below. // fresh chat defaults to it. Validated against the known agent list below.
setSelectedAgentId(getLastAgentId()); setSelectedAgentId(getLastAgentId());
} }
}, [isNewThread, threadId]); }, [isNewThread, threadId]);
@@ -152,10 +156,14 @@ export default function ChatPage() {
// dead agent. Wait for the list to load before deciding. // dead agent. Wait for the list to load before deciding.
useEffect(() => { useEffect(() => {
if (selectorLoading || !selectedAgentId) return; if (selectorLoading || !selectedAgentId) return;
if (!selectorAgents.some((a) => a.id === selectedAgentId)) { // 校验集 = 管理弹窗 ∪ 我的 ∪ 广场;任一存在即视为有效(含用户未配置进管理弹窗
// 的自有智能体)。三者都还没加载出来时不做清除,避免把恢复值误删。
const known = [...selectorAgents, ...mineAgents, ...squareAgents];
if (known.length === 0) return;
if (!known.some((a) => a.id === selectedAgentId)) {
setSelectedAgentId(null); setSelectedAgentId(null);
} }
}, [selectedAgentId, selectorAgents, selectorLoading]); }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
@@ -493,6 +501,8 @@ export default function ChatPage() {
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom} paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
scrollButtonClassName="bottom-40"
onClarificationSubmit={handleClarificationSubmit} onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => onEditResubmit={(text) =>
handleSubmit({ text, files: [] }) handleSubmit({ text, files: [] })
+33 -33
View File
@@ -3,7 +3,6 @@
import { import {
BookMarkedIcon, BookMarkedIcon,
BrainCircuitIcon, BrainCircuitIcon,
CheckIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
} from "lucide-react"; } from "lucide-react";
@@ -23,7 +22,9 @@ import {
} from "@/components/workspace/messages"; } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector } from "@/core/agents"; import { useAgentSelector } from "@/core/agents";
import { getAccessToken } from "@/core/auth"; import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -369,7 +370,7 @@ export default function IframeChatPage() {
}, [handleInterrupt, handleSubmit]); }, [handleInterrupt, handleSubmit]);
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
const bottomBarHeightClass = "pb-28"; const bottomBarHeightClass = "pb-24";
const modeButtons = [ const modeButtons = [
{ {
key: "writing", key: "writing",
@@ -377,8 +378,7 @@ export default function IframeChatPage() {
icon: FileTextIcon, icon: FileTextIcon,
active: !!settings.context.writing_mode, active: !!settings.context.writing_mode,
onClick: toggleWritingMode, onClick: toggleWritingMode,
activeClassName: activeClassName: "text-amber-600 hover:text-amber-700",
"border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100",
}, },
{ {
key: "memory", key: "memory",
@@ -386,8 +386,7 @@ export default function IframeChatPage() {
icon: BrainCircuitIcon, icon: BrainCircuitIcon,
active: settings.context.memory_injection_enabled ?? true, active: settings.context.memory_injection_enabled ?? true,
onClick: toggleMemoryInjection, onClick: toggleMemoryInjection,
activeClassName: activeClassName: "text-blue-600 hover:text-blue-700",
"border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100",
}, },
{ {
key: "references", key: "references",
@@ -396,8 +395,7 @@ export default function IframeChatPage() {
active: ragModeEnabled, active: ragModeEnabled,
onClick: toggleRagMode, onClick: toggleRagMode,
disabled: ragModeLocked, disabled: ragModeLocked,
activeClassName: activeClassName: "text-emerald-600 hover:text-emerald-700",
"border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100",
}, },
{ {
key: "pro", key: "pro",
@@ -405,8 +403,7 @@ export default function IframeChatPage() {
icon: GraduationCapIcon, icon: GraduationCapIcon,
active: proModeEnabled, active: proModeEnabled,
onClick: toggleProMode, onClick: toggleProMode,
activeClassName: activeClassName: "text-violet-600 hover:text-violet-700",
"border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100",
}, },
] as const; ] as const;
@@ -496,29 +493,32 @@ export default function IframeChatPage() {
</div> </div>
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-40 border-t bg-background/95 backdrop-blur"> <div className="absolute right-0 bottom-0 left-0 z-40 border-t bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-2 px-4 py-4"> <TooltipProvider delayDuration={250}>
{modeButtons.map((button) => { <div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-0 px-0 py-1">
const Icon = button.icon; {modeButtons.map((button) => {
return ( const Icon = button.icon;
<Button return (
key={button.key} <Tooltip key={button.key} content={button.label}>
type="button" <Button
variant="outline" type="button"
disabled={button.disabled || isUploading} variant="ghost"
onClick={button.onClick} size="icon"
className={cn( aria-label={button.label}
"gap-1.5 rounded-full border border-transparent bg-muted/40 text-muted-foreground shadow-none hover:bg-muted hover:text-foreground", disabled={button.disabled || isUploading}
button.active && button.activeClassName, onClick={button.onClick}
button.disabled && "cursor-not-allowed opacity-80", className={cn(
)} "size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
> button.active && button.activeClassName,
{button.active && <CheckIcon className="size-3.5" />} button.disabled && "cursor-not-allowed opacity-80",
<Icon className="size-3.5" /> )}
<span className="text-xs font-normal">{button.label}</span> >
</Button> <Icon className="size-4" />
); </Button>
})} </Tooltip>
</div> );
})}
</div>
</TooltipProvider>
</div> </div>
</main> </main>
</div> </div>
+86 -2
View File
@@ -16,6 +16,12 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState"; import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
import {
ExtractTemplateManageDialog,
FolderManageDialog,
FolderSelect,
TemplateSelect,
} from "@/components/wiki/KnowledgeManage";
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar"; import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
import { WikiContent } from "@/components/wiki/WikiContent"; import { WikiContent } from "@/components/wiki/WikiContent";
import { WikiHeader } from "@/components/wiki/WikiHeader"; import { WikiHeader } from "@/components/wiki/WikiHeader";
@@ -28,6 +34,7 @@ import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { import {
useKnowledgeActions, useKnowledgeActions,
useKnowledgeDuplicates, useKnowledgeDuplicates,
useKnowledgeFolders,
useKnowledgeNote, useKnowledgeNote,
useKnowledgeNotes, useKnowledgeNotes,
useNoteWikilinks, useNoteWikilinks,
@@ -70,11 +77,13 @@ function CreateNoteDialog({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [folder, setFolder] = useState<string | null>(null);
const reset = () => { const reset = () => {
setTitle(""); setTitle("");
setTags(""); setTags("");
setContent(""); setContent("");
setFolder(null);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -91,6 +100,7 @@ function CreateNoteDialog({
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean), .filter(Boolean),
status: "approved", status: "approved",
folder,
}); });
toast.success("知识已创建"); toast.success("知识已创建");
onOpenChange(false); onOpenChange(false);
@@ -119,6 +129,10 @@ function CreateNoteDialog({
value={tags} value={tags}
onChange={(e) => setTags(e.target.value)} onChange={(e) => setTags(e.target.value)}
/> />
<label className="flex items-center justify-between gap-2 text-sm">
目录
<FolderSelect value={folder} onChange={setFolder} triggerClassName="w-[14rem]" />
</label>
<Textarea <Textarea
placeholder="Markdown 正文" placeholder="Markdown 正文"
rows={12} rows={12}
@@ -152,6 +166,8 @@ function BatchImportDialog({
const [skipExisting, setSkipExisting] = useState(true); const [skipExisting, setSkipExisting] = useState(true);
// Optional model override for extraction (null = configured default). // Optional model override for extraction (null = configured default).
const [model, setModel] = useState<string | null>(null); const [model, setModel] = useState<string | null>(null);
const [template, setTemplate] = useState<string | null>(null);
const [folder, setFolder] = useState<string | null>(null);
const handleRun = async () => { const handleRun = async () => {
try { try {
@@ -160,8 +176,21 @@ function BatchImportDialog({
skip_existing: skipExisting, skip_existing: skipExisting,
status: "approved", status: "approved",
include_sources: true, include_sources: true,
// Run in the background so the request returns immediately — a large
// batch otherwise blocks past the gateway timeout and shows "request
// failed". Threads stream into the list as they finish.
background: true,
model_name: model, model_name: model,
template_id: template,
folder,
}); });
if (result.queued) {
toast.success(
`已转入后台导入 ${result.processed} 个线程,完成后会陆续出现在左侧列表(可稍后刷新查看)`,
);
onOpenChange(false);
return;
}
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`; const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
if (result.llm_fallback && result.llm_fallback > 0) { if (result.llm_fallback && result.llm_fallback > 0) {
// The model failed on some threads — they were saved via rule-based // The model failed on some threads — they were saved via rule-based
@@ -217,6 +246,25 @@ function BatchImportDialog({
triggerClassName="h-9 w-[11rem]" triggerClassName="h-9 w-[11rem]"
/> />
</label> </label>
<label className="flex items-center justify-between gap-2 text-sm">
抽取模板
<TemplateSelect
value={template}
onChange={setTemplate}
scope="thread"
disabled={batchIngest.isPending}
triggerClassName="w-[11rem]"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
归入目录
<FolderSelect
value={folder}
onChange={setFolder}
disabled={batchIngest.isPending}
triggerClassName="w-[11rem]"
/>
</label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -244,12 +292,16 @@ function ImportFileDialog({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [model, setModel] = useState<string | null>(null); const [model, setModel] = useState<string | null>(null);
const [template, setTemplate] = useState<string | null>(null);
const [folder, setFolder] = useState<string | null>(null);
const reset = () => { const reset = () => {
setFile(null); setFile(null);
setTitle(""); setTitle("");
setTags(""); setTags("");
setModel(null); setModel(null);
setTemplate(null);
setFolder(null);
}; };
const handleImport = async () => { const handleImport = async () => {
@@ -265,6 +317,8 @@ function ImportFileDialog({
tags: tags.trim() || undefined, tags: tags.trim() || undefined,
status: "approved", status: "approved",
model_name: model, model_name: model,
template_id: template,
folder,
}, },
}); });
const count = result.notes?.length ?? 1; const count = result.notes?.length ?? 1;
@@ -330,6 +384,25 @@ function ImportFileDialog({
triggerClassName="h-9 w-[11rem]" triggerClassName="h-9 w-[11rem]"
/> />
</label> </label>
<label className="flex items-center justify-between gap-2 text-sm">
抽取模板
<TemplateSelect
value={template}
onChange={setTemplate}
scope="document"
disabled={ingestFile.isPending}
triggerClassName="w-[11rem]"
/>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
归入目录
<FolderSelect
value={folder}
onChange={setFolder}
disabled={ingestFile.isPending}
triggerClassName="w-[11rem]"
/>
</label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -478,6 +551,8 @@ export default function KnowledgeBasePage() {
const [batchOpen, setBatchOpen] = useState(false); const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false); const [dedupOpen, setDedupOpen] = useState(false);
const [foldersOpen, setFoldersOpen] = useState(false);
const [templatesOpen, setTemplatesOpen] = useState(false);
const [vaultPath, setVaultPath] = useState<string | null>(null); const [vaultPath, setVaultPath] = useState<string | null>(null);
const params = useMemo( const params = useMemo(
@@ -485,14 +560,19 @@ export default function KnowledgeBasePage() {
keyword: debouncedKeyword || undefined, keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType, source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status, status: status === "all" ? undefined : status,
limit: 500, limit: 1000,
}), }),
[debouncedKeyword, sourceType, status], [debouncedKeyword, sourceType, status],
); );
const { data, isLoading } = useKnowledgeNotes(params); const { data, isLoading } = useKnowledgeNotes(params);
const { data: folders = [] } = useKnowledgeFolders();
const notes = data?.items ?? []; const notes = data?.items ?? [];
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]); const folderPaths = useMemo(() => folders.map((f) => f.path), [folders]);
const tree = useMemo(
() => buildKnowledgeTree(notes, folderPaths),
[notes, folderPaths],
);
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId); const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId); const { data: wikiLinks } = useNoteWikilinks(noteId);
@@ -571,6 +651,8 @@ export default function KnowledgeBasePage() {
onDedup={() => setDedupOpen(true)} onDedup={() => setDedupOpen(true)}
onGraph={() => navigate("/page/workspace/knowledge/graph")} onGraph={() => navigate("/page/workspace/knowledge/graph")}
onCreate={() => setCreateOpen(true)} onCreate={() => setCreateOpen(true)}
onManageFolders={() => setFoldersOpen(true)}
onManageTemplates={() => setTemplatesOpen(true)}
/> />
<div <div
@@ -636,6 +718,8 @@ export default function KnowledgeBasePage() {
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} /> <ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} /> <BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} /> <DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
<FolderManageDialog open={foldersOpen} onOpenChange={setFoldersOpen} />
<ExtractTemplateManageDialog open={templatesOpen} onOpenChange={setTemplatesOpen} />
<VaultPageDialog <VaultPageDialog
path={vaultPath} path={vaultPath}
onOpenChange={(open) => { onOpenChange={(open) => {
+34
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useNavigate } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@@ -11,6 +12,11 @@ import {
readExternalLoginParams, readExternalLoginParams,
resolveThemeFromQuery, resolveThemeFromQuery,
} from '@/core/auth/external-login'; } from '@/core/auth/external-login';
import {
fetchCopTaskDetail,
persistCopTaskDetail,
resolveTaskDeeplinkTarget,
} from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import { import {
@@ -34,6 +40,7 @@ type LoginStatus = 'loading' | 'success' | 'error';
export default function LoginPage() { export default function LoginPage() {
const { username } = useParams<{ username?: string }>(); const { username } = useParams<{ username?: string }>();
const router = useRouter(); const router = useRouter();
const navigate = useNavigate();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const sidebarEnabled = useMemo( const sidebarEnabled = useMemo(
@@ -60,6 +67,9 @@ export default function LoginPage() {
const gatePassword = searchParams.get('password') ?? ''; const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开) // token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get('authToken')?.trim() ?? ''; const authToken = searchParams.get('authToken')?.trim() ?? '';
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
const taskId = searchParams.get('taskId')?.trim() ?? '';
const goPath = searchParams.get('goPath')?.trim() ?? '';
const externalParams = useMemo( const externalParams = useMemo(
() => ({ () => ({
theme: searchParams.get('theme') ?? '', theme: searchParams.get('theme') ?? '',
@@ -145,6 +155,27 @@ export default function LoginPage() {
persistAccessToken(accessToken); persistAccessToken(accessToken);
persistSidebarEnabled(sidebarEnabled); persistSidebarEnabled(sidebarEnabled);
setStatus('success'); setStatus('success');
// 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页;
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。
if (taskId && goPath) {
try {
const detail = await fetchCopTaskDetail(taskId);
if (cancelled) return;
if (detail) {
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail);
if (cancelled) return;
if (target) {
navigate(target.path, { replace: true, state: target.state });
return;
}
}
} catch {
/* 深链失败不阻断登录,落默认首页 */
}
}
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath); router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
} catch (error) { } catch (error) {
if (cancelled) { if (cancelled) {
@@ -164,6 +195,7 @@ export default function LoginPage() {
}, [ }, [
resolvedUsername, resolvedUsername,
router, router,
navigate,
sidebarEnabled, sidebarEnabled,
yUserId, yUserId,
toolToken, toolToken,
@@ -171,6 +203,8 @@ export default function LoginPage() {
onlineTicket, onlineTicket,
authToken, authToken,
gatePassword, gatePassword,
taskId,
goPath,
]); ]);
return ( return (
@@ -2,6 +2,9 @@ import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import { getNotebookApiBaseURL } from '@/core/config'; import { getNotebookApiBaseURL } from '@/core/config';
import { useTheme, type ThemeType } from '@/contexts/ThemeContext';
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue'];
interface NotebookTokenData { interface NotebookTokenData {
doc_ids: number[]; doc_ids: number[];
@@ -13,6 +16,17 @@ interface NotebookTokenData {
export default function NotebookAIWritingPage() { export default function NotebookAIWritingPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const themekey = searchParams.get('themekey');
const { setTheme } = useTheme();
// 跟随调用方传入的 themekey 切换主题;缺省或非法值兜底为 light
useEffect(() => {
const next = themekey && VALID_THEMES.includes(themekey as ThemeType)
? (themekey as ThemeType)
: 'light';
setTheme(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themekey]);
const [data, setData] = useState<NotebookTokenData | null>(null); const [data, setData] = useState<NotebookTokenData | null>(null);
const [spaceName, setSpaceName] = useState<string | null>(null); const [spaceName, setSpaceName] = useState<string | null>(null);
+5
View File
@@ -0,0 +1,5 @@
import { PositionManagementPage } from "@/components/workspace/admin/position-management-page";
export default function PositionsPage() {
return <PositionManagementPage />;
}
@@ -8,11 +8,15 @@ import {
Loader2Icon, Loader2Icon,
PencilIcon, PencilIcon,
PlusIcon, PlusIcon,
TagIcon,
Trash2Icon, Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ResourceTagDialog } from "@/components/workspace/tags/resource-tag-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { import {
Dialog, Dialog,
@@ -24,9 +28,9 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
useAdminRecommendedQuestions,
useCreateRecommendedQuestion, useCreateRecommendedQuestion,
useDeleteRecommendedQuestion, useDeleteRecommendedQuestion,
useRecommendedQuestions,
useReorderRecommendedQuestions, useReorderRecommendedQuestions,
useUpdateRecommendedQuestion, useUpdateRecommendedQuestion,
} from "@/core/recommended-questions/hooks"; } from "@/core/recommended-questions/hooks";
@@ -65,7 +69,7 @@ export default function RecommendedQuestionsPage({
embedded?: boolean; embedded?: boolean;
} = {}) { } = {}) {
const { theme } = useTheme(); const { theme } = useTheme();
const { questions, isLoading, error } = useRecommendedQuestions(); const { questions, isLoading, error } = useAdminRecommendedQuestions();
const createMutation = useCreateRecommendedQuestion(); const createMutation = useCreateRecommendedQuestion();
const updateMutation = useUpdateRecommendedQuestion(); const updateMutation = useUpdateRecommendedQuestion();
const deleteMutation = useDeleteRecommendedQuestion(); const deleteMutation = useDeleteRecommendedQuestion();
@@ -75,11 +79,31 @@ export default function RecommendedQuestionsPage({
const [formTitle, setFormTitle] = useState(""); const [formTitle, setFormTitle] = useState("");
const [formContent, setFormContent] = useState(""); const [formContent, setFormContent] = useState("");
const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null); const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null);
const [tagTarget, setTagTarget] = useState<RecommendedQuestion | null>(null);
// drag state // drag state
const [dragIndex, setDragIndex] = useState<number | null>(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// 名称 / 标签搜索
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const isFiltering = search.trim() !== "" || tagIds.length > 0;
const needle = search.trim().toLowerCase();
const visibleQuestions = isFiltering
? questions.filter((q) => {
const matchesText =
needle === "" ||
q.title.toLowerCase().includes(needle) ||
q.content.toLowerCase().includes(needle);
const matchesTags =
tagIds.length === 0 ||
(q.tags ?? []).some((t) => tagIds.includes(t.id));
return matchesText && matchesTags;
})
: questions;
function openAdd() { function openAdd() {
setFormTitle(""); setFormTitle("");
setFormContent(""); setFormContent("");
@@ -200,6 +224,16 @@ export default function RecommendedQuestionsPage({
</Button> </Button>
</div> </div>
{!isLoading && !error && questions.length > 0 && (
<TagSearchBar
targetType="recommended_question"
searchPlaceholder="搜索标题 / 内容"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
/>
)}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" /> <Loader2Icon className="text-muted-foreground size-6 animate-spin" />
@@ -216,15 +250,19 @@ export default function RecommendedQuestionsPage({
添加第一个问题 添加第一个问题
</Button> </Button>
</div> </div>
) : visibleQuestions.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-sm dark:text-[#7FACF2] dark:!border-[#213F62]">
<p>没有匹配的推荐问题</p>
</div>
) : ( ) : (
<div className="flex flex-col gap-2 "> <div className="flex flex-col gap-2 ">
{questions.map((q, index) => ( {visibleQuestions.map((q, index) => (
<div <div
key={q.id} key={q.id}
draggable draggable={!isFiltering}
onDragStart={() => handleDragStart(index)} onDragStart={() => !isFiltering && handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)} onDragOver={(e) => !isFiltering && handleDragOver(e, index)}
onDrop={() => handleDrop(index)} onDrop={() => !isFiltering && handleDrop(index)}
onDragEnd={() => { onDragEnd={() => {
setDragIndex(null); setDragIndex(null);
setDragOverIndex(null); setDragOverIndex(null);
@@ -235,7 +273,13 @@ export default function RecommendedQuestionsPage({
: "border-[#e4e4e7]" : "border-[#e4e4e7]"
}`} }`}
> >
<GripVerticalIcon className="text-muted-foreground/40 mt-0.5 size-4 shrink-0 cursor-grab active:cursor-grabbing dark:text-[#566B7F]" /> <GripVerticalIcon
className={`text-muted-foreground/40 mt-0.5 size-4 shrink-0 dark:text-[#566B7F] ${
isFiltering
? "cursor-not-allowed opacity-40"
: "cursor-grab active:cursor-grabbing"
}`}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[#18181b] dark:text-white"> <div className="truncate text-sm font-medium text-[#18181b] dark:text-white">
{q.title} {q.title}
@@ -243,8 +287,21 @@ export default function RecommendedQuestionsPage({
<div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs dark:text-[#7FACF2]"> <div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs dark:text-[#7FACF2]">
{q.content} {q.content}
</div> </div>
{(q.tags?.length ?? 0) > 0 && (
<TagChipList tags={q.tags!} className="mt-2" />
)}
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => setTagTarget(q)}
disabled={isSaving}
title="打标签(按岗位分发)"
>
<TagIcon className="size-3.5" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -308,6 +365,17 @@ export default function RecommendedQuestionsPage({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 打标签弹窗(按岗位分发推荐问题) */}
{tagTarget && (
<ResourceTagDialog
open={tagTarget !== null}
onOpenChange={(o) => !o && setTagTarget(null)}
targetType="recommended_question"
targetId={tagTarget.id}
title={`为「${tagTarget.title}」打标签`}
/>
)}
{/* 删除确认弹窗 */} {/* 删除确认弹窗 */}
<Dialog <Dialog
open={deleteTarget !== null} open={deleteTarget !== null}
@@ -0,0 +1,141 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useSkillCompressionSettings,
useUpdateSkillCompressionSettings,
} from "@/core/system-settings/hooks";
/**
* 管理员「技能压缩」配置页。
*
* 开启后,只有被标记为「常驻(免压缩)」的技能会全文注入系统提示词;其余已启用
* 技能会被压缩(从提示词正文剔除),由智能体在需要时通过 `search_skills` 工具
* 按关键词检索调出。关闭时行为不变 —— 所有已启用技能全文注入。
*/
export default function SkillCompressionSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = useSkillCompressionSettings();
const update = useUpdateSkillCompressionSettings();
const [enabled, setEnabled] = useState(false);
const [keepIndex, setKeepIndex] = useState(true);
const [searchTopN, setSearchTopN] = useState(5);
const initializedRef = useRef(false);
useEffect(() => {
if (!data || initializedRef.current) return;
setEnabled(data.enabled);
setKeepIndex(data.keep_index_in_prompt);
setSearchTopN(data.search_top_n);
initializedRef.current = true;
}, [data]);
const handleSave = async () => {
const topN = Math.max(1, Math.min(Math.round(searchTopN) || 5, 50));
try {
await update.mutateAsync({
enabled,
keep_index_in_prompt: keepIndex,
search_top_n: topN,
});
setSearchTopN(topN);
toast.success("已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
return (
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
技能压缩配置
</h1>
<p className="text-muted-foreground mt-1 text-sm">
技能过多会让系统提示词变得臃肿、增加 token 消耗。开启「技能压缩」后,只有被标记为
「常驻(免压缩)」的技能会全文注入系统提示词;其余已启用技能会被压缩,智能体在需要时
通过 <code>search_skills</code> 工具按关键词检索调出。关闭时行为不变 ——
所有已启用技能全文注入。某个技能是否常驻,在该技能的详情页单独开启。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">启用技能压缩</div>
<div className="text-muted-foreground mt-0.5 text-xs">
关闭时,所有已启用技能照旧全文注入系统提示词(默认行为)。
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">保留技能名索引</div>
<div className="text-muted-foreground mt-0.5 text-xs">
开启后,被压缩的技能仍以「仅名字」的紧凑列表出现在提示词里,帮助智能体知道存在
哪些技能;关闭则完全隐藏,只能靠 search_skills 检索发现。
</div>
</div>
<Switch
checked={keepIndex}
disabled={!enabled}
onCheckedChange={setKeepIndex}
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">检索返回条数</p>
<p className="text-muted-foreground text-xs">
search_skills 工具每次最多返回的匹配技能数量(1–50)。
</p>
<Input
type="number"
min={1}
max={50}
value={searchTopN}
disabled={!enabled}
onChange={(e) => setSearchTopN(Number(e.target.value))}
className="w-28"
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
)}
</div>
);
}
+77 -1
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar"; import { SidebarProvider } from "@/components/ui/sidebar";
@@ -19,6 +19,8 @@ import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainList
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css"; import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage"; import AgentChatPage from "./AgentChatPage";
import { AgentModuleRoutes } from "./AgentModulePage";
import { AGENT_MODULES } from "@/components/workspace/agents/agent-module-sidebar";
import AgentsPage from "./AgentsPage"; import AgentsPage from "./AgentsPage";
import BusinessChainMappingPage from "./BusinessChainMappingPage"; import BusinessChainMappingPage from "./BusinessChainMappingPage";
import ChatPage from "./ChatPage"; import ChatPage from "./ChatPage";
@@ -41,6 +43,7 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage"; import ScheduledTasksPage from "./ScheduledTasksPage";
import SkillEvolutionPage from "./SkillEvolutionPage"; import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage"; import SkillsPage from "./SkillsPage";
import PositionsPage from "./PositionsPage";
import TagsPage from "./TagsPage"; import TagsPage from "./TagsPage";
import UsersPage from "./UsersPage"; import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage"; import LlmMetricsPage from "./LlmMetricsPage";
@@ -48,6 +51,20 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes"; import { ChatRuntime } from "./WorkspaceRoutes";
/**
* 任务深链:把智能体问答嵌进「任务管理」布局。读路由参数 agent_id,给 AgentChatPage
* 传入以 taskBase 为前缀的对话路由(开新会话 / URL 改写都停留在任务管理上下文,
* 侧边栏保持对应业务高亮)。
*/
function TaskEmbeddedAgentChat({ taskBase }: { taskBase: string }) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} />
</ChatRuntime>
);
}
export default function StrategyRoutes() { export default function StrategyRoutes() {
return ( return (
<RouteBaseProvider value="/page/strategy"> <RouteBaseProvider value="/page/strategy">
@@ -75,6 +92,10 @@ export default function StrategyRoutes() {
} }
/> />
{/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */}
<Route path="qa/zzjc/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzjc} />} />
<Route path="qa/zzxd/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzxd} />} />
<Route path="task-management" element={<TaskManagement />}> <Route path="task-management" element={<TaskManagement />}>
<Route index element={<Navigate to="xk-task-jc" replace />} /> <Route index element={<Navigate to="xk-task-jc" replace />} />
<Route <Route
@@ -107,6 +128,21 @@ export default function StrategyRoutes() {
/> />
} }
/> />
{/* 任务深链(3qfx / 3qfx-A):把智能体问答 / 会商嵌进 3QFX 业务上下文。 */}
<Route
path="plan-task-sentiment/roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route
path="plan-task-sentiment/agent/:agent_id/chats/:thread_id"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task-sentiment" />
}
/>
<Route <Route
path="plan-task/plan-task-fx" path="plan-task/plan-task-fx"
element={ element={
@@ -117,6 +153,21 @@ export default function StrategyRoutes() {
/> />
} }
/> />
{/* 任务深链(rwfx / rwfx-A):嵌进 任务FX 业务上下文。 */}
<Route
path="plan-task/plan-task-fx/roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route
path="plan-task/plan-task-fx/agent/:agent_id/chats/:thread_id"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
}
/>
<Route <Route
path="plan-task/plan-task-action" path="plan-task/plan-task-action"
element={ element={
@@ -127,6 +178,21 @@ export default function StrategyRoutes() {
/> />
} }
/> />
{/* 任务深链(xdfx / xdfx-A):嵌进 XDFX 业务上下文。 */}
<Route
path="plan-task/plan-task-action/roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route
path="plan-task/plan-task-action/agent/:agent_id/chats/:thread_id"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
}
/>
<Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} /> <Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
<Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} /> <Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
<Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} /> <Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />
@@ -289,6 +355,16 @@ export default function StrategyRoutes() {
path="admin/tags" path="admin/tags"
element={<TagsPage />} element={<TagsPage />}
/> />
<Route
path="admin/positions"
element={
getStoredAuth()?.system_role === "admin" ? (
<PositionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes> </Routes>
</div> </div>
</SidebarProvider> </SidebarProvider>
@@ -27,6 +27,7 @@ import { fetchDocumentByChunk } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api"; import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types"; import type { SourceItem } from "@/core/studio/types";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useTheme } from "@/contexts/ThemeContext";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { import {
Select, Select,
@@ -209,8 +210,16 @@ function SourcesDialog({
} }
// ── 主页面 ────────────────────────────────────────────── // ── 主页面 ──────────────────────────────────────────────
export default function StudioNotebookDetailPage() { export default function StudioNotebookDetailPage({
const { notebookId } = useParams<{ notebookId: string }>(); notebookId: notebookIdProp,
}: {
/** 内嵌使用(如课题研究右侧)时直接传入;缺省则从路由参数取。 */
notebookId?: number;
} = {}) {
const params = useParams<{ notebookId: string }>();
const notebookId =
notebookIdProp != null ? String(notebookIdProp) : params.notebookId;
const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
const { models } = useModels(); const { models } = useModels();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -501,7 +510,7 @@ export default function StudioNotebookDetailPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<a <a
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`} href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70" className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
@@ -525,7 +534,7 @@ export default function StudioNotebookDetailPage() {
<div className="relative z-10 flex flex-1 flex-col overflow-hidden"> <div className="relative z-10 flex flex-1 flex-col overflow-hidden">
{/* 右上角跳转按钮 */} {/* 右上角跳转按钮 */}
<a <a
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`} href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted" className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
+50 -16
View File
@@ -8,6 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useStudioNotebooks } from "@/core/studio/hooks"; import { useStudioNotebooks } from "@/core/studio/hooks";
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api"; import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useTheme } from "@/contexts/ThemeContext";
import type { StudioNotebook } from "@/core/studio/types"; import type { StudioNotebook } from "@/core/studio/types";
import { import {
Dialog, Dialog,
@@ -23,13 +24,16 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
function NotebookCardSkeleton() { function NotebookCardSkeleton({ isDark }: { isDark: boolean }) {
return ( return (
<div className="flex flex-col rounded-[20px] bg-[#f2f2f2] p-5" style={{ minHeight: 180 }}> <div
<div className="size-10 animate-pulse rounded-xl bg-[#e0e0e0]" /> className={`flex flex-col rounded-[20px] p-5 ${isDark ? "bg-[#1e2330]" : "bg-[#f2f2f2]"}`}
style={{ minHeight: 180 }}
>
<div className={`size-10 animate-pulse rounded-xl ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
<div className="mt-auto flex flex-col gap-1.5 pt-8"> <div className="mt-auto flex flex-col gap-1.5 pt-8">
<div className="h-4 w-3/4 animate-pulse rounded bg-[#e0e0e0]" /> <div className={`h-4 w-3/4 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
<div className="h-3 w-1/3 animate-pulse rounded bg-[#e0e0e0]" /> <div className={`h-3 w-1/3 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
</div> </div>
</div> </div>
); );
@@ -37,19 +41,20 @@ function NotebookCardSkeleton() {
function NotebookCard({ function NotebookCard({
nb, nb,
isDark,
onRename, onRename,
onDelete, onDelete,
onOpen,
}: { }: {
nb: StudioNotebook; nb: StudioNotebook;
isDark: boolean;
onRename: (nb: StudioNotebook) => void; onRename: (nb: StudioNotebook) => void;
onDelete: (nb: StudioNotebook) => void; onDelete: (nb: StudioNotebook) => void;
/** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */
onOpen?: (id: number) => void;
}) { }) {
return ( const inner = (
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}> <>
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="flex flex-1 flex-col p-5"
>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none"> <div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
{nb.avatar_url ? ( {nb.avatar_url ? (
@@ -62,12 +67,32 @@ function NotebookCard({
<div className="size-7" /> <div className="size-7" />
</div> </div>
<div className="mt-auto flex flex-col gap-1 pt-8"> <div className="mt-auto flex flex-col gap-1 pt-8">
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-[#1f1f1f]"> <span className={`line-clamp-2 text-[15px] font-medium leading-snug ${isDark ? "text-[#e8eaf0]" : "text-[#1f1f1f]"}`}>
{nb.name} {nb.name}
</span> </span>
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span> <span className={`text-[13px] ${isDark ? "text-[#8b93a7]" : "text-[#666]"}`}>{nb.source_count} 个来源</span>
</div> </div>
</Link> </>
);
return (
<div className={`group relative flex flex-col rounded-[20px] transition-colors ${isDark ? "bg-[#1e2330] hover:bg-[#262c3b]" : "bg-[#f2f2f2] hover:bg-[#e8e8e8]"}`} style={{ minHeight: 180 }}>
{onOpen ? (
<button
type="button"
onClick={() => onOpen(nb.id)}
className="flex flex-1 flex-col p-5 text-left"
>
{inner}
</button>
) : (
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="flex flex-1 flex-col p-5"
>
{inner}
</Link>
)}
{/* three-dot menu — absolute so it doesn't shift layout */} {/* three-dot menu — absolute so it doesn't shift layout */}
<div className="absolute right-4 top-4"> <div className="absolute right-4 top-4">
@@ -99,8 +124,15 @@ function NotebookCard({
); );
} }
export default function StudioNotebooksPage() { export default function StudioNotebooksPage({
onOpenNotebook,
}: {
/** 内嵌使用(课题研究右侧)时传入:点击空间卡片回调,不跳路由。 */
onOpenNotebook?: (id: number) => void;
} = {}) {
const { notebooks, isLoading, isError } = useStudioNotebooks(); const { notebooks, isLoading, isError } = useStudioNotebooks();
const { theme } = useTheme();
const isDark = theme === "dark";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// ── 新建 ── // ── 新建 ──
@@ -197,13 +229,15 @@ export default function StudioNotebooksPage() {
{/* Grid */} {/* Grid */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{isLoading {isLoading
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />) ? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} isDark={isDark} />)
: notebooks.map((nb) => ( : notebooks.map((nb) => (
<NotebookCard <NotebookCard
key={nb.id} key={nb.id}
nb={nb} nb={nb}
isDark={isDark}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }} onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }} onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
onOpen={onOpenNotebook}
/> />
))} ))}
</div> </div>
+50 -3
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useParams } from 'react-router-dom'; import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth'; import { getStoredAuth } from '@/core/auth';
@@ -32,6 +32,7 @@ import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage'; import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage'; import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage'; import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage'; import SkillEvolutionPage from './SkillEvolutionPage';
import SkillsPage from './SkillsPage'; import SkillsPage from './SkillsPage';
import UsersPage from './UsersPage'; import UsersPage from './UsersPage';
@@ -39,6 +40,7 @@ import AdminUserActivityPage from './AdminUserActivityPage';
import AdminMemoryPage from './AdminMemoryPage'; import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage'; import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage'; import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage'; import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebooksPage from './StudioNotebooksPage'; import StudioNotebooksPage from './StudioNotebooksPage';
@@ -87,6 +89,10 @@ export function WorkspaceLayout({
insetOverflowHidden?: boolean; insetOverflowHidden?: boolean;
pageSidebarDark?: boolean; pageSidebarDark?: boolean;
}) { }) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
const pageSidebarWidth = pageSidebarHidden const pageSidebarWidth = pageSidebarHidden
? '0rem' ? '0rem'
: pageSidebarDark : pageSidebarDark
@@ -113,7 +119,9 @@ export function WorkspaceLayout({
} }
key={String(compactWorkspaceSidebar)} key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed} data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen={defaultHistorySidebarOpen ?? !compactWorkspaceSidebar} defaultOpen={
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar)
}
> >
{!workspaceSidebarHidden && ( {!workspaceSidebarHidden && (
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} /> <WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
@@ -139,6 +147,21 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
); );
} }
/**
* 通用问答新对话内容区:默认渲染 ChatPage;当 URL 带 ``?agent=<id>``(点击左栏
* 「常用智能体」时由侧边栏写入)时,**在右侧问答区原地嵌入**该智能体的对话页
* (AgentChatPage),路由仍停留在 /chats/new,不跳转出去、不更换左侧栏。
* key 绑定 location.key —— 每次点击(即便同一 agent)都重开一个干净的会话。
*/
function NewChatContent() {
const location = useLocation();
const agentId = new URLSearchParams(location.search).get('agent');
if (agentId) {
return <AgentChatPage key={`${agentId}:${location.key}`} embeddedAgentId={agentId} />;
}
return <ChatPage />;
}
function NewChatEntryRoute({ function NewChatEntryRoute({
layoutProps, layoutProps,
}: { }: {
@@ -157,7 +180,7 @@ function NewChatEntryRoute({
return ( return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen> <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime> <ChatRuntime>
<ChatPage /> <NewChatContent />
</ChatRuntime> </ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
); );
@@ -475,6 +498,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="admin/positions"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PositionsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="settings/recommended-questions" path="settings/recommended-questions"
element={ element={
@@ -523,6 +558,18 @@ export default function WorkspaceRoutes({
) )
} }
/> />
<Route
path="settings/skill-compression"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<SkillCompressionSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="settings/llm-metrics" path="settings/llm-metrics"
element={ element={
@@ -29,6 +29,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
@@ -212,9 +213,11 @@ export function BusinessChainListDialog({
onOpenChange, onOpenChange,
onConfigure, onConfigure,
}: BusinessChainListDialogProps) { }: BusinessChainListDialogProps) {
// 整片脱敏:弹窗内链条列表(链条名/描述/席位名/标题)。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl"> <DialogContent ref={dzRef} className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<LayersIcon className="size-5 text-primary" /> <LayersIcon className="size-5 text-primary" />
@@ -26,6 +26,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog"; import { DialogFooter } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
import { filterRecommendCandidates } from "../api/recommend"; import { filterRecommendCandidates } from "../api/recommend";
@@ -161,9 +162,12 @@ export function BusinessChainPicker({
onConfirm({ agents, mode: "chain", chain: chainMeta }); onConfirm({ agents, mode: "chain", chain: chainMeta });
}; };
// 整片脱敏:链条卡片区(链条名/描述/席位智能体名)。输入框不受影响(Hook 跳过)。
const dzRef = useDesensitizeSubtree<HTMLElement>();
return ( return (
<> <>
<section className="flex-1 min-h-0 flex flex-col overflow-hidden"> <section ref={dzRef} className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* 筛选:我的 / 公共 */} {/* 筛选:我的 / 公共 */}
<div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5"> <div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5">
{(["mine", "public"] as const).map((s) => ( {(["mine", "public"] as const).map((s) => (
@@ -21,6 +21,7 @@
*/ */
import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
@@ -137,7 +138,7 @@ export function DagFlowChart({
return ( return (
<FlowNode <FlowNode
key={agentId} key={agentId}
label={meta.name} label={desensitize(meta.name)}
avatarType={meta.avatarType} avatarType={meta.avatarType}
state={state} state={state}
compact={compact} compact={compact}
@@ -20,6 +20,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { CheckIcon, Loader2Icon, Network, X } from "lucide-react"; import { CheckIcon, Loader2Icon, Network, X } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
@@ -400,7 +401,7 @@ export function DagFlowMiniPanel({
return ( return (
<MiniNode <MiniNode
key={agentId} key={agentId}
label={meta.name} label={desensitize(meta.name)}
avatarType={meta.avatarType} avatarType={meta.avatarType}
state={state} state={state}
/> />
@@ -26,6 +26,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
@@ -236,7 +237,7 @@ export function DispatchChainModal({
done={node.state === "done"} done={node.state === "done"}
/> />
<FlowNode <FlowNode
label={node.name} label={desensitize(node.name)}
avatarType={node.avatarType} avatarType={node.avatarType}
state={node.state} state={node.state}
order={node.order} order={node.order}
@@ -277,7 +278,7 @@ export function DispatchChainModal({
<div key={d.id} className="flex gap-2.5"> <div key={d.id} className="flex gap-2.5">
<div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div> <div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-0.5 text-xs font-semibold text-foreground/80">{d.name}</div> <div className="mb-0.5 text-xs font-semibold text-foreground/80">{desensitize(d.name)}</div>
<div className="prose prose-sm max-w-none break-words text-sm leading-relaxed text-foreground/80 dark:prose-invert"> <div className="prose prose-sm max-w-none break-words text-sm leading-relaxed text-foreground/80 dark:prose-invert">
<MarkdownContent>{d.content || "…"}</MarkdownContent> <MarkdownContent>{d.content || "…"}</MarkdownContent>
</div> </div>
@@ -32,6 +32,7 @@ import {
type ThreadIdMap, type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { import {
Step3ReportMode, Step3ReportMode,
@@ -707,7 +708,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground", lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground",
)} )}
> >
{seat.displayName} {desensitize(seat.displayName)}
</span> </span>
<span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground"> <span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground">
{lit ? `${seat.delivery!.length} 字` : "无交付"} {lit ? `${seat.delivery!.length} 字` : "无交付"}
@@ -771,7 +772,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
{consensusText ? ( {consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80"> <div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText} {desensitize(consensusText)}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) : ( ) : (
@@ -830,7 +831,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
{getAvatar(seat.avatarType, "h-5.5 w-5.5")} {getAvatar(seat.avatarType, "h-5.5 w-5.5")}
</span> </span>
<span className="text-xs font-black text-slate-800 truncate dark:text-foreground"> <span className="text-xs font-black text-slate-800 truncate dark:text-foreground">
{seat.displayName} {desensitize(seat.displayName)}
</span> </span>
</div> </div>
<span className="text-[10px] text-slate-400 font-mono shrink-0 dark:text-muted-foreground"> <span className="text-[10px] text-slate-400 font-mono shrink-0 dark:text-muted-foreground">
@@ -858,7 +859,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={markdownComponents} components={markdownComponents}
> >
{seat.delivery!} {desensitize(seat.delivery!)}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">该席位暂无交付内容。</p> <p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">该席位暂无交付内容。</p>
@@ -18,6 +18,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react"; import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface MessageBubbleProps { export interface MessageBubbleProps {
@@ -58,7 +59,8 @@ export function MessageBubble({
senderNameClassName, senderNameClassName,
)} )}
> >
{senderName} {/* 智能体名称需脱敏(消息列表里唯一要脱敏的就是名称)。 */}
{desensitize(senderName)}
</span> </span>
{badge && ( {badge && (
<span className="roundtable-chat-meta font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded"> <span className="roundtable-chat-meta font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
@@ -190,6 +192,7 @@ export const sharedMarkdownComponents = {
* 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。 * 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。
* 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。 * 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。
*/ */
// 消息列表正文**不脱敏**(仅名称脱敏);MarkdownContent 是会商对话气泡正文渲染器,故原文渲染。
export function MarkdownContent({ children }: { children: string }) { export function MarkdownContent({ children }: { children: string }) {
return ( return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}>
@@ -11,6 +11,7 @@
*/ */
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react"; import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "@/roundtable-planning/components/Avatars"; import { getAvatar } from "@/roundtable-planning/components/Avatars";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration"; import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration";
@@ -42,7 +43,7 @@ export function ParallelSeatStrip({ batch, selectedSeats, onToggleSeat }: Parall
key={seat.agentId} key={seat.agentId}
type="button" type="button"
onClick={() => onToggleSeat(seat.agentId)} onClick={() => onToggleSeat(seat.agentId)}
title={`${seat.displayName}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`} title={`${desensitize(seat.displayName)}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`}
className={cn( className={cn(
"relative shrink-0 rounded-full transition", "relative shrink-0 rounded-full transition",
isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300", isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300",
@@ -23,6 +23,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { listAgents, type Agent } from "@/core/agents"; import { listAgents, type Agent } from "@/core/agents";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -1122,7 +1123,7 @@ function AgentCard({
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div> <div className="font-medium text-sm truncate">{desensitize(candidate.name || candidate.agent_id)}</div>
{recommended && ( {recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
推荐 推荐
@@ -1131,12 +1132,12 @@ function AgentCard({
</div> </div>
{candidate.description && ( {candidate.description && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2"> <div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{candidate.description} {desensitize(candidate.description)}
</div> </div>
)} )}
{reason ? ( {reason ? (
<div className="text-xs text-primary/80 mt-1 italic"> <div className="text-xs text-primary/80 mt-1 italic">
<span className="font-medium not-italic">推荐理由:</span> {reason} <span className="font-medium not-italic">推荐理由:</span> {desensitize(reason)}
</div> </div>
) : streaming ? ( ) : streaming ? (
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div> <div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
@@ -56,6 +56,7 @@ import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToB
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent"; import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants"; import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
import type { import type {
@@ -149,10 +150,13 @@ export function Step1Panel(props: Step1PanelProps) {
setAttachedFiles([]); setAttachedFiles([]);
}; };
// 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
{/* Dialogue Header */} {/* Dialogue Header */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50"> <div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} /> <MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h3 className="text-sm font-extrabold text-slate-900"> <h3 className="text-sm font-extrabold text-slate-900">
@@ -81,6 +81,8 @@ import {
getAvatarTypeFor, getAvatarTypeFor,
type RoleEntry, type RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants"; } from "@/roundtable-planning/lib/roundtable-constants";
import { desensitize } from "@/lib/desensitize";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
@@ -405,10 +407,13 @@ export function Step2Panel(props: Step2PanelProps) {
isOrchestrating || isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true); step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
const chatBody = ( const chatBody = (
<> <>
{/* Headline */} {/* Headline */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm"> <div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} /> <Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div> <div>
@@ -584,7 +589,7 @@ export function Step2Panel(props: Step2PanelProps) {
/> />
) : ( ) : (
<div className="py-6 text-center text-xs text-slate-400"> <div className="py-6 text-center text-xs text-slate-400">
{seat.displayName} · 等待输出… {desensitize(seat.displayName)} · 等待输出…
</div> </div>
)} )}
</div> </div>
@@ -599,7 +604,7 @@ export function Step2Panel(props: Step2PanelProps) {
<span className="roundtable-chat-meta font-medium text-slate-600"> <span className="roundtable-chat-meta font-medium text-slate-600">
仅显示{" "} 仅显示{" "}
<span className="font-bold text-primary"> <span className="font-bold text-primary">
{seatFilterLabel(seatFilter)} {desensitize(seatFilterLabel(seatFilter))}
</span>{" "} </span>{" "}
的发言 的发言
</span> </span>
@@ -615,7 +620,7 @@ export function Step2Panel(props: Step2PanelProps) {
)} )}
{seatFilter && visibleDialogues.length === 0 ? ( {seatFilter && visibleDialogues.length === 0 ? (
<div className="py-10 text-center text-xs text-slate-400"> <div className="py-10 text-center text-xs text-slate-400">
没有匹配「{seatFilterLabel(seatFilter)}」的消息 没有匹配「{desensitize(seatFilterLabel(seatFilter))}」的消息
</div> </div>
) : ( ) : (
visibleDialogues.map((msg) => ( visibleDialogues.map((msg) => (
@@ -878,7 +883,7 @@ export function Step2Message({
title={ title={
isAvatarSelected isAvatarSelected
? "取消筛选,显示全部发言" ? "取消筛选,显示全部发言"
: `只看 ${msg.sender} 的发言` : `只看 ${desensitize(msg.sender)} 的发言`
} }
className={cn( className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60", "shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
@@ -116,6 +116,8 @@ export function Step2SandboxLayout({
threadId={previewThreadId} threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined} onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
allowVirtualMarkdownEdit allowVirtualMarkdownEdit
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
disableDesensitize
/> />
) : ( ) : (
<div className="relative flex size-full justify-center"> <div className="relative flex size-full justify-center">
@@ -35,6 +35,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { desensitizeDeep } from "@/lib/desensitize";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp"; import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context"; import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
@@ -101,10 +102,12 @@ export function Step3DashboardMode({
} = dashboard; } = dashboard;
// 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。 // 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。
const report = useMemo( // 整体深度脱敏后再渲染——大屏(DashboardApp)与自包含 HTML(iframe/新窗口/分享/下载)
() => dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null), // 都从这份 report 派生,故在此一处脱敏即覆盖大屏的所有展示文本(会商页非沙箱内容需脱敏)。
[dashboard.data, savedReport?.dashboardJson], const report = useMemo(() => {
); const raw = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null);
return raw ? desensitizeDeep(raw) : raw;
}, [dashboard.data, savedReport?.dashboardJson]);
// 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。 // 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。
const dashboardData = useMemo( const dashboardData = useMemo(
@@ -44,6 +44,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode"; import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode";
@@ -407,7 +408,7 @@ export function Step3ReportMode({
> >
{consensusText ? ( {consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText} {desensitize(consensusText)}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<p className="text-xs text-slate-400 italic">暂无总控结论。</p> <p className="text-xs text-slate-400 italic">暂无总控结论。</p>
@@ -773,14 +774,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[]
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<ListOrdered className="w-3.5 h-3.5 text-slate-400 shrink-0" /> <ListOrdered className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span> <span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
</div> </div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span> <span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button> </button>
{open && ( {open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white"> <div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"} {desensitize(seat.content || "(空)")}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
@@ -51,6 +51,7 @@ import {
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
@@ -449,7 +450,7 @@ export function Step3SummaryMode({
> >
{consensusText ? ( {consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText} {desensitize(consensusText)}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<p className="text-xs text-slate-400 italic">暂无总控结论。</p> <p className="text-xs text-slate-400 italic">暂无总控结论。</p>
@@ -846,14 +847,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[]
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" /> <FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span> <span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
</div> </div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span> <span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button> </button>
{open && ( {open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white"> <div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"} {desensitize(seat.content || "(空)")}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
@@ -24,6 +24,7 @@ import {
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought"; import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block"; import { CodeBlock } from "@/components/ai-elements/code-block";
import { desensitize } from "@/lib/desensitize";
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble"; import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
/** /**
@@ -52,7 +53,7 @@ export function renderStepArgs(
return ( return (
<> <>
{displayName && ( {displayName && (
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult> <ChainOfThoughtSearchResult>{desensitize(displayName)}</ChainOfThoughtSearchResult>
)} )}
{task && ( {task && (
<div className="roundtable-chat-message mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-slate-600"> <div className="roundtable-chat-message mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-slate-600">
@@ -0,0 +1,67 @@
/**
* 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏,
* 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、
* 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。
*
* 用法:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
*
* 设计要点:
* - 只读改文本节点(display-only),不碰 `<input>/<textarea>` 的 value、不改 React 状态/回传值;
* - `desensitize` 幂等(替换词不再命中词表),MutationObserver 重入安全;改写前先 disconnect 防自触发循环;
* - 想在区域内**保留原文**(如内嵌沙箱/消息列表),给该子树根加 `data-no-desensitize` 属性即可跳过。
*
* 注意:**不要**用它包「消息列表正文」或「沙箱产物预览」——这两处按需求保持原文,
* 应作为独立容器、不挂本 Hook(智能体名称的脱敏单独在 MessageBubble 的 senderName 处理)。
*/
import { useEffect, useRef } from "react";
import { desensitize } from "@/lib/desensitize";
const _SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "IFRAME", "CODE", "PRE"]);
export function useDesensitizeSubtree<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
useEffect(() => {
const root = ref.current;
if (!root || typeof MutationObserver === "undefined") return;
let observer: MutationObserver | null = null;
const apply = () => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (_SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
// 显式标记 data-no-desensitize 的子树保留原文(如内嵌沙箱)。
if (parent.closest("[data-no-desensitize]")) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
const edits: Array<[Text, string]> = [];
let current = walker.nextNode();
while (current) {
const textNode = current as Text;
const original = textNode.nodeValue ?? "";
if (original.trim()) {
const masked = desensitize(original);
if (masked !== original) edits.push([textNode, masked]);
}
current = walker.nextNode();
}
if (edits.length === 0) return;
// 改写期间停掉观察,避免自身改动再次触发,改完恢复。
observer?.disconnect();
for (const [textNode, masked] of edits) textNode.nodeValue = masked;
observer?.observe(root, { subtree: true, childList: true, characterData: true });
};
apply();
observer = new MutationObserver(() => apply());
observer.observe(root, { subtree: true, childList: true, characterData: true });
return () => observer?.disconnect();
});
return ref;
}
@@ -63,6 +63,7 @@ import {
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
@@ -134,6 +135,8 @@ type ActiveDrag =
export default function BusinessChainEditorPage() { export default function BusinessChainEditorPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// 整片脱敏:链条配置页展示文本(链条名/描述/席位名/标题)。可编辑的 input/textarea 不脱敏(Hook 跳过),保证编辑用原文。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
// ─── 数据 ──────────────────────────────────────────────────────────── // ─── 数据 ────────────────────────────────────────────────────────────
const [pool, setPool] = useState<RecommendCandidate[]>([]); const [pool, setPool] = useState<RecommendCandidate[]>([]);
@@ -804,7 +807,7 @@ export default function BusinessChainEditorPage() {
); );
return ( return (
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground"> <div ref={dzRef} className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
{/* 顶栏 */} {/* 顶栏 */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3"> <header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
<button <button
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more