Compare commits

..
2 Commits
Author SHA1 Message Date
gozeros b638b541d0 初始化 2026-09-07 18:24:55 +08:00
wsk 34474bd5fc Initial commit 2026-09-07 17:37:11 +08:00
1900 changed files with 7115 additions and 293323 deletions

No files matched your search

-191
View File
@@ -1,191 +0,0 @@
# Workspace/editor
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
# Local runtime logs and pid files
.runtime/
logs/
offline-backend-20260512/backend/logs/
# Frontend local/runtime artifacts
frontend-web/.env
frontend-web/.env.local
frontend-web/.env.*.local
frontend-web/node_modules/
frontend-web/.pnpm-store/
frontend-web/tsconfig.tsbuildinfo
frontend-web/vite-dev.log
frontend-web/vite-dev.err.log
# Frontend local packages/archives/docs (non-source)
frontend-web/pnpm-lock.yaml
frontend-web/README.md
frontend-web/doc.md
frontend-web/src.zip
frontend-web/src9.zip
frontend-web/src-v1.zip
frontend-web/src-*.rar
frontend-web/dist
# Frontend build artifacts
frontend-web/dist/
frontend-web/dist-scheduled-check/
# Offline backend bundles and local deployment artifacts
offline-backend-20260505.zip
offline-backend-20260505/output/
offline-backend-20260505/bundle/.env
offline-backend-20260505/bundle/.env.example
offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260505/bundle/SHA256SUMS
offline-backend-20260505/bundle/config.yaml
offline-backend-20260505/bundle/config.yaml.example
offline-backend-20260505/bundle/data/
offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar
offline-backend-20260505/bundle/extensions_config.json
offline-backend-20260505/bundle/frontend-vite.zip
offline-backend-20260505/bundle/skills/
offline-backend-20260505/bundle/start_backend.0509.sh
offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/stop_backend.sh
# offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260512/.env.example
offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
skills/*
offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py
# Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned.
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
# Roundtable coordinator/seat capability isolation regressions.
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
!offline-backend-20260512/backend/tests/test_workflow_engine.py
!offline-backend-20260512/backend/tests/test_workflow_runs.py
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
# Writing-mode first-report pipeline enforcement regressions.
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
# LLM call timeout retry classification regression coverage.
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
# Writing-mode pipeline conversation-material regressions.
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
# Report collaboration (RC-BE-000–018) regression coverage.
!offline-backend-20260512/backend/tests/test_report_collaboration_*.py
!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py
deploy
.history
# Cloned third-party repos (local only)
git-clone/
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
# Local dependency caches and backend scratch archives
/.pnpm-store/
/offline-backend-20260512/backend/backend*.zip
# Local offline bundles and scratch tooling
/*.tar.gz
/offline-backend-20260512/*.zip
/tmp/
.codegraph
codegraph.mdc
docs/aidev-docs
docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# Local-only scratch directory (not committed)
0622/
# Python bytecode cache
__pycache__/
# misc
.DS_Store
+1 -1
View File
@@ -96,7 +96,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。 - **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **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/`
+3
View File
@@ -0,0 +1,3 @@
# deerflow-code
deerflow-code
Submodule _skills_src/ai-skills deleted from cde1c8385e.
Submodule _skills_src/anthropics-skills deleted from 5754626092.
Submodule _skills_src/awesome-claude-skills deleted from 92568c1eda.
Submodule _skills_src/ce-kit deleted from d4973c4827.
Submodule _skills_src/csm deleted from 3fa16a94e0.
Submodule _skills_src/superpowers deleted from 6fd4507659.
Submodule _skills_src/tapestry-skills deleted from 80e1dc56df.
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="188" height="241" viewBox="0 0 188 241"><defs><style>.a{fill:url(#a);}.b{fill:#fff;opacity:0.47;}</style><pattern id="a" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 376 482"><image width="376" height="482" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXgAAAHiCAYAAADiYNeDAAAgAElEQVR4nOy993csOZYmdoFIw6R73pav6qrqrvY9flatmdldrWa1+nv1m46OjrSr7ZnZdtW2ulxX1Xt8ho+eTBcB6CACQFzcQEQaMskkeb938iUzvP3w4cPFhdDpGK41hGg+ez3jxZm0veg69r85Vj0zzHqe/thnOmgJAIn9xh8zrQsAqwBwAwA2ybf7rNqPWbaH1nG/V+zfCZRX02y/BQBtO90gAwDz4KcAoOw0bacPAWAAAH3794n9xr/NZx99DiD8dutkdvv4k6F9Toae88Zc+LOk53umZj3fSec57/W7Imhd67NnnCc6loAdETsy7iKiXgOADQBYtyTvvjft9B4i9hW7Tfy7a6dhghcNNKBR0eYIfoRI3hH7iPw2n0NL7OZzhL7N9GNE8kO0PVdwDOw2GYyFggmesWiYZ+wmADwEgHsAcB8AbgPAHft9Cyl2R9DtyHfbbqtlCRx/5JznQMk/sftdt7+d4saf1H7G9jOKfA+Rot8FgB0AeGW/XwDASwB4BgB7dlsMxkLABM84K6zZzyr53LREPongO0t4J5yN1J5j3dEUBL9jSf6EfI7th8E4FdiDZw++wPwefMcS9+sA8BoAPAaABwDwyJL4LUT4MYsmmWIvlxlZjUXjSH3Xkv8WADwHgKcA8AQAvskLAq3ns3LYg59ve1cMrOAZs2DN2hfryCu/awk9RvC3llSZnycSVMDFMLIkHyN483sbeftH9sPqnjEVWMGzgi/QfJ7SEvYbAPCW/X7TEvl9a7GsEZume34HfyXgInSO0efAWjqG6L8CgK8B4M/2e6sxGocV/Hzbu2JggmeCLxCe5xoJVbxrSR0T/BuW4Ncu7qCvBY4twX9NCP5rq+5xiGap7Jng59veFQMTPBN8Ae2P5JFV5+8BwLcA4B077S6yZ9xn3ugVxmxQyJ5xn1fWzvkCAD4FgM+syt/K7yYT/Hzbu2JggmeCv5V/dB7tctcq9LcIwd+5kCNjTMIrQvB/tp9tEHl0zq79nC+Y4JcGTPDXl+DbVpl/GwC+CwAf2obSB6hz0Q0b9cJYXvSRRXNg7RzTQPtHAPgtAPzBqvrze9GZ4JcGTPDXi+BvothzZ8Vggn/AkVWXHqkleUzwzrrZRbH3iwMT/NKACf56ELywZP4hIvN3bPz6Lfu5sbC9My4C+8iieWmtHEf6f7Skvxj2Y4JfGjDBX12C71jv/I5V5h8AwHcswX9gY9YvsimOcX7QNrb+E0vwv7d/P7c+/qszzY3DBL80YIK/mgTfsw2kP7Af02D6trVnbrNav7bYtxaN+XxpG2Z/bT+fWj//9GCCXxowwV8dgu/ZKJh71o75DiL4tzhenUFwbCNuHMH/3to2L218/fxkzwS/NGCCvxoEv2oJ/ScA8GOr3h8ji2aeZFmMq48xsmieWhX/CwD4uW2cnS8lAhP80oAJ/vISfBcpdmO//NAS/I9suCN3QmLMAmXDK39pCf7XtmHWKfrh1Ntigl8aMMFfToJfsQ2lP7GfD23qgHuW9BmMebFtSf1rG23zc6vqP5natmGCXxowwV8egpcoIuZt6607S+ataNrd6/1sM6ZF/LnLrEf/C/txit5F3tQnOmOCXxowwV8OgheW1I398jcA8D0bx37XfkI7RgOzO2NGRJ8/ZRX9tiX33wDAz6yN82XtQ8YEvzTgXovLjRWr2E1UzPcB4C8swX9rqnS8zPGMaVBPktKmg76PQm3v2V7QH1sb55kdwISxhGAFv7wKftX67H8PAH9pOyi5wTTCqBiq2JnYGfMgeP6iin6MBiUxHab+DQD+P+vVn/ilWMEvDVjBLx82LIl/aP31v7fWzKPokeo5XyQGo/IsRX6EgqVtI7Ret436D2270GuW5J/ZkacYSwJW8Mul4Dcsmf8tAPydTQT2wPY+Jcely+Njz51x5rAK3j3O8ed6xyp6EzP/362a/xVok9WSFfwygBX8cmAzt190Tuh/Z332H+fT6QOMiR34AWYsCo6g3QMYVfS3Qefi4zXUqe6u7RW7ZVMjMC4QrOAvXsHfsSGPPwWRe+3fRjnZ64+HiZ1xnnDPdf3jfWgtmj+Chn8FgP/XhlZuz3SUrODPFKzgLw43rPL5nlXsPwUNH03MGcPEzrgI+OeullE37McFAty0ETcf20yWrOYvAKzgL0bB37Ahj39vPx/YRtTV6v6Z0BlLjPjzfuLVPMB/s5//MRXJs4I/U7CCP1/0rMIxqv1/tuT+gyixMxiXF+Z5ftdG2bgBZTZtR6knZ5aWmDERrODPT8EbcjcWzD9aYv++Jfu4JcPKnXGZUP/cH9sG199YJf9/2b/jnaNYwZ8pWMEvHm3bE/A7ltj/yWZ+vHXVT5zBsALmW2h4yFXrz5tImxfnOhj4NQQr+MUq+Jbt4m3i2v+97ZH6ejxChhU74yoh+l4d2vQGxo//P21em0/tQOEFWMGfKVjBLw63rQ9pyP0frHp/fFVPlsGYAhvWprxlLctNO+0z22mKccZgBb8YBX/bhj6ahtR/Z9MO3A+36zqOzLh9BuMywUdXVh70FzbH/H8FgP8bAP4lT0PMCv5MwQr+bNG2se0m3cB/sOT+0VSZHxmM64X7Nlx43fr0Kzbv/BP25c8OTPBnB5E3Jmn9H6wl82NryYTk7hUHSTnAYFwHhM971/bc3rD9QF6z3vwf+M04GzDBnw3MA/q+tWT+2XZiah46j6T4YDCuJCZbkCs2EMGp+Zb15//EmSlPDyb402PNhj3+bzYE8sNoCKSoYXL24BnXAqTNSVcefCOI/toSvbFv/g9r2Rzz8zE/mOBPh7vIb/9fbeclck01p/NlMCg0SpNd4pYNJe7aGSt2eMDZEpYxPJjg54fx1/8KAP5LniisGM6suJ5epWjQ+YAcPCgHgxGiYPg8uMZF2BTvSMt2CuzZTKumhvyvdhQpxoxggp8dLlLGhEH+R9uB6d18K1iMeGLX1z5Ui8GYCCFwu9SKbXztICX/M46wmR1M8LOhZcn8761y/+to56VAuTO5MxhRmPfETi/cmoov/5a1Pl2Kg/9mO0WlfEGnAxP89FixNoyJlPlfAOB/snmvS/jRlpjcGYypEH1HPNEnduzXjjVw2nbml7XJyhgBmOCnQ9fmbP+pVe4/CcjdKRGtUXw7kzuDMRWC90f44WCRon9g3701S/L/j42VH/EFbgYT/GSs2Tjdf7Ke+18FMe4Vtc7EzmDMDCyIzN9+iMCA5I0lmiHe+ozDKJvBBN+Mnm3R/0fbgemHlNwDr51j2hmMU0KXkcVVb/6OTd7Xs9xlrJvf8gAi9WCCr8ea7Z36T9Zz/4s8j7UAFNfOXjuDsRBUkvFZ30bnAsu8iwoApFX0n7CSj4MJPo5VG6b1TyDyHqo/tIMUWCDlDqzcGYwzBcrX5HnedYoq5t20HaI6dkHTGPs7OxYsA4EJvooVOwLNP1rl/qOA3B2xK0XqkWy/MxhnAvw+5danBpCymFbaNbfsuzm0S46skufoGgQm+BBda8uYbJD/2WaEDMndELvSIJxRKJjYGYwzBX6f3N+ZApCiIPqS5G/YoIcEEf0n9u9rD2CCD2Cqe++gOPcf2YE7CuTkbj9Bi/8FHzWDcdXh2ryU/ZZRJX9ifXnT0/VzDqEswARfoG3J/ac2K+Rf2Rb7Aki5c4Mqg3FBcO8hVJS8i65JLLFrS/LXPq0BE3xhsjy2oy/9J9tCf8/P9cpdlR0xGAzGxUAboSUsyQdK/p59d09sZI3x4r+67nVsJniAh1ax/2er4MuxU51iMP4fcKw7g3HhcDXoLCuiJEMlf9++wwYHVs1vXeebdt0J/o4l9/9ke8kV6Qe854eVO1szDMZSwJG8t2vAhVAK1ON11xL8f80H876muM4Ev5E3zmj9v9tG1df8HPP8qKwgeCDkzh48g7EEsFFsRsmb0aFkgg/pNftOg+0A9S/Xdfi/60rwa7bl3WWFfNvPCTx3YOXOYCwrvJK3bWMmjLKwaxL7Tpt3e8cq+Z9fx96u15HgE9tL9b/YiJl3/RyOlmEwLhmsko9H17xr33Gwja+/tA2w1wbXjeDbNjPkP9gcM98JrkHeS1UxuTMYlw35eyuKT0nwXfuOm2RkL62C/+w6hU9eN4J/3Q6Q/c8BueOqHm1QrY4yw2AwlgW006GyqQ3Kd9eN8frPtoer+XxxXe5f60p6zP6UfHIiYSNmfmzHUP2RbWQtkVfx1AUdMIPBOBsom2fS5q4psGHf+UMbNnloI2t00M52BbVc68q2IebtLi5fjLhtwyEdud8LlnPWjELqnZU7g7H8cO+pH5fBkLu1a0Bg0r5r3/0ta9n8DLQuwievcE6pq6vg89PKb/CKbWwx2SH/znZsssshYqfZIbnTKoNxORAMpqZRVI0uOkOFo0IZDjjynryGQTX3/NXBVffguzbHzN/aVAQf2lzvtvXd9lJVqmyNd+AskQzG5YEm728eHy+LmDkhHXuvWg4wETXfWKL//Cpnn7zqBH8HtOnVpv+xGDRbrBWTrcTPQyKzOJGzgmcwLgfo++uVfFa8w17BO5LXH+Q1ep178SalwZOreqevLsELWAWtv1tEzYh/VzSy2ichL+1tT1WqLine truncated

Before

Width:  |  Height:  |  Size: 68 KiB

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

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

Before

Width:  |  Height:  |  Size: 1.9 KiB

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

Before

Width:  |  Height:  |  Size: 924 B

@@ -18,9 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react";
import type { StreamdownProps } from "streamdown"; import { Streamdown } from "streamdown";
import { SafeStreamdown } from "@/core/streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & { export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]; from: UIMessage["role"];
@@ -304,11 +302,11 @@ export const MessageBranchPage = ({
); );
}; };
export type MessageResponseProps = StreamdownProps; export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo( export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => ( ({ className, ...props }: MessageResponseProps) => (
<SafeStreamdown <Streamdown
className={cn( className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className, className,
@@ -34,14 +34,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads"; import type { PromptInputFilePart } from "@/core/uploads";
import { import { splitUnsupportedUploadFiles } from "@/core/uploads";
DEFAULT_UPLOAD_POLICY, import { isIMEComposing } from "@/lib/ime";
splitUnsupportedUploadFiles,
useUploadPolicy,
validateChatUploadFiles,
} from "@/core/uploads";
import { imeEnterAction } from "@/lib/ime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
@@ -62,7 +57,6 @@ import {
Children, Children,
type ClipboardEventHandler, type ClipboardEventHandler,
type ComponentProps, type ComponentProps,
type CompositionEventHandler,
createContext, createContext,
type FormEvent, type FormEvent,
type FormEventHandler, type FormEventHandler,
@@ -151,7 +145,6 @@ const usePromptInputValidation = () => useContext(PromptInputValidationContext);
export type PromptInputProviderProps = PropsWithChildren<{ export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string; initialInput?: string;
uploadPolicy?: UploadPolicy;
}>; }>;
/** /**
@@ -160,12 +153,8 @@ export type PromptInputProviderProps = PropsWithChildren<{
*/ */
export function PromptInputProvider({ export function PromptInputProvider({
initialInput: initialTextInput = "", initialInput: initialTextInput = "",
uploadPolicy,
children, children,
}: PromptInputProviderProps) { }: PromptInputProviderProps) {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
// ----- textInput state // ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput); const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []); const clearInput = useCallback(() => setTextInput(""), []);
@@ -174,7 +163,6 @@ export function PromptInputProvider({
const [attachmentFiles, setAttachmentFiles] = useState< const [attachmentFiles, setAttachmentFiles] = useState<
(PromptInputFilePart & { id: string })[] (PromptInputFilePart & { id: string })[]
>([]); >([]);
const attachmentsRef = useRef(attachmentFiles);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const openRef = useRef<() => void>(() => {}); const openRef = useRef<() => void>(() => {});
@@ -184,26 +172,9 @@ export function PromptInputProvider({
return; return;
} }
const current = attachmentsRef.current; setAttachmentFiles((prev) =>
const existing = current.map((item) => ({ prev.concat(
name: item.filename ?? "attachment", incoming.map((file) => ({
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
incoming,
existing,
effectiveUploadPolicy,
);
for (const message of new Set(
validation.issues.map((issue) => issue.message),
)) {
toast.error(message);
}
if (validation.accepted.length === 0) return;
const next = current.concat(
validation.accepted.map((file) => ({
id: nanoid(), id: nanoid(),
type: "file" as const, type: "file" as const,
url: URL.createObjectURL(file), url: URL.createObjectURL(file),
@@ -211,31 +182,33 @@ export function PromptInputProvider({
filename: file.name, filename: file.name,
file, file,
})), })),
),
); );
attachmentsRef.current = next; }, []);
setAttachmentFiles(next);
}, [effectiveUploadPolicy]);
const remove = useCallback((id: string) => { const remove = useCallback((id: string) => {
const current = attachmentsRef.current; setAttachmentFiles((prev) => {
const found = current.find((f) => f.id === id); const found = prev.find((f) => f.id === id);
if (found?.url) URL.revokeObjectURL(found.url); if (found?.url) {
const next = current.filter((f) => f.id !== id); URL.revokeObjectURL(found.url);
attachmentsRef.current = next; }
setAttachmentFiles(next); return prev.filter((f) => f.id !== id);
});
}, []); }, []);
const clear = useCallback(() => { const clear = useCallback(() => {
for (const f of attachmentsRef.current) { setAttachmentFiles((prev) => {
for (const f of prev) {
if (f.url) { if (f.url) {
URL.revokeObjectURL(f.url); URL.revokeObjectURL(f.url);
} }
} }
attachmentsRef.current = []; return [];
setAttachmentFiles([]); });
}, []); }, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure) // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
const attachmentsRef = useRef(attachmentFiles);
attachmentsRef.current = attachmentFiles; attachmentsRef.current = attachmentFiles;
// Cleanup blob URLs on unmount to prevent memory leaks // Cleanup blob URLs on unmount to prevent memory leaks
@@ -486,15 +459,8 @@ export type PromptInputProps = Omit<
// Minimal constraints // Minimal constraints
maxFiles?: number; maxFiles?: number;
maxFileSize?: number; // bytes maxFileSize?: number; // bytes
uploadPolicy?: UploadPolicy;
onError?: (err: { onError?: (err: {
code: code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
| "max_files"
| "max_file_size"
| "max_total_size"
| "unsupported_type"
| "accept"
| "unsupported_package";
message: string; message: string;
}) => void; }) => void;
onSubmit: ( onSubmit: (
@@ -512,22 +478,11 @@ export const PromptInput = ({
syncHiddenInput, syncHiddenInput,
maxFiles, maxFiles,
maxFileSize, maxFileSize,
uploadPolicy,
onError, onError,
onSubmit, onSubmit,
children, children,
...props ...props
}: PromptInputProps) => { }: PromptInputProps) => {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const policyAccept = useMemo(
() =>
Object.values(effectiveUploadPolicy.groups)
.flatMap((group) => group.extensions)
.join(","),
[effectiveUploadPolicy],
);
// Try to use a provider controller if present // Try to use a provider controller if present
const controller = useOptionalPromptInputController(); const controller = useOptionalPromptInputController();
const usingProvider = !!controller; const usingProvider = !!controller;
@@ -562,9 +517,6 @@ export const PromptInput = ({
.filter(Boolean); .filter(Boolean);
return patterns.some((pattern) => { return patterns.some((pattern) => {
if (pattern.startsWith(".")) {
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
}
if (pattern.endsWith("/*")) { if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/ const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
return f.type.startsWith(prefix); return f.type.startsWith(prefix);
@@ -579,67 +531,52 @@ export const PromptInput = ({
(fileList: File[] | FileList) => { (fileList: File[] | FileList) => {
const incoming = Array.from(fileList); const incoming = Array.from(fileList);
const accepted = incoming.filter((f) => matchesAccept(f)); const accepted = incoming.filter((f) => matchesAccept(f));
if (accepted.length < incoming.length) { if (incoming.length && accepted.length === 0) {
const error = { onError?.({
code: "accept", code: "accept",
message: "部分文件不符合当前输入框允许的格式。", message: "No files match the accepted types.",
} as const; });
onError?.(error); return;
if (!onError) toast.error(error.message);
} }
const localPolicy: UploadPolicy = { const withinSize = (f: File) =>
...effectiveUploadPolicy, maxFileSize ? f.size <= maxFileSize : true;
max_files_per_message: const sized = accepted.filter(withinSize);
typeof maxFiles === "number" if (accepted.length > 0 && sized.length === 0) {
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles) onError?.({
: effectiveUploadPolicy.max_files_per_message, code: "max_file_size",
groups: Object.fromEntries( message: "All files exceed the maximum size.",
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [ });
key, return;
{
...group,
max_bytes:
typeof maxFileSize === "number"
? Math.min(group.max_bytes, maxFileSize)
: group.max_bytes,
},
]),
) as UploadPolicy["groups"],
};
const existing = items.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
accepted,
existing,
localPolicy,
);
for (const issue of Array.from(
new Map(
validation.issues.map((item) => [item.message, item]),
).values(),
)) {
onError?.({ code: issue.code, message: issue.message });
if (!onError) toast.error(issue.message);
} }
if (validation.accepted.length === 0) return;
setItems((prev) => setItems((prev) => {
prev.concat( const capacity =
validation.accepted.map((file) => ({ typeof maxFiles === "number"
? Math.max(0, maxFiles - prev.length)
: undefined;
const capped =
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
if (typeof capacity === "number" && sized.length > capacity) {
onError?.({
code: "max_files",
message: "Too many files. Some were not added.",
});
}
const next: (PromptInputFilePart & { id: string })[] = [];
for (const file of capped) {
next.push({
id: nanoid(), id: nanoid(),
type: "file" as const, type: "file",
url: URL.createObjectURL(file), url: URL.createObjectURL(file),
mediaType: file.type, mediaType: file.type,
filename: file.name, filename: file.name,
file, file,
})), });
), }
); return prev.concat(next);
});
}, },
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError], [matchesAccept, maxFiles, maxFileSize, onError],
); );
const removeLocal = useCallback( const removeLocal = useCallback(
@@ -828,17 +765,11 @@ export const PromptInput = ({
event.preventDefault(); event.preventDefault();
const form = event.currentTarget; const form = event.currentTarget;
// Prefer the live textarea. Firefox 102 applies the chosen candidate to
// the DOM before React state catches up, and a send click in that gap
// would otherwise submit the previous pinyin-less value.
const liveText = form.querySelector<HTMLTextAreaElement>(
"textarea[name='message']",
)?.value;
const text = usingProvider const text = usingProvider
? (liveText ?? controller.textInput.value) ? controller.textInput.value
: (() => { : (() => {
const formData = new FormData(form); const formData = new FormData(form);
return (formData.get("message") as string) || liveText || ""; return (formData.get("message") as string) || "";
})(); })();
// Reset form immediately after capturing text to avoid race condition // Reset form immediately after capturing text to avoid race condition
@@ -901,7 +832,7 @@ export const PromptInput = ({
const inner = ( const inner = (
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}> <PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
<input <input
accept={accept ?? policyAccept} accept={accept}
aria-label="Upload files" aria-label="Upload files"
className="hidden" className="hidden"
multiple={multiple} multiple={multiple}
@@ -916,12 +847,10 @@ export const PromptInput = ({
ref={formRef} ref={formRef}
{...props} {...props}
> >
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions {/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
before 105 ignore them, keep the horizontal flex layout, and versions before 105 ignore :has(), retain h-9, and clip the whole
squeeze the textarea beside the footer controls. */} multi-line composer to a single row. */}
<InputGroup className="h-auto min-h-16 flex-col items-stretch"> <InputGroup className="h-auto min-h-16">{children}</InputGroup>
{children}
</InputGroup>
</form> </form>
</PromptInputValidationContext.Provider> </PromptInputValidationContext.Provider>
); );
@@ -951,43 +880,17 @@ export type PromptInputTextareaProps = ComponentProps<
export const PromptInputTextarea = ({ export const PromptInputTextarea = ({
onChange, onChange,
onInput, onInput,
onKeyDown,
onCompositionStart,
onCompositionEnd,
className, className,
placeholder = "What would you like to know?", placeholder = "What would you like to know?",
defaultValue,
...props ...props
}: PromptInputTextareaProps) => { }: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController(); const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments(); const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation(); const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const composingRef = useRef(false);
const compositionEndedAtRef = useRef(0);
const onChangeRef = useRef(onChange);
const onInputRef = useRef(onInput);
const onKeyDownRef = useRef(onKeyDown);
const onCompositionStartRef = useRef(onCompositionStart);
const onCompositionEndRef = useRef(onCompositionEnd);
const setInputRef = useRef(controller?.textInput.setInput);
const attachmentsRef = useRef(attachments);
onChangeRef.current = onChange;
onInputRef.current = onInput;
onKeyDownRef.current = onKeyDown;
onCompositionStartRef.current = onCompositionStart;
onCompositionEndRef.current = onCompositionEnd;
setInputRef.current = controller?.textInput.setInput;
attachmentsRef.current = attachments;
const publishValue = useCallback((value: string) => {
setInputRef.current?.(value);
}, []);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => { const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
// Firefox 102 commits an open IME composition as raw pinyin when the
// textarea's height or overflow changes mid-composition.
if (composingRef.current) return;
if ( if (
typeof CSS !== "undefined" && typeof CSS !== "undefined" &&
typeof CSS.supports === "function" && typeof CSS.supports === "function" &&
@@ -1008,7 +911,6 @@ export const PromptInputTextarea = ({
// onInput does not run for a controlled value filled by a suggested question // onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too. // or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => { useEffect(() => {
if (composingRef.current) return;
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (!textarea) return; if (!textarea) return;
@@ -1020,28 +922,13 @@ export const PromptInputTextarea = ({
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => { const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
const action = imeEnterAction({ if (isIMEComposing(e, isComposing)) {
isComposing: composingRef.current,
nativeIsComposing: e.nativeEvent.isComposing || e.key === "Process",
keyCode: e.keyCode,
compositionEndedAt: compositionEndedAtRef.current,
now: performance.now(),
});
// Firefox 102 confirms the candidate on this key. preventDefault here
// cancels that confirmation and leaves the pinyin in the box.
if (action === "ignore") {
return; return;
} }
onKeyDownRef.current?.(e); if (e.shiftKey) {
if (e.defaultPrevented || e.shiftKey) {
return; return;
} }
// The confirming Enter already finished the candidate. Swallow it so
// Firefox does not also insert a newline or send the message.
e.preventDefault(); e.preventDefault();
if (action === "swallow") {
return;
}
// Check if the submit button is disabled before submitting // Check if the submit button is disabled before submitting
const form = e.currentTarget.form; const form = e.currentTarget.form;
@@ -1053,59 +940,22 @@ export const PromptInputTextarea = ({
} }
form?.requestSubmit(); form?.requestSubmit();
return;
} }
onKeyDownRef.current?.(e);
if (e.defaultPrevented) return;
if (composingRef.current) return;
// Remove last attachment when Backspace is pressed and textarea is empty // Remove last attachment when Backspace is pressed and textarea is empty
if ( if (
e.key === "Backspace" && e.key === "Backspace" &&
e.currentTarget.value === "" && e.currentTarget.value === "" &&
attachmentsRef.current.files.length > 0 attachments.files.length > 0
) { ) {
e.preventDefault(); e.preventDefault();
const lastAttachment = attachmentsRef.current.files.at(-1); const lastAttachment = attachments.files.at(-1);
if (lastAttachment) { if (lastAttachment) {
attachmentsRef.current.remove(lastAttachment.id); attachments.remove(lastAttachment.id);
} }
} }
}; };
const handleCompositionStart: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
composingRef.current = true;
compositionEndedAtRef.current = 0;
onCompositionStartRef.current?.(event);
};
const handleCompositionEnd: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
onCompositionEndRef.current?.(event);
composingRef.current = false;
compositionEndedAtRef.current = performance.now();
const el = event.currentTarget;
// Firefox 102 dispatches compositionend before the committed candidate is
// visible, and before the input event that carries it. Syncing the
// compositionend value here would write the raw pinyin back over 汉字.
window.setTimeout(() => {
if (el.isConnected) publishValue(el.value);
}, 0);
};
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
// Keep the controlled value identical to the DOM, including the pinyin
// still being composed. Otherwise React restores the previous value at
// the end of the input event and Firefox 102 cannot replace it.
publishValue(event.currentTarget.value);
onChangeRef.current?.(event);
};
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => { const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
const items = event.clipboardData?.items; const items = event.clipboardData?.items;
@@ -1135,28 +985,35 @@ export const PromptInputTextarea = ({
} }
}; };
const controlledProps = controller
? {
value: controller.textInput.value,
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
controller.textInput.setInput(e.currentTarget.value);
onChange?.(e);
},
}
: {
onChange,
};
return ( return (
<InputGroupTextarea <InputGroupTextarea
{...props}
className={cn("field-sizing-content max-h-48 min-h-16", className)} className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message" name="message"
ref={textareaRef} ref={textareaRef}
rows={3} rows={3}
onChange={handleChange} onCompositionEnd={() => setIsComposing(false)}
onCompositionEnd={handleCompositionEnd} onCompositionStart={() => setIsComposing(true)}
onCompositionStart={handleCompositionStart}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onInput={(event) => { onInput={(event) => {
if (!composingRef.current) {
resizeForLegacyChrome(event.currentTarget); resizeForLegacyChrome(event.currentTarget);
} onInput?.(event);
onInputRef.current?.(event);
}} }}
onPaste={handlePaste} onPaste={handlePaste}
placeholder={placeholder} placeholder={placeholder}
{...(controller {...props}
? { value: controller.textInput.value } {...controlledProps}
: { defaultValue })}
/> />
); );
}; };
@@ -1276,22 +1133,18 @@ export const PromptInputSubmit = ({
...props ...props
}: PromptInputSubmitProps) => { }: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-4" />; let Icon = <ArrowUpIcon className="size-4" />;
let ariaLabel = "发送消息";
if (status === "submitted") { if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />; Icon = <Loader2Icon className="size-4 animate-spin" />;
ariaLabel = "正在发送";
} else if (status === "streaming") { } else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />; Icon = <SquareIcon className="size-4" />;
ariaLabel = "停止生成";
} else if (status === "error") { } else if (status === "error") {
Icon = <XIcon className="size-4" />; Icon = <XIcon className="size-4" />;
ariaLabel = "重新发送";
} }
return ( return (
<InputGroupButton <InputGroupButton
aria-label={ariaLabel} aria-label="Submit"
className={cn( className={cn(
"transition-all duration-300", "transition-all duration-300",
status === "streaming" && status === "streaming" &&
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react"; import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react";
import { SafeStreamdown } from "@/core/streamdown"; import { Streamdown } from "streamdown";
import { reasoningPlugins } from "@/core/streamdown/plugins"; import { reasoningPlugins } from "@/core/streamdown/plugins";
import { Shimmer } from "./shimmer"; import { Shimmer } from "./shimmer";
@@ -179,7 +179,7 @@ export const ReasoningContent = memo(
{...props} {...props}
> >
{typeof children === "string" ? ( {typeof children === "string" ? (
<SafeStreamdown {...reasoningPlugins}>{children}</SafeStreamdown> <Streamdown {...reasoningPlugins}>{children}</Streamdown>
) : ( ) : (
children children
)} )}
@@ -31,11 +31,6 @@ import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty"; import Empty from "./empty";
import { NetworkActor } from "./network-actor"; import { NetworkActor } from "./network-actor";
import {
normalizeV2UploadSources,
type StudioUploadSource,
type V2UploadResponse,
} from "@/core/studio/document-upload";
// ── 适配层 ───────────────────────────────────────────────── // ── 适配层 ─────────────────────────────────────────────────
@@ -91,7 +86,22 @@ interface DocumentUploadDialogContextType {
data?: { query: string; mode: string } data?: { query: string; mode: string }
) => Promise<void>; ) => Promise<void>;
// 上传成功回调,返回上传的来源信息 // 上传成功回调,返回上传的来源信息
onUploadComplete?: (sources: StudioUploadSource[]) => void; onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}
// ── 任务轮询类型 ─────────────────────────────────────────────
interface TaskSource {
task_id: string;
title: string;
status: 'processing' | 'success' | 'error';
source_type: string;
}
interface UploadResponse {
status: string;
uploaded: number;
skipped: number;
sources: TaskSource[];
} }
interface listDataItem { interface listDataItem {
@@ -214,7 +224,7 @@ export const DocumentUploadDialogProvider: FC<{
) => void | Promise<void>; ) => void | Promise<void>;
searchSpace: { id: number | string;[key: string]: unknown }; searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void; onUploadSuccess?: () => void;
onUploadComplete?: (sources: StudioUploadSource[]) => void; onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => { }> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [initialSearchText, setInitialSearchText] = useState(""); const [initialSearchText, setInitialSearchText] = useState("");
@@ -343,23 +353,27 @@ const DocumentUploadPopupContent: FC<{
for (const file of files) { for (const file of files) {
formData.append("files", file); formData.append("files", file);
} }
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload-v2`, { const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: formData, body: formData,
}); });
if (res.ok) { if (res.ok) {
const data = await res.json() as V2UploadResponse; const data = await res.json() as UploadResponse;
// 调用上传完成回调,传递来源信息 // 调用上传完成回调,传递来源信息
if (data.sources && data.sources.length > 0) { if (data.sources && data.sources.length > 0) {
onUploadComplete?.(normalizeV2UploadSources(data)); onUploadComplete?.(data.sources.map(s => ({
id: undefined, // 后端返回的数据中暂时没有 id
title: s.title,
task_id: s.task_id,
status: s.status,
})));
} }
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`); toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
// 只关闭上传弹窗 // 只关闭上传弹窗
onOpenChange(false); onOpenChange(false);
} else { } else {
const payload = await res.json().catch(() => null) as { detail?: string } | null; toast.error("上传失败,请重试");
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
} }
} catch { } catch {
toast.error("上传失败,请重试"); toast.error("上传失败,请重试");
@@ -396,6 +410,7 @@ const DocumentUploadPopupContent: FC<{
selectedSearchSources.map((s) => ({ selectedSearchSources.map((s) => ({
id: undefined, id: undefined,
title: s.title, title: s.title,
task_id: undefined,
status: "success" as const, status: "success" as const,
})) }))
); );
+18 -34
View File
@@ -1,19 +1,14 @@
"use client"; "use client";
import { useLayoutEffect } from "react"; import { useEffect } from "react";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import {
applyFontScaleToDocument,
normalizeFontScale,
normalizeFontScaleMode,
} from "@/core/ui/page-zoom";
/** /**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。 * 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。 * 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变; * 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。 * 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* `fontScale === 1` 即默认 16px。 * `fontScale === 1` 即默认 16px。
*/ */
export const BASE_FONT_PX = 16; export const BASE_FONT_PX = 16;
@@ -26,36 +21,25 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
{ value: 1.25, label: "特大", px: 20 }, { value: 1.25, label: "特大", px: 20 },
]; ];
export const FONT_SCALE_MODE_OPTIONS: {
value: "text" | "page";
label: string;
description: string;
}[] = [
{
value: "text",
label: "仅文字",
description: "只放大文字,间距和布局保持原样。",
},
{
value: "page",
label: "整页缩放",
description: "文字、间距、图标和写死的像素一起放大。",
},
];
/** /**
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。 * 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
*/ */
export function FontScaleApplier() { export function FontScaleApplier() {
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const { fontScale, fontScaleMode } = settings.display; const { fontScale } = settings.display;
useLayoutEffect(() => { useEffect(() => {
applyFontScaleToDocument( const scale =
normalizeFontScaleMode(fontScaleMode), typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
normalizeFontScale(fontScale), ? fontScale
); : 1;
}, [fontScale, fontScaleMode]); const root = document.documentElement;
root.style.setProperty("--ui-font-scale", String(scale));
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
root.style.removeProperty("font-size");
}, [fontScale]);
return null; return null;
} }
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`) * is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is * navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected — * a real document URL, so fragment links scroll within the frame as expected —
* The sandbox permits scripts so the preview can use the app-bundled HTML * all without enabling scripts (the sandbox stays script-free for security; the
* visualization runtimes, while still withholding same-origin privileges. * server already sanitizes script vectors and this is defense-in-depth).
* *
* The `charset=utf-8` on the blob type is required: a blob: URL whose * The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on * Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
@@ -26,9 +26,7 @@ export function HtmlPreviewFrame({
const [url, setUrl] = useState<string>(); const [url, setUrl] = useState<string>();
useEffect(() => { useEffect(() => {
const blob = new Blob([ const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
prepareGeneratedHtml(html ?? ""),
], {
type: "text/html;charset=utf-8", type: "text/html;charset=utf-8",
}); });
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
@@ -1,5 +1,3 @@
import { toCssPx } from "@/core/ui/page-zoom";
import type { TooltipState } from "./graphTypes"; import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils"; import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
@@ -11,8 +9,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null; if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind]; const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport. // Keep the tooltip inside the viewport.
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336); const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120); const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
return ( return (
<div className="kg-tooltip" style={{ left, top }}> <div className="kg-tooltip" style={{ left, top }}>
+8 -8
View File
@@ -4,19 +4,19 @@
top: 4rem; top: 4rem;
z-index: 40; z-index: 40;
width: 98px; width: 98px;
height: calc(100vh / var(--df-page-zoom, 1) - 4rem); height: calc(100vh - 4rem);
flex-shrink: 0; flex-shrink: 0;
} }
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */ /* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
.sidebar-contnet--header-hidden { .sidebar-contnet--header-hidden {
top: 0; top: 0;
height: calc(100vh / var(--df-page-zoom, 1)); height: 100vh;
} }
.sidebar-contnet--header-hidden .item-nav-content { .sidebar-contnet--header-hidden .item-nav-content {
height: calc(100vh / var(--df-page-zoom, 1) - 20px); height: calc(100vh - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px); min-height: calc(100vh - 20px);
} }
.sidebar-contents { .sidebar-contents {
@@ -160,8 +160,8 @@
.item-nav-content { .item-nav-content {
width: 155px; width: 155px;
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px); height: calc(100vh - 4rem - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px); min-height: calc(100vh - 4rem - 20px);
background: rgba(3, 38, 80, 0.8); background: rgba(3, 38, 80, 0.8);
border: 1px solid; border: 1px solid;
border-color: rgba(43, 143, 254, 0.6); border-color: rgba(43, 143, 254, 0.6);
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
top: 4rem; top: 4rem;
z-index: 40; z-index: 40;
width: 96px; width: 96px;
height: calc(100vh / var(--df-page-zoom, 1) - 4rem); height: calc(100vh - 4rem);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
background: #ffffff; background: #ffffff;
@@ -653,7 +653,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */ /* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
.light-sidebar--header-hidden { .light-sidebar--header-hidden {
top: 0; top: 0;
height: calc(100vh / var(--df-page-zoom, 1)); height: 100vh;
} }
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */ /* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
@@ -194,7 +194,7 @@ export function PageSidebar({
onOpenChange={setNotificationOpen} onOpenChange={setNotificationOpen}
/> />
<aside <aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh/var(--df-page-zoom,1)-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${ className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark isDark
? "border-r border-blue-800/30 bg-slate-900" ? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white" : "border-r border-red-100 bg-white"
+7 -39
View File
@@ -16,8 +16,6 @@ import {
Settings, Settings,
ShieldAlert, ShieldAlert,
ShieldCheck, ShieldCheck,
ScrollText,
Timer,
User, User,
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
@@ -224,12 +222,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
<User className="size-4" /> <User className="size-4" />
个人信息 个人信息
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")} onClick={() => navigate("/page/strategy/admin/menu-management")}
> >
<ListTree className="size-4" /> <ListTree className="size-4" />
{isAdmin ? "默认菜单管理" : "我的菜单"} 菜单管理
</DropdownMenuItem> </DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/task-buttons")} onClick={() => navigate("/page/strategy/admin/task-buttons")}
@@ -270,22 +270,6 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
模型配置 模型配置
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}> <DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" /> <Cpu className="size-4" />
@@ -500,9 +484,7 @@ function LegacyAccordionSidebar({
/> />
<aside <aside
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${ className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
headerHidden headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
} ${ } ${
isDark isDark
? "border-r border-blue-800/30 bg-slate-900" ? "border-r border-blue-800/30 bg-slate-900"
@@ -579,10 +561,12 @@ function LegacyAccordionSidebar({
<User className="size-4" /> <User className="size-4" />
个人信息 个人信息
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}> <DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
<ListTree className="size-4" /> <ListTree className="size-4" />
{isAdmin ? "默认菜单管理" : "我的菜单"} 菜单管理
</DropdownMenuItem> </DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}> <DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
<MousePointerClick className="size-4" /> <MousePointerClick className="size-4" />
@@ -607,22 +591,6 @@ function LegacyAccordionSidebar({
模型配置 模型配置
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}> <DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" /> <Cpu className="size-4" />
@@ -1,54 +0,0 @@
import { useLayoutEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { ReactUiErrorBoundary } from "./react-ui-error-boundary";
// Offline browser QA: real React production render/commit recursion, not a
// fabricated #185 thrown by a unit-test stub. A manual button click reloads
// this same page without submitting any conversation or model request.
const key = "deerflow.boundary-browser-check";
let mounts = 0;
function InfiniteCommitLoop() {
const [, update] = useState(0);
useState(() => { mounts += 1; return 0; });
useLayoutEffect(() => { update((value) => value + 1); });
return <div>Loop fixture</div>;
}
function result(passed: boolean, details: Record<string, unknown>) {
const output = document.createElement("pre");
output.id = "boundary-check-result";
output.textContent = JSON.stringify({ passed, ...details });
document.body.append(output);
}
const resumed = sessionStorage.getItem(key) === "manual-reload";
createRoot(document.getElementById("root")!).render(
<ReactUiErrorBoundary>
{resumed ? <div id="healthy">Existing page restored</div> : <InfiniteCommitLoop />}
</ReactUiErrorBoundary>,
);
if (resumed) {
sessionStorage.removeItem(key);
window.setTimeout(() => result(Boolean(document.getElementById("healthy")), {
phase: "manual-reload-restored", renderLoopCaught: true, noAutomaticRemount: true,
}), 100);
} else {
window.setTimeout(() => {
const alert = document.querySelector('[role="alert"]');
const before = mounts;
if (!alert || /#185|react\.dev|Maximum update depth/.test(alert.textContent ?? "")) {
result(false, { phase: "friendly-fallback", hasAlert: Boolean(alert) });
return;
}
window.setTimeout(() => {
if (mounts !== before || before !== 1) {
result(false, { phase: "unexpected-automatic-remount", mounts, before });
return;
}
sessionStorage.setItem(key, "manual-reload");
(alert.querySelector("button") as HTMLButtonElement).click();
}, 250);
}, 100);
}
@@ -1,45 +0,0 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { isReactMaximumUpdateDepthError } from "@/core/threads/stream-error-classification";
import { REACT_UI_ERROR_MESSAGE } from "@/core/threads/react-ui-error";
/** Last-resort render/commit guard. It deliberately never auto-remounts a broken
* tree, which could otherwise repeatedly resume the same run and crash again. */
export class ReactUiErrorBoundary extends Component<
{ children: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError(error: unknown) {
if (!isReactMaximumUpdateDepthError(error)) throw error;
return { failed: true };
}
componentDidCatch(_error: unknown, info: ErrorInfo) {
console.warn("[deer-flow] react_ui_render_error", {
errorType: "react_maximum_update_depth",
componentStack: info.componentStack,
recovery: "manual_reload",
});
}
render() {
if (!this.state.failed) return this.props.children;
return (
<main className="flex min-h-screen items-center justify-center p-6">
<section role="alert" className="max-w-lg rounded-lg border p-6 text-center">
<h1 className="mb-3 text-lg font-medium">页面暂时无法显示</h1>
<p className="mb-5 text-sm text-muted-foreground">{REACT_UI_ERROR_MESSAGE}</p>
<button
className="rounded-md bg-primary px-4 py-2 text-primary-foreground"
onClick={() => window.location.reload()}
type="button"
>
重新载入页面
</button>
</section>
</main>
);
}
}
@@ -1,16 +0,0 @@
/** Lightweight, dependency-free fallback used while a route chunk loads. */
export function RouteLoadingFallback() {
return (
<div
className="text-muted-foreground flex size-full min-h-32 items-center justify-center gap-2 bg-background"
role="status"
aria-live="polite"
>
<span
className="border-muted-foreground/30 border-t-foreground size-4 animate-spin rounded-full border-2"
aria-hidden="true"
/>
<span className="text-sm">正在加载页面…</span>
</div>
);
}
+2 -7
View File
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
ref={ref} ref={ref}
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"legacy-dialog-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[200] bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className, className,
)} )}
{...props} {...props}
@@ -82,15 +82,11 @@ const DialogContent = React.forwardRef<
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} /> <DialogOverlay className={overlayClassName} />
<div
data-slot="dialog-viewport"
className={cn("legacy-dialog-viewport", overlayClassName)}
>
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-[200] grid min-h-0 max-h-full w-full max-w-[calc(100%-2rem)] pointer-events-auto gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className, className,
)} )}
{...props} {...props}
@@ -106,7 +102,6 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</div>
</DialogPortal> </DialogPortal>
); );
}); });
@@ -46,7 +46,7 @@ const DropdownMenuContent = React.forwardRef<
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className, className,
)} )}
{...props} {...props}
@@ -240,7 +240,7 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref} ref={ref}
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -62,7 +62,7 @@ const SelectContent = React.forwardRef<
ref={ref} ref={ref}
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className, className,
@@ -1,4 +1,5 @@
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -20,12 +21,12 @@ export function Breadcrumb({
)} )}
> >
{segments.map((segment, i) => ( {segments.map((segment, i) => (
<div className="contents" key={`${segment}-${i}`}> <Fragment key={`${segment}-${i}`}>
<span className="hidden shrink-0 whitespace-nowrap sm:inline"> <span className="hidden shrink-0 whitespace-nowrap sm:inline">
{segment} {segment}
</span> </span>
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" /> <ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
</div> </Fragment>
))} ))}
<span <span
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300" className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
@@ -1,10 +1,8 @@
"use client"; "use client";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/workspace/settings/settings-section"; import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks"; import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -19,11 +17,6 @@ export function UserManagementPage() {
title={t.adminUsers.title} title={t.adminUsers.title}
description={t.adminUsers.description} description={t.adminUsers.description}
> >
<div className="mb-4 flex justify-end">
<Button variant="outline" asChild>
<Link to="/page/strategy/admin/login-whitelist">管理密码与登录口令</Link>
</Button>
</div>
{isLoading && ( {isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center"> <div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -534,6 +534,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
} }
} }
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by // Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row. // both the card header and the compact list row.
const statusBadges = ( const statusBadges = (
@@ -961,7 +966,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</span> </span>
</div> </div>
<SkillTransferSelector <SkillTransferSelector
skills={skills} skills={enabledSkills}
selected={selectedSkills} selected={selectedSkills}
onChange={setSelectedSkills} onChange={setSelectedSkills}
isLoading={isSkillsLoading} isLoading={isSkillsLoading}
@@ -51,10 +51,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents"; import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
@@ -73,8 +69,6 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import { import {
bootstrapResumePath, bootstrapResumePath,
isBootstrapThread, isBootstrapThread,
@@ -385,7 +379,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))), () => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads], [threads],
); );
const projectThreadNames = useProjectThreadNames(visibleThreads);
const filteredThreads = useMemo(() => { const filteredThreads = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -486,10 +479,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : ( ) : (
filteredThreads.map((thread) => { filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname; const isActive = href === pathname;
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
@@ -503,7 +492,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
href={href} href={href}
className={cn( className={cn(
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors", "relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
projectInfo && "h-auto min-h-12 py-1.5",
isActive isActive
? "border border-primary/40 bg-primary/5 font-medium text-primary" ? "border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground", : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
@@ -517,8 +505,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -530,23 +516,9 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
{pinned && ( {pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" /> <Pin className="text-muted-foreground/70 size-3 shrink-0" />
)} )}
<span <span className="truncate">
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)} )}
@@ -152,8 +152,6 @@ export function AgentDetailPanel({
positionName, positionName,
skillsIcon, skillsIcon,
skillsTitle, skillsTitle,
belowSkillsSlot,
feedbackScoreSlot,
showCommonQuestions = false, showCommonQuestions = false,
jobResponsibilities, jobResponsibilities,
panelTitle, panelTitle,
@@ -183,10 +181,6 @@ export function AgentDetailPanel({
positionName?: string; positionName?: string;
skillsIcon?: ReactNode; skillsIcon?: ReactNode;
skillsTitle?: string; skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
/** Optional answer-feedback summary rendered near the agent identity. */
feedbackScoreSlot?: ReactNode;
showCommonQuestions?: boolean; showCommonQuestions?: boolean;
/** /**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示; * 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -750,8 +744,6 @@ export function AgentDetailPanel({
)} )}
</section> </section>
{!editing && feedbackScoreSlot}
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */} {/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
{jobResponsibilities !== undefined && ( {jobResponsibilities !== undefined && (
@@ -913,7 +905,7 @@ export function AgentDetailPanel({
{/* ── 技能管理 ───────────────────────────────────────────────────── */} {/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section <Section
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />} icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
title={skillsTitle ?? `已配置技能 ${skillNamesShown.length} 个`} title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
className="position-roundtable-skills-section" className="position-roundtable-skills-section"
extra={ extra={
editing ? ( editing ? (
@@ -948,9 +940,6 @@ export function AgentDetailPanel({
/> />
</Section> </Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */} {/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />} {showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div> </div>
@@ -1,213 +0,0 @@
"use client";
import { BarChart3Icon, MessageSquareTextIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
useAgentFeedbackScore,
type AnswerFeedbackReason,
type FeedbackDimensionKey,
} from "@/strategy-components/api/answer-feedback";
const DIMENSIONS: Array<{
key: FeedbackDimensionKey;
label: string;
fallbackWeight: number;
}> = [
{ key: "correctness", label: "正确性", fallbackWeight: 30 },
{ key: "completeness", label: "完整性", fallbackWeight: 20 },
{ key: "evidence_grounding", label: "证据与依据", fallbackWeight: 20 },
{ key: "relevance", label: "相关性", fallbackWeight: 15 },
{ key: "actionability", label: "可操作性", fallbackWeight: 15 },
];
const REASON_LABELS: Record<AnswerFeedbackReason, string> = {
incorrect_or_incomplete: "不正确或不完整",
unmet_expectations: "与期望不符",
slow_or_problematic: "速度慢或存在问题",
style_or_tone: "风格或语气",
safety_or_legal: "安全或法律疑虑",
other: "其他",
};
const CONFIDENCE_LABELS = {
none: "暂无置信度",
low: "低置信度",
medium: "中等置信度",
high: "高置信度",
} as const;
function scoreText(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value)
? value.toFixed(1)
: "—";
}
function percent(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const normalized = value <= 1 ? value * 100 : value;
return Math.max(0, Math.min(100, normalized));
}
function weightPercent(value: number | undefined, fallback: number) {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return value <= 1 ? Math.round(value * 100) : Math.round(value);
}
export function AgentFeedbackScoreCard({ agentId }: { agentId: string }) {
const { data, isLoading, isError } = useAgentFeedbackScore(agentId);
if (isLoading) {
return (
<section className="bg-card/60 rounded-xl border p-4" aria-label="回答评价加载中">
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
<div className="bg-muted mt-4 h-12 w-28 animate-pulse rounded-lg" />
<div className="mt-4 space-y-2">
{DIMENSIONS.map((item) => (
<div key={item.key} className="bg-muted h-3 animate-pulse rounded" />
))}
</div>
</section>
);
}
if (isError || !data) {
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-center gap-1.5 text-sm font-semibold">
<BarChart3Icon className="size-3.5" />
回答评价
</div>
<p className="text-muted-foreground mt-2 text-xs">评价数据暂不可用</p>
</section>
);
}
const positiveRate = data.sample_count
? (data.positive_count / data.sample_count) * 100
: null;
const taskSuccessRate = percent(data.task_success_rate);
const topReasons = Object.entries(data.reason_breakdown ?? {})
.filter((entry): entry is [AnswerFeedbackReason, { count: number; rate: number }] =>
Boolean(entry[1]?.count),
)
.sort((left, right) => right[1].count - left[1].count)
.slice(0, 3);
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
<BarChart3Icon className="size-3.5" />
回答评价
</h4>
<p className="text-muted-foreground mt-1 text-xs">用户评价分</p>
</div>
{data.score_band && data.score !== null && (
<span className="border-primary/20 bg-primary/5 text-primary rounded-full border px-2 py-1 text-[11px] font-medium">
{data.score_band.label}
</span>
)}
</div>
<div className="mt-3 flex items-end gap-2">
{data.score === null ? (
<span className="text-muted-foreground text-xl font-semibold">暂无评价</span>
) : (
<>
<span className="text-3xl font-bold tracking-tight tabular-nums">
{scoreText(data.score)}
</span>
<span className="text-muted-foreground mb-1 text-xs">/ 100</span>
</>
)}
</div>
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px]">
<span>{data.sample_count} 条评价</span>
<span>
{CONFIDENCE_LABELS[data.confidence_level]}
{` ${Math.round(data.confidence * 100)}%`}
</span>
<span>
好评率 {positiveRate === null ? "—" : `${positiveRate.toFixed(0)}%`}
</span>
</div>
{data.sample_count > 0 && (
<div className="mt-4 space-y-3" aria-label="回答评价维度">
{DIMENSIONS.map((item) => {
const dimension = data.dimensions[item.key];
const value = percent(dimension?.score);
return (
<div key={item.key}>
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
<span className="text-foreground">
{item.label}
<span className="text-muted-foreground ml-1">
{weightPercent(dimension?.weight, item.fallbackWeight)}%
</span>
</span>
<span className="text-muted-foreground tabular-nums">
{value === null ? "—" : value.toFixed(1)}
{dimension?.evidence_count
? ` · ${dimension.evidence_count} 条依据`
: ""}
</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className={cn(
"h-full rounded-full transition-[width]",
value !== null && value >= 80
? "bg-emerald-500"
: value !== null && value >= 60
? "bg-amber-500"
: "bg-rose-500",
)}
style={{ width: `${value ?? 0}%` }}
/>
</div>
</div>
);
})}
</div>
)}
{(data.task_score !== null || taskSuccessRate !== null) && (
<div className="bg-muted/50 mt-4 flex items-center justify-between rounded-lg px-3 py-2 text-xs">
<span className="flex items-center gap-1.5 font-medium">
<MessageSquareTextIcon className="size-3.5" />
任务成功与稳定性
</span>
<span className="text-muted-foreground tabular-nums">
{data.task_score !== null && `评分 ${scoreText(data.task_score)}`}
{data.task_score !== null && taskSuccessRate !== null && " · "}
{taskSuccessRate !== null && `成功率 ${taskSuccessRate.toFixed(0)}%`}
</span>
</div>
)}
{topReasons.length > 0 && (
<div className="mt-4 border-t pt-3">
<p className="text-muted-foreground mb-2 text-[11px] font-medium">主要改进反馈</p>
<div className="flex flex-wrap gap-1.5">
{topReasons.map(([reason, breakdown]) => (
<span
key={reason}
className="bg-muted text-muted-foreground rounded-full px-2 py-1 text-[11px]"
>
{REASON_LABELS[reason]} · {breakdown.count}
</span>
))}
</div>
</div>
)}
<p className="text-muted-foreground/80 mt-4 border-t pt-3 text-[10px] leading-relaxed">
该分数仅反映用户对回答结果的评价,不代表完整任务轨迹、工具调用或技能能力。
</p>
</section>
);
}
@@ -1,18 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { canViewAgentFeedbackScore } from "./agent-feedback-visibility.ts";
test("allows only the canonical admin role to view agent feedback scores", () => {
assert.equal(canViewAgentFeedbackScore({ system_role: "admin" }), true);
assert.equal(canViewAgentFeedbackScore({ system_role: "user" }), false);
assert.equal(canViewAgentFeedbackScore({ system_role: "Admin" }), false);
});
test("fails closed when authentication or its role is unavailable", () => {
assert.equal(canViewAgentFeedbackScore(null), false);
assert.equal(canViewAgentFeedbackScore(undefined), false);
assert.equal(canViewAgentFeedbackScore({ system_role: "" }), false);
});
@@ -1,12 +0,0 @@
import type { LoginByUsernameResponse } from "@/core/auth";
/**
* Agent-level answer scores are an administrative quality-control view.
* Keep the role check strict so missing, stale or unknown auth roles fail
* closed instead of exposing aggregate evaluation data.
*/
export function canViewAgentFeedbackScore(
auth: Pick<LoginByUsernameResponse, "system_role"> | null | undefined,
): boolean {
return auth?.system_role === "admin";
}
@@ -16,16 +16,8 @@ import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useRouteBase } from "@/core/route-base";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config"; import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks"; import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import {
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
WX_RESTRICTED_TAB_NANJING_DB,
isWxRestrictedPlaceholderTab,
wxRestrictedDefaultSquareTabValue,
wxRestrictedNanjingTabValue,
} from "@/core/system-settings/wx-type-filter";
import { AgentCard } from "./agent-card"; import { AgentCard } from "./agent-card";
import { SentimentAgentCard } from "./sentiment-agent-card"; import { SentimentAgentCard } from "./sentiment-agent-card";
@@ -42,15 +34,9 @@ const PAGE_SIZE = 12;
// password never persists beyond the browsing session. // password never persists beyond the browsing session.
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password"; const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery({ export function AgentGallery() {
restrictWxTypes = false,
}: {
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
restrictWxTypes?: boolean;
} = {}) {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const routeBase = useRouteBase();
const sentimentAgentVisible = isSentimentAgentVisible(); const sentimentAgentVisible = isSentimentAgentVisible();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。 // 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
@@ -66,12 +52,6 @@ export function AgentGallery({
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型, // 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
// 其余自定义广场继续保持后端给出的顺序。 // 其余自定义广场继续保持后端给出的顺序。
const [defaultSquare, ...additionalSquares] = squareTabs; const [defaultSquare, ...additionalSquares] = squareTabs;
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
publishSquares?.default_square_id,
);
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
const wxPlaceholderTab =
restrictWxTypes && isWxRestrictedPlaceholderTab(activeTab);
// Publisher filter: click a card's 发布者 to keep only that user's agents. // Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name. // {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>( const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
@@ -92,30 +72,6 @@ export function AgentGallery({
// If the active square tab disappears (admin removed that square), fall back // If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab. // to "我的" so the gallery never points at a dead tab.
useEffect(() => { useEffect(() => {
if (restrictWxTypes) {
if (
activeTab === WX_RESTRICTED_TAB_NANJING_DB &&
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
) {
setActiveTab(nanjingTabValue);
return;
}
if (
activeTab === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
) {
setActiveTab(defaultSquareTabValue);
return;
}
if (
activeTab !== "mine" &&
activeTab !== defaultSquareTabValue &&
activeTab !== nanjingTabValue
) {
setActiveTab("mine");
}
return;
}
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。 // 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
if (activeTab === "builtin" && !isAdmin) { if (activeTab === "builtin" && !isAdmin) {
setActiveTab("mine"); setActiveTab("mine");
@@ -131,14 +87,7 @@ export function AgentGallery({
) { ) {
setActiveTab("mine"); setActiveTab("mine");
} }
}, [ }, [activeTab, squareTabs, isAdmin]);
activeTab,
squareTabs,
isAdmin,
restrictWxTypes,
defaultSquareTabValue,
nanjingTabValue,
]);
const { data: privateStatus } = usePrivateSquareStatus(); const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled; const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -179,13 +128,10 @@ export function AgentGallery({
sort, sort,
page: mainPage, page: mainPage,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
enabled: enabled: activeTab !== "private" && activeTab !== "sentiment",
activeTab !== "private" &&
activeTab !== "sentiment" &&
!wxPlaceholderTab,
owner: publisher?.id, owner: publisher?.id,
// Each square tab fetches only that square's agents. // Each square tab fetches only that square's agents.
square: isSquareTab && !wxPlaceholderTab ? activeTab : undefined, square: isSquareTab ? activeTab : undefined,
}); });
const { const {
@@ -223,9 +169,7 @@ export function AgentGallery({
(activeTab !== "private" || privateUnlocked); (activeTab !== "private" || privateUnlocked);
const activeTotal = const activeTotal =
wxPlaceholderTab activeTab === "sentiment"
? 0
: activeTab === "sentiment"
? 1 ? 1
: activeTab === "private" : activeTab === "private"
? privateTotal ? privateTotal
@@ -460,14 +404,6 @@ export function AgentGallery({
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
title="打开智能体广场视图"
onClick={() => router.push(`${routeBase}/agents/plaza`)}
>
广场视图
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -506,14 +442,6 @@ export function AgentGallery({
className="w-auto max-w-full flex-wrap justify-start self-start" className="w-auto max-w-full flex-wrap justify-start self-start"
data-agent-guide="tabs" data-agent-guide="tabs"
> >
{restrictWxTypes ? (
<>
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
</>
) : (
<>
<TabsTrigger value="mine">我的</TabsTrigger> <TabsTrigger value="mine">我的</TabsTrigger>
{defaultSquare && ( {defaultSquare && (
<TabsTrigger <TabsTrigger
@@ -547,8 +475,6 @@ export function AgentGallery({
内置智能体 内置智能体
</TabsTrigger> </TabsTrigger>
)} )}
</>
)}
</TabsList> </TabsList>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm whitespace-nowrap"> <span className="text-muted-foreground text-sm whitespace-nowrap">
@@ -619,41 +545,7 @@ export function AgentGallery({
)} )}
</div> </div>
{restrictWxTypes ? ( {squareTabs.map((sq) => (
<>
<TabsContent
value={defaultSquareTabValue}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{wxPlaceholderTab && activeTab === defaultSquareTabValue
? renderEmpty("该广场暂无智能体")
: renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: defaultSquareTabValue,
emptyDescription: "该广场暂无智能体",
})}
</TabsContent>
<TabsContent
value={nanjingTabValue}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{wxPlaceholderTab && activeTab === nanjingTabValue
? renderEmpty("该类型暂无智能体")
: renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: nanjingTabValue,
emptyDescription: "该类型暂无智能体",
})}
</TabsContent>
</>
) : (
squareTabs.map((sq) => (
<TabsContent <TabsContent
key={sq.id} key={sq.id}
value={sq.id} value={sq.id}
@@ -668,8 +560,7 @@ export function AgentGallery({
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`, emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
})} })}
</TabsContent> </TabsContent>
)) ))}
)}
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({ {renderListBody({
@@ -685,7 +576,7 @@ export function AgentGallery({
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑), {/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
接口地址与鉴权在 runtime-config.js 配置。 */} 接口地址与鉴权在 runtime-config.js 配置。 */}
{!restrictWxTypes && sentimentAgentVisible && ( {sentimentAgentVisible && (
<TabsContent <TabsContent
value="sentiment" value="sentiment"
className="flex-1 overflow-y-auto p-6 mt-0" className="flex-1 overflow-y-auto p-6 mt-0"
@@ -702,7 +593,7 @@ export function AgentGallery({
</TabsContent> </TabsContent>
)} )}
{!restrictWxTypes && isAdmin && ( {isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({ {renderListBody({
list: mainAgents, list: mainAgents,
@@ -715,7 +606,7 @@ export function AgentGallery({
</TabsContent> </TabsContent>
)} )}
{!restrictWxTypes && privateFeatureAvailable && ( {privateFeatureAvailable && (
<TabsContent <TabsContent
value="private" value="private"
className="flex-1 overflow-y-auto p-6 mt-0" className="flex-1 overflow-y-auto p-6 mt-0"
@@ -278,7 +278,6 @@ export function AgentGuide({
autoStart?: boolean; autoStart?: boolean;
}) { }) {
const [current, setCurrent] = useState(-1); const [current, setCurrent] = useState(-1);
const [appDialogOpen, setAppDialogOpen] = useState(false);
const stepsRef = useRef<GuideStep[]>([]); const stepsRef = useRef<GuideStep[]>([]);
const start = useCallback(() => { const start = useCallback(() => {
@@ -318,32 +317,7 @@ export function AgentGuide({
setCurrent(-1); setCurrent(-1);
}, [stage]); }, [stage]);
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200, if (!eligible) return null;
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 Dialog 打开时
// 先卸掉引导层,关掉后再从当前步恢复。
useEffect(() => {
if (!eligible) return;
const sync = () => {
setAppDialogOpen(
Boolean(document.querySelector('[data-slot="dialog-content"]')),
);
};
sync();
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [eligible]);
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (
!eligible ||
appDialogOpen ||
current < 0 ||
current >= stepsRef.current.length
) {
return null;
}
return ( return (
<Guide <Guide
@@ -4,13 +4,7 @@ import { MessageSquareIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useThreadsPage } from "@/core/threads/hooks"; import { useThreadsPage } from "@/core/threads/hooks";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils"; import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -32,7 +26,6 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
const threads = (data?.threads ?? []).filter( const threads = (data?.threads ?? []).filter(
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th), (th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
); );
const projectThreadNames = useProjectThreadNames(threads);
return ( return (
<section className="bg-card/60 rounded-xl border p-4"> <section className="bg-card/60 rounded-xl border p-4">
@@ -52,10 +45,6 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
{threads.map((thread) => { {threads.map((thread) => {
const href = pathOfThread(thread); const href = pathOfThread(thread);
const isActive = href === pathname; const isActive = href === pathname;
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
return ( return (
<Link <Link
key={thread.thread_id} key={thread.thread_id}
@@ -63,38 +52,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
title={desensitize(titleOfThread(thread, "新对话"))} title={desensitize(titleOfThread(thread, "新对话"))}
className={cn( className={cn(
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors", "flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
projectInfo && "min-h-12",
isActive isActive
? "bg-primary/5 text-primary font-medium" ? "bg-primary/5 text-primary font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground", : "text-muted-foreground hover:bg-accent hover:text-foreground",
)} )}
> >
{projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : (
<MessageSquareIcon <MessageSquareIcon
className={cn( className={cn(
"size-3.5 shrink-0", "size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70", isActive ? "text-primary" : "text-muted-foreground/70",
)} )}
/> />
)} <span className="truncate">
<span
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, "新对话"))} {desensitize(titleOfThread(thread, "新对话"))}
</span> </span>
{projectInfo && (
<MyProjectThreadBadge
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
</Link> </Link>
); );
})} })}
@@ -6,8 +6,6 @@ import { useMemo } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Skill } from "@/core/skills"; import type { Skill } from "@/core/skills";
import { useI18n } from "@/core/i18n/hooks";
import { isSkillOffline } from "@/core/skill-availability";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -55,7 +53,6 @@ export function EnabledSkillList({
onViewDetail?: (name: string) => void; onViewDetail?: (name: string) => void;
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const { t } = useI18n();
const byName = useMemo( const byName = useMemo(
() => new Map(skills.map((s) => [s.name, s])), () => new Map(skills.map((s) => [s.name, s])),
[skills], [skills],
@@ -82,14 +79,12 @@ export function EnabledSkillList({
<div className="space-y-2"> <div className="space-y-2">
{skillNames.map((name) => { {skillNames.map((name) => {
const skill = byName.get(name); const skill = byName.get(name);
const offline = isSkillOffline(skill);
return ( return (
<div <div
key={name} key={name}
className={cn( className={cn(
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors", "group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
onViewDetail && "hover:border-primary/40 hover:bg-accent/40", onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
offline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
)} )}
> >
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"> <div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
@@ -113,15 +108,6 @@ export function EnabledSkillList({
{skillSourceLabel(skill)} {skillSourceLabel(skill)}
</Badge> </Badge>
)} )}
{offline && (
<Badge
variant="outline"
className="shrink-0 border-red-500/35 bg-red-500/5 text-[10px] text-red-700 dark:text-red-300"
title={t.settings.skills.badgeOfflineTitle}
>
{t.settings.skills.badgeOffline}
</Badge>
)}
</div> </div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed"> <p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{skill {skill
@@ -21,7 +21,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { Skill } from "@/core/skills"; import type { Skill } from "@/core/skills";
import { isSkillOffline } from "@/core/skill-availability";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -29,11 +28,7 @@ const PAGE_SIZE = 5;
const ALL_CATEGORIES = "__all__"; const ALL_CATEGORIES = "__all__";
interface SkillTransferSelectorProps { interface SkillTransferSelectorProps {
/** /** Full pool of selectable skills (caller pre-filters by `enabled`). */
* Full visible skill pool. Offline skills are never offered on the left,
* but an already-selected offline skill stays visible on the right so it
* can be removed without silently changing the persisted whitelist.
*/
skills: Skill[]; skills: Skill[];
/** Currently selected skill names — the persisted `skills: string[]`. */ /** Currently selected skill names — the persisted `skills: string[]`. */
selected: string[]; selected: string[];
@@ -96,9 +91,7 @@ export function SkillTransferSelector({
const categories = useMemo(() => { const categories = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
for (const skill of skills) { for (const skill of skills) {
if (!isSkillOffline(skill) && skill.category?.trim()) { if (skill.category?.trim()) set.add(skill.category.trim());
set.add(skill.category.trim());
}
} }
return Array.from(set).sort((a, b) => a.localeCompare(b)); return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [skills]); }, [skills]);
@@ -109,7 +102,6 @@ export function SkillTransferSelector({
const availableSkills = useMemo(() => { const availableSkills = useMemo(() => {
return skills.filter( return skills.filter(
(skill) => (skill) =>
!isSkillOffline(skill) &&
!selectedSet.has(skill.name) && !selectedSet.has(skill.name) &&
matchesQuery(skill, query) && matchesQuery(skill, query) &&
(category === ALL_CATEGORIES || skill.category?.trim() === category), (category === ALL_CATEGORIES || skill.category?.trim() === category),
@@ -392,7 +384,6 @@ function SkillItemCard({
onPick, onPick,
onViewDetail, onViewDetail,
}: SkillItemCardProps) { }: SkillItemCardProps) {
const isOffline = isSkillOffline(skill);
return ( return (
<div <div
role="button" role="button"
@@ -407,7 +398,6 @@ function SkillItemCard({
title={isSelected ? "点击移除" : "点击添加"} title={isSelected ? "点击移除" : "点击添加"}
className={cn( className={cn(
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors", "group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
isOffline && "border-dashed bg-muted/50",
isSelected isSelected
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]" ? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60", : "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
@@ -435,11 +425,6 @@ function SkillItemCard({
> >
{skillLabel(skill)} {skillLabel(skill)}
</div> </div>
{isOffline && (
<div className="text-destructive truncate text-[10px]">
已下线,仅可移除
</div>
)}
{skill.description?.trim() && ( {skill.description?.trim() && (
<div className="text-muted-foreground truncate text-[11px]"> <div className="text-muted-foreground truncate text-[11px]">
{desensitize(skill.description)} {desensitize(skill.description)}
@@ -8,7 +8,6 @@ import {
ExternalLinkIcon, ExternalLinkIcon,
EyeIcon, EyeIcon,
FileType2Icon, FileType2Icon,
GlobeIcon,
LoaderIcon, LoaderIcon,
PackageIcon, PackageIcon,
SaveIcon, SaveIcon,
@@ -18,11 +17,11 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useNavigate } from "react-router-dom"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { defaultUrlTransform } from "react-markdown"; import { defaultUrlTransform } from "react-markdown";
import { Streamdown } from "streamdown";
import { import {
Artifact, Artifact,
@@ -59,13 +58,17 @@ import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks"; import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import { copyTextToClipboard } from "@/core/clipboard"; import { copyTextToClipboard } from "@/core/clipboard";
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
import { import {
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_STARTED_EVENT, DOCUMENT_REWRITE_STARTED_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT, DOCUMENT_REWRITE_UNDONE_EVENT,
useDocumentRewritePresentation, useDocumentRewritePresentation,
} from "@/core/document-rewrite/context"; } from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
completeArtifactRewriteFallback,
primeArtifactRewriteFallback,
} from "@/core/document-rewrite/local-fallback";
import { import {
downloadArtifactFile, downloadArtifactFile,
} from "@/core/artifacts/download"; } from "@/core/artifacts/download";
@@ -82,11 +85,7 @@ import {
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview"; import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
SafeStreamdown,
streamdownPlugins,
useStreamdownMermaidPngDownload,
} from "@/core/streamdown";
import { import {
checkCodeFile, checkCodeFile,
getFileName, getFileName,
@@ -210,22 +209,6 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
); );
} }
/**
* 识别 Web App 站点清单:`outputs/<site>/site.json`(兼容 `write-file:` 虚拟 URL
* 与查询串)→ 返回站点目录名;其它文件返回 null(按钮不渲染,零行为变化)。
*/
function extractSiteStudioTarget(filepath: string): string | null {
const normalized = filepath.replace(/^write-file:/, "").split("?")[0];
if (!/(^|\/)site\.json$/.test(normalized)) return null;
const parts = normalized.split("/").filter(Boolean);
const idx = parts.lastIndexOf("site.json");
if (idx <= 0) return null;
const siteName = parts[idx - 1];
if (!siteName || siteName === "outputs") return null;
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(siteName)) return null;
return siteName;
}
export function ArtifactFileDetail({ export function ArtifactFileDetail({
className, className,
filepath: filepathFromProps, filepath: filepathFromProps,
@@ -283,16 +266,8 @@ export function ArtifactFileDetail({
headerLeadingActionBare?: boolean; headerLeadingActionBare?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread(); const { isMock, thread, publicShareCode } = useThread();
const navigate = useNavigate();
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
const siteStudioTarget = useMemo(
() => extractSiteStudioTarget(filepathFromProps),
[filepathFromProps],
);
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
const isWriteFileVirtual = useMemo(() => { const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
@@ -439,13 +414,6 @@ export function ArtifactFileDetail({
thinking: "", thinking: "",
}); });
const documentRewriteActive = documentRewrite.isActive; const documentRewriteActive = documentRewrite.isActive;
const generationLockId = useId();
useLayoutEffect(() => {
const owner = `preview:${generationLockId}`;
const generating = (writeFileInFlight && thread.isLoading) || documentRewriteActive;
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
return () => setGeneratingArtifact(owner, null);
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true; const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive const displayContent = documentRewriteActive
@@ -559,17 +527,10 @@ export function ArtifactFileDetail({
) => { ) => {
if (!rewritePresentation) return; if (!rewritePresentation) return;
switch (event.type) { switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed": case "snapshot_fixed":
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
stage: "requirements", stage: "requirements",
fileName: event.fileName || getFileName(filepath), fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount, originalCharCount: event.originalCharCount,
requirementsPlan: "", requirementsPlan: "",
}); });
@@ -634,7 +595,6 @@ export function ArtifactFileDetail({
case "committed": case "committed":
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
rewrittenContent: event.content, rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null, versionId: event.versionId ?? null,
committedHash: event.committedHash, committedHash: event.committedHash,
}); });
@@ -652,11 +612,7 @@ export function ArtifactFileDetail({
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message }); rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
break; break;
case "error": case "error":
rewritePresentation.update(presentationId, { console.error("AI 全文改写历史恢复失败:", event.message);
stage: "error",
error: event.message,
diagnostic: event.diagnostic ?? null,
});
break; break;
default: default:
break; break;
@@ -682,15 +638,7 @@ export function ArtifactFileDetail({
const historicalPresentationId = rewritePresentation?.create({ const historicalPresentationId = rewritePresentation?.create({
threadId, threadId,
source: { kind: "artifact", id: threadId, path: filepath }, source: { kind: "artifact", id: threadId, path: filepath },
jobId: historical.id, fileName: getFileName(filepath),
outputPath: typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath,
fileName: getFileName(
typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath ?? filepath,
),
instruction: historical.request.instruction, instruction: historical.request.instruction,
style: historical.request.style, style: historical.request.style,
modelName: historical.request.modelName, modelName: historical.request.modelName,
@@ -715,10 +663,7 @@ export function ArtifactFileDetail({
? "文件在改写期间已变化,未覆盖原文件。" ? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled" : historical.status === "cancelled"
? "已停止全文改写,原文件未被修改。" ? "已停止全文改写,原文件未被修改。"
: historical.status === "failed" : historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
: null,
diagnostic: historical.diagnostic ?? null,
}); });
} }
const result = latest.result; const result = latest.result;
@@ -727,16 +672,26 @@ export function ArtifactFileDetail({
: null; : null;
const originalContent = latest.request.originalContent || draftContent; const originalContent = latest.request.originalContent || draftContent;
const rewrittenContent = typeof result.content === "string" ? result.content : null; const rewrittenContent = typeof result.content === "string" ? result.content : null;
const versionSnapshotFailed = result.versionSnapshotFailed === true;
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null; const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
const outputPath = typeof result.outputPath === "string" if (!versionSnapshotFailed) {
? result.outputPath clearArtifactRewriteFallback(threadId, filepath);
: latest.outputPath; }
const localFallbackAvailable =
versionSnapshotFailed &&
latest.status === "completed" &&
result.status !== "conflict" &&
committedHash !== null &&
completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
const presentationId = rewritePresentation?.create({ const presentationId = rewritePresentation?.create({
threadId, threadId,
source: { kind: "artifact", id: threadId, path: filepath }, source: { kind: "artifact", id: threadId, path: filepath },
jobId: latest.id, fileName: getFileName(filepath),
outputPath,
fileName: getFileName(outputPath ?? filepath),
instruction: latest.request.instruction, instruction: latest.request.instruction,
style: latest.request.style, style: latest.request.style,
modelName: latest.request.modelName, modelName: latest.request.modelName,
@@ -745,6 +700,7 @@ export function ArtifactFileDetail({
originalContent, originalContent,
rewrittenContent, rewrittenContent,
committedHash, committedHash,
localFallbackAvailable,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
}); });
if (!presentationId || !rewritePresentation) return; if (!presentationId || !rewritePresentation) return;
@@ -757,18 +713,28 @@ export function ArtifactFileDetail({
warnings: summary?.validationWarnings ?? [], warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null, versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash, committedHash,
error: result.status === "conflict" ? "改写版文件未生成。" : null, localFallbackAvailable,
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
}); });
if (rewrittenContent && result.status !== "conflict") {
setDraftContent(rewrittenContent);
setHasLocalEdits(versionSnapshotFailed);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewrittenContent, url: undefined },
);
}
return; return;
} }
if (latest.status === "failed" || latest.status === "cancelled") { if (latest.status === "failed" || latest.status === "cancelled") {
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
stage: latest.status === "cancelled" ? "cancelled" : "error", stage: latest.status === "cancelled" ? "cancelled" : "error",
summary, summary,
error: latest.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
}); });
return; return;
} }
@@ -791,19 +757,32 @@ export function ArtifactFileDetail({
signal, signal,
), ),
onEvent: (event) => updatePresentation(presentationId, event), onEvent: (event) => updatePresentation(presentationId, event),
onCommitted: (rewritten, { committedHash, outputPath }) => { onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
rewrittenContent: rewritten, rewrittenContent: rewritten,
committedHash, committedHash,
...(outputPath ? { outputPath } : {}), localFallbackAvailable,
});
},
onFailure: ({ error, diagnostic }) => {
rewritePresentation.update(presentationId, {
stage: "error",
error,
diagnostic: diagnostic ?? null,
}); });
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewritten, url: undefined },
);
}, },
}, },
); );
@@ -1136,7 +1115,6 @@ export function ArtifactFileDetail({
setDocumentRewriteDialogOpen(false); setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false); setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true); setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code"); setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their // Do not make users manually save before using AI rewrite. Persist their
@@ -1158,6 +1136,11 @@ export function ArtifactFileDetail({
} }
} }
primeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
});
const expectedHash = await sha256Content(draftContent); const expectedHash = await sha256Content(draftContent);
const presentationId = rewritePresentation?.create({ const presentationId = rewritePresentation?.create({
threadId, threadId,
@@ -1193,17 +1176,10 @@ export function ArtifactFileDetail({
onEvent: (event) => { onEvent: (event) => {
if (!presentationId || !rewritePresentation) return; if (!presentationId || !rewritePresentation) return;
switch (event.type) { switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed": case "snapshot_fixed":
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
stage: "requirements", stage: "requirements",
fileName: event.fileName || getFileName(filepath), fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount, originalCharCount: event.originalCharCount,
requirementsPlan: "", requirementsPlan: "",
}); });
@@ -1270,8 +1246,6 @@ export function ArtifactFileDetail({
break; break;
case "committed": case "committed":
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null, versionId: event.versionId ?? null,
committedHash: event.committedHash, committedHash: event.committedHash,
}); });
@@ -1288,41 +1262,70 @@ export function ArtifactFileDetail({
error: event.message, error: event.message,
}); });
break; break;
case "cancelled":
rewritePresentation.update(presentationId, {
stage: "cancelled",
error: event.message,
});
break;
case "error": case "error":
rewritePresentation.update(presentationId, { // The sandbox retains the visible draft and exposes manual
stage: "error", // save instead of showing an error state to the user.
error: event.message, console.error("AI 全文改写接口失败:", event.message);
diagnostic: event.diagnostic ?? null,
});
break; break;
default: default:
break; break;
} }
}, },
onCommitted: (rewritten, { committedHash, outputPath }) => { onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (presentationId && rewritePresentation) { if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
rewrittenContent: rewritten, rewrittenContent: rewritten,
committedHash, committedHash,
...(outputPath ? { outputPath } : {}), localFallbackAvailable,
}); });
} }
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。"); queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
}, },
onFailure: ({ error, diagnostic }) => { { content: rewritten, url: undefined },
);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
} else {
toast.success("Markdown 全文改写完成,已安全替换原文件。");
}
},
onFailure: ({ content, error }) => {
// The rewrite transport can fail after the server has already
// streamed text (or before the first token on a 404). Keep that
// visible content in the sandbox, make Save actionable, and leave
// diagnostics to the developer console only.
const retainedContent = content || draftContent;
setDraftContent(retainedContent);
setHasLocalEdits(true);
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: retainedContent, url: undefined },
);
if (presentationId && rewritePresentation) { if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
stage: "error", stage: "completed",
error, rewrittenContent: retainedContent,
diagnostic: diagnostic ?? null, error: null,
}); });
} }
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
}, },
}, },
); );
@@ -1351,28 +1354,6 @@ export function ArtifactFileDetail({
} }
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]); }, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
// The docked conversation composer is outside this artifact pane. Register
// the same cancellation path so its standard square stop button can abort
// both the local SSE reader and the durable rewrite job.
useEffect(() => {
if (
!documentRewriteActive ||
!documentRewritePresentationId ||
!rewritePresentation
) {
return;
}
return rewritePresentation.registerCancellation(
documentRewritePresentationId,
handleCancelDocumentRewrite,
);
}, [
documentRewriteActive,
documentRewritePresentationId,
handleCancelDocumentRewrite,
rewritePresentation,
]);
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体 // 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。 // 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>(); const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
@@ -1446,9 +1427,7 @@ export function ArtifactFileDetail({
toast.error("文件内容为空,暂时无法在新窗口打开"); toast.error("文件内容为空,暂时无法在新窗口打开");
return; return;
} }
const blob = new Blob([ const blob = new Blob([prepareGeneratedHtml(source)], {
prepareGeneratedHtml(source),
], {
type: "text/html;charset=utf-8", type: "text/html;charset=utf-8",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -1475,11 +1454,7 @@ export function ArtifactFileDetail({
: language === "markdown" : language === "markdown"
? "text/markdown;charset=utf-8" ? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8"; : "text/plain;charset=utf-8";
const downloadSource = const blob = new Blob([source], { type: blobType });
language === "html"
? prepareGeneratedHtml(source)
: source;
const blob = new Blob([downloadSource], { type: blobType });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -1613,7 +1588,7 @@ export function ArtifactFileDetail({
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"} label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={ tooltip={
documentRewriteActive documentRewriteActive
? "正在生成独立改写版文件,原文件保持不变;点击停止" ? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits : hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写" ? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown" : "使用 AI 重写当前完整 Markdown"
@@ -1621,13 +1596,7 @@ export function ArtifactFileDetail({
// Keep the entry available after a completed rewrite and when // Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves // the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite. // them automatically in handleStartDocumentRewrite.
onClick={() => { onClick={() => setDocumentRewriteDialogOpen(true)}
if (documentRewriteActive) {
handleCancelDocumentRewrite();
return;
}
setDocumentRewriteDialogOpen(true);
}}
/> />
)} )}
{!useWriteFileStreaming && filepath.endsWith(".skill") && ( {!useWriteFileStreaming && filepath.endsWith(".skill") && (
@@ -1678,16 +1647,6 @@ export function ArtifactFileDetail({
onClick={handleOpenInNewWindow} onClick={handleOpenInNewWindow}
/> />
)} )}
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
<ArtifactAction
icon={GlobeIcon}
label="站点预览"
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
onClick={() =>
navigate(siteStudioRoute(threadId, siteStudioTarget))
}
/>
)}
<ArtifactAction <ArtifactAction
icon={DownloadIcon} icon={DownloadIcon}
label={t.common.download} label={t.common.download}
@@ -1754,6 +1713,27 @@ export function ArtifactFileDetail({
正在改写 Markdown 正在改写 Markdown
</div> </div>
)} )}
{documentRewriteActive && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<LoaderIcon className="size-3.5 animate-spin" />
{documentRewrite.run.stage === "requirements"
? "正在理解改写需求,原文件未被修改"
: documentRewrite.run.stage === "validation"
? "正在校验 Markdown"
: documentRewrite.run.stage === "committing"
? "正在安全替换原文件"
: "正在写入临时草稿 · " +
documentRewrite.run.writtenCharCount.toLocaleString() +
" 字"}
<button
type="button"
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
onClick={handleCancelDocumentRewrite}
>
停止
</button>
</div>
)}
{showResumeAutoScroll && ( {showResumeAutoScroll && (
<button <button
type="button" type="button"
@@ -2114,9 +2094,7 @@ export function ArtifactFilePreview({
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。 // <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
// Intercept in-page anchor clicks so they scroll within the iframe instead // Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame). // of navigating the sandboxed blob: document (which blanks the frame).
const blob = new Blob([ const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
prepareGeneratedHtml(content ?? ""),
], {
type: "text/html;charset=utf-8", type: "text/html;charset=utf-8",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -2169,7 +2147,7 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]", animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)} )}
> >
<SafeStreamdown <Streamdown
className="size-full" className="size-full"
{...streamdownPlugins} {...streamdownPlugins}
// react-markdown's safe default strips unknown protocols before the // react-markdown's safe default strips unknown protocols before the
@@ -2194,7 +2172,7 @@ export function ArtifactFilePreview({
}} }}
> >
{previewContent} {previewContent}
</SafeStreamdown> </Streamdown>
{activeCursor && ( {activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" /> <span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)} )}
@@ -16,7 +16,6 @@ import {
getFileExtensionDisplayName, getFileExtensionDisplayName,
getFileIcon, getFileIcon,
getFileName, getFileName,
isWordDocumentFile,
} from "@/core/utils/files"; } from "@/core/utils/files";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -97,8 +96,8 @@ export function ArtifactFileList({
{files.map((file) => ( {files.map((file) => (
<Card <Card
key={file} key={file}
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")} className="relative cursor-pointer p-3"
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)} onClick={() => handleClick(file)}
> >
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1"> <CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words"> <CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
@@ -7,11 +7,9 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { toast } from "sonner";
import { useSidebarSafe } from "@/components/ui/sidebar"; import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env"; import { env } from "@/env";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
export function artifactPreviewKey(artifact: string): string { export function artifactPreviewKey(artifact: string): string {
if (!artifact.startsWith("write-file:")) { if (!artifact.startsWith("write-file:")) {
@@ -40,8 +38,6 @@ export interface ArtifactsContextType {
select: (artifact: string, autoSelect?: boolean) => void; select: (artifact: string, autoSelect?: boolean) => void;
/** Open/update a streamed write without overriding a manual close of that call. */ /** Open/update a streamed write without overriding a manual close of that call. */
openStreamedArtifact: (artifact: string, previewKey: string) => boolean; openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
/** Register the file being generated; remove this owner's lock on completion. */
setGeneratingArtifact: (owner: string, artifact: string | null) => void;
deselect: () => void; deselect: () => void;
open: boolean; open: boolean;
@@ -90,26 +86,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [preOpen, setPreOpen] = useState(false); const [preOpen, setPreOpen] = useState(false);
const [autoOpen, setAutoOpen] = useState(true); const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null); const suppressedAutoOpenKeyRef = useRef<string | null>(null);
const generatingArtifactsRef = useRef(new Map<string, string>());
const setGeneratingArtifact = useCallback((owner: string, artifact: string | null) => {
if (artifact) generatingArtifactsRef.current.set(owner, artifact);
else generatingArtifactsRef.current.delete(owner);
}, []);
const canSwitchArtifact = useCallback((artifact: string, notify: boolean) => {
for (const generating of generatingArtifactsRef.current.values()) {
if (
artifactPreviewKey(generating) === artifactPreviewKey(artifact) ||
resolveWriteFilePath(generating) === resolveWriteFilePath(artifact)
) continue;
if (notify) {
toast.info("当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。", {
id: "artifact-generation-switch-locked",
});
}
return false;
}
return true;
}, []);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次 // 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。 // Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey); const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
@@ -153,7 +129,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const select = useCallback( const select = useCallback(
(artifact: string, autoSelect = false) => { (artifact: string, autoSelect = false) => {
if (!canSwitchArtifact(artifact, !autoSelect)) return;
const previewKey = artifactPreviewKey(artifact); const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey; selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact)); setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
@@ -165,12 +140,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setAutoSelect((previous) => (previous ? false : previous)); setAutoSelect((previous) => (previous ? false : previous));
} }
}, },
[canSwitchArtifact, sidebar], [sidebar],
); );
const openStreamedArtifact = useCallback( const openStreamedArtifact = useCallback(
(artifact: string, previewKey: string) => { (artifact: string, previewKey: string) => {
if (!canSwitchArtifact(artifact, false)) return false;
if (suppressedAutoOpenKeyRef.current === previewKey) { if (suppressedAutoOpenKeyRef.current === previewKey) {
return false; return false;
} }
@@ -206,11 +180,10 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
} }
return true; return true;
}, },
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar], [applyOpen, cancelPendingStreamedOpen, sidebar],
); );
const deselect = useCallback(() => { const deselect = useCallback(() => {
generatingArtifactsRef.current.clear();
selectedArtifactKeyRef.current = null; selectedArtifactKeyRef.current = null;
setSelectedArtifact((previous) => (previous === null ? previous : null)); setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null)); setSelectedArtifactKey((previous) => (previous === null ? previous : null));
@@ -251,7 +224,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
selectedArtifactKey, selectedArtifactKey,
select, select,
openStreamedArtifact, openStreamedArtifact,
setGeneratingArtifact,
deselect, deselect,
}; };
@@ -1,28 +0,0 @@
import type { ReactNode } from "react";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { useUploadPolicy } from "@/core/uploads";
/**
* Shared providers required by every full chat surface.
*
* Keep this in a small standalone module: importing it from a route must not
* eagerly evaluate the complete WorkspaceRoutes tree and all of its pages.
*/
export function ChatRuntime({ children }: { children: ReactNode }) {
const { data: uploadPolicy } = useUploadPolicy();
return (
<SubtasksProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider uploadPolicy={uploadPolicy}>
{children}
</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -1,8 +1,8 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { FilesIcon, XIcon } from "lucide-react"; import { FilesIcon, XIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels"; import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { ConversationEmptyState } from "@/components/ai-elements/conversation";
@@ -19,22 +19,11 @@ import {
listThreadArtifacts, listThreadArtifacts,
mergeArtifactPaths, mergeArtifactPaths,
} from "@/core/artifacts/listing"; } from "@/core/artifacts/listing";
import { import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
latestCompletedArtifactMutationKey,
latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
isWriteFileInFlight,
} from "@/core/artifacts/write-file-in-flight";
import {
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
} from "@/core/artifacts/streaming-write-preview";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode"; import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { useIsMobile } from "@/hooks/use-mobile";
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import type { SkillDisplayConfig } from "@/core/skills/type"; import type { SkillDisplayConfig } from "@/core/skills/type";
@@ -46,7 +35,6 @@ import {
buildSkillDisplayMap, buildSkillDisplayMap,
collectRuntimeDisplaySources, collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime"; } from "@/core/skill-display/runtime";
import { isWordDocumentFile } from "@/core/utils/files";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -57,7 +45,6 @@ import {
useArtifacts, useArtifacts,
} from "../artifacts"; } from "../artifacts";
import { ReferenceBatchesContext, useThread } from "../messages/context"; import { ReferenceBatchesContext, useThread } from "../messages/context";
import { KnowledgeWikiPanel } from "../knowledge-wiki-panel";
import { SkillDisplaySources } from "../skill-display-sources"; import { SkillDisplaySources } from "../skill-display-sources";
const CLOSE_MODE = { chat: 100, artifacts: 0 }; const CLOSE_MODE = { chat: 100, artifacts: 0 };
@@ -96,30 +83,15 @@ const ChatBox: React.FC<{
children: React.ReactNode; children: React.ReactNode;
threadId: string; threadId: string;
disableArtifacts?: boolean; disableArtifacts?: boolean;
writingMode?: boolean;
/** Prefer and automatically reveal a specific artifact once it is available. */
preferredArtifact?: string | null;
/** Keep scanning until the preferred artifact appears (for delayed file persistence). */
pollForPreferredArtifact?: boolean;
/** Report the real artifact discovered on disk back to the page state. */
onPreferredArtifactAvailabilityChange?: (artifact: string | null) => void;
/** Stack the artifact below chat on small screens. */
responsiveArtifactLayout?: boolean;
}> = ({ }> = ({
children, children,
threadId, threadId,
disableArtifacts = false, disableArtifacts = false,
writingMode,
preferredArtifact = null,
pollForPreferredArtifact = false,
onPreferredArtifactAvailabilityChange,
responsiveArtifactLayout = false,
}) => { }) => {
const { const {
isMock, isMock,
thread, thread,
knowledgeReferencesEnabled = false, knowledgeReferencesEnabled = false,
knowledgeReferences,
} = useThread(); } = useThread();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -129,8 +101,6 @@ const ChatBox: React.FC<{
}, [searchParams]); }, [searchParams]);
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);
const openedRequestedArtifactRef = useRef<string | null>(null); const openedRequestedArtifactRef = useRef<string | null>(null);
const openedPreferredArtifactRef = useRef<string | null>(null);
const preferredRunWasLoadingRef = useRef(false);
const artifactsCountRef = useRef(0); const artifactsCountRef = useRef(0);
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
const latestReferenceBatchIdRef = useRef<string | null>(null); const latestReferenceBatchIdRef = useRef<string | null>(null);
@@ -142,12 +112,9 @@ const ChatBox: React.FC<{
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null); const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]); const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const stackArtifactPanel = responsiveArtifactLayout && isMobile;
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持 // 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。 // 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode; const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled = const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false; settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
@@ -156,8 +123,7 @@ const ChatBox: React.FC<{
const ragModeEnabled = const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) || (referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) || isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled || knowledgeReferencesEnabled;
!!knowledgeReferences;
// Frontend RAG modes write their own hidden source tool messages. They use // Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference // the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs. // batches through DeerFlow's backend APIs.
@@ -174,7 +140,7 @@ const ChatBox: React.FC<{
const { skills } = useSkills(); const { skills } = useSkills();
const canScanArtifactDirectory = const canScanArtifactDirectory =
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) && (artifactDirectoryModeEnabled || !!requestedArtifact) &&
!disableArtifacts && !disableArtifacts &&
!isMock && !isMock &&
!!threadId && !!threadId &&
@@ -183,8 +149,7 @@ const ChatBox: React.FC<{
queryKey: ["artifact-directory", threadId], queryKey: ["artifact-directory", threadId],
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal), queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
enabled: canScanArtifactDirectory, enabled: canScanArtifactDirectory,
refetchInterval: refetchInterval: thread.isLoading ? 1_500 : false,
thread.isLoading || pollForPreferredArtifact ? 1_500 : false,
retry: false, retry: false,
}); });
const checkpointArtifacts = useMemo( const checkpointArtifacts = useMemo(
@@ -194,7 +159,7 @@ const ChatBox: React.FC<{
const checkpointArtifactSignature = checkpointArtifacts.join("\n"); const checkpointArtifactSignature = checkpointArtifacts.join("\n");
const availableArtifacts = useMemo( const availableArtifacts = useMemo(
() => { () => {
if (!artifactDirectoryModeEnabled && !requestedArtifact && !preferredArtifact) { if (!artifactDirectoryModeEnabled && !requestedArtifact) {
return checkpointArtifacts; return checkpointArtifacts;
} }
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map( const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
@@ -207,28 +172,9 @@ const ChatBox: React.FC<{
artifactDirectoryModeEnabled, artifactDirectoryModeEnabled,
artifactListingQuery.data?.files, artifactListingQuery.data?.files,
checkpointArtifacts, checkpointArtifacts,
preferredArtifact,
requestedArtifact, requestedArtifact,
], ],
); );
const availablePreferredArtifact = useMemo(() => {
if (!preferredArtifact) return null;
const preferredPath = artifactPathname(preferredArtifact);
return (
[...availableArtifacts]
.reverse()
.find((artifact) => artifactPathname(artifact) === preferredPath) ?? null
);
}, [availableArtifacts, preferredArtifact]);
useEffect(() => {
if (!preferredArtifact) return;
onPreferredArtifactAvailabilityChange?.(availablePreferredArtifact);
}, [
availablePreferredArtifact,
onPreferredArtifactAvailabilityChange,
preferredArtifact,
]);
useEffect(() => { useEffect(() => {
if (!canScanArtifactDirectory || thread.isLoading) return; if (!canScanArtifactDirectory || thread.isLoading) return;
@@ -253,20 +199,8 @@ const ChatBox: React.FC<{
selectedArtifact, selectedArtifact,
selectedArtifactKey, selectedArtifactKey,
openStreamedArtifact, openStreamedArtifact,
setGeneratingArtifact,
} = useArtifacts(); } = useArtifacts();
// Keep the lock even when the user closes the preview while writing.
const selectedFileGenerating = Boolean(
selectedArtifact && thread.isLoading &&
isWriteFileInFlight({ messages: thread.messages ?? [] }, selectedArtifact),
);
useLayoutEffect(() => {
const owner = `chat:${threadId}`;
setGeneratingArtifact(owner, selectedFileGenerating ? selectedArtifact : null);
return () => setGeneratingArtifact(owner, null);
}, [selectedArtifact, selectedFileGenerating, setGeneratingArtifact, threadId]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
const skillDisplayMap = useMemo( const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -305,41 +239,6 @@ const ChatBox: React.FC<{
threadId, threadId,
]); ]);
useEffect(() => {
const completedNow =
preferredRunWasLoadingRef.current && !thread.isLoading;
preferredRunWasLoadingRef.current = thread.isLoading;
if (disableArtifacts || !preferredArtifact) {
openedPreferredArtifactRef.current = null;
return;
}
if (!availablePreferredArtifact) return;
const preferredKey = `${threadId}:${artifactPathname(availablePreferredArtifact)}`;
if (openedPreferredArtifactRef.current === preferredKey && !completedNow) {
return;
}
openedPreferredArtifactRef.current = preferredKey;
selectArtifact(availablePreferredArtifact, true);
setArtifactsOpen(true);
setSideTab("document");
// The same canonical report path may be overwritten by a later run while
// its content query is still inside the five-minute cache window. Invalidate
// the real selected path before the detail panel mounts/reopens so it never
// shows the previous report after the assistant announces completion.
void queryClient.invalidateQueries({
queryKey: ["artifact", availablePreferredArtifact, threadId],
});
}, [
availablePreferredArtifact,
disableArtifacts,
preferredArtifact,
queryClient,
selectArtifact,
setArtifactsOpen,
thread.isLoading,
threadId,
]);
useEffect(() => { useEffect(() => {
if (!shouldFetchBackendReferenceBatches) { if (!shouldFetchBackendReferenceBatches) {
setBackendReferenceBatches([]); setBackendReferenceBatches([]);
@@ -391,7 +290,7 @@ const ChatBox: React.FC<{
), ),
[backendReferenceBatches, skillDisplayMap], [backendReferenceBatches, skillDisplayMap],
); );
const resolvedReferenceBatches = useMemo( const referenceBatches = useMemo(
() => { () => {
if ( if (
shouldFetchBackendReferenceBatches && shouldFetchBackendReferenceBatches &&
@@ -407,18 +306,6 @@ const ChatBox: React.FC<{
shouldFetchBackendReferenceBatches, shouldFetchBackendReferenceBatches,
], ],
); );
const referenceBatches = useMemo(() => {
if (!knowledgeReferences) return resolvedReferenceBatches;
return resolvedReferenceBatches
.map((batch) => ({
...batch,
sources: filterKnowledgeWikiSources(
batch.sources,
knowledgeReferences,
),
}))
.filter((batch) => batch.sources.length > 0);
}, [knowledgeReferences, resolvedReferenceBatches]);
useEffect(() => { useEffect(() => {
if (!shouldFetchBackendReferenceBatches) { if (!shouldFetchBackendReferenceBatches) {
return; return;
@@ -547,52 +434,15 @@ const ChatBox: React.FC<{
!disableArtifacts && !disableArtifacts &&
nextArtifacts.length > 0 && nextArtifacts.length > 0 &&
((artifactDirectoryModeEnabled && threadChanged) || ((artifactDirectoryModeEnabled && threadChanged) ||
(hasNewArtifact || !hadArtifacts)) ((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
) { ) {
// The preferred deliverable wins over the generic "newest artifact" const newest = nextArtifacts[nextArtifacts.length - 1]!;
// heuristic. Both effects can run in the same commit with a stale null if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
// selectedArtifact; choosing the preferred path here prevents a sibling selectArtifact(newest);
// file such as research-plan.json from overwriting report.md.
const automaticTarget =
availablePreferredArtifact ?? nextArtifacts[nextArtifacts.length - 1]!;
const isGeneratedHtml =
languageOfStreamingArtifact(automaticTarget) === "html" &&
isAutoPreviewableArtifactPath(automaticTarget);
if (
shouldAutoOpenWritingArtifact(
automaticTarget,
thread.messages,
writingModeEnabled,
) &&
(isGeneratedHtml || !selectedArtifact)
) {
if (isGeneratedHtml) {
// HTML is the requested end product, so a later webpage should
// replace a previously selected document automatically. Preserve a
// matching in-flight write key so a user-closed live preview stays
// closed after the tool call completes; otherwise a completed
// tool-call id makes a later write to the same path a new preview.
const revision = latestCompletedArtifactMutationKey(
{ messages: thread.messages ?? [] },
automaticTarget,
);
const previewKey =
selectedArtifact?.startsWith("write-file:") &&
artifactPathname(selectedArtifact) === artifactPathname(automaticTarget) &&
selectedArtifactKey
? selectedArtifactKey
: revision ?? `artifact:${threadId}:${artifactPathname(automaticTarget)}`;
openStreamedArtifact(
automaticTarget,
previewKey,
);
} else {
selectArtifact(automaticTarget, true);
setArtifactsOpen(true); setArtifactsOpen(true);
setSideTab("document"); setSideTab("document");
} }
} }
}
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if ( // if (
@@ -609,12 +459,7 @@ const ChatBox: React.FC<{
) { ) {
if (thread?.values?.artifacts?.length > 0) { if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false); setAutoSelectFirstArtifact(false);
const firstPreviewableArtifact = thread.values.artifacts.find( selectArtifact(thread.values.artifacts[0]!);
(artifact) => !isWordDocumentFile(artifact),
);
if (firstPreviewableArtifact) {
selectArtifact(firstPreviewableArtifact, true);
}
} }
} }
}, [ }, [
@@ -623,21 +468,17 @@ const ChatBox: React.FC<{
autoSelectFirstArtifact, autoSelectFirstArtifact,
deselect, deselect,
selectArtifact, selectArtifact,
openStreamedArtifact,
selectedArtifact, selectedArtifact,
selectedArtifactKey,
artifacts, artifacts,
setArtifacts, setArtifacts,
setArtifactsOpen,
availableArtifacts, availableArtifacts,
availablePreferredArtifact,
disableArtifacts, disableArtifacts,
thread.messages, thread.messages,
writingModeEnabled, writingModeEnabled,
]); ]);
// Live sandbox: open when an in-flight write_file has a usable output path. // Live sandbox: open as soon as any in-flight write_file has a path or
// Pathless content stays inline in the CoT, including content-first streams. // the first content bytes — not only when that tool is the last CoT step.
// Agentfx (and other agents) often emit write_file + present_files/bash in // Agentfx (and other agents) often emit write_file + present_files/bash in
// the same turn; the collapsed earlier write would otherwise never auto-open. // the same turn; the collapsed earlier write would otherwise never auto-open.
const streamingWritePreview = useMemo( const streamingWritePreview = useMemo(
@@ -656,25 +497,6 @@ const ChatBox: React.FC<{
setSideTab("document"); setSideTab("document");
}, [openStreamedArtifact, streamingWritePreview]); }, [openStreamedArtifact, streamingWritePreview]);
// page_report_template(prepare) creates the output HTML before content
// editing starts. Open that real artifact immediately; this also covers
// models that use str_replace or whose write_file arguments arrive at once.
const preparedHtmlPreview = useMemo(
() =>
disableArtifacts || !thread.isLoading
? null
: latestPreparedHtmlArtifact(thread.messages ?? []),
[disableArtifacts, thread.isLoading, thread.messages],
);
useEffect(() => {
if (!preparedHtmlPreview) return;
openStreamedArtifact(
preparedHtmlPreview.artifactPath,
preparedHtmlPreview.previewKey,
);
setSideTab("document");
}, [openStreamedArtifact, preparedHtmlPreview]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled); const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => { useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current; const wasEnabled = directoryModeWasEnabledRef.current;
@@ -682,13 +504,12 @@ const ChatBox: React.FC<{
if ( if (
wasEnabled || wasEnabled ||
!artifactDirectoryModeEnabled || !artifactDirectoryModeEnabled ||
availableArtifacts.length === 0 || availableArtifacts.length === 0
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
) { ) {
return; return;
} }
if (!selectedArtifact) { if (!selectedArtifact) {
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true); selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
} }
setArtifactsOpen(true); setArtifactsOpen(true);
setSideTab("document"); setSideTab("document");
@@ -843,7 +664,7 @@ const ChatBox: React.FC<{
<ReferenceBatchesContext.Provider value={referenceBatches}> <ReferenceBatchesContext.Provider value={referenceBatches}>
<ResizablePanelGroup <ResizablePanelGroup
id={`${resizableIdBase}-panels`} id={`${resizableIdBase}-panels`}
orientation={stackArtifactPanel ? "vertical" : "horizontal"} orientation="horizontal"
defaultLayout={{ chat: 100, artifacts: 0 }} defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef} groupRef={layoutRef}
> >
@@ -854,8 +675,6 @@ const ChatBox: React.FC<{
id={`${resizableIdBase}-separator`} id={`${resizableIdBase}-separator`}
className={cn( className={cn(
"opacity-33 hover:opacity-100", "opacity-33 hover:opacity-100",
stackArtifactPanel &&
"h-px w-full after:inset-x-0 after:inset-y-auto after:top-1/2 after:h-4 after:w-full after:-translate-x-0 after:-translate-y-1/2 after:cursor-row-resize",
!sidePanelOpen && "pointer-events-none opacity-0", !sidePanelOpen && "pointer-events-none opacity-0",
)} )}
/> />
@@ -877,9 +696,7 @@ const ChatBox: React.FC<{
<div className="mb-3 flex shrink-0 items-center justify-between gap-2"> <div className="mb-3 flex shrink-0 items-center justify-between gap-2">
<TabsList variant="line"> <TabsList variant="line">
<TabsTrigger value="document">文档</TabsTrigger> <TabsTrigger value="document">文档</TabsTrigger>
<TabsTrigger value="references"> <TabsTrigger value="references">参考文献</TabsTrigger>
{knowledgeReferences ? "Wiki" : "参考文献"}
</TabsTrigger>
</TabsList> </TabsList>
<Button <Button
size="icon-sm" size="icon-sm"
@@ -895,19 +712,11 @@ const ChatBox: React.FC<{
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0"> <TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
{artifactDocumentPanel} {artifactDocumentPanel}
</TabsContent> </TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-hidden p-0"> <TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
{knowledgeReferences ? (
<KnowledgeWikiPanel
highlightedReferenceId={pinnedReferenceId}
selection={knowledgeReferences}
sources={referenceSources}
/>
) : (
<ReferencePanel <ReferencePanel
highlightedReferenceId={pinnedReferenceId} highlightedReferenceId={pinnedReferenceId}
sources={referenceSources} sources={referenceSources}
/> />
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) : artifactPanelOpen ? ( ) : artifactPanelOpen ? (
@@ -926,18 +735,10 @@ const ChatBox: React.FC<{
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
{knowledgeReferences ? (
<KnowledgeWikiPanel
highlightedReferenceId={pinnedReferenceId}
selection={knowledgeReferences}
sources={referenceSources}
/>
) : (
<ReferencePanel <ReferencePanel
highlightedReferenceId={pinnedReferenceId} highlightedReferenceId={pinnedReferenceId}
sources={referenceSources} sources={referenceSources}
/> />
)}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -304,9 +304,7 @@ export function CompactModeGuide({
setCurrent(-1); setCurrent(-1);
}, [clearPollTimer, releasePromptDialog]); }, [clearPollTimer, releasePromptDialog]);
// TDesign 1.18 still handles resize when current=-1 and dereferences an if (!eligible) return null;
// undefined step. Keep its listener mounted only during an active guide.
if (!eligible || current < 0 || current >= stepsRef.current.length) return null;
return ( return (
<Guide <Guide
@@ -22,7 +22,6 @@ import {
Download, Download,
FileJson, FileJson,
FileText, FileText,
Loader2,
MessageSquare, MessageSquare,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
@@ -33,17 +32,14 @@ import {
Share2, Share2,
Star, Star,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Select } from "tdesign-react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { findNanjingDbSquareId } from "@/core/system-settings/wx-type-filter";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -64,10 +60,6 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents"; import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -83,8 +75,6 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -97,17 +87,10 @@ import {
} from "@/core/threads/utils"; } from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route"; import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime"; import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
import {
mergeRecentWorkspaceItems,
useRecentStrategyProjects,
} from "@/strategy-selection/recent-projects";
import { agentColor } from "./agent-icon-color"; import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
@@ -131,14 +114,13 @@ function SectionLabel({
onToggle: () => void; onToggle: () => void;
}) { }) {
return ( return (
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none" 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 min-w-0 items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="truncate">{label}</span> <span>{label}</span>
{count != null && ( {count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums"> <span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count} {count}
@@ -152,53 +134,10 @@ function SectionLabel({
)} )}
/> />
</button> </button>
</div>
); );
} }
type CommonAgent = Pick<Agent, "id" | "name" | "description">; export function GeneralQaSidebar() {
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */
const WX_AGENT_TYPE_MINE = "mine";
const WX_AGENT_TYPE_DEFAULT_SQUARE = "default_square";
const WX_AGENT_TYPE_NANJING_DB = "nanjing-db";
const WX_AGENT_TYPE_STORAGE_KEY = "gqa-sidebar:wx-agent-type";
const WX_AGENT_LIST_PAGE_SIZE = 50;
const WX_AGENT_TYPE_OPTIONS = [
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
{ value: WX_AGENT_TYPE_DEFAULT_SQUARE, label: "默认广场" },
{ value: WX_AGENT_TYPE_NANJING_DB, label: "南京-db" },
] as const;
type WxAgentType = (typeof WX_AGENT_TYPE_OPTIONS)[number]["value"];
function isWxAgentType(value: string | null | undefined): value is WxAgentType {
return WX_AGENT_TYPE_OPTIONS.some((option) => option.value === value);
}
function readStoredWxAgentType(): WxAgentType {
if (typeof window === "undefined") return WX_AGENT_TYPE_MINE;
const stored = window.localStorage.getItem(WX_AGENT_TYPE_STORAGE_KEY);
return isWxAgentType(stored) ? stored : WX_AGENT_TYPE_MINE;
}
function resolveWxSquareId(
agentType: WxAgentType,
squares: Array<{ id: string; name: string }> | undefined,
defaultSquareId: string | undefined,
): string | undefined {
if (agentType === WX_AGENT_TYPE_MINE) return undefined;
if (agentType === WX_AGENT_TYPE_DEFAULT_SQUARE) return defaultSquareId;
return findNanjingDbSquareId(squares);
}
export function GeneralQaSidebar({
wxCommonAgents,
}: {
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
} = {}) {
const { t } = useI18n(); const { t } = useI18n();
const routeBase = useRouteBase(); const routeBase = useRouteBase();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。 // 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
@@ -220,69 +159,16 @@ export function GeneralQaSidebar({
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v)); localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
return !v; return !v;
}); });
const { data: publishSquares, isLoading: wxSquaresLoading } = usePublishSquares({
enabled: !!wxCommonAgents,
});
const [wxAgentType, setWxAgentTypeState] = useState<WxAgentType>(
readStoredWxAgentType,
);
const setWxAgentType = useCallback((next: WxAgentType) => {
setWxAgentTypeState(next);
window.localStorage.setItem(WX_AGENT_TYPE_STORAGE_KEY, next);
}, []);
const [wxSearchInput, setWxSearchInput] = useState("");
const [wxSearch, setWxSearch] = useState("");
useEffect(() => {
const timer = window.setTimeout(() => setWxSearch(wxSearchInput.trim()), 300);
return () => window.clearTimeout(timer);
}, [wxSearchInput]);
const wxSquareId = wxCommonAgents
? resolveWxSquareId(
wxAgentType,
publishSquares?.squares,
publishSquares?.default_square_id,
)
: undefined;
const wxFetchEnabled =
!!wxCommonAgents &&
(wxAgentType === WX_AGENT_TYPE_MINE || Boolean(wxSquareId));
const toggleChats = () => const toggleChats = () =>
setChatsCollapsed((v) => { setChatsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v)); localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
return !v; return !v;
}); });
// ── 常用智能体取数:wx 走类型筛选;其它页面仍是置顶 → 我的 → 广场 ── // ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
const { agents: mine } = useAgentsPaginated({ const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
scope: "mine", const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
page: 1, const frequent = useMemo<Agent[]>(() => {
pageSize: 20,
enabled: !wxCommonAgents,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
enabled: !wxCommonAgents,
});
const {
agents: wxAgents,
total: wxTotal,
isLoading: wxAgentsLoading,
isFetching: wxAgentsFetching,
} = useAgentsPaginated({
scope: wxAgentType === WX_AGENT_TYPE_MINE ? "mine" : "square",
page: 1,
pageSize: WX_AGENT_LIST_PAGE_SIZE,
square: wxSquareId,
search: wxSearch || undefined,
enabled: wxFetchEnabled,
});
const frequent = useMemo<CommonAgent[]>(() => {
if (wxCommonAgents) {
return wxFetchEnabled ? wxAgents : [];
}
const out: Agent[] = []; const out: Agent[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const add = (agent: Agent | undefined) => { const add = (agent: Agent | undefined) => {
@@ -294,15 +180,7 @@ export function GeneralQaSidebar({
mine.forEach(add); mine.forEach(add);
square.forEach(add); square.forEach(add);
return out; return out;
}, [mine, square, wxAgents, wxCommonAgents, wxFetchEnabled]); }, [mine, square]);
const wxEmptyHint =
wxAgentType === WX_AGENT_TYPE_NANJING_DB && !wxSquareId
? "该类型暂无智能体"
: wxSearch
? "未找到匹配的智能体"
: wxAgentType === WX_AGENT_TYPE_MINE
? "暂无智能体"
: "该广场暂无智能体";
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。 // 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -315,12 +193,6 @@ export function GeneralQaSidebar({
`${routeBase}/agents`, `${routeBase}/agents`,
currentSearch, currentSearch,
); );
const moreSkillsHref = buildFullChatNavigationPath(
`${routeBase}/skills`,
currentSearch,
);
const wxAgentsPageActive = pathname === `${routeBase}/agents`;
const wxSkillsPageActive = pathname === `${routeBase}/skills`;
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch); const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new, // 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
@@ -328,31 +200,21 @@ export function GeneralQaSidebar({
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。 // 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback( const handleSelectAgent = useCallback(
(agentId: string) => { (agentId: string) => {
if (agentId === SENTIMENT_AGENT_ID) {
navigate( navigate(
buildFullChatNavigationPath( buildFullChatNavigationPath(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`, routeBase === "/page/workspace"
currentSearch,
),
);
return;
}
navigate(
buildFullChatNavigationPath(
routeBase === "/page/workspace" || wxCommonAgents
? `${newChatPath}?agent=${encodeURIComponent(agentId)}` ? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`, : `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
currentSearch, currentSearch,
), ),
); );
}, },
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents], [currentSearch, navigate, newChatPath, routeBase],
); );
// ── 最近的对话取数 + 搜索 ── // ── 最近的对话取数 + 搜索 ──
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const { data: threads = [] } = useThreads(); const { data: threads = [] } = useThreads();
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
const { mutate: deleteThread } = useDeleteThread(); const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread(); const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin(); const { toggle: togglePin } = useToggleThreadPin();
@@ -379,28 +241,17 @@ export function GeneralQaSidebar({
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))), sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads], [threads],
); );
const projectThreadNames = useProjectThreadNames(visibleThreads);
const recentItems = useMemo(
() =>
mergeRecentWorkspaceItems(visibleThreads, recentStrategyProjects),
[recentStrategyProjects, visibleThreads],
);
const filteredRecentItems = useMemo(() => { const filteredThreads = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const list = q const list = q
? recentItems.filter((item) => ? visibleThreads.filter((thread) =>
(item.kind === "thread" titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
? titleOfThread(item.thread, t.pages.newChat)
: item.project.title
) )
.toLowerCase() : visibleThreads;
.includes(q),
)
: recentItems;
// 未搜索时与旧版一致:最多展示 10 条 // 未搜索时与旧版一致:最多展示 10 条
return q ? list : list.slice(0, 10); return q ? list : list.slice(0, 10);
}, [query, recentItems, t.pages.newChat]); }, [visibleThreads, query, t.pages.newChat]);
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => { const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
@@ -503,88 +354,17 @@ export function GeneralQaSidebar({
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{/* ── 常用智能体 ── */} {/* ── 常用智能体 ── */}
{(wxCommonAgents || frequent.length > 0) && ( {frequent.length > 0 && (
<div className="pt-2"> <div className="pt-2">
<SectionLabel <SectionLabel
label="常用智能体" label="常用智能体"
count={wxCommonAgents ? frequent.length : undefined}
collapsed={agentsCollapsed} collapsed={agentsCollapsed}
onToggle={toggleAgents} onToggle={toggleAgents}
/> />
{!agentsCollapsed && ( {!agentsCollapsed && (
<div className="mt-1 flex flex-col gap-1"> <div className="mt-1 flex flex-col gap-1">
{wxCommonAgents && (
<div className="space-y-2 px-1 pb-1">
<Select
size="medium"
value={wxAgentType}
placeholder="智能体类型"
onChange={(value) => {
if (isWxAgentType(String(value))) {
setWxAgentType(String(value) as WxAgentType);
}
}}
className="w-full [&_.t-input]:!h-9 [&_.t-input]:!rounded-lg [&_.t-input]:!border-border [&_.t-input]:!bg-card [&_.t-input]:!px-2.5 [&_.t-input]:!text-[13px]"
popupProps={{
overlayClassName: "z-[80]",
attach: () => document.body,
}}
>
{WX_AGENT_TYPE_OPTIONS.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={wxSearchInput}
onChange={(e) => setWxSearchInput(e.target.value)}
placeholder="搜索智能体"
className="h-8 rounded-lg border-border bg-card pl-8 pr-8 text-[12px]"
aria-label="搜索智能体"
/>
{wxSearchInput && (
<button
type="button"
onClick={() => setWxSearchInput("")}
className="absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
title="清除"
aria-label="清除搜索"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
)}
{wxCommonAgents &&
(wxAgentsLoading ||
(wxAgentType !== WX_AGENT_TYPE_MINE && wxSquaresLoading)) &&
frequent.length === 0 ? (
<div className="flex h-20 items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
加载中…
</div>
) : frequent.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground/70">
{wxCommonAgents ? wxEmptyHint : null}
</p>
) : (
<div
className={cn(
"flex max-h-[240px] flex-col gap-1 overflow-y-auto overscroll-contain pr-0.5",
wxCommonAgents && wxAgentsFetching && "opacity-60",
)}
>
{frequent.map((agent) => { {frequent.map((agent) => {
const isActive = const isActive = activeAgentId === agent.id;
activeAgentId === agent.id ||
(agent.id === SENTIMENT_AGENT_ID &&
pathname.startsWith(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
));
const { from, to, foreground } = agentColor(agent.id, theme); const { from, to, foreground } = agentColor(agent.id, theme);
return ( return (
<button <button
@@ -657,42 +437,6 @@ export function GeneralQaSidebar({
</button> </button>
); );
})} })}
</div>
)}
{wxCommonAgents ? (
<div className="grid grid-cols-2 gap-1.5 px-1 pt-0.5">
<Link
href={moreAgentsHref}
className={cn(
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
wxAgentsPageActive
? isDark
? "bg-[#1a5dad] text-white"
: "bg-primary text-primary-foreground"
: isDark
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
: "bg-accent text-foreground hover:bg-accent/80",
)}
>
智能体
</Link>
<Link
href={moreSkillsHref}
className={cn(
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
wxSkillsPageActive
? isDark
? "bg-[#1a5dad] text-white"
: "bg-primary text-primary-foreground"
: isDark
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
: "bg-accent text-foreground hover:bg-accent/80",
)}
>
技能
</Link>
</div>
) : (
<Link <Link
href={moreAgentsHref} href={moreAgentsHref}
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground" 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"
@@ -700,7 +444,6 @@ export function GeneralQaSidebar({
<span>{t.common.more}</span> <span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" /> <ChevronRight className="size-3.5 shrink-0" />
</Link> </Link>
)}
</div> </div>
)} )}
</div> </div>
@@ -710,7 +453,7 @@ export function GeneralQaSidebar({
<div className="mt-3 border-t border-sidebar-border/60 pt-3"> <div className="mt-3 border-t border-sidebar-border/60 pt-3">
<SectionLabel <SectionLabel
label={t.sidebar.recentChats} label={t.sidebar.recentChats}
count={recentItems.length} count={visibleThreads.length}
collapsed={chatsCollapsed} collapsed={chatsCollapsed}
onToggle={toggleChats} onToggle={toggleChats}
/> />
@@ -728,75 +471,29 @@ export function GeneralQaSidebar({
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{filteredRecentItems.length === 0 ? ( {filteredThreads.length === 0 ? (
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70"> <div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
{query ? "没有匹配的会话" : t.chats.noChats} {query ? "没有匹配的会话" : t.chats.noChats}
</div> </div>
) : ( ) : (
filteredRecentItems.map((item) => { filteredThreads.map((thread) => {
if (item.kind === "strategy-project") {
const { project } = item;
const href = strategyProjectWorkbenchPath(
"/page/strategy/qa/projects",
project.id,
);
const isActive = href === pathname;
return (
<div
key={`strategy-project:${project.id}`}
className="group/item relative flex items-center"
>
<Link
href={href}
className={cn(
"gq-history-item relative flex min-h-12 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] transition-colors",
isActive
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<MyProjectThreadIcon type="strategy" />
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
<span className="w-full truncate">
{desensitize(project.title)}
</span>
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={project.title}
type="strategy"
/>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
</div>
);
}
const { thread } = item;
const href = buildFullChatNavigationPath( const href = buildFullChatNavigationPath(
pathForThread(thread), pathForThread(thread),
currentSearch, currentSearch,
); );
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
return ( return (
<div <div
key={`thread:${thread.thread_id}`} key={thread.thread_id}
className="group/item relative flex items-center" className="group/item relative flex items-center"
> >
<Link <Link
href={href} href={href}
className={cn( className={cn(
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors", "gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
projectInfo && "h-auto min-h-12 py-1.5",
isActive isActive
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary" ? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground", : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
@@ -810,8 +507,6 @@ export function GeneralQaSidebar({
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -823,23 +518,9 @@ export function GeneralQaSidebar({
{pinned && ( {pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" /> <Pin className="size-3 shrink-0 text-muted-foreground/70" />
)} )}
<span <span className="truncate">
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)} )}
@@ -1,19 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { hasPromptInputPayload } from "./input-box-submit.ts";
test("allows attachment-only prompt submissions", () => {
assert.equal(hasPromptInputPayload({ text: "", files: [{}] }), true);
});
test("rejects submissions with neither text nor attachments", () => {
assert.equal(hasPromptInputPayload({ text: "", files: [] }), false);
assert.equal(hasPromptInputPayload({ text: " \n", files: [] }), false);
});
test("allows text-only prompt submissions", () => {
assert.equal(hasPromptInputPayload({ text: "总结一下", files: [] }), true);
});
@@ -1,9 +0,0 @@
export type PromptInputPayload = {
text: string;
files: readonly unknown[];
};
/** A prompt may contain text, attachments, or both. */
export function hasPromptInputPayload(message: PromptInputPayload): boolean {
return message.text.trim().length > 0 || message.files.length > 0;
}
@@ -1,10 +1,9 @@
"use client"; "use client";
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { toast } from "sonner";
import { import {
BookMarkedIcon,
BrainCircuitIcon, BrainCircuitIcon,
BookMarkedIcon,
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
FolderTreeIcon, FolderTreeIcon,
@@ -64,22 +63,11 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import {
isDocumentRewriteActiveStage,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import {
REASONING_EFFORTS,
resolveModelReasoningEffort,
type ReasoningEffort,
type StoredReasoningEffort,
} from "@/core/models/reasoning-effort";
import type { Model } from "@/core/models/types"; import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -113,12 +101,26 @@ import {
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu"; import { CustomPromptBar } from "./custom-prompt-menu";
import { resolveInputMode, type InputMode } from "./input-mode";
import { hasPromptInputPayload } from "./input-box-submit";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (!supportsThinking && mode !== "flash") {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
export function InputBox({ export function InputBox({
className, className,
disabled, disabled,
@@ -130,20 +132,18 @@ export function InputBox({
threadId, threadId,
initialValue, initialValue,
onContextChange, onContextChange,
onFollowupsPresenceChange, onFollowupsVisibilityChange,
followupsDisplayAllowed = true,
onSubmit, onSubmit,
onStop, onStop,
promptPrefix, promptPrefix,
agentSelector, agentSelector,
knowledgeSelector, knowledgeSelector,
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
extraModels = [], extraModels = [],
assistantId, assistantId,
hideAdminRecommendedQuestions, hideAdminRecommendedQuestions,
compact, compact,
hideContextTools, hideContextTools,
hideMemoryTool = true,
...props ...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & { }: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null; assistantId?: string | null;
@@ -151,17 +151,23 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框, /** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */ * 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean; compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等 /** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */ * 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean; hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean; disabled?: boolean;
context: Omit< context: Omit<
AgentThreadContext, AgentThreadContext,
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & { > & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: StoredReasoningEffort; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
isNewThread?: boolean; isNewThread?: boolean;
@@ -176,18 +182,12 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & { > & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: StoredReasoningEffort; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}, },
) => void; ) => void;
/** onFollowupsVisibilityChange?: (visible: boolean) => void;
* Whether generated follow-up questions may be rendered. Hosts with a
* scrollable transcript disable this while the user is reviewing history;
* the generated questions are retained and reappear on returning to bottom.
*/
followupsDisplayAllowed?: boolean;
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
onFollowupsPresenceChange?: (present: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void; onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void; onStop?: () => void;
/** /**
@@ -211,17 +211,6 @@ export function InputBox({
}; };
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */ /** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode; knowledgeSelector?: ReactNode;
/** Per-thread ordinary-knowledge retrieval mode. Omit when unavailable. */
knowledgeRetrieval?: {
mode: KnowledgeRetrievalMode;
smartAvailable: boolean;
smartAvailabilityReason: string;
/** The administrator policy used only when a new conversation is created. */
smartIsDefault: boolean;
onModeChange: (mode: KnowledgeRetrievalMode) => void;
};
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */ /** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[]; extraModels?: Model[];
}) { }) {
@@ -239,25 +228,11 @@ export function InputBox({
return singleRoundRagActive ? extraModels : models; return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]); }, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread(); const { thread, isMock } = useThread();
const rewritePresentation = useDocumentRewritePresentation();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null); const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled = const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false; citationDisplay?.reference_mode_enabled ?? false;
const documentRewriteActive = useMemo(
() =>
rewritePresentation?.forThread(threadId).some((item) =>
isDocumentRewriteActiveStage(item.stage),
) ?? false,
[rewritePresentation, threadId],
);
// Keep normal chat generation authoritative when both states overlap. In
// the usual rewrite-only case this gives the composer the same square stop
// affordance as a normal streamed reply.
const composerStatus: ChatStatus = documentRewriteActive
? "streaming"
: status;
const [followups, setFollowups] = useState<string[]>([]); const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -272,10 +247,8 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions(); const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences(); const userPreferences = useUserPreferences();
const followupsEnabled = userPreferences.followup_suggestions_enabled;
const showAdminRecommendedQuestions = const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions && !hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
!userPreferences.hide_admin_prompt_prefix && !userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0; recommendedQuestions.length > 0;
@@ -291,20 +264,12 @@ export function InputBox({
const currentModel = modelOptions.find((m) => m.name === context.model_name); const currentModel = modelOptions.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? modelOptions[0]!; const fallbackModel = currentModel ?? modelOptions[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false; const supportsThinking = fallbackModel.supports_thinking ?? false;
const supportsReasoningEffort =
fallbackModel.supports_reasoning_effort ?? false;
const nextModelName = fallbackModel.name; const nextModelName = fallbackModel.name;
const nextMode = resolveInputMode(context.mode, supportsThinking); const nextMode = getResolvedMode(context.mode, supportsThinking);
const nextReasoningEffort = resolveModelReasoningEffort(
supportsReasoningEffort,
context.reasoning_effort,
context.model_name !== nextModelName,
);
if ( if (
context.model_name === nextModelName && context.model_name === nextModelName &&
context.mode === nextMode && context.mode === nextMode &&
context.reasoning_effort === nextReasoningEffort &&
context.execution_profile === fallbackModel.execution_profile context.execution_profile === fallbackModel.execution_profile
) { ) {
return; return;
@@ -314,7 +279,6 @@ export function InputBox({
...context, ...context,
model_name: nextModelName, model_name: nextModelName,
mode: nextMode, mode: nextMode,
reasoning_effort: nextReasoningEffort,
execution_profile: fallbackModel.execution_profile, execution_profile: fallbackModel.execution_profile,
}); });
}, [context, modelOptions, multiRoundRagActive, onContextChange]); }, [context, modelOptions, multiRoundRagActive, onContextChange]);
@@ -339,31 +303,6 @@ export function InputBox({
[selectedModel], [selectedModel],
); );
const selectedReasoningEffort = useMemo(
() =>
resolveModelReasoningEffort(
supportReasoningEffort,
context.reasoning_effort,
),
[context.reasoning_effort, supportReasoningEffort],
);
const reasoningEffortIndex = Math.max(
0,
REASONING_EFFORTS.indexOf(selectedReasoningEffort ?? "xhigh"),
);
const reasoningEffortThumbLeft =
reasoningEffortIndex === 0
? "0.5rem"
: reasoningEffortIndex === 1
? "50%"
: "calc(100% - 0.5rem)";
const reasoningEffortLabel =
selectedReasoningEffort === "low"
? t.inputBox.reasoningEffortLow
: selectedReasoningEffort === "medium"
? t.inputBox.reasoningEffortMedium
: t.inputBox.reasoningEffortXHigh;
const handleModelSelect = useCallback( const handleModelSelect = useCallback(
(model_name: string) => { (model_name: string) => {
const model = modelOptions.find((m) => m.name === model_name); const model = modelOptions.find((m) => m.name === model_name);
@@ -373,12 +312,8 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name, model_name,
mode: resolveInputMode(context.mode, model.supports_thinking ?? false), mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: resolveModelReasoningEffort( reasoning_effort: context.reasoning_effort,
model.supports_reasoning_effort ?? false,
context.reasoning_effort,
model_name !== context.model_name,
),
execution_profile: model.execution_profile, execution_profile: model.execution_profile,
}); });
setModelDialogOpen(false); setModelDialogOpen(false);
@@ -393,24 +328,22 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: ordinaryModel?.name ?? context.model_name, model_name: ordinaryModel?.name ?? context.model_name,
mode: resolveInputMode( mode: getResolvedMode(
mode, mode,
ordinaryModel?.supports_thinking ?? supportThinking, ordinaryModel?.supports_thinking ?? supportThinking,
), ),
reasoning_effort: resolveModelReasoningEffort( reasoning_effort:
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort, mode === "ultra"
context.reasoning_effort, ? "high"
), : mode === "pro"
? "medium"
: mode === "thinking"
? "low"
: "minimal",
execution_profile: undefined, execution_profile: undefined,
}); });
}, },
[ [onContextChange, context, models, supportThinking],
onContextChange,
context,
models,
supportThinking,
supportReasoningEffort,
],
); );
const handleSingleRoundRagSelect = useCallback(() => { const handleSingleRoundRagSelect = useCallback(() => {
@@ -424,7 +357,7 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: ragModel.name, model_name: ragModel.name,
mode: resolveInputMode( mode: getResolvedMode(
context.mode, context.mode,
ragModel.supports_thinking ?? false, ragModel.supports_thinking ?? false,
), ),
@@ -440,7 +373,7 @@ export function InputBox({
}, [context, onContextChange]); }, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback( const handleReasoningEffortSelect = useCallback(
(effort: ReasoningEffort) => { (effort: "minimal" | "low" | "medium" | "high") => {
onContextChange?.({ onContextChange?.({
...context, ...context,
reasoning_effort: effort, reasoning_effort: effort,
@@ -464,13 +397,16 @@ export function InputBox({
}); });
}, [artifactDirectoryModeEnabled, setLocalSettings]); }, [artifactDirectoryModeEnabled, setLocalSettings]);
const smartKnowledgeRetrievalActive = const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
knowledgeRetrieval?.mode === "smart_reasoning"; const handleMemoryInjectionToggle = useCallback(() => {
const ragModeLocked = onContextChange?.({
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId); ...context,
const ragModeEnabled = memory_injection_enabled: !memoryInjectionEnabled,
!smartKnowledgeRetrievalActive && });
(localSettings.display.ragModeEnabled || ragModeLocked); }, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => { const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) { if (ragModeLocked) {
return; return;
@@ -488,15 +424,11 @@ export function InputBox({
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (composerStatus === "streaming") { if (status === "streaming") {
toast.info("正在生成中,请等待当前回复完成后再发送。", { onStop?.();
description: "已保留您输入的内容。", return;
});
// Reject rather than resolve: PromptInput treats a resolved submit as
// accepted and clears its draft. The current message has not been sent.
throw new Error("Cannot submit while a response is streaming.");
} }
if (!hasPromptInputPayload(message)) { if (!message.text) {
return; return;
} }
setFollowups([]); setFollowups([]);
@@ -508,19 +440,14 @@ export function InputBox({
// Guard against submitting before the initial model auto-selection // Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state. // effect has flushed thread settings to storage/state.
if ( if (resolvedModelName && context.model_name !== resolvedModelName) {
resolvedModelName &&
(context.model_name !== resolvedModelName ||
context.reasoning_effort !== selectedReasoningEffort)
) {
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: resolvedModelName, model_name: resolvedModelName,
mode: resolveInputMode( mode: getResolvedMode(
context.mode, context.mode,
selectedModel?.supports_thinking ?? false, selectedModel?.supports_thinking ?? false,
), ),
reasoning_effort: selectedReasoningEffort,
execution_profile: selectedModel?.execution_profile, execution_profile: selectedModel?.execution_profile,
}); });
setTimeout(() => onSubmit?.(message), 0); setTimeout(() => onSubmit?.(message), 0);
@@ -533,30 +460,16 @@ export function InputBox({
context, context,
onContextChange, onContextChange,
onSubmit, onSubmit,
composerStatus, onStop,
resolvedModelName, resolvedModelName,
ragModeEnabled, ragModeEnabled,
selectedModel?.supports_thinking, selectedModel?.supports_thinking,
selectedModel?.execution_profile, selectedModel?.execution_profile,
selectedReasoningEffort,
status, status,
threadId, threadId,
], ],
); );
const handleStopCurrentGeneration = useCallback(() => {
if (status === "streaming") {
onStop?.();
return;
}
if (!documentRewriteActive || !rewritePresentation) return;
void rewritePresentation.cancelActiveForThread(threadId).then((result) => {
if (!result.cancelled) {
toast.error(result.error ?? "停止全文改写失败,请稍后重试。");
}
});
}, [documentRewriteActive, onStop, rewritePresentation, status, threadId]);
const requestFormSubmit = useCallback(() => { const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form"); const form = promptRootRef.current?.querySelector("form");
form?.requestSubmit(); form?.requestSubmit();
@@ -564,7 +477,7 @@ export function InputBox({
const handleFollowupClick = useCallback( const handleFollowupClick = useCallback(
(suggestion: string) => { (suggestion: string) => {
if (composerStatus === "streaming") { if (status === "streaming") {
return; return;
} }
const current = (textInput.value ?? "").trim(); const current = (textInput.value ?? "").trim();
@@ -577,7 +490,7 @@ export function InputBox({
setFollowupsHidden(true); setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0); setTimeout(() => requestFormSubmit(), 0);
}, },
[composerStatus, requestFormSubmit, textInput], [requestFormSubmit, status, textInput],
); );
const confirmReplaceAndSend = useCallback(() => { const confirmReplaceAndSend = useCallback(() => {
@@ -608,43 +521,32 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0); setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]); }, [pendingSuggestion, requestFormSubmit, textInput]);
const followupsPresent = const showFollowups =
followupsEnabled &&
!disabled && !disabled &&
!isNewThread && !isNewThread &&
!selectedSingleRoundRag && !selectedSingleRoundRag &&
!followupsHidden && !followupsHidden &&
(followupsLoading || followups.length > 0); (followupsLoading || followups.length > 0);
const showFollowups = followupsDisplayAllowed && followupsPresent;
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange); const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
useEffect(() => { useEffect(() => {
followupsPresenceChangeRef.current = onFollowupsPresenceChange; followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
}, [onFollowupsPresenceChange]); }, [onFollowupsVisibilityChange]);
useEffect(() => { useEffect(() => {
followupsPresenceChangeRef.current?.(followupsPresent); followupsVisibilityChangeRef.current?.(showFollowups);
}, [followupsPresent]); }, [showFollowups]);
useEffect(() => { useEffect(() => {
return () => followupsPresenceChangeRef.current?.(false); return () => followupsVisibilityChangeRef.current?.(false);
}, []); }, []);
useEffect(() => {
if (followupsEnabled) return;
setFollowups([]);
setFollowupsLoading(false);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
}, [followupsEnabled]);
useEffect(() => { useEffect(() => {
const streaming = status === "streaming"; const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current; const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = streaming; wasStreamingRef.current = streaming;
if (!followupsEnabled || !wasStreaming || streaming) { if (!wasStreaming || streaming) {
return; return;
} }
@@ -696,7 +598,6 @@ export function InputBox({
return (await res.json()) as { suggestions?: string[] }; return (await res.json()) as { suggestions?: string[] };
}) })
.then((data) => { .then((data) => {
if (controller.signal.aborted) return;
const suggestions = (data.suggestions ?? []) const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : "")) .map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0) .filter((s) => s.length > 0)
@@ -704,19 +605,14 @@ export function InputBox({
setFollowups(suggestions); setFollowups(suggestions);
}) })
.catch(() => { .catch(() => {
if (controller.signal.aborted) return;
setFollowups([]); setFollowups([]);
}) })
.finally(() => { .finally(() => {
if (controller.signal.aborted) return;
setFollowupsLoading(false); setFollowupsLoading(false);
}); });
return () => { return () => controller.abort();
controller.abort(); }, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
setFollowupsLoading(false);
};
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return ( return (
<div ref={promptRootRef} className="relative flex flex-col gap-6"> <div ref={promptRootRef} className="relative flex flex-col gap-6">
@@ -726,28 +622,6 @@ export function InputBox({
</div> </div>
)} )}
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
<div className="px-4 mb-[10px]">
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
<div className="flex flex-wrap justify-center gap-2.5">
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
<button
key={question}
type="button"
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
onClick={() => {
textInput.setInput(question);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
}}
>
{question}
</button>
))}
</div>
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && ( {isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<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) => (
@@ -859,12 +733,12 @@ export function InputBox({
</PromptInputBody> </PromptInputBody>
<PromptInputFooter <PromptInputFooter
className={cn( className={cn(
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5", "flex items-center border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3", compact && "gap-2 px-3 pt-1.5 pb-3",
)} )}
> >
<PromptInputTools <PromptInputTools
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")} className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
> >
{/* TODO: Add more connectors here {/* TODO: Add more connectors here
<PromptInputActionMenu> <PromptInputActionMenu>
@@ -939,11 +813,33 @@ export function InputBox({
<span className="text-xs font-normal">文件</span> <span className="text-xs font-normal">文件</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{referenceModeEntryEnabled && {!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
!frontendRagActive && content={
!smartKnowledgeRetrievalActive && memoryInjectionEnabled
!hideContextTools && ( ? "记忆注入已开启:对话前会注入历史记忆"
<Tooltip : "记忆注入已关闭:本次对话不注入记忆"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={ content={
ragModeEnabled ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示" ? "参考文献模式已开启:检索类结果会按编号引用展示"
@@ -956,8 +852,7 @@ export function InputBox({
disabled={ragModeLocked || disabled} disabled={ragModeLocked || disabled}
className={cn( className={cn(
"gap-1.5! rounded-full px-3! shadow-none", "gap-1.5! rounded-full px-3! shadow-none",
compact && compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80", ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200" ? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
@@ -973,8 +868,7 @@ export function InputBox({
<BookMarkedIcon className="size-3" /> <BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span> <span className="text-xs font-normal">参考文献</span>
</PromptInputButton> </PromptInputButton>
</Tooltip> </Tooltip>}
)}
{!frontendRagActive && <Tooltip {!frontendRagActive && <Tooltip
content={ content={
contextCompressionEnabled contextCompressionEnabled
@@ -1267,63 +1161,124 @@ export function InputBox({
</DropdownMenuGroup> </DropdownMenuGroup>
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> </PromptInputActionMenu>
{!frontendRagActive && {!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
supportReasoningEffort &&
context.mode !== "flash" && (
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuTrigger <PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
aria-label={`${t.inputBox.reasoningEffortSelect}: ${reasoningEffortLabel}`} <div className="text-xs font-normal">
className={cn( {t.inputBox.reasoningEffort}:
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", {context.reasoning_effort === "minimal" &&
compact && " " + t.inputBox.reasoningEffortMinimal}
"rounded-lg border border-border px-2.5! py-1! text-[13px]", {context.reasoning_effort === "low" &&
)} " " + t.inputBox.reasoningEffortLow}
> {context.reasoning_effort === "medium" &&
<div className="flex items-center gap-1.5 text-xs font-medium"> " " + t.inputBox.reasoningEffortMedium}
<SparklesIcon className="size-3" /> {context.reasoning_effort === "high" &&
<span className="text-foreground"> " " + t.inputBox.reasoningEffortHigh}
{reasoningEffortLabel}
</span>
</div> </div>
</PromptInputActionMenuTrigger> </PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-56 p-3"> <PromptInputActionMenuContent className="w-70">
<div className="flex items-center justify-between gap-3 px-1"> <DropdownMenuGroup>
<span className="text-xs text-muted-foreground"> <DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.reasoningEffortSelect} {t.inputBox.reasoningEffort}
</span> </DropdownMenuLabel>
<span className="text-sm font-semibold text-violet-600 dark:text-violet-300"> <PromptInputActionMenu>
{reasoningEffortLabel} <PromptInputActionMenuItem
</span> className={cn(
context.reasoning_effort === "minimal"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("minimal")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMinimal}
</div> </div>
<div className="mt-2 px-1"> <div className="pl-2 text-xs">
<div className="relative h-9 w-full"> {t.inputBox.reasoningEffortMinimalDescription}
<input
type="range"
aria-label={`${t.inputBox.reasoningEffortSelect}: ${selectedModel?.display_name || selectedModel?.name || ""}`}
aria-valuetext={reasoningEffortLabel}
className="peer absolute inset-x-0 -inset-y-1 z-10 h-auto w-full cursor-grab touch-none opacity-0 active:cursor-grabbing"
value={reasoningEffortIndex}
min={0}
max={2}
step={1}
onChange={(event) => {
const effort =
REASONING_EFFORTS[Number(event.currentTarget.value)];
if (effort) handleReasoningEffortSelect(effort);
}}
/>
<div className="pointer-events-none absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-fuchsia-500 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/70 peer-focus-visible:ring-offset-2" />
<div
className="pointer-events-none absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-violet-600 shadow-md peer-active:scale-110 dark:border-slate-800"
style={{ left: reasoningEffortThumbLeft }}
/>
</div> </div>
</div> </div>
{context.reasoning_effort === "minimal" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "low"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("low")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortLow}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortLowDescription}
</div>
</div>
{context.reasoning_effort === "low" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "medium" ||
!context.reasoning_effort
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("medium")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMedium}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMediumDescription}
</div>
</div>
{context.reasoning_effort === "medium" ||
!context.reasoning_effort ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "high"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("high")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortHigh}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortHighDescription}
</div>
</div>
{context.reasoning_effort === "high" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> </PromptInputActionMenu>
)} )}
</PromptInputTools> </PromptInputTools>
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}> <PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && ( {!frontendRagActive && agentSelector && (
<AgentSelector <AgentSelector
selectedAgentId={agentSelector.selectedAgentId} selectedAgentId={agentSelector.selectedAgentId}
@@ -1333,100 +1288,6 @@ export function InputBox({
/> />
)} )}
{!frontendRagActive && knowledgeSelector} {!frontendRagActive && knowledgeSelector}
{!frontendRagActive && knowledgeRetrieval && (
<DropdownMenu>
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索已开启:直接提问即可,系统会检索原文分片并沿 Wiki 线索扩展背景资料"
: knowledgeRetrieval.smartAvailabilityReason
}
>
<DropdownMenuTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
aria-label={`普通知识库检索模式:${
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索"
: "经典检索"
}`}
aria-haspopup="menu"
className={cn(
"rounded-full px-3 shadow-none transition-colors",
knowledgeRetrieval.mode === "smart_reasoning"
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
>
<BrainCircuitIcon className="size-3.5" />
<span className="max-w-[160px] truncate text-xs font-normal">
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
</span>
</PromptInputButton>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-80 rounded-xl p-1.5"
>
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
普通知识库检索模式
</DropdownMenuLabel>
<DropdownMenuItem
className="min-h-16 items-start rounded-lg py-2"
disabled={!knowledgeRetrieval.smartAvailable}
onSelect={() =>
knowledgeRetrieval.onModeChange("smart_reasoning")
}
>
<BrainCircuitIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
智能检索
{knowledgeRetrieval.smartIsDefault ? "(新对话默认)" : ""}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
先检索原文分片,再按命中线索查阅并扩展 Wiki;资料仅作为回答背景
</div>
</div>
{knowledgeRetrieval.mode === "smart_reasoning" && (
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-14 items-start rounded-lg py-2"
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
>
<BookMarkedIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
经典检索
{knowledgeRetrieval.smartIsDefault ? "(候选)" : "(新对话默认)"}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
沿用原有知识库检索与回答方式
</div>
</div>
{knowledgeRetrieval.mode === "classic" && (
<CheckIcon className="mt-0.5 size-4 text-primary" />
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<p
className={cn(
"px-2 py-1 text-xs leading-5",
knowledgeRetrieval.smartAvailable
? "text-muted-foreground"
: "text-amber-600 dark:text-amber-300",
)}
>
{knowledgeRetrieval.smartAvailabilityReason}
</p>
</DropdownMenuContent>
</DropdownMenu>
)}
{!frontendRagActive && promptPrefix && ( {!frontendRagActive && promptPrefix && (
<Tooltip <Tooltip
content={ content={
@@ -1513,18 +1374,8 @@ export function InputBox({
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted", "size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)} )}
disabled={disabled} disabled={disabled}
onClick={(event) => {
if (composerStatus !== "streaming") return;
// A deliberate press of the square button stops the stream.
// Prevent the enclosing form from treating it as a new submit.
event.preventDefault();
handleStopCurrentGeneration();
}}
variant="outline" variant="outline"
status={composerStatus} status={status}
{...(documentRewriteActive
? { "aria-label": "停止 AI 全文改写" }
: {})}
/> />
</PromptInputTools> </PromptInputTools>
</PromptInputFooter> </PromptInputFooter>
@@ -1,21 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { resolveInputMode } from "./input-mode.ts";
test("keeps plan and subagent modes for models without native thinking", () => {
assert.equal(resolveInputMode("pro", false), "pro");
assert.equal(resolveInputMode("ultra", false), "ultra");
});
test("falls back only when native thinking mode is unsupported", () => {
assert.equal(resolveInputMode("thinking", false), "flash");
assert.equal(resolveInputMode("thinking", true), "thinking");
});
test("keeps the existing defaults when no mode has been chosen", () => {
assert.equal(resolveInputMode(undefined, false), "flash");
assert.equal(resolveInputMode(undefined, true), "pro");
});
@@ -1,20 +0,0 @@
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
/**
* Keep plan/subagent modes available even when the selected model has no
* native thinking switch. The backend already falls back to non-thinking for
* that model, while `pro` and `ultra` still control planning and subagents.
* Only the model-specific `thinking` mode requires `supports_thinking`.
*/
export function resolveInputMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (mode === "thinking" && !supportsThinking) {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
@@ -1,44 +1,40 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react"; import { BookOpen, Database } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
// import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
// import {
// useAssistantKnowledgeVisibility,
// useUpdateAssistantKnowledgeVisibility,
// } from "@/core/system-settings/hooks";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki"; import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import { import {
loadKnowledgeBaseProductVersion,
loadOrdinaryKnowledgeLanding, loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding, saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding, type OrdinaryKnowledgeLanding,
type KnowledgeBaseProductVersion,
updateKnowledgeBaseProductVersion,
} from "@/core/system-settings/api"; } from "@/core/system-settings/api";
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const; const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
export function useOrdinaryKnowledgeLanding(enabled = true) { export function useOrdinaryKnowledgeLanding() {
return useQuery({ return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
queryKey: LANDING_KEY,
queryFn: ({ signal }) => loadOrdinaryKnowledgeLanding(signal),
enabled,
staleTime: 30_000,
retry: false,
});
} }
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal"; export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`; export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
export function useKnowledgeBaseProductVersion() {
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
}
export function KnowledgeProductNav() { export function KnowledgeProductNav() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const client = useQueryClient(); const client = useQueryClient();
// const assistantVisibility = useAssistantKnowledgeVisibility(); const version = useKnowledgeBaseProductVersion();
const isAdmin = getStoredAuth()?.system_role === "admin"; const landing = useOrdinaryKnowledgeLanding();
const landing = useOrdinaryKnowledgeLanding(isAdmin);
const saveLanding = useMutation({ const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding, mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); }, onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
@@ -46,32 +42,24 @@ export function KnowledgeProductNav() {
}); });
const runtime = useLlmWikiRuntime(); const runtime = useLlmWikiRuntime();
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim()); const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
// const assistantAllowed = canAccessAssistantKnowledge( const isAdmin = getStoredAuth()?.system_role === "admin";
// isAdmin, const save = useMutation({
// assistantVisibility.isError ? undefined : assistantVisibility.data, mutationFn: updateKnowledgeBaseProductVersion,
// ); onSuccess: (value) => {
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility(); client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug"); toast.success(`默认知识库体验已切换为 ${value}`);
},
});
const assistant = location.pathname.includes("/knowledge/assistant"); const assistant = location.pathname.includes("/knowledge/assistant");
const ordinary = !assistant && !retrievalDebug;
return ( return (
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2"> <div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}> <Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库 <BookOpen />普通知识库
</Button> </Button>
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
{assistantAllowed && (
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}> <Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库 <Database />助手知识库
</Button> </Button>
)}
{isAdmin && (
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
<SearchCheck />检索调试
</Button>
)}
*/}
{isAdmin && ( {isAdmin && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面 普通知识库默认页面
@@ -79,22 +67,11 @@ export function KnowledgeProductNav() {
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent> <SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
</Select> </Select>
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。 默认产品版本
助手知识库日常展示 <Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
<Select <SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending} <SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
value={assistantVisibility.data === true ? "visible" : "hidden"}
onValueChange={(value) =>
saveAssistantVisibility.mutate(value === "visible", {
onSuccess: (visible) => toast.success(visible ? "已向普通用户开放助手知识库" : "已隐藏普通用户的助手知识库"),
onError: (error) => toast.error(error.message),
})
}
>
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
</Select> </Select>
*/}
</div> </div>
)} )}
</div> </div>
@@ -1,418 +0,0 @@
import { BookOpenText, ChevronRight, FileText } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
import {
classifyKnowledgeWikiSource,
filterKnowledgeWikiSources,
} from "@/core/llmwiki/knowledge-reference";
import { resolveDisplaySourceChunk } from "@/core/llmwiki/source-excerpt";
import {
formatRetrievalScore,
retrievalModeLabel,
retrievalRankScoreLabel,
} from "@/core/llmwiki/retrieval-debug";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { MarkdownContent } from "./messages/markdown-content";
type KnowledgeWikiPanelProps = {
highlightedReferenceId?: string | null;
selection: KnowledgeWikiReferenceSelection;
sources: RuntimeDisplaySource[];
};
function sourceKey(source: RuntimeDisplaySource) {
return `${source.skillName}:${source.index}:${source.id ?? source.url ?? source.title}`;
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function sourceContent(source: RuntimeDisplaySource) {
return (source.content || source.snippet || "").trim();
}
function sourceMode(source: RuntimeDisplaySource) {
return stringValue(
source.raw?.retrieval_mode ??
(source as RuntimeDisplaySource & { retrieval_mode?: unknown })
.retrieval_mode,
).toLowerCase();
}
function matchedHeading(source: RuntimeDisplaySource) {
const sections = Array.isArray(source.raw?.matched_sections)
? source.raw.matched_sections
: [];
const firstSectionWithHeading = sections.find(
(section): section is Record<string, unknown> =>
!!section &&
typeof section === "object" &&
!Array.isArray(section) &&
Boolean(stringValue((section as Record<string, unknown>).heading)),
);
const matchedSection =
source.raw?.matched_section &&
typeof source.raw.matched_section === "object" &&
!Array.isArray(source.raw.matched_section)
? (source.raw.matched_section as Record<string, unknown>)
: null;
return stringValue(
source.raw?.matched_heading ??
source.raw?.heading ??
firstSectionWithHeading?.heading ??
matchedSection?.heading,
);
}
function knowledgeSpaceName(source: RuntimeDisplaySource) {
return (
stringValue(source.raw?.knowledge_base_name) ||
stringValue(source.raw?.base_name) ||
stringValue(source.raw?.source) ||
source.source ||
"知识空间"
);
}
function wikiPageTypeLabel(value: unknown) {
const normalized = stringValue(value).toLowerCase();
if (normalized === "directory") return "目录页";
if (normalized === "overview") return "概览页";
if (normalized === "article") return "文章页";
return normalized ? stringValue(value) : "";
}
function plainTextPreview(value: string) {
return value
.replace(/^---[\s\S]*?---\s*/m, "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+[.)]\s+/gm, "")
.replace(/[>*_`~|]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function cardPreview(source: RuntimeDisplaySource) {
const summary = stringValue(source.raw?.summary);
return plainTextPreview(sourceContent(source) || summary).slice(0, 420);
}
function updatedAtLabel(source: RuntimeDisplaySource) {
const value =
source.time ||
stringValue(source.raw?.updated_at) ||
stringValue(source.raw?.remote_updated_at);
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleString("zh-CN", { hour12: false });
}
function scoreLabel(source: RuntimeDisplaySource) {
const score = formatRetrievalScore(source.score);
if (!score) return "";
const label = retrievalRankScoreLabel(
source.raw?.rank_score_semantics,
Boolean(source.raw?.channel_score || source.raw?.vector_score),
);
return label ? `${label} ${score}` : "";
}
/**
* Knowledge-space references keep both evidence layers visible: formatted Wiki
* pages and their original document chunks. They deliberately use dedicated
* Wiki cards instead of the generic numbered reference-card presentation.
*/
export function KnowledgeWikiPanel({
highlightedReferenceId,
selection,
sources,
}: KnowledgeWikiPanelProps) {
const wikiSources = useMemo(
() => filterKnowledgeWikiSources(sources, selection),
[selection, sources],
);
const orderedSources = useMemo(() => {
const formattedWiki = wikiSources.filter(
(source) => classifyKnowledgeWikiSource(source) === "wiki",
);
const rawDocuments = wikiSources.filter(
(source) => classifyKnowledgeWikiSource(source) === "raw",
);
return [...formattedWiki, ...rawDocuments];
}, [wikiSources]);
const [openSourceKey, setOpenSourceKey] = useState<string | null>(null);
const openSource =
wikiSources.find((source) => sourceKey(source) === openSourceKey) ?? null;
const previewTarget = openSource
? resolveWeKnoraSourceTarget({
...(openSource.raw ?? {}),
url: openSource.url,
})
: null;
const openContent = openSource ? sourceContent(openSource) : "";
const openChunk = openSource ? resolveDisplaySourceChunk(openSource) : "";
const selectedSpaceCount =
selection.knowledgeBaseIds.length +
selection.assistantKnowledgeBaseIds.length;
const formattedWikiCount = wikiSources.filter(
(source) => classifyKnowledgeWikiSource(source) === "wiki",
).length;
const rawDocumentCount = wikiSources.length - formattedWikiCount;
return (
<div className="flex size-full min-h-0 flex-col overflow-hidden">
<header className="shrink-0 border-b border-emerald-200/60 px-1 pb-3 dark:border-emerald-900/50">
<div className="flex flex-wrap items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
<BookOpenText className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<h2 className="text-foreground text-base font-semibold tracking-tight">
Wiki 资料
</h2>
<span className="text-muted-foreground text-xs tabular-nums">
{wikiSources.length} 条
</span>
</div>
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">
来自已选 {selectedSpaceCount} 个知识空间,点击卡片查看完整内容。
</p>
</div>
</div>
{wikiSources.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
Wiki 页面 {formattedWikiCount}
</Badge>
<Badge variant="outline" className="bg-background/80">
原始资料 {rawDocumentCount}
</Badge>
</div>
)}
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-3 pb-6">
{orderedSources.length > 0 ? (
<div className="grid gap-3">
{orderedSources.map((source) => (
<KnowledgeWikiCard
key={sourceKey(source)}
highlighted={highlightedReferenceId === String(source.index)}
source={source}
onOpen={() => setOpenSourceKey(sourceKey(source))}
/>
))}
</div>
) : (
<div className="text-muted-foreground flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/20 px-6 text-center text-sm">
<BookOpenText className="mb-3 size-8 opacity-50" />
<span>本轮未检索到所选知识空间内的 Wiki 资料。</span>
</div>
)}
</main>
{openSource && previewTarget && (
<WeKnoraSourceDrawer
open
target={previewTarget}
title={openSource.title}
quote={openChunk}
collapseRetrievedExcerptByDefault={
classifyKnowledgeWikiSource(openSource) === "wiki"
}
onOpenChange={(open) => {
if (!open) setOpenSourceKey(null);
}}
/>
)}
<Sheet
open={Boolean(openSource && !previewTarget)}
onOpenChange={(open) => {
if (!open) setOpenSourceKey(null);
}}
>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b border-emerald-200/60 bg-emerald-50/60 px-5 py-4 pr-12 text-left dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="flex items-start gap-3 pr-8">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-600 text-white">
<BookOpenText className="size-4.5" />
</div>
<div className="min-w-0">
<SheetTitle className="text-base leading-relaxed">
{openSource?.title || "Wiki 内容"}
</SheetTitle>
<SheetDescription className="mt-1">
{openSource ? knowledgeSpaceName(openSource) : "知识空间"}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{openContent ? (
<article className="mx-auto max-w-3xl rounded-xl bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<MarkdownContent
className="max-w-none"
content={openContent}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</article>
) : (
<div className="text-muted-foreground flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm">
当前 Wiki 暂无可展示内容。
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
);
}
function KnowledgeWikiCard({
highlighted,
source,
onOpen,
}: {
highlighted: boolean;
source: RuntimeDisplaySource;
onOpen: () => void;
}) {
const formattedWiki = classifyKnowledgeWikiSource(source) === "wiki";
const heading = matchedHeading(source);
const pageType = wikiPageTypeLabel(source.raw?.page_type);
const mode = sourceMode(source);
const modeLabel = mode ? retrievalModeLabel(mode) : "";
const preview = cardPreview(source);
const score = scoreLabel(source);
const updatedAt = updatedAtLabel(source);
const target = resolveWeKnoraSourceTarget({
...(source.raw ?? {}),
url: source.url,
});
const canOpen = Boolean(target || sourceContent(source));
return (
<button
id={`reference-${source.index}`}
type="button"
disabled={!canOpen}
className={cn(
"group w-full scroll-mt-12 rounded-xl border bg-background/90 p-4 text-left shadow-sm transition",
"focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:opacity-70",
formattedWiki
? "border-emerald-200/80 hover:-translate-y-0.5 hover:border-emerald-400 hover:shadow-md focus-visible:ring-emerald-500/40 dark:border-emerald-900/70"
: "border-sky-200/80 hover:-translate-y-0.5 hover:border-sky-400 hover:shadow-md focus-visible:ring-sky-500/40 dark:border-sky-900/70",
highlighted &&
(formattedWiki
? "border-emerald-500 bg-emerald-50/80 ring-2 ring-emerald-500/20 dark:bg-emerald-950/25"
: "border-sky-500 bg-sky-50/80 ring-2 ring-sky-500/20 dark:bg-sky-950/25"),
)}
onClick={onOpen}
>
<div className="flex items-start gap-3">
<div
className={cn(
"mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg",
formattedWiki
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
)}
>
{formattedWiki ? (
<BookOpenText className="size-4.5" />
) : (
<FileText className="size-4.5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant="secondary"
className={cn(
formattedWiki
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
)}
>
{formattedWiki ? "Wiki 页面" : "原始资料"}
</Badge>
{formattedWiki && pageType && (
<Badge variant="outline">{pageType}</Badge>
)}
{modeLabel && <Badge variant="outline">{modeLabel}</Badge>}
<span className="text-muted-foreground min-w-0 truncate text-xs">
{knowledgeSpaceName(source)}
</span>
</div>
<h3 className="text-foreground mt-2 line-clamp-2 text-[15px] leading-6 font-semibold">
{source.title ||
(formattedWiki ? "未命名 Wiki 页面" : "未命名原始资料")}
</h3>
{heading && (
<div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
命中章节:{heading}
</div>
)}
{preview && (
<p className="text-muted-foreground mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6">
{preview}
</p>
)}
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{score && <span>{score}</span>}
{updatedAt && <span>更新于 {updatedAt}</span>}
</div>
{canOpen && (
<span
className={cn(
"inline-flex items-center font-medium transition group-hover:translate-x-0.5",
formattedWiki
? "text-emerald-700 dark:text-emerald-300"
: "text-sky-700 dark:text-sky-300",
)}
>
{target
? formattedWiki
? "查看完整 Wiki"
: "查看原始资料"
: "查看命中内容"}
<ChevronRight className="ml-1 size-3.5" />
</span>
)}
</div>
</div>
</div>
</button>
);
}
@@ -21,9 +21,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAssistantKnowledgeAccess } from "@/core/llmwiki/assistant-knowledge-access";
import { ASSISTANT_KNOWLEDGE_UI_VISIBLE } from "@/core/llmwiki/assistant-knowledge-ui";
import { userFacingKnowledgeSuggestedQuestions } from "@/core/llmwiki/knowledge-suggested-questions";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
type AssistantKnowledgeBase, type AssistantKnowledgeBase,
@@ -38,28 +35,6 @@ import {
type SelectorSource = "all" | "agent"; type SelectorSource = "all" | "agent";
type SelectorVariant = "popover" | "panel"; type SelectorVariant = "popover" | "panel";
const EMPTY_IDS: string[] = [];
const IGNORE_ASSISTANT_CHANGE = () => undefined;
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
if (cleanNames.length === 0) return [];
if (cleanNames.length === 1) {
const name = cleanNames[0]!;
return [
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
`「${name}」包含哪些核心主题?请按重要性说明。`,
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
];
}
return [
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
"所选知识空间中有哪些相互冲突或证据不足的结论?",
];
}
function groupRows(rows: LlmWikiKnowledgeBase[]) { function groupRows(rows: LlmWikiKnowledgeBase[]) {
return [ return [
@@ -348,14 +323,9 @@ function SelectionContent({
</div> </div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2"> <div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<div> <span className="text-[11px] text-muted-foreground">
<div className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`} {readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</div> </span>
{selectedCount > 0 && (
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
)}
</div>
{readOnly ? ( {readOnly ? (
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span> <span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
) : ( ) : (
@@ -393,10 +363,9 @@ function SelectionContent({
export function LlmWikiKnowledgeSelector({ export function LlmWikiKnowledgeSelector({
value, value,
assistantValue = EMPTY_IDS, assistantValue = [],
onChange, onChange,
onAssistantChange, onAssistantChange = () => undefined,
onSuggestedQuestionsChange,
source = "all", source = "all",
agentId, agentId,
autoSelectAll = false, autoSelectAll = false,
@@ -404,15 +373,12 @@ export function LlmWikiKnowledgeSelector({
readOnly = false, readOnly = false,
className, className,
label = "知识空间", label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover", variant = "popover",
}: { }: {
value: string[]; value: string[];
assistantValue?: string[]; assistantValue?: string[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
onAssistantChange?: (value: string[]) => void; onAssistantChange?: (value: string[]) => void;
onSuggestedQuestionsChange?: (questions: string[]) => void;
source?: SelectorSource; source?: SelectorSource;
agentId?: string | null; agentId?: string | null;
autoSelectAll?: boolean; autoSelectAll?: boolean;
@@ -421,59 +387,31 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean; readOnly?: boolean;
className?: string; className?: string;
label?: string; label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant; variant?: SelectorVariant;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const assistantAccess = useAssistantKnowledgeAccess({
enabled:
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
});
const assistantAllowed =
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
onAssistantChange !== undefined &&
assistantAccess.canAccess;
const runtime = useLlmWikiRuntime(); const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora"; const weknoraEnabled = runtime.data?.provider === "weknora";
const shouldLoadOptions =
variant === "panel" ||
open ||
autoSelectAll ||
value.length > 0 ||
assistantValue.length > 0;
const all = useLlmWikiKnowledgeBases( const all = useLlmWikiKnowledgeBases(
"all", "all",
weknoraEnabled && shouldLoadOptions, weknoraEnabled,
); );
const agent = useAgentLlmWikiKnowledgeBases( const agent = useAgentLlmWikiKnowledgeBases(
agentId, agentId,
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId, weknoraEnabled && source === "agent" && !!agentId,
); );
const assistantBases = useAssistantKnowledgeBases( const assistantBases = useAssistantKnowledgeBases();
assistantAllowed && const rows = weknoraEnabled ? (all.data ?? []) : [];
(shouldLoadOptions || const assistantRows = assistantBases.data ?? [];
runtime.isError ||
(!runtime.isLoading && !weknoraEnabled)),
);
// Stable empty results are essential: suggested questions are reported to
// the parent, so a fresh [] on every render creates an effect/update loop
// before lazy knowledge queries have loaded (or when they are disabled).
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
const assistantRows = useMemo(
() => assistantAllowed ? (assistantBases.data ?? []) : [],
[assistantAllowed, assistantBases.data],
);
const effectiveAssistantValue = assistantAllowed ? assistantValue : EMPTY_IDS;
const defaultIds = useMemo(() => { const defaultIds = useMemo(() => {
if (source !== "agent") { if (source !== "agent") {
return rows.map((item) => item.id); return rows.map((item) => item.id);
} }
return (agent.data ?? []).map((item) => item.id); return (agent.data ?? []).map((item) => item.id);
}, [agent.data, rows, source]); }, [agent.data, rows, source]);
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || (assistantAllowed && assistantBases.isLoading) || (source === "agent" && !!agentId && agent.isLoading); const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? (assistantAllowed ? assistantBases.error : null); const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]); const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
const assistantAvailableIds = useMemo( const assistantAvailableIds = useMemo(
() => new Set(assistantRows.map((item) => item.id)), () => new Set(assistantRows.map((item) => item.id)),
@@ -481,41 +419,6 @@ export function LlmWikiKnowledgeSelector({
); );
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`; const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null); const initialized = useRef<string | null>(null);
const selectedNames = useMemo(
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[rows, value],
);
const selectedAssistantNames = useMemo(
() => effectiveAssistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[assistantRows, effectiveAssistantValue],
);
const allSelectedNames = useMemo(
() => [...selectedNames, ...selectedAssistantNames],
[selectedAssistantNames, selectedNames],
);
const selectedConfiguredQuestions = useMemo(
() =>
userFacingKnowledgeSuggestedQuestions(
value.flatMap(
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
),
),
[rows, value],
);
const suggestedQuestions = useMemo(
() => {
if (value.length === 1 && effectiveAssistantValue.length === 0 && selectedConfiguredQuestions.length > 0) {
return selectedConfiguredQuestions.slice(0, 12);
}
const general = buildKnowledgeSpaceQuestions(allSelectedNames);
return Array.from(new Set([...selectedConfiguredQuestions, ...general])).slice(0, 12);
},
[allSelectedNames, effectiveAssistantValue.length, selectedConfiguredQuestions, value.length],
);
useEffect(() => {
onSuggestedQuestionsChange?.(suggestedQuestions);
}, [onSuggestedQuestionsChange, suggestedQuestions]);
useEffect(() => { useEffect(() => {
if (!runtime.isLoading && !weknoraEnabled) { if (!runtime.isLoading && !weknoraEnabled) {
@@ -534,36 +437,27 @@ export function LlmWikiKnowledgeSelector({
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]); }, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
useEffect(() => { useEffect(() => {
if (!assistantAllowed || !assistantBases.data || !onAssistantChange) return; if (!assistantBases.data) return;
const valid = effectiveAssistantValue.filter((id) => assistantAvailableIds.has(id)); const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
if (valid.length !== effectiveAssistantValue.length || valid.some((id, index) => id !== effectiveAssistantValue[index])) { if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
onAssistantChange(valid); onAssistantChange(valid);
} }
}, [assistantAllowed, assistantAvailableIds, assistantBases.data, effectiveAssistantValue, onAssistantChange]); }, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
useEffect(() => { useEffect(() => {
if (!open) setSearch(""); if (!open) setSearch("");
}, [open]); }, [open]);
if ( if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
!runtime.isLoading &&
(runtime.isError || !weknoraEnabled) &&
(!assistantAllowed || !assistantBases.isLoading) &&
assistantRows.length === 0
) return null;
const content = ( const content = (
<SelectionContent <SelectionContent
rows={rows} rows={rows}
assistantRows={assistantRows} assistantRows={assistantRows}
value={value} value={value}
assistantValue={effectiveAssistantValue} assistantValue={assistantValue}
onChange={onChange} onChange={onChange}
onAssistantChange={ onAssistantChange={onAssistantChange}
assistantAllowed
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
: IGNORE_ASSISTANT_CHANGE
}
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
loading={loading} loading={loading}
@@ -580,20 +474,27 @@ export function LlmWikiKnowledgeSelector({
<div className="flex items-start justify-between gap-4 border-b px-4 py-3"> <div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div> <div>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />{panelTitle} <Database className="size-4 text-primary" />绑定知识空间
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{panelDescription} 新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p> </p>
</div> </div>
<Badge variant="secondary" className="shrink-0">{value.length + effectiveAssistantValue.length} 个</Badge> <Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
</div> </div>
{content} {content}
</div> </div>
); );
} }
const selectedCount = value.length + effectiveAssistantValue.length; const selectedNames = value
.map((id) => rows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const selectedAssistantNames = assistantValue
.map((id) => assistantRows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
const selectedCount = value.length + assistantValue.length;
const selectedLabel = allSelectedNames.length > 0 const selectedLabel = allSelectedNames.length > 0
? allSelectedNames.join("、") ? allSelectedNames.join("、")
: selectedCount > 0 : selectedCount > 0
@@ -1,248 +0,0 @@
"use client";
import {
CheckIcon,
Loader2Icon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
useUpsertAnswerFeedback,
type AnswerFeedbackContext,
type AnswerFeedbackReason,
} from "@/strategy-components/api/answer-feedback";
import { Tooltip } from "../tooltip";
const REASON_OPTIONS: Array<{
value: AnswerFeedbackReason;
label: string;
}> = [
{ value: "incorrect_or_incomplete", label: "不正确或不完整" },
{ value: "unmet_expectations", label: "与期望不符" },
{ value: "slow_or_problematic", label: "速度慢或存在问题" },
{ value: "style_or_tone", label: "风格或语气" },
{ value: "safety_or_legal", label: "安全或法律疑虑" },
{ value: "other", label: "其他" },
];
export function AnswerFeedbackControl({
threadId,
messageId,
context,
}: {
threadId: string;
messageId: string;
context: AnswerFeedbackContext;
}) {
const saveFeedback = useUpsertAnswerFeedback();
const feedback = context?.feedback ?? null;
const [poorDialogOpen, setPoorDialogOpen] = useState(false);
const [reasons, setReasons] = useState<AnswerFeedbackReason[]>([]);
const [detail, setDetail] = useState("");
useEffect(() => {
if (!poorDialogOpen) return;
setReasons(feedback?.rating === -1 ? feedback.reasons : []);
setDetail(
feedback?.rating === -1
? (feedback.detail ?? feedback.comment ?? "")
: "",
);
}, [feedback, poorDialogOpen]);
const submitPositive = async () => {
try {
await saveFeedback.mutateAsync({
threadId,
messageId,
input: { rating: 1, reasons: [], detail: null },
});
toast.success("感谢你的评价");
} catch (error) {
toast.error(error instanceof Error ? error.message : "评价提交失败");
}
};
const submitNegative = async () => {
if (reasons.length === 0) return;
try {
await saveFeedback.mutateAsync({
threadId,
messageId,
input: {
rating: -1,
reasons,
detail: detail.trim() || null,
},
});
setPoorDialogOpen(false);
toast.success("反馈已提交,感谢你的帮助");
} catch (error) {
toast.error(error instanceof Error ? error.message : "反馈提交失败");
}
};
const toggleReason = (reason: AnswerFeedbackReason) => {
setReasons((current) =>
current.includes(reason)
? current.filter((item) => item !== reason)
: [...current, reason],
);
};
const triggerIcon =
feedback?.rating === 1 ? (
<ThumbsUpIcon
size={12}
className="fill-emerald-500/15 text-emerald-600 dark:text-emerald-400"
/>
) : feedback?.rating === -1 ? (
<ThumbsDownIcon
size={12}
className="fill-amber-500/15 text-amber-600 dark:text-amber-400"
/>
) : (
<span className="flex items-center gap-px" aria-hidden="true">
<ThumbsUpIcon className="size-3" />
<ThumbsDownIcon className="size-3" />
</span>
);
return (
<>
<DropdownMenu>
<Tooltip content="评价回复">
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon-sm"
variant="ghost"
className={cn(
"size-6",
feedback?.rating === 1 &&
"bg-emerald-500/10 hover:bg-emerald-500/15",
feedback?.rating === -1 &&
"bg-amber-500/10 hover:bg-amber-500/15",
)}
disabled={saveFeedback.isPending}
aria-label={
feedback?.rating === 1
? "已评价为回答优秀"
: feedback?.rating === -1
? "已评价为回答不佳"
: "评价回复"
}
>
{saveFeedback.isPending ? (
<Loader2Icon className="size-3 animate-spin" />
) : (
triggerIcon
)}
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="start" sideOffset={6} className="min-w-44 p-1.5">
<DropdownMenuItem
className="gap-2.5 py-2"
onSelect={() => void submitPositive()}
>
<ThumbsUpIcon className="size-4" />
<span>回答优秀</span>
{feedback?.rating === 1 && (
<CheckIcon className="text-primary ml-auto size-4" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 py-2"
onSelect={() => setPoorDialogOpen(true)}
>
<ThumbsDownIcon className="size-4" />
<span>回答不佳</span>
{feedback?.rating === -1 && (
<CheckIcon className="text-primary ml-auto size-4" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={poorDialogOpen} onOpenChange={setPoorDialogOpen}>
<DialogContent className="gap-5 sm:max-w-[min(48rem,calc(100vw-2rem))]">
<DialogHeader className="pr-8">
<DialogTitle className="text-xl font-medium">分享反馈</DialogTitle>
<DialogDescription>
可多选这条回答需要改进的方面。
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-2.5">
{REASON_OPTIONS.map((option) => {
const selected = reasons.includes(option.value);
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => toggleReason(option.value)}
className={cn(
"rounded-full border px-4 py-2 text-sm transition-colors",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background hover:bg-accent",
)}
>
{option.label}
</button>
);
})}
</div>
<Textarea
value={detail}
onChange={(event) => setDetail(event.currentTarget.value)}
maxLength={2000}
placeholder="分享详细信息(可选)"
className="min-h-28 resize-y"
/>
<div className="bg-muted/60 text-muted-foreground rounded-lg px-4 py-3 text-sm">
你的反馈将用于改进智能体的回答质量和稳定性。
</div>
<DialogFooter>
<Button
type="button"
onClick={() => void submitNegative()}
disabled={reasons.length === 0 || saveFeedback.isPending}
>
{saveFeedback.isPending && (
<Loader2Icon className="size-4 animate-spin" />
)}
提交
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,63 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility.ts";
function message(overrides: Partial<Message> = {}): Message {
return {
id: "answer-1",
type: "ai",
content: "A persisted answer",
additional_kwargs: {},
...overrides,
} as Message;
}
test("accepts a completed persisted assistant answer", () => {
assert.equal(isEligibleAnswerFeedbackMessage(message()), true);
});
test("rejects loading, user, optimistic and empty messages", () => {
assert.equal(isEligibleAnswerFeedbackMessage(message(), true), false);
assert.equal(
isEligibleAnswerFeedbackMessage(message({ type: "human" })),
false,
);
assert.equal(
isEligibleAnswerFeedbackMessage(message({ id: "opt-ai-1" })),
false,
);
assert.equal(
isEligibleAnswerFeedbackMessage(message({ content: " " })),
false,
);
});
test("rejects tool-call shells and virtual business cards", () => {
assert.equal(
isEligibleAnswerFeedbackMessage(
message({
tool_calls: [{ id: "tool-1", name: "search", args: {} }],
}),
),
false,
);
assert.equal(
isEligibleAnswerFeedbackMessage(
message({
additional_kwargs: { setup_research_approval: { stage: "plan" } },
}),
),
false,
);
});
test("rejects ids outside the message-scoped API contract", () => {
assert.equal(
isEligibleAnswerFeedbackMessage(message({ id: "x".repeat(65) })),
false,
);
});
@@ -1,52 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
function hasTextContent(content: Message["content"]): boolean {
if (typeof content === "string") return content.trim().length > 0;
if (!Array.isArray(content)) return false;
return content.some((part) => {
if (!part || typeof part !== "object") return false;
const text = "text" in part ? part.text : undefined;
return typeof text === "string" && text.trim().length > 0;
});
}
/**
* Only completed, persisted, user-visible assistant answers can be rated.
* Tool-call shells, optimistic/transient rows and special virtual cards are
* deliberately excluded because they are not standalone answers.
*/
export function isEligibleAnswerFeedbackMessage(
message: Message,
isLoading = false,
): boolean {
if (
isLoading ||
message.type !== "ai" ||
!message.id ||
message.id.length > 64
) {
return false;
}
if (message.id.startsWith("opt-")) return false;
if (!hasTextContent(message.content)) return false;
const directToolCalls = "tool_calls" in message ? message.tool_calls : null;
const additional = message.additional_kwargs ?? {};
const additionalToolCalls = additional.tool_calls;
if (
(Array.isArray(directToolCalls) && directToolCalls.length > 0) ||
(Array.isArray(additionalToolCalls) && additionalToolCalls.length > 0)
) {
return false;
}
const virtualCardKeys = [
"setup_agent_approval",
"setup_writing_approval",
"setup_report_structure_approval",
"setup_research_approval",
"task_report_import_approval",
"report_collaboration_card",
];
return !virtualCardKeys.some((key) => additional[key] !== undefined);
}
@@ -1,7 +1,6 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime"; import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
@@ -14,11 +13,6 @@ export interface ThreadContextType {
* from the admin-controlled manual reference-mode button. * from the admin-controlled manual reference-mode button.
*/ */
knowledgeReferencesEnabled?: boolean; knowledgeReferencesEnabled?: boolean;
/**
* Server-scoped knowledge selection for the dedicated Wiki reference view.
* Sources outside these ids are never displayed as knowledge evidence.
*/
knowledgeReferences?: KnowledgeWikiReferenceSelection;
/** /**
* When set, the conversation is rendered from a public read-only share. * When set, the conversation is rendered from a public read-only share.
* Artifact/image URLs then resolve to the unauthenticated * Artifact/image URLs then resolve to the unauthenticated
@@ -4,7 +4,6 @@ import {
CheckCircle2Icon, CheckCircle2Icon,
ChevronDownIcon, ChevronDownIcon,
ChevronsDownIcon, ChevronsDownIcon,
CopyIcon,
FilePenLineIcon, FilePenLineIcon,
LightbulbIcon, LightbulbIcon,
Loader2Icon, Loader2Icon,
@@ -15,7 +14,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@@ -24,7 +22,6 @@ import {
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api"; import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { copyTextToClipboard } from "@/core/clipboard";
import { import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
@@ -45,7 +42,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const REWRITE_STEPS = [ const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" }, { key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" }, { key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "生成改写版 Markdown" }, { key: "writing", label: "流式重写 Markdown" },
{ key: "illustrations", label: "生成文档配图" }, { key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" }, { key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" }, { key: "comparison", label: "对比原文并生成说明" },
@@ -224,9 +221,6 @@ export function DocumentRewriteTimeline({
const presentation = useDocumentRewritePresentation(); const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false); const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false); const [diffOpen, setDiffOpen] = useState(false);
const diagnosticText = item.diagnostic
? JSON.stringify(item.diagnostic, null, 2)
: "";
const reapplying = Boolean(item.revertedAt); const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId; const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
@@ -376,7 +370,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary", : "bg-primary/10 text-primary",
)} )}
> >
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"} {completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
</span> </span>
</div> </div>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
@@ -446,13 +440,13 @@ export function DocumentRewriteTimeline({
{step.key === "writing" && ( {step.key === "writing" && (
item.writtenCharCount > 0 ? ( item.writtenCharCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。 正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
</p> </p>
) : active ? ( ) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status"> <p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration {isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。" ? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"} : "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
</p> </p>
) : null ) : null
)} )}
@@ -589,42 +583,9 @@ export function DocumentRewriteTimeline({
)} )}
{item.error && ( {item.error && (
<div className="border-t px-5 py-3 text-sm text-destructive"> <div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
<div className="flex items-start gap-2"> <XCircleIcon className="size-4 shrink-0" />
<XCircleIcon className="mt-0.5 size-4 shrink-0" /> {item.error}
<span>{item.error}</span>
</div>
{item.diagnostic && (
<Collapsible className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<CollapsibleTrigger asChild>
<Button className="h-8 gap-1.5 px-2.5 text-xs" size="sm" type="button" variant="outline">
<ChevronDownIcon className="size-3.5" />
查看后台诊断
</Button>
</CollapsibleTrigger>
<Button
className="h-8 gap-1.5 px-2.5 text-xs"
onClick={() => {
void copyTextToClipboard(diagnosticText)
.then(() => toast.success("诊断信息已复制"))
.catch(() => toast.error("复制诊断信息失败"));
}}
size="sm"
type="button"
variant="outline"
>
<CopyIcon className="size-3.5" />
复制诊断
</Button>
</div>
<CollapsibleContent>
<pre className="bg-muted/55 text-foreground mt-2 max-h-72 overflow-auto rounded-lg border p-3 font-mono text-xs leading-5 whitespace-pre-wrap break-words">
{diagnosticText}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</div> </div>
)} )}
<DocumentRewriteDiffDialog <DocumentRewriteDiffDialog
@@ -637,36 +598,3 @@ export function DocumentRewriteTimeline({
</article> </article>
); );
} }
/** A standalone chat row for a sandbox document-rewrite task. */
export function DocumentRewriteMessage({
item,
threadId,
onOpenArtifact,
onDownloadArtifact,
}: {
item: DocumentRewritePresentation;
threadId: string;
onOpenArtifact?: (filepath: string) => void;
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
}) {
const artifactPath = item.source.kind === "artifact" ? item.outputPath : undefined;
const completed = item.stage === "completed";
return (
<section aria-label={`AI 全文改写:${item.fileName}`} className="w-full space-y-3">
<DocumentRewriteTimeline item={item} />
{completed && artifactPath && (
<ArtifactFileList
fileDescriptions={{
[artifactPath]: "AI 改写版 Markdown 文件",
}}
files={[artifactPath]}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
)}
</section>
);
}
@@ -12,13 +12,7 @@ import {
MessageResponse, MessageResponse,
type MessageResponseProps, type MessageResponseProps,
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
looksLikeHtmlDocument,
MarkdownFallback,
MarkdownRenderBoundary,
streamdownPlugins,
useStreamdownMermaidPngDownload,
} from "@/core/streamdown";
import { import {
createMarkdownAnchorResolver, createMarkdownAnchorResolver,
isInPageAnchor, isInPageAnchor,
@@ -26,9 +20,6 @@ import {
} from "@/core/utils/markdown"; } from "@/core/utils/markdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { VisualizationBlock } from "../visualizations/visualization-block";
import { splitVisualizationContent } from "../visualizations/visualization-spec";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean { function isExternalUrl(href: string | undefined): boolean {
@@ -59,7 +50,6 @@ export type MarkdownContentProps = {
/** Renders markdown content. */ /** Renders markdown content. */
export function MarkdownContent({ export function MarkdownContent({
content, content,
isLoading,
rehypePlugins, rehypePlugins,
className, className,
remarkPlugins = streamdownPlugins.remarkPlugins, remarkPlugins = streamdownPlugins.remarkPlugins,
@@ -69,7 +59,6 @@ export function MarkdownContent({
}: MarkdownContentProps) { }: MarkdownContentProps) {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]); const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
useStreamdownMermaidPngDownload(rootRef); useStreamdownMermaidPngDownload(rootRef);
const components = useMemo(() => { const components = useMemo(() => {
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
@@ -150,23 +139,8 @@ export function MarkdownContent({
if (!content) return null; if (!content) return null;
// Full HTML pages belong in the artifact iframe. Feeding them to Streamdown
// mid-stream can recurse forever in marked and white-screen the chat.
if (looksLikeHtmlDocument(content)) {
return (
<MarkdownFallback message="正在生成 HTML 页面,请查看右侧预览。" />
);
}
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
// visualization renderers are lazy-loaded only when a dedicated fence exists.
if (
visualizationSegments.length === 1 &&
visualizationSegments[0]?.type === "markdown"
) {
return ( return (
<div ref={rootRef} data-markdown-anchor-root className="contents"> <div ref={rootRef} data-markdown-anchor-root className="contents">
<MarkdownRenderBoundary resetKey={`${content.length}:${content.slice(-32)}`}>
<MessageResponse <MessageResponse
className={className} className={className}
remarkPlugins={remarkPlugins} remarkPlugins={remarkPlugins}
@@ -176,46 +150,6 @@ export function MarkdownContent({
> >
{content} {content}
</MessageResponse> </MessageResponse>
</MarkdownRenderBoundary>
</div>
);
}
return (
<div ref={rootRef} data-markdown-anchor-root className="contents">
{visualizationSegments.map((segment, index) =>
segment.type === "visualization" ? (
<VisualizationBlock
key={`visualization-${index}`}
language={segment.language}
source={segment.source}
complete={segment.complete}
isLoading={isLoading}
/>
) : segment.content ? (
looksLikeHtmlDocument(segment.content) ? (
<MarkdownFallback
key={`markdown-${index}`}
message="正在生成 HTML 页面,请查看右侧预览。"
/>
) : (
<MarkdownRenderBoundary
key={`markdown-${index}`}
resetKey={`${segment.content.length}:${segment.content.slice(-32)}`}
>
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{segment.content}
</MessageResponse>
</MarkdownRenderBoundary>
)
) : null,
)}
</div> </div>
); );
} }
@@ -33,20 +33,20 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { import {
Sheet, Dialog,
SheetContent, DialogContent,
SheetDescription, DialogDescription,
SheetHeader, DialogHeader,
SheetTitle, DialogTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/dialog";
import { import {
buildWriteFileVirtualUrl, buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath, isAutoPreviewableArtifactPath,
languageOfStreamingArtifact, languageOfStreamingArtifact,
normalizeStreamingArtifactPath, normalizeStreamingArtifactPath,
namedStreamingWriteFilePath, provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview"; } from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
normalizeSearchResults, normalizeSearchResults,
@@ -57,12 +57,9 @@ import {
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
findToolCallResult, findToolCallResult,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility";
import { import {
applyInlineCitations, applyInlineCitations,
buildSkillDisplayMap, buildSkillDisplayMap,
@@ -85,8 +82,7 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources"; import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDrawer } from "./message-list-item"; import { SingleRoundRagCitationDialog } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
export function MessageGroup({ export function MessageGroup({
className, className,
@@ -94,17 +90,14 @@ export function MessageGroup({
isLoading = false, isLoading = false,
displaySources = [], displaySources = [],
ragModeEnabled = false, ragModeEnabled = false,
threadId,
}: { }: {
className?: string; className?: string;
messages: Message[]; messages: Message[];
isLoading?: boolean; isLoading?: boolean;
displaySources?: RuntimeDisplaySource[]; displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean; ragModeEnabled?: boolean;
threadId?: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { data: citationDisplay } = useCitationDisplaySettings();
const [showAbove, setShowAbove] = useState( const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
@@ -120,12 +113,6 @@ export function MessageGroup({
[messages, writingModeEnabled], [messages, writingModeEnabled],
); );
const { skills } = useSkills(); const { skills } = useSkills();
const retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
const skillDisplayMap = useMemo( const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills], [ragModeEnabled, skills],
@@ -223,7 +210,6 @@ export function MessageGroup({
{...step} {...step}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
), ),
@@ -236,7 +222,6 @@ export function MessageGroup({
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
@@ -286,8 +271,7 @@ export function MessageGroup({
</> </>
)} )}
</ChainOfThought> </ChainOfThought>
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />} <SingleRoundRagCitationDialog
<SingleRoundRagCitationDrawer
source={selectedCitation} source={selectedCitation}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setSelectedCitation(null); if (!open) setSelectedCitation(null);
@@ -352,7 +336,6 @@ function ToolCall({
isLast = false, isLast = false,
isLoading = false, isLoading = false,
skillDisplayMap = new Map(), skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false, ragModeEnabled = false,
}: { }: {
id?: string; id?: string;
@@ -363,7 +346,6 @@ function ToolCall({
isLast?: boolean; isLast?: boolean;
isLoading?: boolean; isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>; skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean; ragModeEnabled?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
@@ -374,41 +356,18 @@ function ToolCall({
null); null);
const displayItems = const displayItems =
displayMatch && result !== undefined displayMatch && result !== undefined
? syncDisplaySourceIndexes( ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
rankDisplaySources(
normalizeDisplaySources(result, displayMatch.display).map(
(item, index) => ({
...item, ...item,
index: index + 1, index: index + 1,
skillName: displayMatch.skillName, skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode, mode: item.displayMode ?? displayMatch.display.mode,
}), }))
),
),
displaySources,
)
: []; : [];
const isLlmWikiTool = const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
name === "llmwiki_search" ||
name === "llmwiki_read_wiki_page" ||
name === "llmwiki_browse_wiki_index";
const wikiQuery = isLlmWikiTool
? resolveSearchQuery(args, result) ||
(name === "llmwiki_read_wiki_page"
? normalizeSearchResults(result)[0]?.title ||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
: "")
: "";
const wikiSearchLabel = name === "llmwiki_search" const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery ? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"` ? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索" : "Wiki 文档检索"
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null; : null;
if (displayItems.length > 0) { if (displayItems.length > 0) {
return ( return (
@@ -435,17 +394,12 @@ function ToolCall({
const searchItems = normalizeSearchResults(result); const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) { if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = syncDisplaySourceIndexes( const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
rankDisplaySources(
normalizeDefaultDisplaySources(result).map((item, index) => ({
...item, ...item,
index: index + 1, index: index + 1,
skillName: name, skillName: name,
mode: "citation" as const, mode: "citation" as const,
})), }));
),
displaySources,
);
return ( return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}> <ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} /> <DisplayResultsList results={defaultItems} />
@@ -827,10 +781,17 @@ function ArtifactWriteToolCall({
const rawPath = typeof args.path === "string" ? args.path : ""; const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : ""; const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath); const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const sourcePath = namedStreamingWriteFilePath(rawPath); const hasUsablePath =
const showInlineContent = name === "write_file" && !sourcePath; Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null; const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Until a real path arrives, both auto-open and manual clicks stay inline. // Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
const artifactPath = const artifactPath =
sourcePath && name === "write_file" && messageId && id sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id }) ? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
@@ -871,9 +832,7 @@ function ArtifactWriteToolCall({
artifacts.setOpen(true); artifacts.setOpen(true);
}} }}
> >
{showInlineContent ? ( {(normalizedPath || sourcePath) && (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer"> <ChainOfThoughtSearchResult className="cursor-pointer">
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))} {artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult> </ChainOfThoughtSearchResult>
@@ -882,28 +841,6 @@ function ArtifactWriteToolCall({
); );
} }
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
useLayoutEffect(() => {
if (follow.current && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [content]);
return (
<pre
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
}}
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
>
<code>{content}</code>
</pre>
);
}
interface GenericCoTStep<T extends string = string> { interface GenericCoTStep<T extends string = string> {
id?: string; id?: string;
messageId?: string; messageId?: string;
@@ -924,10 +861,6 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] { function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = []; const steps: CoTStep[] = [];
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
// per-name cursor so parallel searches consume distinct unbound results in
// order instead of every step reusing the first page.
const toolCallNameFallbackIndexes = new Map<string, number>();
for (const message of messages) { for (const message of messages) {
if (message.type === "ai") { if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message); const reasoning = extractReasoningContentFromMessage(message);
@@ -940,37 +873,14 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
}; };
steps.push(step); steps.push(step);
} }
const writes = new Map( for (const tool_call of message.tool_calls ?? []) {
listStreamingWriteFileCalls(message).map((write) => [write.id, write]), if (tool_call.name === "task") {
);
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
.tool_call_chunks;
const toolCalls = [...(message.tool_calls ?? [])];
for (const write of writes.values()) {
if (
!toolCalls.some((call) => call.id === write.id) &&
chunks?.some(
(chunk) => chunk.id === write.id && chunk.name === "write_file",
)
) {
toolCalls.push({
id: write.id,
name: "write_file",
args: { path: write.path, content: write.content },
});
}
}
for (const tool_call of toolCalls) {
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
continue; continue;
} }
const write = tool_call.name === "write_file" && tool_call.id const writePath =
? writes.get(tool_call.id) typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
: undefined; ? (tool_call.args as { path: string }).path
const args = write : "";
? { ...tool_call.args, path: write.path, content: write.content }
: tool_call.args;
const writePath = typeof args?.path === "string" ? args.path : "";
if ( if (
( (
tool_call.name === "write_file" || tool_call.name === "write_file" ||
@@ -988,27 +898,15 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
messageId: message.id, messageId: message.id,
type: "toolCall", type: "toolCall",
name: tool_call.name, name: tool_call.name,
args, args: tool_call.args,
}; };
const toolCallId = tool_call.id; const toolCallId = tool_call.id;
if (toolCallId) { if (toolCallId) {
let toolCallResult = findToolCallResult( const toolCallResult = findToolCallResult(
toolCallId, toolCallId,
messages, messages,
tool_call.name, tool_call.name,
{ allowNameFallback: false },
); );
if (toolCallResult === undefined) {
const nameKey = tool_call.name.toLowerCase();
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
);
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
}
if (toolCallResult !== undefined) { if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") { if (typeof toolCallResult === "string") {
try { try {
@@ -1075,48 +973,6 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
); );
} }
function rankDisplaySources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }, index) => ({ ...source, index: index + 1 }));
}
function syncDisplaySourceIndexes(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const indexByCitationId = new Map(
globalSources.flatMap((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
return citationId ? [[citationId, source.index] as const] : [];
}),
);
return sources.map((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
const index = citationId ? indexByCitationId.get(citationId) : undefined;
return index ? { ...source, index } : source;
});
}
function SearchResultCard({ item }: { item: SearchResultItem }) { function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content; const content = item.snippet ?? item.content;
@@ -1154,35 +1010,23 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
</div> </div>
)} )}
</button> </button>
<Sheet open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<SheetContent <DialogContent className="max-w-2xl">
side="right" <DialogHeader>
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none" <DialogTitle className="pr-6 text-base leading-relaxed">
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"} {item.title ?? "检索结果"}
</SheetTitle> </DialogTitle>
{(sourceLabel || date) && ( {(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs"> <DialogDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>} {sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>} {sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>} {date && <span>{date}</span>}
</SheetDescription> </DialogDescription>
)} )}
{!sourceLabel && !date && ( </DialogHeader>
<SheetDescription className="sr-only">检索结果详情</SheetDescription> {content && (
)} <div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content} {content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div> </div>
)} )}
{item.url && ( {item.url && (
@@ -1190,10 +1034,8 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
{item.url} {item.url}
</div> </div>
)} )}
</div> </DialogContent>
</div> </Dialog>
</SheetContent>
</Sheet>
</> </>
); );
} }
@@ -1,8 +1,7 @@
import type { Message } from '@langchain/langgraph-sdk'; import type { Message } from '@langchain/langgraph-sdk';
import { import {
ArrowLeftIcon,
BookMarkedIcon, BookMarkedIcon,
ChevronRightIcon, ChevronDownIcon,
ClockIcon, ClockIcon,
ExternalLinkIcon, ExternalLinkIcon,
FileIcon, FileIcon,
@@ -26,6 +25,7 @@ import { Loader } from '@/components/ai-elements/loader';
import { import {
Message as AIElementMessage, Message as AIElementMessage,
MessageContent as AIElementMessageContent, MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse,
MessageToolbar, MessageToolbar,
} from '@/components/ai-elements/message'; } from '@/components/ai-elements/message';
import { import {
@@ -37,12 +37,17 @@ import { Task, TaskTrigger } from '@/components/ai-elements/task';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Sheet, Dialog,
SheetContent, DialogContent,
SheetDescription, DialogDescription,
SheetHeader, DialogHeader,
SheetTitle, DialogTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/dialog';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { import {
UploadedFilePreviewDialog, UploadedFilePreviewDialog,
@@ -53,7 +58,6 @@ import { useChatAskTimes } from '@/core/chat-timestamps';
import { getBackendBaseURL } from '@/core/config'; import { getBackendBaseURL } from '@/core/config';
import { readRuntimeEnv } from '@/env'; import { readRuntimeEnv } from '@/env';
import { useI18n } from '@/core/i18n/hooks'; import { useI18n } from '@/core/i18n/hooks';
import { stripKnowledgeRetrievalDiagnostics } from '@/core/llmwiki/answer-display';
import { import {
extractContentFromMessage, extractContentFromMessage,
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
@@ -67,20 +71,18 @@ import {
applyInlineCitations, applyInlineCitations,
type RuntimeDisplaySource, type RuntimeDisplaySource,
} from '@/core/skill-display/runtime'; } from '@/core/skill-display/runtime';
import { humanMessagePlugins } from '@/core/streamdown';
import { import {
getMultiRoundRagTrace, getMultiRoundRagTrace,
isMultiRoundRagMessage, isMultiRoundRagMessage,
} from '@/core/threads/use-multi-round-rag'; } from '@/core/threads/use-multi-round-rag';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AnswerFeedbackContext } from '@/strategy-components/api/answer-feedback';
import { CitationLink } from '../citations/citation-link'; import { CitationLink } from '../citations/citation-link';
import { CopyButton } from '../copy-button'; import { CopyButton } from '../copy-button';
import { Tooltip } from '../tooltip'; import { Tooltip } from '../tooltip';
import { SkillDisplayBlock } from '../skill-display-block'; import { SkillDisplayBlock } from '../skill-display-block';
import { ThreadContext } from './context'; import { ThreadContext } from './context';
import { AnswerFeedbackControl } from './answer-feedback-control';
import { isEligibleAnswerFeedbackMessage } from './answer-feedback-eligibility';
import { DeleteMessageButton } from './delete-message-button'; import { DeleteMessageButton } from './delete-message-button';
import { DownloadWordButton } from './download-word-button'; import { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from './markdown-content'; import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
@@ -166,8 +168,6 @@ export function MessageListItem({
onEditSave, onEditSave,
onMessageDeleted, onMessageDeleted,
turnTime, turnTime,
answerFeedbackEnabled = false,
answerFeedbackContext,
}: { }: {
className?: string; className?: string;
fitRowToContent?: boolean; fitRowToContent?: boolean;
@@ -194,10 +194,6 @@ export function MessageListItem({
* wall clock at render time and appear to "drift" to now on every reload. * wall clock at render time and appear to "drift" to now on every reload.
*/ */
turnTime?: string; turnTime?: string;
/** Opt-in feedback affordance for completed, persisted assistant answers. */
answerFeedbackEnabled?: boolean;
/** Authoritative run/agent mapping hydrated in one conversation batch. */
answerFeedbackContext?: AnswerFeedbackContext;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const isHuman = message.type === 'human'; const isHuman = message.type === 'human';
@@ -222,9 +218,6 @@ export function MessageListItem({
!!message.id && !!message.id &&
!message.id.startsWith('opt-') && !message.id.startsWith('opt-') &&
!!messageMarkdown; !!messageMarkdown;
const canEvaluateAnswer =
answerFeedbackEnabled &&
isEligibleAnswerFeedbackMessage(message, Boolean(isLoading));
const confirmAssistantEdit = useCallback(async () => { const confirmAssistantEdit = useCallback(async () => {
const value = assistantDraft.trim(); const value = assistantDraft.trim();
if (!value || !message.id || !onEditSave || assistantSaving) { if (!value || !message.id || !onEditSave || assistantSaving) {
@@ -323,24 +316,17 @@ export function MessageListItem({
{/* {/*
Human messages carry their own inline copy/edit toolbar inside Human messages carry their own inline copy/edit toolbar inside
MessageContent (above the timestamp). Only assistant answers use the MessageContent (above the timestamp). Only assistant answers use the
always-visible floating bottom toolbar here. floating bottom toolbar here.
*/} */}
{!isLoading && !isHuman && ( {!isLoading && !isHuman && (
<MessageToolbar <MessageToolbar
className={cn( className={cn(
'-bottom-8', '-bottom-8',
'absolute right-0 left-0 z-20', 'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
)} )}
> >
<div className="flex gap-1"> <div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} /> <CopyButton clipboardData={messageMarkdown} />
{canEvaluateAnswer && message.id && answerFeedbackContext && (
<AnswerFeedbackControl
threadId={threadId}
messageId={message.id}
context={answerFeedbackContext}
/>
)}
{messageMarkdown && ( {messageMarkdown && (
<DownloadWordButton <DownloadWordButton
markdown={messageMarkdown} markdown={messageMarkdown}
@@ -372,7 +358,7 @@ export function MessageListItem({
/** /**
* Inline copy/edit affordances + editor for the user's own question bubble. * Inline copy/edit affordances + editor for the user's own question bubble.
* *
* Layout: bubble → toolbar (copy + edit) → timestamp. The toolbar sits * Layout: bubble → hover toolbar (copy + edit) → timestamp. The toolbar sits
* in normal flow above the timestamp (not floating) so it never covers the * in normal flow above the timestamp (not floating) so it never covers the
* following message. Clicking edit turns the bubble into a textarea seeded with * following message. Clicking edit turns the bubble into a textarea seeded with
* the question; confirming sends the edited text as a brand-new turn (the * the question; confirming sends the edited text as a brand-new turn (the
@@ -384,6 +370,7 @@ function HumanMessageBlock({
pageContext, pageContext,
filesList, filesList,
messageTime, messageTime,
components,
onEditResubmit, onEditResubmit,
onEditSave, onEditSave,
deleteButton, deleteButton,
@@ -393,7 +380,7 @@ function HumanMessageBlock({
pageContext?: EmbeddedPageContext | null; pageContext?: EmbeddedPageContext | null;
filesList: ReactNode; filesList: ReactNode;
messageTime: { label: string; full: string } | null; messageTime: { label: string; full: string } | null;
components?: MarkdownContentProps["components"]; components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void; onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>; onEditSave?: (messageId: string, text: string) => void | Promise<void>;
deleteButton?: ReactNode; deleteButton?: ReactNode;
@@ -473,9 +460,14 @@ function HumanMessageBlock({
} }
const messageResponse = text ? ( const messageResponse = text ? (
<div className="whitespace-pre-wrap break-words text-[15px] leading-7"> <AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
parseIncompleteMarkdown={false}
>
{text} {text}
</div> </AIElementMessageResponse>
) : null; ) : null;
return ( return (
@@ -489,7 +481,7 @@ function HumanMessageBlock({
<MessageTimeStamp time={messageTime} align="right" /> <MessageTimeStamp time={messageTime} align="right" />
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />} {pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
{(text || deleteButton) && ( {(text || deleteButton) && (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
{text && <CopyButton clipboardData={text} className="size-6" />} {text && <CopyButton clipboardData={text} className="size-6" />}
{text && (onEditResubmit || onEditSave) && ( {text && (onEditResubmit || onEditSave) && (
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}> <Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
@@ -700,16 +692,13 @@ function MessageContent_({
? stripArtifactTag(stripUploadedFilesTag(rawContent)) ? stripArtifactTag(stripUploadedFilesTag(rawContent))
: ''; : '';
} }
const sanitizedContent = stripKnowledgeRetrievalDiagnostics(rawContent ?? '');
if (isMultiRoundRag) { if (isMultiRoundRag) {
return stripIncompleteCitationMarker(sanitizedContent); return stripIncompleteCitationMarker(rawContent ?? '');
} }
const withCitations = applyInlineCitations(sanitizedContent, displaySources); const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations); return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, isMultiRoundRag, displaySources]); }, [rawContent, isHuman, isMultiRoundRag, displaySources]);
const markdownRehypePlugins = useMemo< const markdownRehypePlugins = useMemo(
NonNullable<MarkdownContentProps['rehypePlugins']>
>(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]], () => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins], [rehypePlugins],
); );
@@ -819,7 +808,10 @@ function MessageContent_({
)} )}
</div> </div>
{isSingleRoundRag && displaySources.length > 0 && ( {isSingleRoundRag && displaySources.length > 0 && (
<FrontendRagSourcesPanel sources={displaySources} /> <FrontendRagSourcesPanel
sources={displaySources}
onSelect={setSelectedCitation}
/>
)} )}
<MessageTokenUsage <MessageTokenUsage
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
@@ -831,7 +823,7 @@ function MessageContent_({
) : null} ) : null}
</AIElementMessageContent> </AIElementMessageContent>
{isSingleRoundRag && ( {isSingleRoundRag && (
<SingleRoundRagCitationDrawer <SingleRoundRagCitationDialog
source={selectedCitation} source={selectedCitation}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setSelectedCitation(null); if (!open) setSelectedCitation(null);
@@ -843,173 +835,109 @@ function MessageContent_({
} }
/** /**
* 单轮 RAG 的参考文献入口。先打开右侧抽屉查看列表, * 单轮 RAG 专属的「引用来源」折叠面板。
* 选中条目后在同一抽屉中阅读详情,避免跳出居中弹框打断问答。 *
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
*/ */
function FrontendRagSourcesPanel({ sources }: { sources: RuntimeDisplaySource[] }) { function FrontendRagSourcesPanel({
sources,
onSelect,
}: {
sources: RuntimeDisplaySource[];
onSelect: (source: RuntimeDisplaySource) => void;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedSource, setSelectedSource] = useState<RuntimeDisplaySource | null>(null);
if (sources.length === 0) return null; if (sources.length === 0) return null;
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) setSelectedSource(null);
};
return ( return (
<> <Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button <button
type="button" type="button"
onClick={() => setOpen(true)} className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs transition-colors"
className="text-muted-foreground hover:text-foreground mt-3 inline-flex items-center gap-1.5 rounded-md py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
aria-label={`查看 ${sources.length} 条参考文献`}
> >
<BookMarkedIcon className="size-3.5" /> <BookMarkedIcon className="size-3.5" />
<span className="font-medium">参考文献</span> <span className="font-medium">引用来源</span>
<span className="text-muted-foreground/70">({sources.length})</span> <span className="text-muted-foreground/70">({sources.length})</span>
<span>查看</span> <ChevronDownIcon
<ChevronRightIcon className="size-3.5" /> className={cn(
</button> 'size-3.5 transition-transform duration-200',
!open && '-rotate-90',
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="flex h-full min-h-0 flex-col">
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<div className="flex items-start gap-3">
{selectedSource && (
<Button
type="button"
size="icon-sm"
variant="ghost"
className="mt-0.5 shrink-0"
onClick={() => setSelectedSource(null)}
aria-label="返回参考文献列表"
>
<ArrowLeftIcon className="size-4" />
</Button>
)} )}
<div className="min-w-0"> />
<SheetTitle className="truncate text-base"> </button>
{selectedSource?.title || "参考文献"} </CollapsibleTrigger>
</SheetTitle> <CollapsibleContent className="mt-2 grid gap-2">
<SheetDescription className="mt-1 text-xs">
{selectedSource
? "引用详情;可返回列表继续查看其他文献。"
: `本次回答共引用 ${sources.length} 条资料,点击条目查看具体内容。`}
</SheetDescription>
</div>
</div>
</SheetHeader>
{selectedSource ? (
<CitationSourceDetail source={selectedSource} />
) : (
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-5">
<div className="grid gap-2">
{sources.map((source, idx) => ( {sources.map((source, idx) => (
<button <button
key={source.id ?? `${source.title}-${idx}`} key={source.id ?? `${source.title}-${idx}`}
type="button" type="button"
onClick={() => setSelectedSource(source)} onClick={() => onSelect(source)}
className="group bg-card hover:border-primary/40 hover:bg-accent block w-full cursor-pointer rounded-xl border p-3.5 text-left shadow-sm transition-colors" className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
> >
<div className="flex items-start gap-2.5"> <div className="flex items-center gap-2">
<span className="bg-primary/10 text-primary mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold"> <span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{source.index} {source.index}
</span> </span>
<div className="min-w-0 flex-1"> <span
<div className="flex items-center gap-2"> className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
<span className="text-foreground line-clamp-2 min-w-0 flex-1 text-sm font-medium"> title={source.title}
>
{source.title} {source.title}
</span> </span>
{source.type && ( {source.type && (
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[11px]"> <span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
{source.type} {source.type}
</span> </span>
)} )}
</div> </div>
{(source.source || source.time) && ( {source.snippet && (
<p className="text-muted-foreground mt-1 text-[11px]">
{[source.source, source.time].filter(Boolean).join(" · ")}
</p>
)}
{(source.snippet || source.content) && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed"> <p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.snippet || source.content} {source.snippet}
</p> </p>
)} )}
</div>
<ChevronRightIcon className="text-muted-foreground mt-1 size-4 shrink-0 transition-transform group-hover:translate-x-0.5" />
</div>
</button> </button>
))} ))}
</div> </CollapsibleContent>
</div> </Collapsible>
)}
</div>
</SheetContent>
</Sheet>
</>
); );
} }
export function SingleRoundRagCitationDrawer({ export function SingleRoundRagCitationDialog({
source, source,
onOpenChange, onOpenChange,
}: { }: {
source: RuntimeDisplaySource | null; source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
return (
<Sheet open={!!source} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="leading-relaxed">
{source?.title || "引用资料"}
</SheetTitle>
<SheetDescription className="space-y-1 text-xs">
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
{source?.time && <span className="block">时间:{source.time}</span>}
</SheetDescription>
</SheetHeader>
{source && <CitationSourceDetail source={source} />}
</SheetContent>
</Sheet>
);
}
function CitationSourceDetail({ source }: { source: RuntimeDisplaySource }) {
const sourceUrl = resolveSingleRoundRagSourceUrl(source); const sourceUrl = resolveSingleRoundRagSourceUrl(source);
return ( return (
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6"> <Dialog open={!!source} onOpenChange={onOpenChange}>
<div className="mx-auto max-w-3xl space-y-4"> <DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
<DialogTitle className="leading-relaxed">
{source?.title || "引用资料"}
</DialogTitle>
<DialogDescription className="space-y-1 text-xs">
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
{source?.time && <span className="block">时间:{source.time}</span>}
</DialogDescription>
</DialogHeader>
{sourceUrl && ( {sourceUrl && (
<a <a
href={sourceUrl} href={sourceUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="border-input bg-background hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors" className="border-input hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
> >
<ExternalLinkIcon className="size-4" /> <ExternalLinkIcon className="size-4" />
查看原文链接 查看原文链接
</a> </a>
)} )}
<article className="bg-background rounded-xl border px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6"> <div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-lg p-3 text-sm leading-7 whitespace-pre-wrap">
{source.content || source.snippet || "暂无引用内容"} {source?.content || source?.snippet || "暂无引用内容"}
</article>
{sourceUrl && (
<p className="text-muted-foreground border-t pt-3 text-xs break-all">
{sourceUrl}
</p>
)}
</div>
</div> </div>
</DialogContent>
</Dialog>
); );
} }
@@ -1235,8 +1163,6 @@ function RichFileCard({
<UploadedFilePreviewDialog <UploadedFilePreviewDialog
filename={file.filename} filename={file.filename}
fileUrl={fileUrl} fileUrl={fileUrl}
threadId={threadId}
virtualPath={file.path}
open={previewOpen} open={previewOpen}
onOpenChange={setPreviewOpen} onOpenChange={setPreviewOpen}
/> />
@@ -32,7 +32,6 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { import {
buildSkillDisplayMap, buildSkillDisplayMap,
collectRuntimeDisplaySources, collectRuntimeDisplaySources,
@@ -41,35 +40,20 @@ import {
import type { Subtask } from "@/core/tasks"; import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import {
shouldRenderUnattachedKnowledgeRetrievalProgress,
useKnowledgeRetrievalProgress,
} from "@/core/threads/knowledge-retrieval-progress";
import {
buildSmartKnowledgeRetrievalDisplaySegments,
collectSmartKnowledgeRetrievalTurnAssistantMessageIds,
hasSmartKnowledgeRetrievalCalls,
latestSmartKnowledgeRetrievalMessageId,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag"; import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { getQuestionAnchorId } from "../question-navigator"; import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator"; import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context"; import { ReferenceBatchesContext, ThreadContext } from "./context";
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteMessage, DocumentRewriteTimeline } from "./document-rewrite-timeline"; import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group"; import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item"; import { MessageListItem } from "./message-list-item";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
import { import {
MessageMetricsProvider, MessageMetricsProvider,
MessageTokenUsageList, MessageTokenUsageList,
@@ -128,50 +112,6 @@ function ScrollToBottomOnUserMessage({
return null; return null;
} }
/**
* Exposes the conversation's scroll position to hosts that render controls
* outside the scroll container (for example the docked follow-up questions).
*/
function ConversationBottomObserver({
onAtBottomChange,
}: {
onAtBottomChange?: (isAtBottom: boolean) => void;
}) {
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const update = () => {
const distance =
scrollElement.scrollHeight -
scrollElement.scrollTop -
scrollElement.clientHeight;
setIsExactlyAtBottom(distance <= 2);
};
update();
scrollElement.addEventListener("scroll", update, { passive: true });
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
resizeObserver?.observe(scrollElement);
if (contentRef.current) resizeObserver?.observe(contentRef.current);
return () => {
scrollElement.removeEventListener("scroll", update);
resizeObserver?.disconnect();
};
}, [contentRef, scrollRef]);
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
useEffect(() => {
onAtBottomChange?.(displayControlsAtBottom);
}, [displayControlsAtBottom, onAtBottomChange]);
return null;
}
export function MessageList({ export function MessageList({
className, className,
contentClassName, contentClassName,
@@ -182,7 +122,6 @@ export function MessageList({
tokenUsageEnabled = false, tokenUsageEnabled = false,
extraRehypePlugins, extraRehypePlugins,
scrollButtonClassName, scrollButtonClassName,
onAtBottomChange,
onClarificationSubmit, onClarificationSubmit,
onEditResubmit, onEditResubmit,
onEditSave, onEditSave,
@@ -202,7 +141,6 @@ export function MessageList({
onOpenArtifact, onOpenArtifact,
onDownloadArtifact, onDownloadArtifact,
afterDocumentRewriteItems, afterDocumentRewriteItems,
answerFeedbackEnabled = false,
}: { }: {
className?: string; className?: string;
contentClassName?: string; contentClassName?: string;
@@ -213,8 +151,6 @@ export function MessageList({
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */ /** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string; scrollButtonClassName?: string;
/** Reports whether the message viewport is currently at its bottom edge. */
onAtBottomChange?: (isAtBottom: boolean) => void;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>; extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>; onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void; onEditResubmit?: (text: string) => void;
@@ -260,12 +196,6 @@ export function MessageList({
onDownloadArtifact?: (filepath: string) => void | Promise<void>; onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */ /** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode; afterDocumentRewriteItems?: React.ReactNode;
/**
* Enables per-answer user feedback for persisted assistant replies. Kept
* opt-in because MessageList is also reused by read-only previews, setup
* flows and virtual business-card feeds.
*/
answerFeedbackEnabled?: boolean;
}) { }) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo( const rehypePlugins = useMemo(
@@ -287,13 +217,8 @@ export function MessageList({
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>( const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
() => new Set(), () => new Set(),
); );
const [selectedRetrievalResult, setSelectedRetrievalResult] = useState<{
item: SmartKnowledgeRetrievalResultItem;
target: WeKnoraSourceTarget;
} | null>(null);
useEffect(() => { useEffect(() => {
setHiddenMessageIds(new Set()); setHiddenMessageIds(new Set());
setSelectedRetrievalResult(null);
}, [threadId]); }, [threadId]);
const handleMessageDeleted = useCallback((deletedIds: string[]) => { const handleMessageDeleted = useCallback((deletedIds: string[]) => {
if (deletedIds.length === 0) return; if (deletedIds.length === 0) return;
@@ -304,20 +229,9 @@ export function MessageList({
}); });
}, []); }, []);
const documentRewriteItems = useMemo(
() => documentRewritePresentation?.forThread(threadId) ?? [],
[documentRewritePresentation, threadId],
);
const artifactRewriteItems = useMemo(
() => documentRewriteItems.filter(
(item) => item.source.kind === "artifact" && item.operation === "rewrite",
),
[documentRewriteItems],
);
const allMessages = useMemo(() => { const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0; const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
const persistedMessages = thread.messages return thread.messages
.filter((m) => !isHiddenFromUIMessage(m)) .filter((m) => !isHiddenFromUIMessage(m))
.map((message) => { .map((message) => {
const override = const override =
@@ -328,23 +242,7 @@ export function MessageList({
? message ? message
: ({ ...message, content: override } as Message); : ({ ...message, content: override } as Message);
}); });
const rewriteMessages = artifactRewriteItems.flatMap((item) => [ }, [messageContentOverrides, thread.messages]);
{
id: `document-rewrite-input-${item.id}`,
type: "human",
content: "AI改写",
} as unknown as Message,
{
id: `document-rewrite-${item.id}`,
type: "ai",
content: "",
additional_kwargs: {
document_rewrite_presentation_id: item.id,
},
} as unknown as Message,
]);
return [...persistedMessages, ...rewriteMessages];
}, [artifactRewriteItems, messageContentOverrides, thread.messages]);
const messages = useMemo( const messages = useMemo(
() => { () => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>(); const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
@@ -358,56 +256,8 @@ export function MessageList({
}, },
[allMessages, externallyHiddenMessageIds, hiddenMessageIds], [allMessages, externallyHiddenMessageIds, hiddenMessageIds],
); );
const knowledgeRetrievalProgress = useKnowledgeRetrievalProgress(threadId); const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const hasHumanMessage = messages.some((message) => message.type === "human");
const currentTurnMessages = useMemo(() => {
let lastHumanIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.type === "human") {
lastHumanIndex = index;
break;
}
}
return messages.slice(lastHumanIndex + 1);
}, [messages]);
const latestCurrentSmartMessageId = useMemo(
() => latestSmartKnowledgeRetrievalMessageId(currentTurnMessages),
[currentTurnMessages],
);
const lastCurrentTurnMessageId = currentTurnMessages.at(-1)?.id;
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
const answerFeedbackMessageIds = useMemo(() => {
if (!feedbackAvailable) return [];
const streamingAssistantId = thread.isLoading
? [...messages].reverse().find((message) => message.type === "ai")?.id
: undefined;
return messages.flatMap((message) => {
const kwargs = message.additional_kwargs ?? {};
const explicitlyStreaming =
kwargs.deep_research_summary_streaming === true ||
kwargs.deep_research_follow_up_streaming === true;
const loading =
explicitlyStreaming ||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
? [message.id]
: [];
});
}, [feedbackAvailable, messages, thread.isLoading]);
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
threadId,
answerFeedbackMessageIds,
feedbackAvailable,
);
const ratableAnswerMessageIds = useMemo(() => {
const lastMessageByRun = new Map<string, string>();
for (const message of messages) {
if (!message.id) continue;
const context = answerFeedbackContexts?.[message.id];
if (context) lastMessageByRun.set(context.run_id, message.id);
}
return new Set(lastMessageByRun.values());
}, [answerFeedbackContexts, messages]);
const askTimes = useChatAskTimes(); const askTimes = useChatAskTimes();
// Stamp a wall-clock time onto every *question* the first time it is seen, in // Stamp a wall-clock time onto every *question* the first time it is seen, in
@@ -471,8 +321,7 @@ export function MessageList({
const referenceModeEntryEnabled = const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false; citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled = const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true || threadContext?.knowledgeReferencesEnabled === true;
!!threadContext?.knowledgeReferences;
const ragModeEnabled = const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) || (referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) || isThreadRagModeLocked(threadId) ||
@@ -534,29 +383,10 @@ export function MessageList({
return map; return map;
}, [thread.messages]); }, [thread.messages]);
const smartRetrievalAssistantMessageIds = useMemo(() => {
return collectSmartKnowledgeRetrievalTurnAssistantMessageIds(messages);
}, [messages]);
const resolveDisplaySources = useCallback( const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => { (msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return []; if (!referencesEnabled || !msg || msg.type !== "ai") return [];
if (isMultiRoundRagMessage(msg)) return []; if (isMultiRoundRagMessage(msg)) return [];
const previousHumanId = findPreviousHumanMessageId(messages, msg.id);
// Smart retrieval has its own clickable evidence trace and explicitly
// does not require numbered references. Do not feed the same transport
// results into the legacy citation layer, which would append [1][2] to
// an otherwise complete answer.
const isSmartRetrievalTurn = msg.id
? smartRetrievalAssistantMessageIds.has(msg.id)
: hasSmartKnowledgeRetrievalCalls(
messages.slice(
previousHumanId
? messages.findIndex((message) => message.id === previousHumanId) + 1
: 0,
),
);
if (isSmartRetrievalTurn) return [];
const batchSources = const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id) referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? []; ?.sources ?? [];
@@ -566,7 +396,7 @@ export function MessageList({
if (batchSources.length > 0) return batchSources; if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources; if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, { return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: previousHumanId, afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id, beforeMessageId: msg.id,
includeBeforeMessage: true, includeBeforeMessage: true,
}); });
@@ -577,7 +407,6 @@ export function MessageList({
referenceBatches, referenceBatches,
referencesEnabled, referencesEnabled,
skillDisplayMap, skillDisplayMap,
smartRetrievalAssistantMessageIds,
], ],
); );
@@ -695,7 +524,6 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)} className={cn("flex size-full flex-col justify-center", className)}
> >
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} /> <ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
<ConversationContent <ConversationContent
className={cn( className={cn(
"mx-auto w-full max-w-(--container-width-lg)", "mx-auto w-full max-w-(--container-width-lg)",
@@ -705,7 +533,13 @@ export function MessageList({
> >
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}> <MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{(() => { {(() => {
let didRenderLiveKnowledgeRetrieval = false; const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
const grouped = groupMessages(messages, (group) => { const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") { if (group.type === "human" || group.type === "assistant") {
const items = group.messages.map((msg) => { const items = group.messages.map((msg) => {
@@ -751,12 +585,6 @@ export function MessageList({
allowDelete ? handleMessageDeleted : undefined allowDelete ? handleMessageDeleted : undefined
} }
turnTime={msg.id ? turnTimes.get(msg.id) : undefined} turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
answerFeedbackEnabled={answerFeedbackEnabled}
answerFeedbackContext={
msg.id && ratableAnswerMessageIds.has(msg.id)
? answerFeedbackContexts?.[msg.id]
: undefined
}
/> />
); );
}); });
@@ -891,21 +719,6 @@ export function MessageList({
{businessCardSlot(message)} {businessCardSlot(message)}
</div> </div>
) : null; ) : null;
} else if (group.type === "assistant:document-rewrite") {
const presentationId = group.messages[0]?.additional_kwargs?.document_rewrite_presentation_id;
const item = typeof presentationId === "string"
? artifactRewriteItems.find((candidate) => candidate.id === presentationId)
: undefined;
return item ? (
<div className="w-full" key={group.id}>
<DocumentRewriteMessage
item={item}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") { } else if (group.type === "assistant:present-files") {
const files: string[] = []; const files: string[] = [];
for (const message of group.messages) { for (const message of group.messages) {
@@ -914,8 +727,15 @@ export function MessageList({
files.push(...presentFiles); files.push(...presentFiles);
} }
} }
const rewriteAbove = !didRenderRewriteAboveFile
? rewriteItemsAboveFile
: [];
didRenderRewriteAboveFile = true;
return ( return (
<div className="w-full" key={group.id}> <div className="w-full" key={group.id}>
{rewriteAbove.map((item) => (
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
))}
{group.messages[0] && hasContent(group.messages[0]) && ( {group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent <MarkdownContent
content={extractContentFromMessage(group.messages[0])} content={extractContentFromMessage(group.messages[0])}
@@ -995,7 +815,6 @@ export function MessageList({
results.push( results.push(
<MessageGroup <MessageGroup
key={"thinking-group-" + message.id} key={"thinking-group-" + message.id}
threadId={threadId}
messages={[message]} messages={[message]}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)} displaySources={resolveDisplaySources(message)}
@@ -1046,95 +865,14 @@ export function MessageList({
const processingAi = [...group.messages] const processingAi = [...group.messages]
.reverse() .reverse()
.find((message) => message.type === "ai"); .find((message) => message.type === "ai");
const hasSmartRetrievalCalls = hasSmartKnowledgeRetrievalCalls(
group.messages,
);
const isCurrentTurnRetrievalGroup = group.messages.some(
(message) =>
message.id ===
(latestCurrentSmartMessageId ?? lastCurrentTurnMessageId),
);
const receivesLiveRetrievalProgress =
knowledgeRetrievalProgress.length > 0 &&
isCurrentTurnRetrievalGroup;
const retrievalDisplaySegments =
hasSmartRetrievalCalls || receivesLiveRetrievalProgress
? buildSmartKnowledgeRetrievalDisplaySegments(
group.messages,
receivesLiveRetrievalProgress
? knowledgeRetrievalProgress
: [],
)
: [
{
kind: "processing" as const,
id: `processing-${group.id ?? "group"}`,
messages: group.messages,
},
];
if (
retrievalDisplaySegments.some(
(segment) =>
segment.kind === "retrieval" &&
segment.progressFrames.length > 0,
)
) {
didRenderLiveKnowledgeRetrieval = true;
}
const processingBody = ( const processingBody = (
<div className="w-full space-y-2"> <div className="w-full">
{retrievalDisplaySegments.map((segment) => {
if (segment.kind === "retrieval") {
return (
<SmartKnowledgeRetrievalTrace
key={segment.id}
messages={segment.messages}
progressFrames={segment.progressFrames}
dedupeScopeMessages={group.messages}
dedupeScopeProgressFrames={
receivesLiveRetrievalProgress
? knowledgeRetrievalProgress
: []
}
roundOffset={segment.roundOffset}
// The AI tool-call message can arrive before its custom
// `started` frame. Keep the current turn active through
// that short gap instead of misclassifying the pending
// call as an interrupted historical action.
isLoading={
isCurrentTurnRetrievalGroup && thread.isLoading
}
onOpenResult={(item, target) =>
setSelectedRetrievalResult({ item, target })
}
/>
);
}
const segmentHasVisibleProcessing = segment.messages.some(
(message) =>
message.type === "tool" ||
(message.type === "ai" &&
(hasReasoning(message) ||
hasContent(message) ||
hasToolCalls(message))),
);
if (!segmentHasVisibleProcessing) return null;
const segmentAi = [...segment.messages]
.reverse()
.find((message) => message.type === "ai");
return (
<MessageGroup <MessageGroup
key={segment.id} messages={group.messages}
threadId={threadId}
messages={segment.messages}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources( displaySources={resolveDisplaySources(processingAi)}
segmentAi ?? processingAi,
)}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
);
})}
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
isLoading={thread.isLoading} isLoading={thread.isLoading}
@@ -1148,27 +886,12 @@ export function MessageList({
</div> </div>
); );
}); });
const leftoverRewriteItems = documentRewriteItems.filter( const leftoverRewriteItems = didRenderRewriteAboveFile
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite", ? rewriteItemsBelowFile
); : documentRewriteItems;
return ( return (
<> <>
{grouped} {grouped}
{shouldRenderUnattachedKnowledgeRetrievalProgress({
frameCount: knowledgeRetrievalProgress.length,
isLoading: thread.isLoading,
hasHumanMessage,
}) &&
!didRenderLiveKnowledgeRetrieval && (
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={knowledgeRetrievalProgress}
isLoading={thread.isLoading}
onOpenResult={(item, target) =>
setSelectedRetrievalResult({ item, target })
}
/>
)}
{leftoverRewriteItems.map((item) => ( {leftoverRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} /> <DocumentRewriteTimeline key={item.id} item={item} />
))} ))}
@@ -1190,16 +913,6 @@ export function MessageList({
aria-label="回到最新" aria-label="回到最新"
title="回到最新" title="回到最新"
/> />
<WeKnoraSourceDrawer
open={selectedRetrievalResult !== null}
target={selectedRetrievalResult?.target ?? null}
title={selectedRetrievalResult?.item.title}
quote={selectedRetrievalResult?.item.preview ?? ""}
collapseRetrievedExcerptByDefault
onOpenChange={(open) => {
if (!open) setSelectedRetrievalResult(null);
}}
/>
</Conversation> </Conversation>
); );
} }
@@ -1244,7 +957,6 @@ export type SetupWritingApprovalPayload = {
}; };
export type SetupReportStructureApprovalPayload = { export type SetupReportStructureApprovalPayload = {
kind?: "report_structure" | "gongwen_template";
title?: string; title?: string;
name?: string; name?: string;
content?: string; content?: string;
@@ -1664,7 +1376,6 @@ function SetupReportStructureApprovalCard({
} }
const approvalPayload = payload; const approvalPayload = payload;
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
const busy = disabled || submitting !== null; const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? []; const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? []; const skills = approvalPayload.retrieval_skills ?? [];
@@ -1689,8 +1400,8 @@ function SetupReportStructureApprovalCard({
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm"> <div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div> <div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div> <div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
</div> </div>
{approvalPayload.title ? ( {approvalPayload.title ? (
@@ -1732,7 +1443,7 @@ function SetupReportStructureApprovalCard({
{approvalPayload.content ? ( {approvalPayload.content ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div> <div className="text-muted-foreground text-xs">报告大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap"> <pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content} {approvalPayload.content}
</pre> </pre>
@@ -74,18 +74,6 @@ export function SpeedLegend({
} }
export function SpeedPill({ metric }: { metric: MessageMetric }) { export function SpeedPill({ metric }: { metric: MessageMetric }) {
if (metric.isPaused) {
return (
<span
className="inline-flex items-center gap-1.5"
title="本段生成停顿,窗口内无新 token"
>
<GaugeIcon className="text-muted-foreground size-3" />
<span className="text-muted-foreground">暂停</span>
</span>
);
}
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC)); const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec); const { color, label } = classifySpeed(metric.tokensPerSec);
const display = const display =
@@ -97,7 +85,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
return ( return (
<span <span
className="inline-flex items-center gap-1.5" className="inline-flex items-center gap-1.5"
title={`${label} · ${metric.fromUsage ? "按本段 output_tokens / 本段生成时长" : "字符估算 ≈ 2 字符 / token"}`} title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
> >
<GaugeIcon className="size-3" style={{ color }} /> <GaugeIcon className="size-3" style={{ color }} />
<span className="font-mono tabular-nums" style={{ color }}> <span className="font-mono tabular-nums" style={{ color }}>
@@ -1,207 +0,0 @@
import { ChevronDownIcon, DownloadIcon, EyeIcon, Loader2Icon } from "lucide-react";
import { useContext, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { urlOfArtifact } from "@/core/artifacts/utils";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import type { RetrievedFile } from "@/core/messages/retrieved-files";
import { getFileIcon } from "@/core/utils/files";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
getLlmWikiSourceContext,
resolveLlmWikiSourceKnowledgeBase,
} from "@/strategy-components/api/llmwiki";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ThreadContext } from "./context";
import { UploadedFilePreviewDialog } from "./uploaded-file-preview-dialog";
// File links belong to the upstream service. Never attach DeerFlow auth headers.
const fetchExternalFile: typeof fetch = (input, init) =>
fetch(input, { ...init, credentials: "omit" });
export function RetrievedFileList({ files, threadId }: { files: RetrievedFile[]; threadId: string }) {
const { isMock, publicShareCode } = useContext(ThreadContext) ?? {};
const [expanded, setExpanded] = useState(false);
const visible = files.filter((file) => !(publicShareCode && file.kind === "knowledge"));
// Results arrive in retrieval order; keep the newest file outside the fold.
const latest = visible.at(-1);
if (!latest) return null;
const older = visible.slice(0, -1).reverse();
return (
<Collapsible asChild open={expanded} onOpenChange={setExpanded}>
<section aria-label="检索到的文件" className="not-prose mt-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-muted-foreground text-xs">检索到的文件 · {visible.length} 个</div>
{older.length > 0 && (
<CollapsibleTrigger asChild>
<Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
{expanded ? "收起" : "展开"}
<ChevronDownIcon className={`size-3.5 transition-transform ${expanded ? "rotate-180" : ""}`} />
</Button>
</CollapsibleTrigger>
)}
</div>
<ul className="grid gap-2">
<RetrievedFileCard
key={latest.key}
file={latest}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
</ul>
<CollapsibleContent>
<ul className="grid gap-2">
{older.map((file) => (
<RetrievedFileCard
key={file.key}
file={file}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
))}
</ul>
</CollapsibleContent>
</section>
</Collapsible>
);
}
function RetrievedFileCard({ file, threadId, isMock, publicShareCode }: {
file: RetrievedFile;
threadId: string;
isMock?: boolean;
publicShareCode?: string;
}) {
const [previewOpen, setPreviewOpen] = useState(false);
const [preparingPreview, setPreparingPreview] = useState(false);
const [downloading, setDownloading] = useState(false);
const [knowledgeTarget, setKnowledgeTarget] = useState<WeKnoraSourceTarget | null>(null);
const fileUrl = file.kind === "artifact"
? urlOfArtifact({ filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode })
: file.kind === "external" ? file.url : "";
const resolveKnowledgeTarget = async () => {
if (file.kind !== "knowledge") throw new Error("当前文件不是知识库文档");
if (knowledgeTarget) return knowledgeTarget;
const knowledgeBaseId = await resolveLlmWikiSourceKnowledgeBase(file.target.knowledgeBaseId);
const target = { ...file.target, knowledgeBaseId };
setKnowledgeTarget(target);
return target;
};
const preview = async () => {
if (preparingPreview) return;
setPreparingPreview(true);
try {
if (file.kind === "knowledge") await resolveKnowledgeTarget();
setPreviewOpen(true);
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件预览失败");
} finally {
setPreparingPreview(false);
}
};
const download = async () => {
if (downloading || file.kind === "external") return;
setDownloading(true);
try {
if (file.kind === "artifact") {
await downloadArtifactFile({
filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode,
});
} else {
const target = await resolveKnowledgeTarget();
let documentId = target.documentId;
if (!documentId && target.chunkId) {
const context = await getLlmWikiSourceContext(target.knowledgeBaseId, target.chunkId);
documentId = context.document_id || undefined;
}
if (!documentId) throw new Error("当前检索结果没有可下载的文件原件,请先查看预览");
const [blob, document] = await Promise.all([
getLlmWikiDocumentPreview(target.knowledgeBaseId, documentId),
getLlmWikiDocument(target.knowledgeBaseId, documentId).catch(() => null),
]);
const objectUrl = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = objectUrl;
anchor.download = document?.file_name || file.name;
anchor.rel = "noopener";
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件下载失败");
} finally {
setDownloading(false);
}
};
return (
<li className="bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
{getFileIcon(file.name, "text-muted-foreground size-5 shrink-0")}
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium break-words">{file.name}</div>
{file.source && <div className="text-muted-foreground mt-0.5 truncate text-xs">{file.source}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{file.kind === "external" && file.previewUrl ? (
<Button asChild size="sm" variant="ghost">
<a href={file.previewUrl} target="_blank" rel="noopener noreferrer">
<EyeIcon className="size-4" />预览
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={preparingPreview} onClick={() => void preview()}>
{preparingPreview ? <Loader2Icon className="size-4 animate-spin" /> : <EyeIcon className="size-4" />}预览
</Button>
)}
{file.kind === "external" ? (
<Button asChild size="sm" variant="ghost">
<a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer">
<DownloadIcon className="size-4" />下载
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={downloading} onClick={() => void download()}>
{downloading ? <Loader2Icon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}下载
</Button>
)}
</div>
{previewOpen && (file.kind === "knowledge" ? (
<WeKnoraSourceDrawer
open
target={knowledgeTarget || file.target}
title={file.name}
quote={file.quote || ""}
onOpenChange={setPreviewOpen}
/>
) : (
<UploadedFilePreviewDialog
filename={file.name}
fileUrl={fileUrl}
open
onOpenChange={setPreviewOpen}
threadId={file.kind === "artifact" && !publicShareCode ? threadId : undefined}
virtualPath={file.kind === "artifact" ? file.path : undefined}
requestFile={file.kind === "external" ? fetchExternalFile : undefined}
onDownload={file.kind === "artifact" ? download : undefined}
downloading={downloading}
/>
))}
</li>
);
}
@@ -1,406 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { Message } from "@langchain/langgraph-sdk";
import type { KnowledgeRetrievalProgressFrame } from "@/core/threads/smart-knowledge-retrieval";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
const completedFrames: KnowledgeRetrievalProgressFrame[] = [
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "started",
queries: ["成都世运会"],
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 3,
elapsed_ms: 420,
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "started",
pattern: "火炬|开幕|闭幕",
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 4,
elapsed_ms: 1380,
},
];
test("completed and replayed retrieval traces start collapsed with their hit summary visible", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading={false}
/>,
);
assert.match(html, /aria-label="展开智能检索过程"/);
assert.match(html, /aria-expanded="false"/);
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
assert.match(html, /累计耗时 1\.8 秒/);
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
});
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
const messages = [
{
type: "ai",
id: "ai-pending-wiki",
content: "",
tool_calls: [
{
id: "wiki-pending",
name: "ordinary_wiki_search",
args: { pattern: "数字中国|数据要素" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /检索中/);
assert.match(html, /正在查 Wiki/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤未完成|已中止/);
});
test("labels and normalizes a visible model-authored regex", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[
{
type: "ai",
id: "ai-regex",
content: "",
tool_calls: [
{
id: "wiki-regex",
name: "ordinary_wiki_search",
args: { pattern: "^# [一-龥]{2,10}篇�" },
},
],
},
] as unknown as Message[]}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /正则:\^# \\p\{Han\}\{2,10\}篇/);
assert.doesNotMatch(html, /一-龥|�/);
});
test("presents an interrupted historical action as neutral instead of failed", () => {
const messages = [
{
type: "ai",
id: "ai-interrupted-wiki",
content: "",
tool_calls: [
{
id: "wiki-interrupted",
name: "ordinary_wiki_search",
args: { pattern: "数字中国" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading={false}
/>,
);
assert.match(html, /检索已结束(部分步骤未完成)/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤异常/);
});
test("an in-flight retrieval stays expanded and does not expose a collapse control", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索中/);
assert.match(html, /成都世运会/);
assert.doesNotMatch(html, /收起智能检索过程|展开智能检索过程/);
});
test("completed retrieval can be collapsed while the final answer is still streaming", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索完成/);
assert.match(html, /aria-label="收起智能检索过程"/);
assert.match(html, /检索 Wiki/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
assert.doesNotMatch(html, /检索中/);
});
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
roundOffset={2}
/>,
);
assert.match(html, /检索原始文档/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
});
test("renders stable retrieval results as right-drawer controls", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-news",
slug: "news/opening",
title: "开幕新闻 Wiki",
preview: "页面摘要",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /data-retrieval-result-detail="true"/);
assert.match(html, /在右侧查看:开幕新闻 Wiki/);
assert.doesNotMatch(html, /data-retrieval-result-summary="true"/);
});
test("renders a Wiki read as a preview card with elapsed time and right-drawer entry", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_read_page",
status: "completed",
result_count: 1,
knowledge_base_count: 1,
coverage: [
{ knowledge_base: "数字中国政策目录智能检索远端验收" },
],
elapsed_ms: 1260,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-digital-china",
knowledge_base: "数字中国政策目录智能检索远端验收",
slug: "digital-china/timeline",
page_ref: "wiki-page:v1:digital-china-timeline",
title: "数字中国建设时间线",
content_markdown:
"页面正文概览,包含建设目标、关键年份、量化指标和制度保障。",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /读取结果/);
assert.match(html, /数字中国建设时间线/);
assert.match(html, /页面正文概览,包含建设目标/);
assert.match(html, /右侧查看/);
assert.match(html, /耗时 1\.3 秒/);
assert.match(html, /覆盖 1 个知识库:数字中国政策目录智能检索远端验收/);
assert.doesNotMatch(html, /知识库知识库/);
assert.match(html, /data-retrieval-result-detail="true"/);
});
test("shows all usable document-read results without a generic partial warning", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "document_chunk_read",
status: "completed",
result_count: 20,
partial: true,
warning_codes: ["PROVIDER_CONTEXT_PARTIAL"],
message: "原文上下文读取完成,找到 20 条结果",
result_items: Array.from({ length: 20 }, (_, index) => ({
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_base: "国内新闻",
knowledge_id: "document-long-news",
chunk_id: `chunk-${index + 1}`,
title: `长篇新闻第 ${index + 1} 段`,
content: `原文上下文第 ${index + 1} 段`,
})),
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(html.match(/data-retrieval-result-detail="true"/g) ?? []).length,
20,
);
assert.match(html, /长篇新闻第 1 段/);
assert.match(html, /长篇新闻第 20 段/);
assert.doesNotMatch(html, /部分数据源返回警告/);
assert.doesNotMatch(html, /部分完成/);
});
test("keeps historical results without stable ids as non-clickable summaries", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base: "旧知识库",
title: "旧检索结果",
},
],
},
]}
isLoading
/>,
);
assert.match(html, /data-retrieval-result-summary="true"/);
assert.match(html, /该历史检索结果缺少稳定详情标识/);
assert.doesNotMatch(html, /data-retrieval-result-detail="true"/);
});
test("uses the whole turn as the dedupe scope for interleaved retrieval cards", () => {
const firstFrame: KnowledgeRetrievalProgressFrame = {
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
retrieval_id: "raw-first",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-1",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const repeatedFrame: KnowledgeRetrievalProgressFrame = {
...firstFrame,
retrieval_id: "raw-second",
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-provider-remapped",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const firstHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[firstFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
const repeatedHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[repeatedFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(firstHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
1,
);
assert.equal(
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
0,
);
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
});
@@ -1,525 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
AlertCircleIcon,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
CirclePauseIcon,
DatabaseIcon,
Loader2Icon,
PanelRightOpenIcon,
SearchCheckIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
buildSmartKnowledgeRetrievalRounds,
type KnowledgeRetrievalProgressFrame,
type SmartKnowledgeRetrievalAction,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { cn } from "@/lib/utils";
function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
if (action.status === "failed") return "检索失败";
if (action.status === "interrupted") return "已中止";
if (action.status === "running") {
return action.surface === "wiki" ? "正在查 Wiki" : "正在查知识库";
}
if (action.allResultsAlreadyShown) return "均为已展示结果";
const hasUsableResults =
action.resultItems.length > 0 || (action.resultCount ?? 0) > 0;
const partialPrefix = action.partial && !hasUsableResults ? "部分完成 · " : "";
if (action.kind === "search") {
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
}
return typeof action.resultCount === "number"
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
: `${partialPrefix}读取完成`;
}
function formatElapsedTime(elapsedMs: number): string {
const normalized = Math.max(0, elapsedMs);
if (normalized < 1_000) return `${Math.round(normalized)} 毫秒`;
if (normalized < 60_000) {
const seconds = (normalized / 1_000).toFixed(1).replace(/\.0$/, "");
return `${seconds} 秒`;
}
const roundedSeconds = Math.round(normalized / 1_000);
const minutes = Math.floor(roundedSeconds / 60);
const seconds = roundedSeconds % 60;
return seconds > 0 ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`;
}
function channelText(channel: string | undefined): string | null {
if (!channel) return null;
if (channel === "local_wiki_mirror") return "本地 Wiki 镜像";
if (channel === "provider") return "知识库 Wiki";
if (channel === "mixed") return "本地镜像 + 知识库 Wiki";
return channel;
}
function ActionStatusIcon({ action }: { action: SmartKnowledgeRetrievalAction }) {
if (action.status === "running") {
return <Loader2Icon className="size-4 animate-spin text-primary" />;
}
if (action.status === "completed") {
return <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />;
}
if (action.status === "failed") {
return <AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400" />;
}
return <CirclePauseIcon className="size-4 text-muted-foreground" />;
}
function resultDetailTarget(
item: SmartKnowledgeRetrievalResultItem,
): WeKnoraSourceTarget | null {
if (!item.knowledgeBaseId) return null;
if (item.kind === "wiki" && item.wikiSlug) {
return {
knowledgeBaseId: item.knowledgeBaseId,
wikiSlug: item.wikiSlug,
tab: "wiki",
};
}
if (item.kind === "raw" && (item.chunkId || item.documentId)) {
return {
knowledgeBaseId: item.knowledgeBaseId,
chunkId: item.chunkId,
documentId: item.documentId,
tab: "documents",
};
}
return null;
}
function RetrievalAction({
action,
onOpenResult,
}: {
action: SmartKnowledgeRetrievalAction;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const SurfaceIcon = action.surface === "wiki" ? BookOpenTextIcon : DatabaseIcon;
const channel = channelText(action.channel);
return (
<div
className="rounded-lg border border-border/70 bg-background/75 px-3 py-2.5"
data-retrieval-operation={action.operation}
>
<div className="flex min-w-0 items-start gap-2.5">
<SurfaceIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span className="text-sm font-medium text-foreground">{action.label}</span>
<span
className={cn(
"inline-flex items-center gap-1 text-xs",
action.status === "running"
? "text-primary"
: action.status === "completed"
? "text-emerald-700 dark:text-emerald-300"
: action.status === "failed"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground",
)}
>
<ActionStatusIcon action={action} />
{actionStatusText(action)}
</span>
</div>
{action.queries.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-muted-foreground">查询</span>
{action.queries.map((query) => (
<span
key={query}
className="max-w-full rounded-md bg-muted px-2 py-0.5 text-foreground/85 break-all"
>
{query}
</span>
))}
</div>
)}
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{typeof action.knowledgeBaseCount === "number" ? (
<span>
覆盖 {action.knowledgeBaseCount} 个知识库
{action.knowledgeBases.length > 0
? `:${action.knowledgeBases.join("、")}`
: ""}
</span>
) : action.knowledgeBases.length > 0 ? (
<span>知识库:{action.knowledgeBases.join("、")}</span>
) : null}
{typeof action.scannedPages === "number" && (
<span>扫描 {action.scannedPages} 个 Wiki 页面</span>
)}
{typeof action.scannedChunks === "number" && (
<span>扫描 {action.scannedChunks} 个原文片段</span>
)}
{action.kind === "read" && typeof action.total === "number" && (
<span>共 {action.total} 个片段</span>
)}
{channel && <span>通道:{channel}</span>}
{typeof action.elapsedMs === "number" && (
<span>耗时 {formatElapsedTime(action.elapsedMs)}</span>
)}
</div>
{action.allResultsAlreadyShown ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
本次查询结果均已在前面展示
</p>
) : action.resultItems.length > 0 ? (
<div className="mt-2">
<div className="mb-1.5 text-xs text-muted-foreground">
{action.kind === "read" ? "读取结果" : "查询结果"}
</div>
{action.kind === "read" ? (
<div className="space-y-2">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
const preview = item.preview?.trim().slice(0, 600);
const content = (
<>
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground">
{item.title}
</span>
{target && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
右侧查看
<PanelRightOpenIcon className="size-3.5" />
</span>
)}
</div>
{preview && (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
{preview}
</p>
)}
</>
);
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="block w-full rounded-lg border border-primary/20 bg-primary/[0.045] px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.075] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
{content}
</button>
) : (
<div
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="rounded-lg border border-border/70 bg-muted/45 px-3 py-2.5"
title="该历史检索结果缺少稳定详情标识"
>
{content}
</div>
);
})}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.055] px-2 py-1 text-left text-xs leading-5 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
<span className="truncate">{item.title}</span>
<PanelRightOpenIcon className="size-3.5 shrink-0 text-primary" />
</button>
) : (
<span
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="inline-flex max-w-full rounded-md bg-muted px-2 py-1 text-xs leading-5 text-muted-foreground"
title="该历史检索结果缺少稳定详情标识"
>
<span className="truncate">{item.title}</span>
</span>
);
})}
{(action.resultCount ?? 0) > action.resultItems.length && (
<span className="inline-flex items-center px-1 text-xs text-muted-foreground">
另有 {(action.resultCount ?? 0) - action.resultItems.length} 条结果
</span>
)}
</div>
)}
</div>
) : action.resultTitles.length > 0 ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
结果摘要:{action.resultTitles.join(";")}
{(action.resultCount ?? 0) > action.resultTitles.length ? ";…" : ""}
</p>
) : null}
{action.message && (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
{action.message}
</p>
)}
{action.error && (
<p className="mt-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300">
{action.error}
</p>
)}
{/* Provider warning codes stay in the structured trace for
diagnostics. The old generic warning was misleading both when
useful evidence existed and when the result set was empty. */}
</div>
</div>
</div>
);
}
export function SmartKnowledgeRetrievalTrace({
messages,
progressFrames = [],
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
onOpenResult,
}: {
messages: Message[];
progressFrames?: KnowledgeRetrievalProgressFrame[];
/**
* The whole current user-turn group. Interleaved rendering splits one
* retrieval invocation into one component, but result dedupe must still see
* every invocation in the turn.
*/
dedupeScopeMessages?: Message[];
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
isLoading: boolean;
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
roundOffset?: number;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const rounds = useMemo(
() => {
const localRounds = buildSmartKnowledgeRetrievalRounds(
messages,
progressFrames,
isLoading,
);
if (!dedupeScopeMessages) return localRounds;
const globallyDedupedActions = new Map(
buildSmartKnowledgeRetrievalRounds(
dedupeScopeMessages,
dedupeScopeProgressFrames ?? [],
isLoading,
)
.flatMap((round) => round.actions)
.map((action) => [action.id, action] as const),
);
return localRounds.map((round) => ({
...round,
actions: round.actions.map((action) => {
const globalAction = globallyDedupedActions.get(action.id);
return globalAction ? { ...globalAction, round: action.round } : action;
}),
}));
},
[
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
messages,
progressFrames,
],
);
const actions = rounds.flatMap((round) => round.actions);
// The chat can keep streaming its final answer after retrieval has already
// finished. Only an unfinished retrieval action owns the "检索中" state;
// the broader thread loading flag must not keep the spinner alive or block
// the collapse control during answer generation.
const isActive = actions.some((action) => action.status === "running");
const hasFailure = actions.some((action) => action.status === "failed");
const hasInterrupted = actions.some(
(action) => action.status === "interrupted",
);
const rawHits = actions
.filter((action) => action.surface === "raw" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const wikiHits = actions
.filter((action) => action.surface === "wiki" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const elapsedActions = actions.filter(
(action) => typeof action.elapsedMs === "number",
);
const cumulativeElapsedMs =
elapsedActions.length > 0
? elapsedActions.reduce(
(total, action) => total + (action.elapsedMs ?? 0),
0,
)
: undefined;
// A live trace opens so newly completed result cards remain visible while
// the final answer starts streaming. Replayed history starts collapsed.
const [isExpanded, setIsExpanded] = useState(
isLoading && actions.length > 0,
);
const wasActive = useRef(isActive);
const wasLoading = useRef(isLoading);
useEffect(() => {
if (!wasActive.current && isActive) {
setIsExpanded(true);
}
if (wasLoading.current && !isLoading) {
setIsExpanded(false);
}
wasActive.current = isActive;
wasLoading.current = isLoading;
}, [isActive, isLoading]);
// An in-flight retrieval always stays visible. A just-completed live trace
// remains open and user-collapsible while the answer streams; replayed
// history starts collapsed and can be reopened for inspection.
const isOpen = isActive || isExpanded;
if (actions.length === 0) return null;
return (
<Collapsible
open={isOpen}
onOpenChange={(open) => {
if (!isActive) setIsExpanded(open);
}}
>
<section
aria-label="智能检索过程"
aria-live={isActive ? "polite" : undefined}
className="w-full rounded-xl border border-primary/20 bg-primary/[0.035] p-3.5 shadow-sm"
data-smart-knowledge-retrieval-trace
>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{isActive ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SearchCheckIcon className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">智能检索过程</h3>
<div className="flex items-center gap-1.5">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
isActive
? "bg-primary/10 text-primary"
: hasFailure
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: hasInterrupted
? "bg-muted text-muted-foreground"
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
{isActive
? "检索中"
: hasFailure
? "已完成(部分步骤失败)"
: hasInterrupted
? "检索已结束(部分步骤未完成)"
: "检索完成"}
</span>
{!isActive && (
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={isOpen ? "收起智能检索过程" : "展开智能检索过程"}
>
<span>{isOpen ? "收起" : "展开"}</span>
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
)}
</div>
</div>
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
{typeof cumulativeElapsedMs === "number"
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
: ""}
</p>
)}
{isActive && (
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
</div>
</div>
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
<div className="mt-3 space-y-3">
{actions.map((action) => (
<RetrievalAction
key={action.id}
action={action}
onOpenResult={onOpenResult}
/>
))}
</div>
</CollapsibleContent>
</section>
</Collapsible>
);
}
@@ -1,119 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import {
GENERATION_IDLE_MS,
ROLLING_WINDOW_MS,
charsToTokensPerSec,
messageHasToolCalls,
rollingTokensPerSec,
shouldFreezeGeneration,
usageTokensPerSec,
} from "./speed-metrics.ts";
test("rolling window reports the recent rate while characters grow", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 500, chars: 100 },
{ at: 1000, chars: 200 },
];
const rate = rollingTokensPerSec(samples, 200, 1000, 1000);
// 200 chars / 1000ms / 2 chars-per-token = 100 tok/s
assert.equal(rate, 100);
});
test("rolling window drops to 0 when every sample is older than the window", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 200, chars: 80 },
{ at: 400, chars: 160 },
];
const now = 400 + ROLLING_WINDOW_MS + 50;
assert.equal(rollingTokensPerSec(samples, 160, now), 0);
});
test("rolling window decays toward 0 after a mid-stream pause", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 400, chars: 80 },
];
const live = rollingTokensPerSec(samples, 80, 500, 1000);
const paused = rollingTokensPerSec(samples, 80, 900, 1000);
assert.ok(live > paused);
assert.ok(paused > 0);
assert.equal(rollingTokensPerSec(samples, 80, 400 + 1000 + 1, 1000), 0);
});
test("usageTokensPerSec is output tokens over the segment duration", () => {
assert.equal(usageTokensPerSec(278, 6200), (278 / 6200) * 1000);
assert.equal(usageTokensPerSec(0, 1000), 0);
assert.equal(usageTokensPerSec(10, 0), 0);
});
test("charsToTokensPerSec uses the 2-chars-per-token heuristic", () => {
assert.equal(charsToTokensPerSec(100, 1000), 50);
});
test("shouldFreezeGeneration ends the segment on the next call, tools, idle usage, or run stop", () => {
const base = {
endedAt: null,
isLoading: true,
isLatestGenerating: true,
hasToolCalls: false,
grewThisTick: false,
lastGrewAt: 1000,
now: 1100,
outputTokens: 0,
};
assert.equal(shouldFreezeGeneration(base), false);
assert.equal(
shouldFreezeGeneration({ ...base, isLatestGenerating: false }),
true,
);
assert.equal(shouldFreezeGeneration({ ...base, hasToolCalls: true }), true);
assert.equal(
shouldFreezeGeneration({
...base,
hasToolCalls: true,
grewThisTick: true,
}),
false,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS,
}),
true,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS - 1,
}),
false,
);
assert.equal(shouldFreezeGeneration({ ...base, isLoading: false }), true);
assert.equal(shouldFreezeGeneration({ ...base, endedAt: 1000 }), false);
});
test("messageHasToolCalls reads both top-level and additional_kwargs", () => {
assert.equal(messageHasToolCalls({}), false);
assert.equal(
messageHasToolCalls({
tool_calls: [{ id: "1", name: "search" }],
}),
true,
);
assert.equal(
messageHasToolCalls({
additional_kwargs: { tool_calls: [{ id: "1" }] },
}),
true,
);
});
@@ -1,108 +0,0 @@
/**
* Shared tok/s helpers for the chat / roundtable speed pills.
*
* Live rate uses a rolling window with a virtual tail at "now" so a pause
* (no new characters) decays toward 0 instead of freezing on the last two
* samples. Final rate prefers provider `output_tokens` over the segment
* duration, and only after that model-call segment has ended.
*/
export const CHARS_PER_TOKEN = 2;
export const SAMPLE_THROTTLE_MS = 60;
export const ROLLING_WINDOW_MS = 1000;
export const MAX_SPEED_SAMPLES = 256;
/** Idle after last visible growth before we treat this model call as finished. */
export const GENERATION_IDLE_MS = 400;
export type SpeedSample = { at: number; chars: number };
export function estimateTokensFromChars(chars: number): number {
return Math.max(0, chars) / CHARS_PER_TOKEN;
}
export function charsToTokensPerSec(chars: number, elapsedMs: number): number {
if (elapsedMs <= 0) return 0;
return (estimateTokensFromChars(chars) / elapsedMs) * 1000;
}
export function usageTokensPerSec(
outputTokens: number,
durationMs: number,
): number {
if (outputTokens <= 0 || durationMs <= 0) return 0;
return (outputTokens / durationMs) * 1000;
}
/**
* Instantaneous tok/s over the last `windowMs`.
*
* The tail is always `(now, lastChars)`. If every sample is older than the
* window, the result is 0 — a generation pause must not keep the last
* observed rate pinned on screen.
*/
export function rollingTokensPerSec(
samples: SpeedSample[],
lastChars: number,
now: number,
windowMs: number = ROLLING_WINDOW_MS,
): number {
if (samples.length === 0 || windowMs <= 0) return 0;
const windowStart = now - windowMs;
let headIdx = samples.findIndex((sample) => sample.at >= windowStart);
if (headIdx === -1) {
return 0;
}
if (headIdx > 0) {
headIdx -= 1;
}
const head = samples[headIdx];
if (!head) return 0;
const elapsedMs = now - head.at;
if (elapsedMs <= 0) return 0;
return charsToTokensPerSec(Math.max(0, lastChars - head.chars), elapsedMs);
}
export function pushSpeedSample(
samples: SpeedSample[],
at: number,
chars: number,
): void {
samples.push({ at, chars });
if (samples.length > MAX_SPEED_SAMPLES) {
samples.splice(0, samples.length - MAX_SPEED_SAMPLES);
}
}
export function shouldFreezeGeneration(args: {
endedAt: number | null;
isLoading: boolean;
isLatestGenerating: boolean;
hasToolCalls: boolean;
grewThisTick: boolean;
lastGrewAt: number;
now: number;
outputTokens: number;
}): boolean {
if (args.endedAt != null) return false;
if (!args.isLatestGenerating) return true;
if (args.hasToolCalls && !args.grewThisTick) return true;
if (
args.outputTokens > 0 &&
args.now - args.lastGrewAt >= GENERATION_IDLE_MS
) {
return true;
}
return !args.isLoading;
}
export function messageHasToolCalls(message: {
tool_calls?: unknown[] | null;
additional_kwargs?: { tool_calls?: unknown[] } | null;
}): boolean {
const direct = message.tool_calls;
if (Array.isArray(direct) && direct.length > 0) return true;
const extra = message.additional_kwargs?.tool_calls;
return Array.isArray(extra) && extra.length > 0;
}
@@ -6,6 +6,8 @@ import {
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Streamdown } from "streamdown";
import { import {
ChainOfThought, ChainOfThought,
ChainOfThoughtContent, ChainOfThoughtContent,
@@ -17,10 +19,7 @@ import { ShineBorder } from "@/components/ui/shine-border";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { hasToolCalls } from "@/core/messages/utils"; import { hasToolCalls } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
SafeStreamdown,
streamdownPluginsWithWordAnimation,
} from "@/core/streamdown";
import { useSubtask } from "@/core/tasks/context"; import { useSubtask } from "@/core/tasks/context";
import { explainLastToolCall } from "@/core/tools/utils"; import { explainLastToolCall } from "@/core/tools/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -127,12 +126,12 @@ export function SubtaskCard({
{task.prompt && ( {task.prompt && (
<ChainOfThoughtStep <ChainOfThoughtStep
label={ label={
<SafeStreamdown <Streamdown
{...streamdownPluginsWithWordAnimation} {...streamdownPluginsWithWordAnimation}
components={{ a: CitationLink }} components={{ a: CitationLink }}
> >
{task.prompt} {task.prompt}
</SafeStreamdown> </Streamdown>
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
)} )}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { DownloadIcon, InfoIcon, Loader2 } from "lucide-react"; import { DownloadIcon, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -13,10 +13,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
import { DEFAULT_UPLOAD_POLICY, useUploadPolicy } from "@/core/uploads";
const markdownClassName = const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " + "!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
@@ -58,111 +56,20 @@ function kindOf(filename: string): PreviewKind {
return "binary"; return "binary";
} }
function withAppendedMarkdownExtension(url: string): string { function withReplacedExtension(url: string): string {
const [base, query] = url.split("?"); // Replace the path extension with .md, keeping any query string intact.
const appended = `${base}.md`;
return query ? `${appended}?${query}` : appended;
}
function withLegacyReplacedExtension(url: string): string {
// Compatibility with uploads converted before sidecars switched from
// `report.md` to the collision-safe `report.docx.md` naming scheme.
const [base, query] = url.split("?"); const [base, query] = url.split("?");
const i = base.lastIndexOf("."); const i = base.lastIndexOf(".");
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md"; const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
return query ? `${swapped}?${query}` : swapped; return query ? `${swapped}?${query}` : swapped;
} }
type ArtifactPreviewResponse = {
path: string;
name: string;
total_bytes: number;
content: string;
truncated: boolean;
strategy: string;
stats?: Record<string, unknown> | null;
warnings?: string[];
};
type PreviewMeta = Pick<
ArtifactPreviewResponse,
"total_bytes" | "truncated" | "strategy" | "warnings"
>;
function parseTotalBytes(response: Response): number | undefined {
const contentRange = response.headers.get("Content-Range");
const rangeTotal = contentRange?.match(/\/(\d+)$/)?.[1];
if (rangeTotal) return Number(rangeTotal);
const contentLengthHeader = response.headers.get("Content-Length");
if (!contentLengthHeader) return undefined;
const contentLength = Number(contentLengthHeader);
return Number.isFinite(contentLength) ? contentLength : undefined;
}
/** Read a streamed response up to maxBytes and cancel the rest. */
async function readBoundedTextResponse(
response: Response,
maxBytes: number,
): Promise<{ content: string; totalBytes: number; truncated: boolean }> {
if (!response.body) throw new Error("浏览器不支持流式预览");
const reader = response.body.getReader();
const decoder = new TextDecoder();
const declaredTotal = parseTotalBytes(response);
let received = 0;
let content = "";
let completed = false;
while (received < maxBytes) {
const { done, value } = await reader.read();
if (done) {
completed = true;
break;
}
if (!value) continue;
const remaining = maxBytes - received;
const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
received += slice.byteLength;
content += decoder.decode(slice, { stream: received < maxBytes });
if (value.byteLength > remaining || received >= maxBytes) {
await reader.cancel();
break;
}
}
content += decoder.decode();
const totalBytes = declaredTotal ?? received;
return {
content,
totalBytes,
truncated: !completed || totalBytes > received,
};
}
function strategyLabel(strategy: string): string {
if (strategy.includes("sampling") || strategy.includes("sample")) {
return "流式统计 + 开头/中段/结尾分块采样";
}
if (strategy === "complete-text") return "完整文本";
if (strategy === "bounded-prefix") return "有界前缀";
if (strategy === "client-bounded-prefix") return "兼容模式有界前缀";
return strategy;
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KiB`;
return `${bytes} B`;
}
/** /**
* Fetch an auth-gated artifact (the artifact route requires an * Fetch an auth-gated artifact (the artifact route requires an
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send — * `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display. * hence the 401s) and expose it as an object URL for inline display.
*/ */
export function useAuthedObjectUrl( export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
url: string | null,
enabled: boolean,
requestFile: typeof apiFetch = apiFetch,
) {
const [objectUrl, setObjectUrl] = useState<string | null>(null); const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -174,10 +81,9 @@ export function useAuthedObjectUrl(
} }
let cancelled = false; let cancelled = false;
let created: string | null = null; let created: string | null = null;
const controller = new AbortController();
setLoading(true); setLoading(true);
setError(null); setError(null);
requestFile(url, { signal: controller.signal }) apiFetch(url)
.then(async (res) => { .then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob(); return res.blob();
@@ -196,10 +102,9 @@ export function useAuthedObjectUrl(
}); });
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort();
if (created) URL.revokeObjectURL(created); if (created) URL.revokeObjectURL(created);
}; };
}, [url, enabled, requestFile]); }, [url, enabled]);
return { objectUrl, loading, error }; return { objectUrl, loading, error };
} }
@@ -217,152 +122,47 @@ export function useAuthedObjectUrl(
export function UploadedFilePreviewDialog({ export function UploadedFilePreviewDialog({
filename, filename,
fileUrl, fileUrl,
threadId,
virtualPath,
requestFile = apiFetch,
onDownload,
downloading = false,
open, open,
onOpenChange, onOpenChange,
}: { }: {
filename: string; filename: string;
/** Absolute artifact URL for the original uploaded file. */ /** Absolute artifact URL for the original uploaded file. */
fileUrl: string; fileUrl: string;
/** Used by the bounded preview endpoint; optional for old/public messages. */
threadId?: string;
virtualPath?: string;
/** External skill links supply an unauthenticated fetch, avoiding token leakage. */
requestFile?: typeof apiFetch;
/** Authenticated original-file download used by retrieved-file cards. */
onDownload?: () => void | Promise<void>;
downloading?: boolean;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { data: uploadPolicy } = useUploadPolicy();
const effectivePolicy = uploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const maxPreviewChars = Math.max(
256,
Math.min(
32_000,
effectivePolicy.large_file.max_result_chars,
effectivePolicy.large_file.hard_max_result_chars,
),
);
const kind = useMemo(() => kindOf(filename), [filename]); const kind = useMemo(() => kindOf(filename), [filename]);
const ext = useMemo(() => extOf(filename), [filename]); const ext = useMemo(() => extOf(filename), [filename]);
// image / pdf → fetch as authed blob and display via object URL. // image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf"); const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } = const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile); useAuthedObjectUrl(fileUrl, blobEnabled);
// text / office → fetch text (office reads the converted sibling .md). // text / office → fetch text (office reads the converted sibling .md).
const textUrls = useMemo( const textUrl = useMemo(
() => () => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
kind === "office"
? Array.from(
new Set([
withAppendedMarkdownExtension(fileUrl),
withLegacyReplacedExtension(fileUrl),
]),
)
: [fileUrl],
[kind, fileUrl], [kind, fileUrl],
); );
const previewPaths = useMemo(
() =>
virtualPath
? kind === "office"
? Array.from(
new Set([
withAppendedMarkdownExtension(virtualPath),
withLegacyReplacedExtension(virtualPath),
]),
)
: [virtualPath]
: [],
[kind, virtualPath],
);
const [text, setText] = useState<string | null>(null); const [text, setText] = useState<string | null>(null);
const [previewMeta, setPreviewMeta] = useState<PreviewMeta | null>(null);
const [textLoading, setTextLoading] = useState(false); const [textLoading, setTextLoading] = useState(false);
const [textError, setTextError] = useState<string | null>(null); const [textError, setTextError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return; if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false; let cancelled = false;
const controller = new AbortController();
setTextLoading(true); setTextLoading(true);
setTextError(null); setTextError(null);
setText(null); setText(null);
setPreviewMeta(null); apiFetch(textUrl)
.then(async (res) => {
const load = async () => { if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (threadId && previewPaths.length > 0) { return res.text();
for (const previewPath of previewPaths) { })
try { .then((body) => {
const endpoint = `${getBackendBaseURL()}/api/threads/${encodeURIComponent( if (!cancelled) setText(body);
threadId, })
)}/artifact-preview?path=${encodeURIComponent(
previewPath,
)}&max_chars=${maxPreviewChars}`;
const response = await apiFetch(endpoint, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = (await response.json()) as ArtifactPreviewResponse;
const rawContent = String(body.content ?? "");
const content = rawContent.slice(0, maxPreviewChars);
if (!cancelled) {
setText(content);
setPreviewMeta({
total_bytes: Number(body.total_bytes) || 0,
truncated:
Boolean(body.truncated) || rawContent.length > content.length,
strategy: String(body.strategy || "bounded-prefix"),
warnings: Array.isArray(body.warnings) ? body.warnings : [],
});
}
return;
} catch {
// Try a legacy Office sidecar, then fall through to the bounded
// Range fallback during a rolling upgrade or public share.
}
}
}
const maxBytes = Math.max(4_096, maxPreviewChars * 4);
let lastError: Error | null = null;
for (const textUrl of textUrls) {
try {
const response = await requestFile(textUrl, {
signal: controller.signal,
headers: { Range: `bytes=0-${maxBytes - 1}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bounded = await readBoundedTextResponse(response, maxBytes);
if (!cancelled) {
const content = bounded.content.slice(0, maxPreviewChars);
setText(content);
setPreviewMeta({
total_bytes: bounded.totalBytes,
truncated:
bounded.truncated || bounded.content.length > content.length,
strategy: "client-bounded-prefix",
warnings: [
"采样预览接口暂不可用,当前仅显示受限的文件开头,未读取剩余内容。",
],
});
}
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw lastError ?? new Error("无法读取文件预览");
};
void load()
.catch((err: unknown) => { .catch((err: unknown) => {
if (!cancelled) if (!cancelled)
setTextError(err instanceof Error ? err.message : String(err)); setTextError(err instanceof Error ? err.message : String(err));
@@ -372,9 +172,8 @@ export function UploadedFilePreviewDialog({
}); });
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort();
}; };
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]); }, [open, kind, textUrl]);
const renderAsMarkdown = const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown"; kind === "office" || ext === "md" || ext === "markdown";
@@ -393,26 +192,12 @@ export function UploadedFilePreviewDialog({
style={{ style={{
width: "min(56rem, calc(100vw - 2rem))", width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none", maxWidth: "none",
minHeight: "min(420px, calc(100vh - 2rem))", height: "min(720px, calc(100dvh - 2rem))",
height: "min(720px, calc(100vh - 2rem))",
maxHeight: "min(720px, calc(100vh - 2rem))",
}} }}
> >
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12"> <DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle> <DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs"> <DialogDescription className="mt-0.5 truncate text-xs">
{onDownload ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3" />
{t.common.download}
</Button>
) : (
<a <a
href={fileUrl} href={fileUrl}
target="_blank" target="_blank"
@@ -422,7 +207,6 @@ export function UploadedFilePreviewDialog({
<DownloadIcon className="size-3" /> <DownloadIcon className="size-3" />
{t.common.download} {t.common.download}
</a> </a>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -431,7 +215,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? ( blobLoading ? (
loadingView loadingView
) : blobError || !objectUrl ? ( ) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} /> <BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : ( ) : (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<img <img
@@ -445,7 +229,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? ( blobLoading ? (
loadingView loadingView
) : blobError || !objectUrl ? ( ) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} /> <BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : ( ) : (
<iframe <iframe
src={objectUrl} src={objectUrl}
@@ -457,29 +241,8 @@ export function UploadedFilePreviewDialog({
textLoading ? ( textLoading ? (
loadingView loadingView
) : textError ? ( ) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} /> <BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : ( ) : renderAsMarkdown ? (
<div className="space-y-3">
{previewMeta &&
(previewMeta.truncated ||
previewMeta.strategy !== "complete-text" ||
(previewMeta.warnings?.length ?? 0) > 0) && (
<div className="border-amber-300/70 bg-amber-50/70 text-amber-950 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 rounded-lg border px-3 py-2 text-xs">
<div className="flex items-start gap-2 font-medium">
<InfoIcon className="mt-0.5 size-3.5 shrink-0" />
<span>
{previewMeta.truncated ? "当前为截断/采样预览" : "预览读取方式"}
:{strategyLabel(previewMeta.strategy)};文件大小 {formatBytes(previewMeta.total_bytes)},本次最多展示 {maxPreviewChars.toLocaleString()} 个字符。
</span>
</div>
{previewMeta.warnings?.map((warning) => (
<p key={warning} className="mt-1 pl-5 opacity-80">
{warning}
</p>
))}
</div>
)}
{renderAsMarkdown ? (
<MarkdownContent <MarkdownContent
className={markdownClassName} className={markdownClassName}
content={text ?? ""} content={text ?? ""}
@@ -491,11 +254,9 @@ export function UploadedFilePreviewDialog({
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap"> <pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""} {text ?? ""}
</pre> </pre>
)}
</div>
) )
) : ( ) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} /> <BinaryFallback fileUrl={fileUrl} label={t.common.download} />
)} )}
</div> </div>
</DialogContent> </DialogContent>
@@ -503,34 +264,15 @@ export function UploadedFilePreviewDialog({
); );
} }
function BinaryFallback({ fileUrl, label, onDownload, downloading }: { function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
fileUrl: string;
label: string;
onDownload?: () => void | Promise<void>;
downloading?: boolean;
}) {
return ( return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm"> <div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<p>暂不支持在线预览,请下载原文件查看。</p>
{onDownload ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3.5" />
{label}
</Button>
) : (
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download> <a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" /> <DownloadIcon className="size-3.5" />
{label} {label}
</a> </a>
</Button> </Button>
)}
</div> </div>
); );
} }
@@ -7,37 +7,29 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
import { getUsageMetadata } from "@/core/messages/usage"; import { getUsageMetadata } from "@/core/messages/usage";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { /**
GENERATION_IDLE_MS, * Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
SAMPLE_THROTTLE_MS, * speed indicator for mixed Chinese/English replies without dragging in a
charsToTokensPerSec, * real tokeniser.
messageHasToolCalls, */
pushSpeedSample, const CHARS_PER_TOKEN = 2;
rollingTokensPerSec,
shouldFreezeGeneration, /** Sample throttle while a message is streaming. */
usageTokensPerSec, const SAMPLE_THROTTLE_MS = 60;
type SpeedSample,
} from "./speed-metrics";
/** /**
* Per-AI-message metric: live rolling rate during that model-call segment, * Per-AI-message metric: real-time during streaming, frozen once the run ends.
* frozen once the segment ends (not when the whole agent turn ends).
*/ */
export interface MessageMetric { export interface MessageMetric {
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */ /** Rolling tokens/sec, or 0 when we cannot estimate yet. */
tokensPerSec: number; tokensPerSec: number;
/** /**
* Wall-clock duration of this model-call segment: first visible character * Wall-clock duration from when the message first appeared with text to
* → last visible character (tool-wait after the call is excluded). * either *now* (active) or to when the stream ended (frozen).
*/ */
durationMs: number; durationMs: number;
/** True while this message is still generating visible tokens. */ /** True while this is the currently streaming message. */
isActive: boolean; isActive: boolean;
/**
* True while this message is the live generator but the rolling window
* has seen no new characters — the pill should not keep a stale tok/s.
*/
isPaused: boolean;
/** /**
* Whether the speed value was computed from real usage_metadata output * Whether the speed value was computed from real usage_metadata output
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this * tokens (``true``) vs. a char-count fallback (``false``). UI uses this
@@ -53,12 +45,15 @@ export interface MessageMetric {
isTurnFinal: boolean; isTurnFinal: boolean;
} }
type Sample = { at: number; chars: number };
type TimingEntry = { type TimingEntry = {
firstSeenAt: number; firstSeenAt: number;
endedAt: number | null; endedAt: number | null;
lastGrewAt: number; samples: Sample[];
samples: SpeedSample[];
lastChars: number; lastChars: number;
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
hadUsage: boolean;
}; };
function isAiMessage(message: Message): boolean { function isAiMessage(message: Message): boolean {
@@ -87,12 +82,11 @@ function messageText(message: Message): string {
* *
* Recording strategy: * Recording strategy:
* - Whenever a message renders with text, we anchor `firstSeenAt`. * - Whenever a message renders with text, we anchor `firstSeenAt`.
* - On every text-length change we push a sample. * - On every text-length change we push a sample and recompute the rate.
* - The segment freezes when this model call ends (next AI message, tool * - When `isLoading` flips to false we mark *all* still-active messages as
* calls, usage + idle, or the run stops) so tool-wait is not in the * ended so subsequent renders return a frozen duration.
* denominator. * - Token totals come from usage_metadata when present (most accurate);
* - Live rate is a rolling window that decays to 0 on pause. * otherwise we fall back to char-count / 2.
* - `usage_metadata.output_tokens` only corrects the frozen final value.
*/ */
export function useMessageMetrics( export function useMessageMetrics(
messages: Message[], messages: Message[],
@@ -110,8 +104,7 @@ export function useMessageMetrics(
// synchronously inside an effect so React isn't tracking the map identity. // synchronously inside an effect so React isn't tracking the map identity.
useEffect(() => { useEffect(() => {
const now = performance.now(); const now = performance.now();
const grewIds = new Set<string>(); let activeId: string | null = null;
let latestId: string | null = null;
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
const message = messages[i]; const message = messages[i];
@@ -125,62 +118,50 @@ export function useMessageMetrics(
entry = { entry = {
firstSeenAt: now, firstSeenAt: now,
endedAt: null, endedAt: null,
lastGrewAt: now,
samples: [{ at: now, chars: text.length }], samples: [{ at: now, chars: text.length }],
lastChars: text.length, lastChars: text.length,
hadUsage: false,
}; };
timingRef.current.set(id, entry); timingRef.current.set(id, entry);
} }
latestId = id; // Track the most recent AI message as the active one (while streaming).
activeId = id;
// Sample only when actively streaming OR when content actually grew —
// throttle to keep arrays bounded.
const grew = text.length !== entry.lastChars; const grew = text.length !== entry.lastChars;
if (grew) { if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
grewIds.add(id); entry.samples.push({ at: now, chars: text.length });
entry.lastGrewAt = now; entry.lastChars = text.length;
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) { if (entry.samples.length > 256) {
pushSpeedSample(entry.samples, now, text.length); entry.samples.splice(0, entry.samples.length - 256);
lastSampleAtRef.current = now;
} }
lastSampleAtRef.current = now;
} else if (grew) {
// Still update lastChars so the next render captures it eventually.
entry.lastChars = text.length; entry.lastChars = text.length;
} }
}
for (let i = 0; i < messages.length; i++) { if (getUsageMetadata(message)) {
const message = messages[i]; entry.hadUsage = true;
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const entry = timingRef.current.get(id);
if (!entry) continue;
const grewThisTick = grewIds.has(id);
if (grewThisTick && entry.endedAt != null && isLoading) {
entry.endedAt = null;
continue;
}
const usage = getUsageMetadata(message);
if (
shouldFreezeGeneration({
endedAt: entry.endedAt,
isLoading,
isLatestGenerating: id === latestId,
hasToolCalls: messageHasToolCalls(message),
grewThisTick,
lastGrewAt: entry.lastGrewAt,
now,
outputTokens: usage?.outputTokens ?? 0,
})
) {
entry.endedAt = entry.lastGrewAt;
} }
} }
lastActiveIdRef.current = latestId; // When streaming stops, freeze any non-ended entry to the current time.
if (!isLoading) {
for (const entry of timingRef.current.values()) {
if (entry.endedAt === null) {
entry.endedAt = now;
}
}
}
lastActiveIdRef.current = activeId;
setTick((v) => v + 1); setTick((v) => v + 1);
}, [messages, isLoading]); }, [messages, isLoading]);
// Drive a slow tick while streaming so live durations / pause decay refresh. // Drive a slow tick while streaming so live durations refresh smoothly.
useEffect(() => { useEffect(() => {
if (!isLoading) return; if (!isLoading) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300); const interval = window.setInterval(() => setTick((v) => v + 1), 300);
@@ -217,36 +198,45 @@ export function useMessageMetrics(
const entry = timingRef.current.get(id); const entry = timingRef.current.get(id);
if (!entry) continue; if (!entry) continue;
const isActive =
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
const endedAt = entry.endedAt ?? now; const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt); const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
const isActive = isLoading && id === lastActiveIdRef.current;
let tokensPerSec = 0; let tokensPerSec = 0;
let fromUsage = false; let fromUsage = false;
let isPaused = false;
const usage = getUsageMetadata(message); const usage = getUsageMetadata(message);
if (entry.endedAt != null) {
if (usage && usage.outputTokens > 0 && durationMs > 0) { if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs); tokensPerSec = (usage.outputTokens / durationMs) * 1000;
fromUsage = true; fromUsage = true;
} else if (durationMs > 0) {
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
}
} else { } else {
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now); // Char-count fallback. Uses the rolling window during streaming so a
isPaused = // mid-stream snapshot reflects current speed rather than average since
isActive && // the start.
tokensPerSec <= 0 && if (isActive && entry.samples.length >= 2) {
now - entry.lastGrewAt >= GENERATION_IDLE_MS; const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
}
}
} else if (durationMs > 0) {
// Finished message without usage data: average over full duration.
const chars = entry.lastChars;
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
}
} }
out.set(id, { out.set(id, {
tokensPerSec, tokensPerSec,
durationMs, durationMs,
isActive, isActive,
isPaused,
fromUsage, fromUsage,
isTurnFinal: turnFinalIds.has(id), isTurnFinal: turnFinalIds.has(id),
}); });
@@ -25,7 +25,6 @@ export function ModelNameSelect({
className, className,
triggerClassName, triggerClassName,
defaultLabel = "默认模型", defaultLabel = "默认模型",
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
}: { }: {
value: string | null; value: string | null;
onChange: (modelName: string | null) => void; onChange: (modelName: string | null) => void;
@@ -33,7 +32,6 @@ export function ModelNameSelect({
className?: string; className?: string;
triggerClassName?: string; triggerClassName?: string;
defaultLabel?: string; defaultLabel?: string;
title?: string;
}) { }) {
const { models } = useModels(); const { models } = useModels();
return ( return (
@@ -44,7 +42,7 @@ export function ModelNameSelect({
> >
<SelectTrigger <SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)} className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title={title} title="选择用于本次操作的大模型(默认使用系统配置的模型)"
> >
<SelectValue placeholder={defaultLabel} /> <SelectValue placeholder={defaultLabel} />
</SelectTrigger> </SelectTrigger>
@@ -1,86 +0,0 @@
import {
FileText,
MessageSquareText,
Sparkles,
type LucideIcon,
} from "lucide-react";
import {
MY_PROJECT_CONVERSATION_LABELS,
type MyProjectConversationType,
} from "@/core/threads/project-metadata";
import { cn } from "@/lib/utils";
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
strategy:
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
solution:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
};
const ICONS: Record<MyProjectConversationType, LucideIcon> = {
qa: MessageSquareText,
strategy: Sparkles,
solution: FileText,
};
const ICON_STYLES: Record<MyProjectConversationType, string> = {
qa: "bg-sky-50 text-sky-600 dark:bg-sky-950/70 dark:text-sky-300",
strategy:
"bg-violet-50 text-violet-600 dark:bg-violet-950/70 dark:text-violet-300",
solution:
"bg-emerald-50 text-emerald-600 dark:bg-emerald-950/70 dark:text-emerald-300",
};
export function MyProjectThreadIcon({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
const Icon = ICONS[type];
const label = `我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`;
return (
<span
aria-label={label}
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-md",
ICON_STYLES[type],
className,
)}
role="img"
title={label}
>
<Icon aria-hidden="true" className="size-3.5" />
</span>
);
}
export function MyProjectThreadBadge({
type,
projectName,
className,
}: {
type: MyProjectConversationType;
projectName?: string | null;
className?: string;
}) {
const typeLabel = `${MY_PROJECT_CONVERSATION_LABELS[type]}项目`;
const ownerLabel = projectName?.trim() || "我的项目";
return (
<span
className={cn(
"inline-flex min-w-0 max-w-full items-center gap-1 self-start rounded border px-1.5 py-0.5 text-[9px] font-medium leading-none",
BADGE_STYLES[type],
className,
)}
title={`来自我的项目 · ${typeLabel} · ${ownerLabel}`}
>
<span className="shrink-0">{typeLabel}</span>
<span aria-hidden="true" className="opacity-55">·</span>
<span className="min-w-0 truncate">{ownerLabel}</span>
</span>
);
}
@@ -1,49 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
extractContentFromMessage,
isHiddenFromUIMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
const SUMMARY_MAX_LENGTH = 80;
export type QuestionItem = {
anchorId: string;
summary: string;
};
/** Preserve the raw message index for historical messages without an id. */
export function getQuestionAnchorId(message: Pick<Message, "id">, index: number): string {
return `message-${message.id ?? index}`;
}
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > SUMMARY_MAX_LENGTH ? `${text.slice(0, SUMMARY_MAX_LENGTH)}…` : text;
}
/** Index only real, visible questions, using the same visibility policy as chat. */
export function buildQuestionItems(messages: Message[]): QuestionItem[] {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human" || isHiddenFromUIMessage(message)) return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (summary) items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}
@@ -11,21 +11,68 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import {
extractContentFromMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
export { getQuestionAnchorId } from "./question-navigator-data";
/** Width of the expanded floating panel, in px. */ /** Width of the expanded floating panel, in px. */
const PANEL_WIDTH = 300; const PANEL_WIDTH = 300;
/** Minimum side gutter (beyond the panel itself) required to float the panel. */ /** Minimum side gutter (beyond the panel itself) required to float the panel. */
const PANEL_GUTTER_MARGIN = 24; const PANEL_GUTTER_MARGIN = 24;
/** Max characters kept in a question summary card. */
const SUMMARY_MAX_LENGTH = 80;
/** Questions needed before the navigator shows up at all. */ /** Questions needed before the navigator shows up at all. */
const MIN_QUESTIONS = 2; const MIN_QUESTIONS = 2;
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed"; const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
/**
* Stable DOM anchor id for a message. Prefers the server-side message id and
* falls back to the render index so messages without ids still get a target
* (requirement: `message-${index}` style fallback).
*/
export function getQuestionAnchorId(
message: Pick<Message, "id">,
index: number,
): string {
return `message-${message.id ?? index}`;
}
/**
* Turn the raw markdown of a user question into a short plain-text summary:
* strips upload/artifact/page-context envelopes and common Markdown syntax,
* collapses whitespace, and truncates with an ellipsis.
*/
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
if (text.length > SUMMARY_MAX_LENGTH) {
return `${text.slice(0, SUMMARY_MAX_LENGTH)}…`;
}
return text;
}
type QuestionItem = {
anchorId: string;
summary: string;
};
/** Walk up from a message anchor to the element that actually scrolls. */ /** Walk up from a message anchor to the element that actually scrolls. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null { function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null; let node = el?.parentElement ?? null;
@@ -81,7 +128,16 @@ export function QuestionNavigator({
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const questions = useMemo(() => buildQuestionItems(messages), [messages]); const questions = useMemo(() => {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human") return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (!summary) return;
items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}, [messages]);
const visible = questions.length >= MIN_QUESTIONS; const visible = questions.length >= MIN_QUESTIONS;
@@ -48,13 +48,8 @@ import {
SidebarMenuAction, SidebarMenuAction,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -69,8 +64,6 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -85,11 +78,6 @@ import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime"; import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
import {
mergeRecentWorkspaceItems,
useRecentStrategyProjects,
} from "@/strategy-selection/recent-projects";
export function RecentChatList({ export function RecentChatList({
compact = false, compact = false,
@@ -105,7 +93,6 @@ export function RecentChatList({
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const [internalCollapsed, setInternalCollapsed] = useState( const [internalCollapsed, setInternalCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true", () => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
); );
@@ -124,7 +111,6 @@ export function RecentChatList({
agent_id?: string; agent_id?: string;
}>(); }>();
const { data: threads = [] } = useThreads(); const { data: threads = [] } = useThreads();
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
const { mutate: deleteThread } = useDeleteThread(); const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread(); const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin(); const { toggle: togglePin } = useToggleThreadPin();
@@ -148,15 +134,15 @@ export function RecentChatList({
}); });
if (threadIndex > -1) { if (threadIndex > -1) {
if (threads[threadIndex + 1]) { if (threads[threadIndex + 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname); nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) { } else if (threads[threadIndex - 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname); nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
} }
} }
void router.push(nextThreadPath); void router.push(nextThreadPath);
} }
}, },
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads], [agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
); );
const handleRenameClick = useCallback( const handleRenameClick = useCallback(
@@ -212,16 +198,12 @@ export function RecentChatList({
const visibleThreads = sortThreadsPinnedFirst( const visibleThreads = sortThreadsPinnedFirst(
threads.filter((thread) => !isHiddenFromRecentChats(thread)), threads.filter((thread) => !isHiddenFromRecentChats(thread)),
); );
const projectThreadNames = useProjectThreadNames(visibleThreads);
const recentItems = mergeRecentWorkspaceItems(
visibleThreads,
recentStrategyProjects,
);
if (recentItems.length === 0) { if (visibleThreads.length === 0) {
return null; return null;
} }
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems; const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
return ( return (
<> <>
<SidebarGroup <SidebarGroup
@@ -243,7 +225,7 @@ export function RecentChatList({
</span> </span>
{compact && ( {compact && (
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums"> <span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
{recentItems.length} {visibleThreads.length}
</span> </span>
)} )}
</span> </span>
@@ -260,84 +242,32 @@ export function RecentChatList({
> >
<SidebarMenu> <SidebarMenu>
<div className="flex w-full flex-col gap-0.5"> <div className="flex w-full flex-col gap-0.5">
{shownItems.map((item) => { {shownThreads.map((thread) => {
if (item.kind === "strategy-project") { const href = listPathOfThread(thread);
const { project } = item;
const href = strategyProjectWorkbenchPath(
"/page/strategy/qa/projects",
project.id,
);
return (
<SidebarMenuItem
key={`strategy-project:${project.id}`}
className="group/side-menu-item"
>
<SidebarMenuButton
isActive={href === pathname}
asChild
className={cn(
"h-auto min-h-11 rounded-lg px-3 py-1.5 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]",
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
compact &&
"min-h-11 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
)}
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href}
onClick={() => {
if (isMobile) setOpenMobile(false);
}}
>
<MyProjectThreadIcon type="strategy" />
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
<span className="w-full truncate">
{desensitize(project.title)}
</span>
<MyProjectThreadBadge
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={project.title}
type="strategy"
/>
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
const { thread } = item;
const href = listPathOfThread(thread, pathname);
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread(
thread,
projectThreadNames,
);
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
return ( return (
<SidebarMenuItem <SidebarMenuItem
key={`thread:${thread.thread_id}`} key={thread.thread_id}
className="group/side-menu-item" className="group/side-menu-item"
> >
<SidebarMenuButton <SidebarMenuButton
isActive={isActive} isActive={isActive}
asChild asChild
className={cn( className={
"h-7 rounded-lg px-3 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground", "rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]", "data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none", "dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none" +
compact && (compact
"h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border", ? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
projectInfo && "h-auto min-h-11 py-1.5", : "")
)} }
> >
<Link <Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden" className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href} href={href}
onClick={() => { if (isMobile) setOpenMobile(false); }}
> >
{isBoot ? ( {isBoot ? (
<Bot <Bot
@@ -349,8 +279,6 @@ export function RecentChatList({
} }
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectInfo ? (
<MyProjectThreadIcon type={projectInfo.type} />
) : ( ) : (
compact && ( compact && (
<MessageSquare <MessageSquare
@@ -366,23 +294,9 @@ export function RecentChatList({
{pinned && ( {pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" /> <Pin className="size-3 shrink-0 text-muted-foreground/70" />
)} )}
<span <span className="truncate">
className={cn(
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -5,15 +5,12 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account"; import { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log"; import { OptType, writeYLog } from "@/core/log/y-log";
import { clearYLogToken } from "@/core/log/y-log-token"; import { clearYLogToken } from "@/core/log/y-log-token";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
import { PasswordSettings } from "./password-settings";
function roleLabel(role?: string) { function roleLabel(role?: string) {
if (role === "admin") return "管理员"; if (role === "admin") return "管理员";
@@ -60,15 +57,14 @@ export function AccountSettingsPage() {
// Match the rest of the app's logout flow: blow away the cookie session // Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload // by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too. // so any cached query state goes too.
void apiFetch(`${getBackendBaseURL()}/api/v1/auth/logout`, { method: "POST" }) void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
.catch(() => undefined) .catch(() => undefined)
.finally(() => { .finally(() => {
window.location.assign(`${window.location.pathname}#/login`); window.location.assign("/");
}); });
}; };
return ( return (
<div className="space-y-8">
<SettingsSection title="个人信息" description="当前登录账号的基本信息。"> <SettingsSection title="个人信息" description="当前登录账号的基本信息。">
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5"> <div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -110,8 +106,6 @@ export function AccountSettingsPage() {
</div> </div>
</div> </div>
</SettingsSection> </SettingsSection>
<PasswordSettings key={auth.user_id} userId={auth.user_id} />
</div>
); );
} }
@@ -23,7 +23,7 @@ import { toast } from "sonner";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { FONT_SCALE_MODE_OPTIONS, FONT_SCALE_OPTIONS } from "@/components/font-scale"; import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -42,20 +42,13 @@ import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-sessio
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n"; import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import {
useUpdateUserPreferences,
useUserPreferencesQuery,
} from "@/core/user-preferences";
import { import {
useAppearanceSettings, useAppearanceSettings,
useCitationDisplaySettings, useCitationDisplaySettings,
useHtmlMapSettings,
useLayoutMode, useLayoutMode,
useUpdateAppearanceSettings, useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings, useUpdateCitationDisplaySettings,
useUpdateHtmlMapSettings,
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import type { HtmlMapEngine } from "@/core/system-settings/api";
import type { import type {
CompactShortcutButton, CompactShortcutButton,
CompactShortcutRouteParam, CompactShortcutRouteParam,
@@ -186,16 +179,12 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const currentTheme = theme; const currentTheme = theme;
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const { data: userPreferences, isPending: preferencesLoading } = useUserPreferencesQuery();
const updateUserPreferences = useUpdateUserPreferences();
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: appearance } = useAppearanceSettings(); const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings(); const updateAppearance = useUpdateAppearanceSettings();
const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]); const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings(); const updateCitationDisplay = useUpdateCitationDisplaySettings();
const { data: htmlMap } = useHtmlMapSettings();
const updateHtmlMap = useUpdateHtmlMapSettings();
const globalDefault = appearance?.default_theme ?? "light"; const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list"; const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card"; const globalAgentView = appearance?.default_agent_view ?? "card";
@@ -758,7 +747,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<SettingsSection <SettingsSection
title="字号" title="字号"
description="调整界面字号。默认 16px(标准)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。" description="调整界面整体字号,方便放大或缩小阅读。默认 16px(标准)。"
> >
<div className="space-y-3"> <div className="space-y-3">
<Select <Select
@@ -781,29 +770,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex flex-wrap items-center gap-2">
{FONT_SCALE_MODE_OPTIONS.map((item) => {
const selected = (settings.display.fontScaleMode ?? "text") === item.value;
return (
<Button
key={item.value}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => setSettings("display", { fontScaleMode: item.value })}
>
{item.label}
</Button>
);
})}
</div>
<p className="text-muted-foreground text-xs">
{
FONT_SCALE_MODE_OPTIONS.find(
(item) => item.value === (settings.display.fontScaleMode ?? "text"),
)?.description
}
</p>
<div className="text-muted-foreground rounded-lg border p-4 text-sm leading-relaxed"> <div className="text-muted-foreground rounded-lg border p-4 text-sm leading-relaxed">
预览:多智能体圆桌研讨 · The quick brown fox · 0123456789 预览:多智能体圆桌研讨 · The quick brown fox · 0123456789
</div> </div>
@@ -865,30 +831,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
title="对话体验" title="对话体验"
description="问答过程中的辅助显示。" description="问答过程中的辅助显示。"
> >
<div className="mb-4 flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<label htmlFor="followup-suggestions" className="text-sm font-medium">
问答后推荐问题
</label>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
默认关闭。开启后,在后续回答结束时生成并显示可继续提问的问题。仅对当前账号生效,设置随账号保存。
</p>
</div>
<Switch
id="followup-suggestions"
checked={userPreferences?.followup_suggestions_enabled ?? false}
disabled={preferencesLoading || updateUserPreferences.isPending}
onCheckedChange={(checked) => {
updateUserPreferences.mutate(
{ followup_suggestions_enabled: checked },
{
onError: (error) => toast.error(error.message || "保存失败"),
},
);
}}
aria-label="问答后推荐问题"
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4"> <div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div> <div>
<div className="text-sm font-medium">回答速度指示器</div> <div className="text-sm font-medium">回答速度指示器</div>
@@ -925,53 +867,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
/> />
</div> </div>
)} )}
{isAdmin && (
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">HTML 地图</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
管理员配置。生成带地图的 HTML 网页时使用哪种地图。ECharts 使用本地行政区划;地图网页使用 Leaflet 瓦片,地址在后台 config.yaml 的 html_runtime.map_tile_url。下一次生成的页面生效。
</p>
</div>
<Select
value={htmlMap?.engine ?? "echarts"}
disabled={!htmlMap || updateHtmlMap.isPending}
onValueChange={(value) => {
if (value !== "echarts" && value !== "leaflet") return;
updateHtmlMap.mutate(value as HtmlMapEngine, {
onError: (error) => toast.error(error.message || "保存失败"),
});
}}
>
<SelectTrigger className="w-[160px]" aria-label="HTML 地图">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="echarts">ECharts</SelectItem>
<SelectItem value="leaflet">地图网页</SelectItem>
</SelectContent>
</Select>
</div>
)}
{isAdmin && (
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">技能检索文件展示</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
全局设置。开启后,问答步骤下展示技能检索到的文件,支持预览和下载;不展示生成文件。默认开启。
</p>
</div>
<Switch
checked={citationDisplay?.retrieved_files_enabled ?? false}
disabled={!citationDisplay || updateCitationDisplay.isPending}
onCheckedChange={(checked) => updateCitationDisplay.mutate(
{ retrieved_files_enabled: checked },
{ onError: (error) => toast.error(error.message || "保存失败") },
)}
aria-label="技能检索文件展示"
/>
</div>
)}
{referenceModeEntryEnabled && ( {referenceModeEntryEnabled && (
<div className="flex items-start justify-between gap-4 rounded-lg border p-4"> <div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div> <div>
@@ -1,102 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { AUTH_STATE_CHANGED_EVENT, getStoredAuth } from "@/core/auth";
import {
getAuthenticationSettings,
saveAuthenticationSettings,
type AuthenticationSettings,
} from "@/core/auth/account-settings-api";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { SettingsSection } from "./settings-section";
export function AuthenticationSettingsPage({ onClose }: { onClose?: () => void }) {
const navigate = useNavigate();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin";
const queryClient = useQueryClient();
const queryKey = ["authentication-settings", live.userId];
const query = useQuery({ queryKey, queryFn: getAuthenticationSettings, enabled: isAdmin });
const [draft, setDraft] = useState<AuthenticationSettings | null>(null);
const mutation = useMutation({ mutationFn: saveAuthenticationSettings });
const settings = draft ?? query.data;
if (!isAdmin) return null;
const update = (key: keyof AuthenticationSettings, value: boolean) => {
if (!settings) return;
const next = { ...settings, [key]: value };
if (key === "password_login_enabled" && !value) next.registration_enabled = false;
setDraft(next);
};
const save = async () => {
if (!settings || mutation.isPending) return;
try {
const saved = await mutation.mutateAsync(settings);
queryClient.setQueryData(queryKey, saved);
setDraft(null);
void queryClient.invalidateQueries({ queryKey: ["auth-options"] });
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
toast.success("登录与注册设置已保存,即时生效");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const invalid = settings && !settings.password_login_enabled && !settings.sso_enabled;
return (
<SettingsSection title="登录与注册" description="管理登录页面、自主注册与账号在线限制,保存后即时生效。">
{query.isPending ? (
<p className="text-muted-foreground text-sm">正在加载设置…</p>
) : query.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{query.error.message}</p>
<Button variant="outline" onClick={() => void query.refetch()}>重新加载</Button>
</div>
) : settings && (
<div className="space-y-5">
<div className="divide-y rounded-xl border px-5">
<SettingSwitch id="password-login-enabled" title="账号密码登录" description="默认关闭。开启后须登录才能进入系统。未设置个人密码的账号使用现有系统口令。" checked={settings.password_login_enabled} disabled={mutation.isPending} onChange={(value) => update("password_login_enabled", value)} />
<SettingSwitch id="registration-enabled" title="用户自主注册" description="允许用户在注册页创建自己的普通账号。需先开启账号密码登录。" checked={settings.registration_enabled} disabled={mutation.isPending || !settings.password_login_enabled} onChange={(value) => update("registration_enabled", value)} />
<SettingSwitch id="single-session-enabled" title="单点登录" description="默认关闭。开启后同一账号只允许一处在线,新登录会使其他登录会话自动退出。" checked={settings.single_session_enabled ?? false} disabled={mutation.isPending} onChange={(value) => update("single_session_enabled", value)} />
<SettingSwitch id="sso-enabled" title="外部系统免登" description="允许外部系统通过票据、令牌或口令链接登录,与单点登录的在线限制独立。" checked={settings.sso_enabled} disabled={mutation.isPending} onChange={(value) => update("sso_enabled", value)} />
</div>
<p className="text-muted-foreground text-sm">密码与登录口令共用。至少保留账号密码登录或外部系统免登中的一种;关闭自主注册不影响已有账号。</p>
<Button variant="outline" onClick={() => { onClose?.(); navigate("/page/strategy/admin/login-whitelist"); }}>管理用户密码与登录口令</Button>
{invalid && <p role="alert" className="text-destructive text-sm">请至少开启账号密码登录或外部系统免登。</p>}
<div className="flex justify-end">
<Button disabled={!draft || Boolean(invalid) || mutation.isPending} onClick={() => void save()}>{mutation.isPending ? "保存中…" : "保存设置"}</Button>
</div>
</div>
)}
</SettingsSection>
);
}
function SettingSwitch({ id, title, description, checked, disabled, onChange }: {
id: string;
title: string;
description: string;
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-5 py-5">
<div className="space-y-1">
<label htmlFor={id} className="cursor-pointer text-sm font-medium">{title}</label>
<p id={`${id}-description`} className="text-muted-foreground text-sm">{description}</p>
</div>
<Switch id={id} aria-describedby={`${id}-description`} checked={checked} disabled={disabled} onCheckedChange={onChange} />
</div>
);
}
@@ -1,86 +0,0 @@
"use client";
import { Loader2Icon, ShrinkIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useConversationContextSettings,
useUpdateConversationContextSettings,
} from "@/core/system-settings/hooks";
export function ContextWindowSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useConversationContextSettings();
const update = useUpdateConversationContextSettings();
const enabled = data?.recent_turn_window_enabled ?? false;
const recentTurns = data?.recent_turns ?? 3;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ recent_turn_window_enabled: checked });
toast.success(
checked
? `近 ${recentTurns} 轮上下文模式已开启`
: `近 ${recentTurns} 轮上下文模式已关闭`,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<ShrinkIcon className="size-5" />
上下文管理
</h3>
<p className="text-muted-foreground text-sm">
控制长对话发给模型的历史范围。该模式默认关闭,只有管理员显式开启后才会生效。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">仅保留最近 {recentTurns} 轮对话</div>
<p className="text-muted-foreground text-sm">
开启后,主问答引擎的模型请求只携带最近 {recentTurns}
个真实用户回合(含当前轮)及其回复和工具结果,不再调用摘要模型。完整聊天记录仍会保存并显示;若这三轮本身仍超过模型上限,最终安全限制会继续裁剪超长内容。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label={`仅保留最近 ${recentTurns} 轮对话`}
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前上下文模式,本页暂不能修改;服务端会继续保持原设置。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
开启后优先于聊天框中的“上下文压缩”选项,并从下一次新问答或运行开始生效,无需重启服务。
</p>
</section>
);
}
@@ -1,120 +0,0 @@
"use client";
import { Loader2Icon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useOrdinaryKnowledgeRetrievalSettings,
useUpdateOrdinaryKnowledgeRetrievalSettings,
} from "@/core/system-settings/hooks";
export function KnowledgeRetrievalSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useOrdinaryKnowledgeRetrievalSettings();
const update = useUpdateOrdinaryKnowledgeRetrievalSettings();
const enabled = data?.smart_reasoning_enabled ?? false;
const advancedManagementEnabled =
data?.advanced_management_enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ smart_reasoning_enabled: checked });
toast.success(
checked ? "普通知识库智能检索已开启" : "普通知识库智能检索已关闭",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleAdvancedManagementChange = async (checked: boolean) => {
try {
await update.mutateAsync({ advanced_management_enabled: checked });
toast.success(
checked ? "知识库高级管理入口已开放" : "知识库高级管理入口已隐藏",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<SearchIcon className="size-5" />
知识检索
</h3>
<p className="text-muted-foreground text-sm">
控制普通知识库问答是否默认使用智能检索。该能力默认关闭,经典检索模式始终保留为候选。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">普通知识库智能推理检索</div>
<p className="text-muted-foreground text-sm">
开启后,新对话默认使用智能检索:先召回原文分片,再根据命中线索查阅并扩展
Wiki,把知识库资料作为回答背景。已有对话继续使用各自保存的模式,用户仍可在
输入框的检索模式菜单中切换到经典检索。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="普通知识库智能推理检索"
/>
)}
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">知识库高级管理入口</div>
<p className="text-muted-foreground text-sm">
默认隐藏检索测试、增量同步和知识库数据包导入导出。开启后按原有账号权限显示;
基础知识库管理和查看 Wiki 索引始终可用。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={advancedManagementEnabled}
disabled={isError || update.isPending}
onCheckedChange={handleAdvancedManagementChange}
aria-label="知识库高级管理入口"
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前知识检索设置,客户端按关闭处理,本页暂不能修改。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
用户只需直接提问,无需填写“先检索什么、再检索什么”的固定模板;系统不会向输入框
注入检索提示词。智能检索将检索资料与模型原有能力结合后回答,不强制生成参考文献编号。
关闭后不会删除历史对话;已保存为智能检索的线程需要切回经典模式后才能继续发送。
</p>
</section>
);
}
@@ -1,67 +0,0 @@
"use client";
import { BrainIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useMemoryRuntimeSettings,
useUpdateMemoryRuntimeSettings,
} from "@/core/system-settings/hooks";
export function MemoryRuntimeSettingsPage() {
const { data, isLoading, isError } = useMemoryRuntimeSettings();
const update = useUpdateMemoryRuntimeSettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<BrainIcon className="size-5" />
问答记忆
</h3>
<p className="text-muted-foreground text-sm">
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">启用问答记忆</div>
<p className="text-muted-foreground text-sm">
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="启用问答记忆"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取记忆开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
</p>
</section>
);
}
@@ -1,102 +0,0 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getStoredAuth, setStoredAuth } from "@/core/auth";
import { changeAccountPassword, getAccountPasswordStatus } from "@/core/auth/account-settings-api";
import { SettingsSection } from "./settings-section";
export function PasswordSettings({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const status = useQuery({
queryKey: ["account-password", userId],
queryFn: getAccountPasswordStatus,
});
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const hasPassword = status.data?.has_password ?? true;
const requiresCurrentPassword = status.data?.requires_current_password ?? true;
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (pending || !status.data) return;
setError("");
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
if (newPassword.length < 8) {
setError("新密码至少需要 8 位,请避免常见弱密码");
return;
}
const auth = getStoredAuth();
if (!auth || auth.user_id !== userId) {
setError("登录账号已变化,请重新打开个人信息");
return;
}
setPending(true);
try {
const session = await changeAccountPassword(requiresCurrentPassword ? currentPassword : "", newPassword);
// Another tab may have changed the active account while the request was pending.
if (getStoredAuth()?.access_token !== auth.access_token) return;
if (session.access_token) {
setStoredAuth({
...auth,
access_token: session.access_token,
token_type: session.token_type ?? auth.token_type,
expires_in: session.expires_in ?? auth.expires_in,
needs_setup: false,
});
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
queryClient.setQueryData(["account-password", userId], { ...status.data, has_password: true, requires_current_password: true, uses_default_password: false });
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
toast.success("密码与登录口令已同步更新,其他登录会话已失效");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "密码修改失败");
} finally {
setPending(false);
}
};
return (
<SettingsSection title={hasPassword ? "修改密码" : "设置密码"} description="密码与登录口令共用,修改后原密码和原口令链接失效。">
{status.isPending ? <p className="text-muted-foreground text-sm">正在读取密码状态…</p> : status.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{status.error.message}</p>
<Button variant="outline" onClick={() => void status.refetch()}>重新加载</Button>
</div>
) : (
<form onSubmit={(event) => void submit(event)} className="space-y-4 rounded-xl border p-5">
{!hasPassword && <p className="text-muted-foreground text-sm">此账号尚未设置个人密码,当前使用系统口令。请先输入现有口令,再设置个人密码。</p>}
{requiresCurrentPassword && (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-current-password">当前密码 / 口令</label>
<Input id="account-current-password" type="password" autoComplete="current-password" required value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={pending} />
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-new-password">新密码</label>
<Input id="account-new-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} placeholder="至少 8 位,避免常见弱密码" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={pending} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-confirm-password">确认新密码</label>
<Input id="account-confirm-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={pending} />
</div>
{error && <p role="alert" className="text-destructive text-sm">{error}</p>}
<div className="flex justify-end"><Button type="submit" disabled={pending}>{pending ? "保存中…" : "保存密码"}</Button></div>
</form>
)}
</SettingsSection>
);
}
@@ -1,19 +1,6 @@
"use client"; "use client";
import { import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
BellIcon,
BrainIcon,
CpuIcon,
FileOutputIcon,
PaletteIcon,
SearchIcon,
ShieldCheckIcon,
ShrinkIcon,
SparklesIcon,
UploadCloudIcon,
UserIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
@@ -25,29 +12,18 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AuthenticationSettingsPage } from "@/components/workspace/settings/authentication-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page"; import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page"; import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace"; import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
type SettingsSection = type SettingsSection =
| "account" | "account"
| "authentication"
| "appearance" | "appearance"
| "conversation-context"
| "knowledge-retrieval"
| "memory" | "memory"
| "fileWriting"
| "uploads"
| "tools" | "tools"
| "encoder" | "encoder"
| "about"; | "about";
@@ -59,7 +35,6 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) { export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props; const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n(); const { t } = useI18n();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection); useState<SettingsSection>(defaultSection);
@@ -86,19 +61,15 @@ export function SettingsDialog(props: SettingsDialogProps) {
// }, // },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, // { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon }, // { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
...(isAdmin // 记忆已移至侧边栏入口 (/page/workspace/memory)
? [ // {
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon }, // id: "memory",
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon }, // label: t.settings.sections.memory,
{ id: "memory", label: "问答记忆", icon: BrainIcon }, // icon: BrainIcon,
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon }, // },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
{ id: "authentication", label: "登录与注册", icon: ShieldCheckIcon },
]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin ...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }] ? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
: []), : []),
{ {
id: "account", id: "account",
@@ -158,36 +129,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border"> <ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6"> <div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />} {activeSection === "account" && <AccountSettingsPage />}
{activeSection === "authentication" && isAdmin && (
<AuthenticationSettingsPage
key={live.userId}
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "appearance" && ( {activeSection === "appearance" && (
<AppearanceSettingsPage <AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "conversation-context" && isAdmin && (
<ContextWindowSettingsPage />
)}
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)} )}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />} {activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && ( {activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
<LocalEncoderSettingsCard />
)}
{activeSection === "about" && <AboutSettingsPage />} {activeSection === "about" && <AboutSettingsPage />}
</div> </div>
</ScrollArea> </ScrollArea>
@@ -19,8 +19,6 @@ import {
LockIcon, LockIcon,
PinIcon, PinIcon,
PlusIcon, PlusIcon,
Power,
PowerOff,
RefreshCwIcon, RefreshCwIcon,
SparklesIcon, SparklesIcon,
TagsIcon, TagsIcon,
@@ -72,13 +70,11 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
import type { ValidateSkillUploadResponse } from "@/core/skills/api"; import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import { import {
useDeleteBuiltinSkill, useDeleteBuiltinSkill,
useDeleteCustomSkill, useDeleteCustomSkill,
useDuplicateSkill, useDuplicateSkill,
useEnableSkill,
useGenerateMissingSkillNameZh, useGenerateMissingSkillNameZh,
useGenerateSkillNameZh, useGenerateSkillNameZh,
useInstallSkillUpload, useInstallSkillUpload,
@@ -91,23 +87,11 @@ import {
useSkills, useSkills,
useValidateSkillUpload, useValidateSkillUpload,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import {
canToggleSkillAvailability,
isSkillOffline,
type SkillAvailabilityControl,
} from "@/core/skill-availability";
import { import {
usePublishSquares, usePublishSquares,
useSkillCompressionSettings, useSkillCompressionSettings,
useViewMode, useViewMode,
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import {
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
WX_RESTRICTED_TAB_NANJING_DB,
isWxRestrictedPlaceholderTab,
wxRestrictedDefaultSquareTabValue,
wxRestrictedNanjingTabValue,
} from "@/core/system-settings/wx-type-filter";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge"; import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
import { formatDateTime } from "@/core/utils/datetime"; import { formatDateTime } from "@/core/utils/datetime";
@@ -121,25 +105,18 @@ import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ export function SkillSettingsPage({
onClose, onClose,
embedded = false, embedded = false,
restrictWxTypes = false,
}: { }: {
onClose?: () => void; onClose?: () => void;
embedded?: boolean; embedded?: boolean;
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
restrictWxTypes?: boolean;
} = {}) { } = {}) {
const { t } = useI18n(); const { t } = useI18n();
if (embedded) { if (embedded) return <SkillSettingsList onClose={onClose} />;
return (
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
);
}
return ( return (
<SettingsSection <SettingsSection
title={t.settings.skills.title} title={t.settings.skills.title}
description={t.settings.skills.description} description={t.settings.skills.description}
> >
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} /> <SkillSettingsList onClose={onClose} />
</SettingsSection> </SettingsSection>
); );
} }
@@ -188,13 +165,7 @@ const rescanErrorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />, icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const; } as const;
function SkillSettingsList({ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
onClose,
restrictWxTypes = false,
}: {
onClose?: () => void;
restrictWxTypes?: boolean;
}) {
const { t } = useI18n(); const { t } = useI18n();
const { theme } = useTheme(); const { theme } = useTheme();
const router = useRouter(); const router = useRouter();
@@ -216,10 +187,6 @@ function SkillSettingsList({
// Each configured publish square (发布广场) becomes its own browse tab; the // Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default). // backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? []; const squareTabs = publishSquares?.squares ?? [];
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
publishSquares?.default_square_id,
);
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
const reconcileMutation = useReconcileCustomSkills(); const reconcileMutation = useReconcileCustomSkills();
const fillNamesMutation = useGenerateMissingSkillNameZh(); const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default). // Optional model override for the admin "一键补充中文名" action (null = default).
@@ -286,9 +253,6 @@ function SkillSettingsList({
const isSquareTab = filter !== "mine"; const isSquareTab = filter !== "mine";
const scopedSkills = filter === "mine" ? activeMine : activeAll; const scopedSkills = filter === "mine" ? activeMine : activeAll;
const filteredSkills = useMemo(() => { const filteredSkills = useMemo(() => {
if (restrictWxTypes && isWxRestrictedPlaceholderTab(filter)) {
return [];
}
let list = publisher let list = publisher
? scopedSkills.filter((s) => s.owner_user_id === publisher.id) ? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
: scopedSkills; : scopedSkills;
@@ -302,14 +266,7 @@ function SkillSettingsList({
}); });
} }
return list; return list;
}, [ }, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
scopedSkills,
publisher,
filter,
isSquareTab,
publishSquares?.default_square_id,
restrictWxTypes,
]);
const orderedSkills = useMemo( const orderedSkills = useMemo(
() => () =>
filteredSkills filteredSkills
@@ -339,30 +296,6 @@ function SkillSettingsList({
// If the active square tab disappears (admin removed that square), fall back // If the active square tab disappears (admin removed that square), fall back
// to "我的" so the page never points at a dead tab. // to "我的" so the page never points at a dead tab.
useEffect(() => { useEffect(() => {
if (restrictWxTypes) {
if (
filter === WX_RESTRICTED_TAB_NANJING_DB &&
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
) {
setFilter(nanjingTabValue);
return;
}
if (
filter === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
) {
setFilter(defaultSquareTabValue);
return;
}
if (
filter !== "mine" &&
filter !== defaultSquareTabValue &&
filter !== nanjingTabValue
) {
setFilter("mine");
}
return;
}
if ( if (
filter !== "mine" && filter !== "mine" &&
squareTabs.length > 0 && squareTabs.length > 0 &&
@@ -370,7 +303,7 @@ function SkillSettingsList({
) { ) {
setFilter("mine"); setFilter("mine");
} }
}, [filter, squareTabs, restrictWxTypes, defaultSquareTabValue, nanjingTabValue]); }, [filter, squareTabs]);
const pagedSkills = useMemo( const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page], [orderedSkills, page],
@@ -421,6 +354,7 @@ function SkillSettingsList({
theme === "dark" ? rescanErrorToastDarkOptions : undefined, theme === "dark" ? rescanErrorToastDarkOptions : undefined,
); );
} }
}; };
return ( return (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
@@ -428,22 +362,12 @@ function SkillSettingsList({
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}> <Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line" className="flex-wrap"> <TabsList variant="line" className="flex-wrap">
{restrictWxTypes ? (
<>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
</>
) : (
<>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger> <TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
{squareTabs.map((sq) => ( {squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}> <TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name} {sq.name}
</TabsTrigger> </TabsTrigger>
))} ))}
</>
)}
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button <Button
@@ -625,7 +549,6 @@ function SkillSettingsList({
skill={skill} skill={skill}
currentUserId={currentUserId} currentUserId={currentUserId}
isAdmin={isAdmin} isAdmin={isAdmin}
availabilityControl="owner"
layout={viewMode === "card" ? "card" : "row"} layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher} onPublisherClick={setPublisher}
/> />
@@ -684,7 +607,6 @@ export function SkillRow({
skill, skill,
currentUserId, currentUserId,
isAdmin, isAdmin,
availabilityControl,
layout = "row", layout = "row",
onPublisherClick, onPublisherClick,
knowledgeBindings = [], knowledgeBindings = [],
@@ -692,8 +614,6 @@ export function SkillRow({
skill: Skill; skill: Skill;
currentUserId: string | null; currentUserId: string | null;
isAdmin: boolean; isAdmin: boolean;
/** Which permission boundary exposes the global online/offline control. */
availabilityControl?: SkillAvailabilityControl;
/** "row" = full-width horizontal item (default); "card" = grid card cell. */ /** "row" = full-width horizontal item (default); "card" = grid card cell. */
layout?: "row" | "card"; layout?: "row" | "card";
/** Click the 发布者 line to filter the list to that publisher. Receives the /** Click the 发布者 line to filter the list to that publisher. Receives the
@@ -709,7 +629,6 @@ export function SkillRow({
const setPinned = useSetSkillPinned(); const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured(); const setFeatured = useSetSkillFeatured();
const setAlwaysOn = useSetSkillAlwaysOn(); const setAlwaysOn = useSetSkillAlwaysOn();
const setEnabled = useEnableSkill();
const { data: compression } = useSkillCompressionSettings(); const { data: compression } = useSkillCompressionSettings();
const deleteCustom = useDeleteCustomSkill(); const deleteCustom = useDeleteCustomSkill();
const deleteBuiltin = useDeleteBuiltinSkill(); const deleteBuiltin = useDeleteBuiltinSkill();
@@ -720,7 +639,6 @@ export function SkillRow({
const [duplicateOpen, setDuplicateOpen] = useState(false); const [duplicateOpen, setDuplicateOpen] = useState(false);
const [duplicateName, setDuplicateName] = useState(""); const [duplicateName, setDuplicateName] = useState("");
const [publishPickerOpen, setPublishPickerOpen] = useState(false); const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const [confirmOfflineOpen, setConfirmOfflineOpen] = useState(false);
const isCustom = skill.category === "custom"; const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without // Built-in flag falls back to "no DB owner" so legacy responses without
@@ -735,27 +653,6 @@ export function SkillRow({
const canTogglePublish = canManage && !isBuiltin; const canTogglePublish = canManage && !isBuiltin;
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。 // 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
const canSetAlwaysOn = isAdmin || isOwned; const canSetAlwaysOn = isAdmin || isOwned;
const canToggleAvailability = canToggleSkillAvailability({
skill,
currentUserId,
isAdmin,
control: availabilityControl,
});
const isOffline = isSkillOffline(skill);
async function handleSetEnabled(enabled: boolean) {
try {
await setEnabled.mutateAsync({ skillName: skill.name, enabled });
toast.success(
enabled
? t.settings.skills.onlineSuccess
: t.settings.skills.offlineSuccess,
);
setConfirmOfflineOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleAlwaysOn(next: boolean) { async function handleToggleAlwaysOn(next: boolean) {
try { try {
@@ -986,16 +883,6 @@ export function SkillRow({
{knowledgeBindings.length > 3 && ( {knowledgeBindings.length > 3 && (
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge> <Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
)} )}
{isOffline && (
<Badge
variant="outline"
className="border-red-500/35 bg-red-500/5 text-red-700 dark:text-red-300 text-xs"
title={t.settings.skills.badgeOfflineTitle}
>
<PowerOff className="size-3" />
{t.settings.skills.badgeOffline}
</Badge>
)}
</div> </div>
); );
@@ -1054,43 +941,6 @@ export function SkillRow({
const actionButtons = ( const actionButtons = (
<> <>
{canToggleAvailability && (
<Button
size="sm"
variant={isOffline ? "default" : "outline"}
className={cn(
"h-8 shrink-0 gap-1.5 px-2.5",
!isOffline && "text-destructive hover:text-destructive",
)}
disabled={
setEnabled.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onClick={() => {
if (isOffline) {
void handleSetEnabled(true);
} else {
setConfirmOfflineOpen(true);
}
}}
title={
isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction
}
>
{setEnabled.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : isOffline ? (
<Power className="size-3.5" />
) : (
<PowerOff className="size-3.5" />
)}
{isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction}
</Button>
)}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -1210,12 +1060,7 @@ export function SkillRow({
return ( return (
<> <>
{isCard ? ( {isCard ? (
<div <div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
className={cn(
"flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
)}
>
<div <div
className="min-w-0 flex-1 cursor-pointer" className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)} onClick={() => setDetailOpen(true)}
@@ -1231,13 +1076,7 @@ export function SkillRow({
</div> </div>
</div> </div>
) : ( ) : (
<Item <Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
className={cn(
"w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
)}
variant="outline"
>
<ItemContent <ItemContent
className="cursor-pointer" className="cursor-pointer"
onClick={() => setDetailOpen(true)} onClick={() => setDetailOpen(true)}
@@ -1318,37 +1157,6 @@ export function SkillRow({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={confirmOfflineOpen} onOpenChange={setConfirmOfflineOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
<DialogDescription>
{t.settings.skills.offlineConfirmDescription.replace(
"{name}",
desensitize(skill.name),
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmOfflineOpen(false)}
disabled={setEnabled.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleSetEnabled(false)}
disabled={setEnabled.isPending}
>
{setEnabled.isPending
? t.common.loading
: t.settings.skills.offlineAction}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TagPickerDialog <TagPickerDialog
open={tagPickerOpen} open={tagPickerOpen}
onOpenChange={setTagPickerOpen} onOpenChange={setTagPickerOpen}
@@ -1564,9 +1372,6 @@ function InstallSkillDialog({
const canOverwrite = !!validation && !validationError && validation.can_overwrite; const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite; const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other; const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
// On-disk leftover with no ownership record: replaceable by whoever uploads it.
const unclaimedCustomConflict =
!!isCustomConflict && !validation?.owned_by_me && !validation?.owned_by_other && canOverwrite;
const customConflictMessage = !validation const customConflictMessage = !validation
? "" ? ""
: otherCustomConflict : otherCustomConflict
@@ -1581,8 +1386,6 @@ function InstallSkillDialog({
? dialog.conflictOwnedByMe ? dialog.conflictOwnedByMe
: dialog.conflictOwnedByMeWrongTarget : dialog.conflictOwnedByMeWrongTarget
).replace("{name}", validation.skill_name) ).replace("{name}", validation.skill_name)
: unclaimedCustomConflict
? dialog.conflictUnclaimed.replace("{name}", validation.skill_name)
: dialog.conflictCustom.replace("{name}", validation.skill_name); : dialog.conflictCustom.replace("{name}", validation.skill_name);
return ( return (
@@ -1693,16 +1496,6 @@ function InstallSkillDialog({
/> />
</div> </div>
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-sm font-medium">{dialog.exampleTitle}</div>
<p className="text-muted-foreground mt-1 text-xs">
{dialog.exampleHint}
</p>
<pre className="mt-2 overflow-x-auto rounded bg-background px-3 py-2 text-xs leading-5">
<code>{MINIMAL_SKILL_MD_EXAMPLE}</code>
</pre>
</div>
{isAdmin && ( {isAdmin && (
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span> <span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
@@ -1779,7 +1572,7 @@ function InstallSkillDialog({
<div <div
className={cn( className={cn(
"mt-1 flex items-start gap-2 text-xs", "mt-1 flex items-start gap-2 text-xs",
ownCustomCanOverwrite || unclaimedCustomConflict ? "text-amber-600" : "text-destructive", ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
)} )}
> >
<AlertCircle className="mt-0.5 size-3.5 shrink-0" /> <AlertCircle className="mt-0.5 size-3.5 shrink-0" />
@@ -1,346 +0,0 @@
"use client";
import {
Loader2Icon,
RotateCcwIcon,
SaveIcon,
UploadCloudIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DEFAULT_UPLOAD_POLICY,
MEBIBYTE,
normalizeUploadExtension,
type UploadPolicy,
type UploadPolicyGroupKey,
useResetUploadPolicy,
useUpdateUploadPolicy,
useUploadPolicy,
} from "@/core/uploads";
type UploadPolicyDraft = {
maxFiles: string;
maxTotalMiB: string;
groups: Record<
UploadPolicyGroupKey,
{ maxMiB: string; extensions: string }
>;
thresholdMiB: string;
maxResultChars: string;
hardMaxResultChars: string;
};
const GROUP_META: Array<{
key: UploadPolicyGroupKey;
label: string;
description: string;
}> = [
{ key: "document", label: "文档", description: "Word、PPT、TXT、PDF 等" },
{ key: "image", label: "图片", description: "JPG、PNG 等" },
{ key: "spreadsheet", label: "表格", description: "Excel 等" },
];
function toDraft(policy: UploadPolicy): UploadPolicyDraft {
return {
maxFiles: String(policy.max_files_per_message),
maxTotalMiB: String(policy.max_total_bytes / MEBIBYTE),
groups: {
document: {
maxMiB: String(policy.groups.document.max_bytes / MEBIBYTE),
extensions: policy.groups.document.extensions.join(", "),
},
image: {
maxMiB: String(policy.groups.image.max_bytes / MEBIBYTE),
extensions: policy.groups.image.extensions.join(", "),
},
spreadsheet: {
maxMiB: String(policy.groups.spreadsheet.max_bytes / MEBIBYTE),
extensions: policy.groups.spreadsheet.extensions.join(", "),
},
},
thresholdMiB: String(policy.large_file.threshold_bytes / MEBIBYTE),
maxResultChars: String(policy.large_file.max_result_chars),
hardMaxResultChars: String(policy.large_file.hard_max_result_chars),
};
}
function positiveNumber(value: string, label: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${label}必须大于 0`);
}
return parsed;
}
function extensionsOf(value: string, label: string): string[] {
const extensions = Array.from(
new Set(
value
.split(/[,,\s]+/)
.map(normalizeUploadExtension)
.filter(Boolean),
),
);
if (extensions.length === 0) throw new Error(`${label}至少填写一个扩展名`);
return extensions;
}
function fromDraft(draft: UploadPolicyDraft): UploadPolicy {
const maxResultChars = Math.floor(
positiveNumber(draft.maxResultChars, "常规输出上限"),
);
const hardMaxResultChars = Math.floor(
positiveNumber(draft.hardMaxResultChars, "硬输出上限"),
);
if (maxResultChars > hardMaxResultChars) {
throw new Error("常规输出上限不能大于硬输出上限");
}
const groups = {} as UploadPolicy["groups"];
for (const { key, label } of GROUP_META) {
groups[key] = {
max_bytes: Math.floor(
positiveNumber(draft.groups[key].maxMiB, `${label}单文件上限`) *
MEBIBYTE,
),
extensions: extensionsOf(draft.groups[key].extensions, label),
};
}
return {
max_files_per_message: Math.floor(
positiveNumber(draft.maxFiles, "单条消息文件数"),
),
max_total_bytes: Math.floor(
positiveNumber(draft.maxTotalMiB, "附件总大小") * MEBIBYTE,
),
groups,
large_file: {
threshold_bytes: Math.floor(
positiveNumber(draft.thresholdMiB, "大文件阈值") * MEBIBYTE,
),
max_result_chars: maxResultChars,
hard_max_result_chars: hardMaxResultChars,
},
};
}
export function UploadSettingsPage() {
const { data, isLoading } = useUploadPolicy();
const update = useUpdateUploadPolicy();
const reset = useResetUploadPolicy();
const [draft, setDraft] = useState<UploadPolicyDraft>(() =>
toDraft(DEFAULT_UPLOAD_POLICY),
);
useEffect(() => {
if (data) setDraft(toDraft(data));
}, [data]);
const setGroup = (
key: UploadPolicyGroupKey,
patch: Partial<UploadPolicyDraft["groups"][UploadPolicyGroupKey]>,
) => {
setDraft((current) => ({
...current,
groups: {
...current.groups,
[key]: { ...current.groups[key], ...patch },
},
}));
};
const save = async () => {
try {
const saved = await update.mutateAsync(fromDraft(draft));
setDraft(toDraft(saved));
toast.success("上传设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存上传设置失败");
}
};
const restoreDefaults = async () => {
if (!window.confirm("确定恢复系统默认上传策略吗?当前自定义设置会被覆盖。")) {
return;
}
try {
const saved = await reset.mutateAsync();
setDraft(toDraft(saved));
toast.success("已恢复默认上传设置");
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复默认上传设置失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<UploadCloudIcon className="size-5" />
上传设置
</h3>
<p className="text-muted-foreground text-sm">
控制聊天附件的数量、格式和大小,并限制大文件读取结果,防止文件内容一次性占满模型上下文。
</p>
</div>
<div className="grid gap-4 rounded-lg border p-4 sm:grid-cols-2">
<NumberField
id="upload-max-files"
label="单条消息最多文件数"
value={draft.maxFiles}
onChange={(maxFiles) => setDraft((value) => ({ ...value, maxFiles }))}
step="1"
/>
<NumberField
id="upload-max-total"
label="单条消息附件总上限(MiB)"
value={draft.maxTotalMiB}
onChange={(maxTotalMiB) =>
setDraft((value) => ({ ...value, maxTotalMiB }))
}
/>
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold">文件分组</h4>
{GROUP_META.map(({ key, label, description }) => (
<div
key={key}
className="grid gap-4 rounded-lg border p-4 sm:grid-cols-[180px_minmax(0,1fr)]"
>
<NumberField
id={`upload-${key}-size`}
label={`${label}单文件上限(MiB)`}
value={draft.groups[key].maxMiB}
onChange={(maxMiB) => setGroup(key, { maxMiB })}
/>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor={`upload-${key}-extensions`}
>
{label}扩展名
</label>
<Input
id={`upload-${key}-extensions`}
value={draft.groups[key].extensions}
onChange={(event) =>
setGroup(key, { extensions: event.currentTarget.value })
}
placeholder=".doc, .docx"
/>
<p className="text-muted-foreground text-xs">
{description};用逗号或空格分隔,保存时自动补全小数点。
</p>
</div>
</div>
))}
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<h4 className="text-sm font-semibold">大文件读取保护</h4>
<p className="text-muted-foreground mt-1 text-xs">
达到阈值后使用头部、中部、尾部采样;输出始终受字符预算约束,并在界面中标明截断或采样。
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<NumberField
id="upload-large-threshold"
label="大文件阈值(MiB)"
value={draft.thresholdMiB}
onChange={(thresholdMiB) =>
setDraft((value) => ({ ...value, thresholdMiB }))
}
/>
<NumberField
id="upload-result-chars"
label="常规输出上限(字符)"
value={draft.maxResultChars}
onChange={(maxResultChars) =>
setDraft((value) => ({ ...value, maxResultChars }))
}
step="100"
/>
<NumberField
id="upload-hard-result-chars"
label="硬输出上限(字符)"
value={draft.hardMaxResultChars}
onChange={(hardMaxResultChars) =>
setDraft((value) => ({ ...value, hardMaxResultChars }))
}
step="100"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
{isLoading && (
<span className="text-muted-foreground flex items-center gap-1 text-xs">
<Loader2Icon className="size-3.5 animate-spin" /> 正在读取配置
</span>
)}
<Button
type="button"
variant="outline"
onClick={() => void restoreDefaults()}
disabled={update.isPending || reset.isPending}
>
{reset.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<RotateCcwIcon className="size-4" />
)}
恢复默认
</Button>
<Button
onClick={() => void save()}
disabled={update.isPending || reset.isPending}
>
{update.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
保存
</Button>
</div>
</section>
);
}
function NumberField({
id,
label,
value,
onChange,
step = "0.1",
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
step?: string;
}) {
return (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={id}>
{label}
</label>
<Input
id={id}
type="number"
min="0.01"
step={step}
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
/>
</div>
);
}
@@ -1,70 +0,0 @@
"use client";
import { FileOutputIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useUpdateWriteFileCompatibilitySettings,
useWriteFileCompatibilitySettings,
} from "@/core/system-settings/hooks";
export function WriteFileCompatibilitySettingsPage() {
const { data, isLoading, isError } = useWriteFileCompatibilitySettings();
const update = useUpdateWriteFileCompatibilitySettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "文件流式兼容已开启" : "文件流式兼容已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<FileOutputIcon className="size-5" />
文件流式兼容
</h3>
<p className="text-muted-foreground text-sm">
临时兼容会缓存长工具参数的内网模型网关。开启后,所有兼容模型仍调用原有
write_file 工具,但文件正文改由普通文本流生成,再投影为标准 write_file
流式事件。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">所有模型启用兼容写入</div>
<p className="text-muted-foreground text-sm">
适用于当前配置的 OpenAI-compatible Chat Completions
模型。关闭后恢复各模型原有写入方式。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="所有模型启用文件流式兼容"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取文件流式兼容开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
保存后无需重启后台,从下一次发起的问答开始生效;正在生成中的回答不会中途切换。
</p>
</section>
);
}
@@ -2,17 +2,14 @@ import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } fr
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Sheet, Dialog,
SheetContent, DialogContent,
SheetDescription, DialogDescription,
SheetHeader, DialogHeader,
SheetTitle, DialogTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime"; import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
resolveWeKnoraSourceTarget,
type WeKnoraSourceTarget,
} from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
@@ -59,9 +56,6 @@ export function SourceCard({
}) { }) {
const [relatedOpen, setRelatedOpen] = useState(false); const [relatedOpen, setRelatedOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(
null,
);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const previewTarget = resolveWeKnoraSourceTarget({ const previewTarget = resolveWeKnoraSourceTarget({
@@ -71,13 +65,7 @@ export function SourceCard({
const cleanupWords = const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords); const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const isWikiIndexSource = const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
source.type === "llmwiki_wiki_index_intro" ||
source.raw?.type === "llmwiki_wiki_index_intro";
const isWikiSource =
Boolean(previewTarget?.wikiSlug) ||
source.type === "llmwiki_wiki" ||
source.type === "llmwiki_wiki_page";
const matchedHeading = isWikiSource const matchedHeading = isWikiSource
? String( ? String(
(source.raw?.matched_heading as string | undefined) || (source.raw?.matched_heading as string | undefined) ||
@@ -90,17 +78,13 @@ export function SourceCard({
const summary = isWikiSource && typeof source.raw?.summary === "string" const summary = isWikiSource && typeof source.raw?.summary === "string"
? source.raw.summary ? source.raw.summary
: ""; : "";
const snippet = plainTextReferencePreview(content || summary).slice(0, 280); const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string" const score =
? source.raw.rank_score_semantics typeof source.score === "number"
: ""; ? source.score <= 1
const scoreMeta = typeof source.score !== "number" ? `${Math.round(source.score * 100)}%`
? null : String(source.score)
: scoreSemantics === "rrf" : null;
? `融合排序分 ${source.score.toFixed(4)}`
: scoreSemantics === "provider_relevance"
? `上游通道分 ${source.score.toFixed(4)}`
: `相关度 ${source.score <= 1 ? `${Math.round(source.score * 100)}%` : String(source.score)}`;
const meta = [ const meta = [
source.source, source.source,
isWikiSource ? "检索对象:Wiki 文档" : null, isWikiSource ? "检索对象:Wiki 文档" : null,
@@ -108,7 +92,7 @@ export function SourceCard({
source.author ? `作者 ${source.author}` : null, source.author ? `作者 ${source.author}` : null,
!isWikiSource && source.type && source.type !== "result" ? source.type : null, !isWikiSource && source.type && source.type !== "result" ? source.type : null,
source.time, source.time,
scoreMeta, score ? `相关度 ${score}` : null,
].filter(Boolean); ].filter(Boolean);
const isCitation = source.mode === "citation"; const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation; const showReferenceNumber = isCitation;
@@ -167,7 +151,7 @@ export function SourceCard({
</p> </p>
)} )}
</div> </div>
{(source.url || isWikiSource || isWikiIndexSource) && ( {(source.url || isWikiSource) && (
previewTarget ? ( previewTarget ? (
<span className={cn( <span className={cn(
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium", "mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
@@ -178,47 +162,30 @@ export function SourceCard({
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />} {isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
{isWikiSource ? "Wiki 文档" : "知识库文档"} {isWikiSource ? "Wiki 文档" : "知识库文档"}
</span> </span>
) : isWikiIndexSource ? (
<span className="mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md bg-emerald-100 px-1.5 py-1 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
<BookOpenTextIcon className="size-3.5" />
Wiki 索引
</span>
) : ( ) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" /> <ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
) )
)} )}
</div> </div>
{(previewTarget || isWikiIndexSource) && ( {previewTarget && (
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground"> <div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
<span> <span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
{isWikiIndexSource
? "点击卡片查看完整 Wiki 索引"
: isWikiSource
? "点击卡片查看完整 Wiki"
: "点击卡片查看详情"}
</span>
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" /> <ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div> </div>
)} )}
</div> </div>
{(previewTarget || linkedTarget) && ( {previewTarget && (
<WeKnoraSourceDrawer <WeKnoraSourceDrawer
open={detailOpen} open={detailOpen}
target={linkedTarget ?? previewTarget} target={previewTarget}
title={source.title} title={source.title}
quote={content} quote={content}
onOpenChange={(open) => { onOpenChange={setDetailOpen}
setDetailOpen(open);
if (!open) setLinkedTarget(null);
}}
/> />
)} )}
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}> {!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
<SheetContent <DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
side="right" <DialogHeader>
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<div className="flex items-start justify-between gap-3 pr-8"> <div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@@ -227,19 +194,16 @@ export function SourceCard({
{source.index} {source.index}
</span> </span>
)} )}
<SheetTitle className="text-base leading-relaxed"> <DialogTitle className="text-base leading-relaxed">
{source.title} {source.title}
</SheetTitle> </DialogTitle>
</div> </div>
{meta.length > 0 && ( {meta.length > 0 && (
<SheetDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs"> <DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
{meta.map((item, index) => ( {meta.map((item, index) => (
<span key={`${item}-${index}`}>{item}</span> <span key={`${item}-${index}`}>{item}</span>
))} ))}
</SheetDescription> </DialogDescription>
)}
{meta.length === 0 && (
<SheetDescription className="sr-only">参考文献详情</SheetDescription>
)} )}
</div> </div>
{source.url && !previewTarget && ( {source.url && !previewTarget && (
@@ -255,10 +219,9 @@ export function SourceCard({
</Button> </Button>
)} )}
</div> </div>
</SheetHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{content ? ( {content ? (
<> <div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
{isWikiSource && ( {isWikiSource && (
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300"> <div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<BookOpenTextIcon className="size-4" /> <BookOpenTextIcon className="size-4" />
@@ -272,34 +235,17 @@ export function SourceCard({
isLoading={false} isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins} remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins} rehypePlugins={reasoningPlugins.rehypePlugins}
linkClassName={(href) =>
href.startsWith("/weknora-source-preview?")
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
: undefined
}
onLinkClick={(event, href) => {
const target = resolveWeKnoraSourceTarget({ url: href });
if (!target) return;
event.preventDefault();
setLinkedTarget(target);
setDetailOpen(true);
}}
/> />
</article> </article>
</>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的引用内容
</div> </div>
)} ) : null}
{source.url && !previewTarget && ( {source.url && !previewTarget && (
<div className="text-muted-foreground mt-4 border-t pt-3 text-xs break-all"> <div className="text-muted-foreground border-t pt-3 text-xs break-all">
{source.url} {source.url}
</div> </div>
)} )}
</div> </DialogContent>
</SheetContent> </Dialog>}
</Sheet>}
</> </>
); );
} }
@@ -27,7 +27,7 @@ const PHASE_LABELS: Record<string, string> = {
writing_sources: "上传知识资料", writing_sources: "上传知识资料",
generating_wiki: "分析并生成分类 Wiki", generating_wiki: "分析并生成分类 Wiki",
vectorizing_wiki: "Wiki 向量化", vectorizing_wiki: "Wiki 向量化",
syncing_assistant: "同步检索索引", syncing_assistant: "同步知识梳理总库",
activating: "激活新版本", activating: "激活新版本",
completed: "完成", completed: "完成",
}; };
@@ -219,14 +219,14 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && ( {!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground"> <p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora" {runtime.data?.provider !== "weknora"
? "当前未配置普通知识库地址;请先配置知识服务地址,再归纳技能内容。" ? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"} : "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p> </p>
)} )}
</div> </div>
</div> </div>
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground"> <div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及所需索引同步均在后台完成。 第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
</div> </div>
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}> <Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />} {actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
@@ -226,9 +226,7 @@ export function SkillDetailDialog({
style={{ style={{
width: "min(48rem, calc(100vw - 2rem))", width: "min(48rem, calc(100vw - 2rem))",
maxWidth: "none", maxWidth: "none",
minHeight: "min(420px, calc(100vh - 2rem))", height: "min(760px, calc(100dvh - 2rem))",
height: "min(760px, calc(100vh - 2rem))",
maxHeight: "min(760px, calc(100vh - 2rem))",
}} }}
> >
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left"> <DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
@@ -149,9 +149,7 @@ export function SkillFilePreviewDialog({
style={{ style={{
width: "min(56rem, calc(100vw - 2rem))", width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none", maxWidth: "none",
minHeight: "min(420px, calc(100vh - 2rem))", height: "min(720px, calc(100dvh - 2rem))",
height: "min(720px, calc(100vh - 2rem))",
maxHeight: "min(720px, calc(100vh - 2rem))",
}} }}
> >
<DialogHeader className="shrink-0 border-b px-5 py-3"> <DialogHeader className="shrink-0 border-b px-5 py-3">
@@ -8,31 +8,21 @@ export interface TagColorDef {
chip: string; chip: string;
/** A solid swatch used in the colour picker. */ /** A solid swatch used in the colour picker. */
swatch: string; swatch: string;
/** Real border hex — Tailwind `border-*-200` is often not generated, so `* { border-color: var(--border) }` greys every chip. */
border: string;
borderDark: string;
} }
export const TAG_COLORS: TagColorDef[] = [ export const TAG_COLORS: TagColorDef[] = [
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400", border: "#d1d5db", borderDark: "#4b5563" }, { name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500", border: "#fca5a5", borderDark: "#7f1d1d" }, { name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500", border: "#fdba74", borderDark: "#7c2d12" }, { name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500", border: "#fcd34d", borderDark: "#78350f" }, { name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500", border: "#86efac", borderDark: "#14532d" }, { name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500", border: "#5eead4", borderDark: "#134e4a" }, { name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500", border: "#93c5fd", borderDark: "#1e3a8a" }, { name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500", border: "#a5b4fc", borderDark: "#312e81" }, { name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500", border: "#d8b4fe", borderDark: "#581c87" }, { name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500", border: "#f9a8d4", borderDark: "#831843" }, { name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
]; ];
export function tagBorderStyle(color: TagColorDef) {
return {
"--lam-tag-border": color.border,
"--lam-tag-border-dark": color.borderDark,
} as Record<string, string>;
}
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c])); const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
export function resolveTagColor(name: string | undefined | null): TagColorDef { export function resolveTagColor(name: string | undefined | null): TagColorDef {
@@ -1,214 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as maplibregl from "maplibre-gl";
import type { StyleSpecification } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
import type { MapSpec } from "./visualization-spec";
import { VisualizationCard } from "./visualization-card";
function createStyle(): StyleSpecification {
return {
version: 8,
sources: {
"google-raster": {
type: "raster",
tiles: [getMapTileUrl()],
tileSize: 256,
attribution: getMapTileAttribution(),
},
},
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
};
}
function allCoordinates(spec: MapSpec): Array<[number, number]> {
return [
...spec.markers.map((marker) => marker.coordinate),
...spec.lines.flatMap((line) => line.coordinates),
...spec.polygons.flatMap((polygon) => polygon.coordinates),
];
}
function defaultCenter(spec: MapSpec): [number, number] {
if (spec.center) return spec.center;
const coordinates = allCoordinates(spec);
if (coordinates.length === 0) return [104.1954, 35.8617];
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
return [total[0] / coordinates.length, total[1] / coordinates.length];
}
export default function MapRenderer({ spec }: { spec: MapSpec }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [tileFailed, setTileFailed] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
setTileFailed(false);
const map = new maplibregl.Map({
container,
style: createStyle(),
center: defaultCenter(spec),
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
minZoom: 1,
maxZoom: 20,
attributionControl: { compact: true },
});
mapRef.current = map;
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
const markers: maplibregl.Marker[] = [];
for (const marker of spec.markers) {
const element = document.createElement("button");
element.type = "button";
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
element.title = marker.name;
element.setAttribute("aria-label", marker.name);
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
);
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
}
let baseTileLoaded = false;
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
if (event.sourceId !== "google-raster" || event.sourceDataType !== "content") return;
baseTileLoaded = true;
setTileFailed(false);
};
const onError = (event: maplibregl.ErrorEvent) => {
const message = event.error?.message ?? "";
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
};
map.on("sourcedata", onSourceData);
map.on("error", onError);
map.on("load", () => {
if (spec.lines.length > 0) {
map.addSource("visual-lines", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.lines.map((line) => ({
type: "Feature",
properties: { name: line.name ?? "" },
geometry: { type: "LineString", coordinates: line.coordinates },
})),
},
});
map.addLayer({
id: "visual-lines",
type: "line",
source: "visual-lines",
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
});
}
if (spec.polygons.length > 0) {
map.addSource("visual-polygons", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.polygons.map((polygon) => ({
type: "Feature",
properties: { name: polygon.name ?? "" },
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
})),
},
});
map.addLayer({
id: "visual-polygons-fill",
type: "fill",
source: "visual-polygons",
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
});
map.addLayer({
id: "visual-polygons-border",
type: "line",
source: "visual-polygons",
paint: { "line-color": "#2563eb", "line-width": 2 },
});
}
if (!spec.center && coordinates.length > 1) {
const bounds = coordinates.reduce(
(current, coordinate) => current.extend(coordinate),
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
);
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
}
});
return () => {
map.off("sourcedata", onSourceData);
map.off("error", onError);
markers.forEach((marker) => marker.remove());
mapRef.current = null;
map.remove();
};
}, [coordinates, reloadToken, spec]);
return (
<VisualizationCard>
<div className="flex min-h-12 items-center gap-2 border-b px-3 py-2">
<MapPinned aria-hidden="true" className="size-4 shrink-0 text-primary" />
<span className="truncate text-sm font-medium">{spec.title}</span>
<span className="ml-auto text-xs text-muted-foreground">{spec.markers.length} 个地点</span>
</div>
<div className="relative">
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
{tileFailed ? (
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<span className="flex-1">地图底图当前不可访问;请检查网络。地点和线路数据不会丢失。</span>
<Button
aria-label="重新加载地图"
className="shrink-0 text-amber-950 hover:bg-amber-100"
onClick={() => {
setTileFailed(false);
setReloadToken((value) => value + 1);
}}
size="sm"
variant="ghost"
>
<RefreshCw aria-hidden="true" />
重试
</Button>
</div>
) : null}
</div>
{spec.markers.length > 0 ? (
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看地点列表</summary>
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`}>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</li>
))}
</ul>
</details>
) : null}
</VisualizationCard>
);
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
@@ -1,682 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Background,
BackgroundVariant,
Handle,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Download,
FileJson,
ImageDown,
LocateFixed,
Maximize2,
Minus,
Plus,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { renderSvgElementToPngBlob, triggerBlobDownload } from "@/core/utils/mermaid";
import { cn } from "@/lib/utils";
import { VisualizationCard } from "./visualization-card";
import type { MindmapNodeKind, MindmapSpec } from "./visualization-spec";
const NODE_WIDTH = 184;
const NODE_HEIGHT = 68;
const LAYER_GAP = 244;
const ROW_GAP = 88;
const PREVIEW_DEPTH = 2;
const KIND_META: Record<MindmapNodeKind, { label: string; className: string }> = {
topic: { label: "主题", className: "border-primary/50 bg-primary/10" },
dimension: { label: "维度", className: "border-sky-500/45 bg-sky-500/10" },
finding: { label: "发现", className: "border-violet-500/45 bg-violet-500/10" },
evidence: { label: "依据", className: "border-emerald-500/45 bg-emerald-500/10" },
risk: { label: "风险", className: "border-amber-500/50 bg-amber-500/10" },
action: { label: "建议", className: "border-rose-500/45 bg-rose-500/10" },
};
type MindmapNode = MindmapSpec["nodes"][number];
type LayoutNode = MindmapNode & {
depth: number;
x: number;
y: number;
hiddenCount: number;
hasChildren: boolean;
collapsed: boolean;
};
type MindmapLayout = {
nodes: LayoutNode[];
edges: Array<{ id: string; from: string; to: string }>;
width: number;
height: number;
};
function childrenByParent(spec: MindmapSpec) {
const children = new Map<string, MindmapNode[]>();
for (const node of spec.nodes) {
if (node.parentId === null) continue;
const list = children.get(node.parentId) ?? [];
list.push(node);
children.set(node.parentId, list);
}
return children;
}
function getRoot(spec: MindmapSpec): MindmapNode {
return spec.nodes.find((node) => node.parentId === null)!;
}
function descendantsCount(id: string, children: Map<string, MindmapNode[]>): number {
let count = 0;
for (const child of children.get(id) ?? []) {
count += 1 + descendantsCount(child.id, children);
}
return count;
}
/**
* A rooted analysis tree uses a left-to-right layout in messages. It reads more
* naturally than a radial graph inside a narrow chat column and stays stable
* when the same persisted message is replayed.
*/
function layoutMindmap(
spec: MindmapSpec,
maxDepth?: number,
collapsedIds: ReadonlySet<string> = new Set(),
): MindmapLayout {
const children = childrenByParent(spec);
const root = getRoot(spec);
const visible = new Map<string, { node: MindmapNode; depth: number }>();
const collect = (node: MindmapNode, depth: number) => {
visible.set(node.id, { node, depth });
if ((maxDepth !== undefined && depth >= maxDepth) || collapsedIds.has(node.id)) return;
for (const child of children.get(node.id) ?? []) collect(child, depth + 1);
};
collect(root, 0);
const unitHeight = (id: string): number => {
const childIds = (children.get(id) ?? []).filter((child) => visible.has(child.id));
if (childIds.length === 0) return 1;
return childIds.reduce((sum, child) => sum + unitHeight(child.id), 0);
};
const positions = new Map<string, { x: number; y: number }>();
const assign = (id: string, depth: number, top: number): number => {
const ownUnits = unitHeight(id);
positions.set(id, {
x: depth * LAYER_GAP,
y: top + ((ownUnits - 1) * ROW_GAP) / 2,
});
let nextTop = top;
for (const child of children.get(id) ?? []) {
if (!visible.has(child.id)) continue;
nextTop = assign(child.id, depth + 1, nextTop) + ROW_GAP;
}
return top + (ownUnits - 1) * ROW_GAP;
};
assign(root.id, 0, 0);
const nodes = [...visible.values()].map(({ node, depth }) => ({
...node,
depth,
x: positions.get(node.id)?.x ?? 0,
y: positions.get(node.id)?.y ?? 0,
hiddenCount:
(maxDepth !== undefined && depth === maxDepth) || collapsedIds.has(node.id)
? descendantsCount(node.id, children)
: 0,
hasChildren: (children.get(node.id) ?? []).length > 0,
collapsed: collapsedIds.has(node.id),
}));
const nodeIds = new Set(nodes.map((node) => node.id));
const edges = nodes
.filter((node) => node.parentId && nodeIds.has(node.parentId))
.map((node) => ({ id: `${node.parentId}->${node.id}`, from: node.parentId!, to: node.id }));
const maxX = Math.max(...nodes.map((node) => node.x), 0);
const maxY = Math.max(...nodes.map((node) => node.y), 0);
return {
nodes,
edges,
width: maxX + NODE_WIDTH + 64,
height: maxY + NODE_HEIGHT + 64,
};
}
type FlowNodeData = {
node: LayoutNode;
selected: boolean;
onToggle?: (id: string) => void;
};
function MindmapFlowNode({ data }: NodeProps) {
const { node, selected, onToggle } = data as FlowNodeData;
const meta = KIND_META[node.kind];
return (
<div
className={cn(
"relative w-[184px] rounded-xl border bg-background px-3 py-2 shadow-sm transition-shadow",
"hover:shadow-md focus-within:ring-2 focus-within:ring-primary/50",
meta.className,
selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
)}
title={node.label}
>
{node.parentId ? <Handle type="target" position={Position.Left} className="!size-1.5 !border-0 !bg-border" /> : null}
<div className="mb-1 flex items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
<span className="font-medium">{meta.label}</span>
{node.hiddenCount > 0 ? (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+{node.hiddenCount}
</span>
) : null}
</div>
<div className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{node.label}</div>
{node.hasChildren ? (
<>
<Handle type="source" position={Position.Right} className="!size-6 !border-0 !bg-transparent" />
<button
type="button"
className="absolute -right-3 top-1/2 z-20 flex size-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-400 bg-background text-slate-600 shadow-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={`${node.collapsed ? "展开" : "收起"}${node.label}`}
aria-expanded={!node.collapsed}
title={node.collapsed ? "展开子节点" : "收起子节点"}
onClick={(event) => {
event.stopPropagation();
onToggle?.(node.id);
}}
>
{node.collapsed ? <Plus aria-hidden="true" className="size-3.5" /> : <Minus aria-hidden="true" className="size-3.5" />}
</button>
</>
) : null}
</div>
);
}
const NODE_TYPES = { mindmap: MindmapFlowNode };
function MindmapCanvasControls({
compact,
onClose,
}: {
compact: boolean;
onClose?: () => void;
}) {
const rf = useReactFlow();
const buttonClassName = compact ? "size-7" : "size-8";
const padding = compact ? 0.16 : 0.12;
return (
<Panel position="top-right" className="flex items-center gap-1 rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur">
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="放大"
aria-label="放大思维导图"
onClick={() => void rf.zoomIn({ duration: 150 })}
>
<Plus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="缩小"
aria-label="缩小思维导图"
onClick={() => void rf.zoomOut({ duration: 150 })}
>
<Minus aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="重置位置与缩放"
aria-label="重置思维导图位置与缩放"
onClick={() => void rf.fitView({ duration: 220, padding })}
>
<LocateFixed aria-hidden="true" className="size-4" />
</Button>
{onClose ? (
<>
<span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="icon"
className={buttonClassName}
title="关闭"
aria-label="关闭思维导图"
onClick={onClose}
>
<X aria-hidden="true" className="size-4" />
</Button>
</>
) : null}
</Panel>
);
}
function overviewNodeColor(kind: MindmapNodeKind): string {
return svgColors(kind).fill;
}
function MindmapOverview({ layout }: { layout: MindmapLayout }) {
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]));
return (
<Panel position="bottom-right" className="overflow-hidden rounded-lg border bg-background/95 p-1 shadow-md backdrop-blur">
<div className="mb-1 px-1.5 pt-0.5 text-[10px] font-medium text-muted-foreground">全图预览</div>
<svg
className="block h-28 w-44 rounded bg-muted/30"
viewBox={`0 0 ${layout.width} ${layout.height}`}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="思维导图全图预览"
>
{layout.edges.map((edge) => {
const from = nodesById.get(edge.from);
const to = nodesById.get(edge.to);
if (!from || !to) return null;
return (
<path
key={edge.id}
d={`M ${from.x + NODE_WIDTH} ${from.y + NODE_HEIGHT / 2} L ${to.x} ${to.y + NODE_HEIGHT / 2}`}
fill="none"
stroke="#64748b"
strokeWidth="4"
strokeOpacity="0.8"
/>
);
})}
{layout.nodes.map((node) => (
<rect
key={node.id}
x={node.x}
y={node.y}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx="10"
fill={overviewNodeColor(node.kind)}
stroke="#94a3b8"
strokeWidth="2"
/>
))}
</svg>
</Panel>
);
}
function MindmapCanvas({
spec,
preview,
selectedId,
onSelect,
onClose,
}: {
spec: MindmapSpec;
preview: boolean;
selectedId?: string | null;
onSelect?: (id: string) => void;
onClose?: () => void;
}) {
const rf = useReactFlow();
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => new Set());
const toggleNode = useCallback((id: string) => {
setCollapsedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const layout = useMemo(
() => layoutMindmap(spec, preview ? PREVIEW_DEPTH : undefined, collapsedIds),
[collapsedIds, preview, spec],
);
const nodes = useMemo<Node[]>(
() =>
layout.nodes.map((node) => ({
id: node.id,
type: "mindmap",
position: { x: node.x, y: node.y },
width: NODE_WIDTH,
height: NODE_HEIGHT,
data: { node, selected: node.id === selectedId, onToggle: toggleNode } satisfies FlowNodeData,
})),
[layout.nodes, selectedId, toggleNode],
);
const edges = useMemo<Edge[]>(
() =>
layout.edges.map((edge) => ({
id: edge.id,
source: edge.from,
target: edge.to,
type: "smoothstep",
style: { stroke: "#64748b", strokeWidth: 2, strokeOpacity: 0.8 },
})),
[layout.edges],
);
const handleNodeClick = useCallback((id: string) => {
const node = layout.nodes.find((item) => item.id === id);
if (node?.hasChildren) {
toggleNode(id);
return;
}
onSelect?.(id);
}, [layout.nodes, onSelect, toggleNode]);
useEffect(() => {
const timer = window.setTimeout(() => {
void rf.fitView({ padding: preview ? 0.16 : 0.12, duration: 180 });
}, 0);
return () => window.clearTimeout(timer);
}, [collapsedIds, preview, rf, spec]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: preview ? 0.16 : 0.12 }}
minZoom={0.12}
maxZoom={2}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={Boolean(onSelect)}
panOnDrag
panOnScroll={false}
zoomOnScroll
zoomOnDoubleClick
zoomOnPinch
preventScrolling
onNodeClick={(_, node) => handleNodeClick(node.id)}
aria-label={`${spec.title}思维导图`}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--border)" />
<MindmapCanvasControls compact={preview} onClose={onClose} />
{!preview ? <MindmapOverview layout={layout} /> : null}
</ReactFlow>
);
}
function svgEscape(value: string): string {
return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[char]!);
}
function splitSvgLabel(label: string): string[] {
const chunks = label.match(/.{1,16}/gu) ?? [label];
return chunks.slice(0, 2);
}
function buildMindmapSvg(spec: MindmapSpec): string {
const layout = layoutMindmap(spec);
const offset = 32;
const lines = layout.edges
.map((edge) => {
const from = layout.nodes.find((node) => node.id === edge.from)!;
const to = layout.nodes.find((node) => node.id === edge.to)!;
const x1 = from.x + NODE_WIDTH + offset;
const y1 = from.y + NODE_HEIGHT / 2 + offset;
const x2 = to.x + offset;
const y2 = to.y + NODE_HEIGHT / 2 + offset;
const control = Math.max(42, (x2 - x1) / 2);
return `<path d="M ${x1} ${y1} C ${x1 + control} ${y1}, ${x2 - control} ${y2}, ${x2} ${y2}" fill="none" stroke="#94a3b8" stroke-width="1.5"/>`;
})
.join("");
const nodes = layout.nodes
.map((node) => {
const meta = KIND_META[node.kind];
const { fill, stroke } = svgColors(node.kind);
const lines = splitSvgLabel(node.label)
.map((line, index) => `<tspan x="${node.x + offset + 14}" dy="${index === 0 ? 0 : 18}">${svgEscape(line)}</tspan>`)
.join("");
return [
`<g>`,
`<rect x="${node.x + offset}" y="${node.y + offset}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="12" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 18}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="10" fill="#64748b">${meta.label}</text>`,
`<text x="${node.x + offset + 14}" y="${node.y + offset + 42}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="13" font-weight="600" fill="#0f172a">${lines}</text>`,
`</g>`,
].join("");
})
.join("");
return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width + offset}" height="${layout.height + offset}" viewBox="0 0 ${layout.width + offset} ${layout.height + offset}" role="img" aria-label="${svgEscape(spec.title)}"><rect width="100%" height="100%" fill="#ffffff"/><text x="32" y="26" font-family="Microsoft YaHei, Arial, sans-serif" font-size="16" font-weight="700" fill="#0f172a">${svgEscape(spec.title)}</text>${lines}${nodes}</svg>`;
}
function svgColors(kind: MindmapNodeKind): { fill: string; stroke: string } {
switch (kind) {
case "topic": return { fill: "#eff6ff", stroke: "#60a5fa" };
case "dimension": return { fill: "#f0f9ff", stroke: "#38bdf8" };
case "evidence": return { fill: "#ecfdf5", stroke: "#34d399" };
case "risk": return { fill: "#fffbeb", stroke: "#fbbf24" };
case "action": return { fill: "#fff1f2", stroke: "#fb7185" };
default: return { fill: "#f5f3ff", stroke: "#a78bfa" };
}
}
function safeDownloadName(title: string, extension: string): string {
const clean = title.replace(/[\\/:*?"<>|]/g, "-").trim().slice(0, 80) || "分析思维导图";
return `${clean}.${extension}`;
}
function MindmapDownloads({ spec }: { spec: MindmapSpec }) {
const [downloading, setDownloading] = useState<"png" | "svg" | "json" | null>(null);
const [open, setOpen] = useState(false);
const download = useCallback(async (format: "png" | "svg" | "json") => {
setDownloading(format);
try {
if (format === "json") {
triggerBlobDownload(
new Blob([JSON.stringify(spec, null, 2)], { type: "application/json;charset=utf-8" }),
safeDownloadName(spec.title, "mindmap.json"),
);
setOpen(false);
return;
}
const svgMarkup = buildMindmapSvg(spec);
if (format === "svg") {
triggerBlobDownload(
new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" }),
safeDownloadName(spec.title, "svg"),
);
setOpen(false);
return;
}
const host = document.createElement("div");
host.style.cssText = "position:fixed;left:-10000px;top:-10000px;pointer-events:none;opacity:0";
host.innerHTML = svgMarkup;
document.body.appendChild(host);
try {
const svg = host.querySelector("svg");
if (!(svg instanceof SVGSVGElement)) throw new Error("无法生成思维导图图片");
const blob = await renderSvgElementToPngBlob(svg);
triggerBlobDownload(blob, safeDownloadName(spec.title, "png"));
setOpen(false);
} finally {
host.remove();
}
} catch (error) {
console.error("Failed to download mindmap", error);
toast.error(error instanceof Error ? error.message : "下载思维导图失败");
} finally {
setDownloading(null);
}
}, [spec]);
return (
<>
<Button
type="button"
variant="outline"
size="icon"
className="size-9"
title="下载思维导图"
aria-label="下载思维导图"
onClick={() => setOpen(true)}
>
<Download aria-hidden="true" className="size-4" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm p-5">
<DialogHeader className="text-left">
<DialogTitle>下载思维导图</DialogTitle>
<DialogDescription>选择需要导出的文件格式。</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("png")}
disabled={downloading !== null}
>
<ImageDown aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">PNG 图片</span>
<span className="block text-xs font-normal text-muted-foreground">
{downloading === "png" ? "正在生成…" : "便于在文档或图片中使用"}
</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("svg")}
disabled={downloading !== null}
>
<Download aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">SVG 矢量图</span>
<span className="block text-xs font-normal text-muted-foreground">适合无损缩放和二次编辑</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
onClick={() => void download("json")}
disabled={downloading !== null}
>
<FileJson aria-hidden="true" className="size-4 shrink-0" />
<span>
<span className="block text-sm font-medium">JSON 数据</span>
<span className="block text-xs font-normal text-muted-foreground">保留完整节点结构</span>
</span>
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
function MindmapDialog({
open,
onOpenChange,
spec,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
spec: MindmapSpec;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[calc(100dvh-2rem)] !w-[calc(100vw-2rem)] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden rounded-xl p-0 sm:!max-w-[calc(100vw-2rem)]"
>
<DialogTitle className="sr-only">{spec.title}</DialogTitle>
<DialogDescription className="sr-only">
思维导图画布。支持鼠标滚轮缩放、拖动画布,并可使用右上角按钮调整视图。
</DialogDescription>
<div className="relative min-h-0 flex-1 bg-muted/20">
<div className="absolute inset-0">
<ReactFlowProvider>
<MindmapCanvas spec={spec} preview={false} onClose={() => onOpenChange(false)} />
</ReactFlowProvider>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default function MindmapRenderer({ spec }: { spec: MindmapSpec }) {
const [open, setOpen] = useState(false);
const root = getRoot(spec);
const ariaSummary = spec.nodes.map((node) => `${KIND_META[node.kind].label}:${node.label}`).join(";");
return (
<>
<VisualizationCard className="my-4" >
<section aria-label={`${spec.title}思维导图`}>
<div className="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold">{spec.title}</h2>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">思维导图</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{spec.summary || `围绕“${root.label}”整理的 ${spec.nodes.length} 个分析节点。`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<MindmapDownloads spec={spec} />
<Button type="button" size="sm" className="h-8 gap-1.5" onClick={() => setOpen(true)}>
<Maximize2 aria-hidden="true" className="size-3.5" />
展开查看
</Button>
</div>
</div>
<p className="sr-only">{ariaSummary}</p>
<div className="h-[20rem] bg-muted/15">
<ReactFlowProvider>
<MindmapCanvas
spec={spec}
preview
onSelect={() => {
setOpen(true);
}}
/>
</ReactFlowProvider>
</div>
<div className="flex items-center justify-between gap-3 border-t px-4 py-2 text-xs text-muted-foreground">
<span>预览显示前 {PREVIEW_DEPTH + 1} 层;完整导图可缩放查看。</span>
<span>{spec.nodes.length} 个节点</span>
</div>
</section>
</VisualizationCard>
<MindmapDialog open={open} onOpenChange={setOpen} spec={spec} />
</>
);
}
Loaded 100 of 1900 files, more files were not shown because too many files have changed in this diff. Show more