Compare commits

..
3 Commits
810 changed files with 10642 additions and 79887 deletions

No files matched your search

+16 -39
View File
@@ -69,10 +69,9 @@ 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/data/*.db-* offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/* offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local offline-backend-20260512/backend/.env.local
offline-backend-20260512/backend/.extensions_config.json.lock offline-backend-20260512/start_backend.sh
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh offline-backend-20260512/check_backend.sh
@@ -88,18 +87,14 @@ offline-backend-20260512/backend/skill_evolution.json
skills/ skills/
hermes-agent-main/ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_large_file_safety.py # Keep wx common-agent category configuration regressions versioned.
# Keep answer-level feedback persistence, scoring, resolver, and migration regressions versioned.
!offline-backend-20260512/backend/tests/test_answer_feedback.py
# Keep wx common-agent category configuration regressions versioned.
!offline-backend-20260512/backend/tests/test_wx_common_agents.py !offline-backend-20260512/backend/tests/test_wx_common_agents.py
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned. # 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_gauss_database.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.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_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_llmwiki_advanced_retrieval.py !offline-backend-20260512/backend/tests/test_knowledge_live_embedding.py
!offline-backend-20260512/backend/tests/test_knowledge_live_embedding.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py !offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned. # Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py !offline-backend-20260512/backend/tests/test_word_export.py
@@ -121,28 +116,19 @@ offline-backend-20260512/backend/tests/*
# Keep the local LLMWiki vector-index and external API regression coverage versioned. # 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_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.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_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_llmwiki_incremental_sync_contract.py !offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py
!offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.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_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py !offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Keep assistant-source snapshot publication atomic across re-import jobs.
!offline-backend-20260512/backend/tests/test_assistant_snapshot_pointer_contract.py
# Deep-research reliability regression coverage. # Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py !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_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.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_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py !offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_llmwiki_title_ranking.py !offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
!offline-backend-20260512/backend/tests/test_menu_overrides.py # Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Keep database-backed upload-policy, enforcement and bounded-preview regressions versioned.
!offline-backend-20260512/backend/tests/test_upload_policy.py
!offline-backend-20260512/backend/tests/test_uploads_policy_enforcement.py
!offline-backend-20260512/backend/tests/test_artifact_preview.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py !offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts). # Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py !offline-backend-20260512/backend/tests/test_position_artifact_cap.py
@@ -199,22 +185,13 @@ codegraph.mdc
docs/aidev-docs docs/aidev-docs
docs/backups docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above) # Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/ !frontend-web/src/components/workspace/skills/
# Built-in analysis mind-map skill is product source, not a per-user runtime skill.
!offline-backend-20260512/backend/skills/
offline-backend-20260512/backend/skills/*
!offline-backend-20260512/backend/skills/public/
offline-backend-20260512/backend/skills/public/*
!offline-backend-20260512/backend/skills/public/analysis-mindmap/
!offline-backend-20260512/backend/skills/public/analysis-mindmap/SKILL.md
# uv lockfile (not tracked per project preference) # uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py !offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
!offline-backend-20260512/backend/tests/test_llmwiki_incremental_sync_scheduler.py
# Local-only scratch directory (not committed) # Local-only scratch directory (not committed)
0622/ 0622/
+1 -2
View File
@@ -90,12 +90,11 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin defaults + personal layouts)**: `MenuManagementPage` (`pages/MenuManagementPage.tsx`) is available from「设置和更多」and the global Header account dropdown. Administrators edit the shared default menu; ordinary users edit only their own full/compact menu snapshot. For users with a saved snapshot, known node ids keep the personal `label`/`disabled`/`parentId`/`sortOrder` (or compact placement/order), while administrator defaults for node ids absent from that snapshot flow through—so newly-added code menus and iframe light apps follow the current default without overwriting older personal choices. Temporarily absent dynamic nodes are preserved when an unrelated personal edit is saved;「恢复默认」deletes only that user's snapshot. The static tree (`core/page-layout/sidebar-menu.ts`) remains the source of icons/routes and administrator defaults remain sparse per-node deltas. Merge/snapshot helpers live in `core/page-layout/menu-overrides.ts` and `compact-menu-overrides.ts`; full-menu writes carry the complete effective parent hierarchy for cycle/strict three-level validation while persisting only the sparse admin delta or full personal snapshot. Backend `/api/menu-overrides` uses `menu_overrides` (default) + `user_menu_override_configs` (per-user snapshot); `/api/compact-menu-overrides` uses `compact_menu_overrides` (type `A` / `B` defaults) + `user_compact_menu_override_configs` (per-user/type snapshots). Each website selects its compact type via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`; `CompactToolbox` renders `toolbox` items. - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** uses a separate overlay: every compact workspace menu item can be placed in `sidebar`(左侧菜单)、`settings`(设置和更多)or `toolbox`(右侧工具箱); `CompactToolbox` (`components/workspace/compact-toolbox.tsx`, mounted by `PageRoutes.tsx`) opens only when the square right-edge trigger is clicked, then slides a compact, content-height right-side drawer over a backdrop above global dropdowns; configured items render top-to-bottom as centered square cards with a large icon above the label while the effective layout is compact. `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。 - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。 - **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。
- **智能体使用引导 (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 Availability, owner/admin)**: the global `Skill.enabled` flag is the online/offline switch. Taking a skill offline persists `skills.<name>.enabled=false` in `extensions_config.json` but does **not** delete its directory or remove it from existing agent/workflow configurations; the row remains visible and bringing it online restores those references. Administrators may toggle every skill from 「技能管理」; ordinary users may toggle only their own non-built-in `custom` skills from 「技能 → 技能列表 → 我的」. Offline skills must be absent from every model-facing skill catalogue (`lead` prompt, `search_skills`, `skill_list`, `skill_view`, subagents, AI writing/deep research/roundtable and Workflow run-start validation), from agent skill pickers, and from direct sandbox access: `read_file`/`ls`/`glob`/`grep`/`bash` reject offline, unknown, root-wide and category-wide `/mnt/skills` paths. The backend mutation is `PUT /api/skills/{name}`, authorizes either an administrator or the persisted owner of the target custom skill, patches the raw document so MCP placeholders and unrelated extension fields survive, uses atomic replacement when supported plus a Docker single-file-bind fallback, and refreshes the skill prompt cache. Launchers keep the runtime extensions file in a writable persisted directory. Existing agent/strategy-agent references stay visible as 「已下线」 and may be removed but not newly selected.
- **技能地址管理 (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, 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`.
- **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`
@@ -1,188 +0,0 @@
# 报告协作工作台 — 交互重设计方案(v2)
> 日期:2026-09-07
> 状态:**待确认**(确认后按 §7 分期实施)
> 取代:现行「规则澄清 + 三张模板方案卡 + 画布为主」的交互
> 原则:回到最初设计 —— **协调者智能体全程与用户对话的主线**
---
## 1. 设计目标与总体形态
用户视角只有一条主线:**和一个规划师智能体对话 → 它摸清意图并规划工作流 → 确认后执行、过程在流程图上可见 → 随时插话干预、指定智能体重跑 → 最终成稿、继续对话式修改**。
```
┌─────────────────────────────────────────────────────────┐
│ 阶段1 计划模式 阶段2/3 执行+干预 阶段4 成稿 │
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
│ │ 规划师对话 │ 确认 │ 聊天时间线 │ 审稿过 │ 对话改稿 │ │
│ │ 需求摘要卡 │ ─────→ │ +成员Tab │ ─────→ │ 版本/diff│ │
│ │ 工作流卡 │ 冻结 │ +流程图动画│ 报告卡 │ 上游重跑 │ │
│ └───────────┘ └───────────┘ └──────────┘ │
│ ↑ 回到计划模式(replan)随时可触发 ↑ │
└─────────────────────────────────────────────────────────┘
```
与现行的核心差异:
| 维度 | 现行(做偏的) | v2 |
|------|----------------|-----|
| 需求澄清 | 规则模板问句秒回 | 规划师智能体(LLM)领域化提问 |
| 工作流来源 | 3 张确定性模板 | LLM 按意图生成自定义 DAG,对话迭代修改 |
| 执行展示 | 画布为主、聊天为辅 | **聊天时间线为主**(成员 Tab 切换保留),**执行状态在流程图上动画呈现** |
| 干预 | 命令确认卡体系 | 输入框常开 → 协调者智能体分类;支持**指定智能体重跑** |
| 报告修改 | 改写候选(纯文字层) | 意图分流:润色走改写;**事实性问题触发上游节点重跑** |
---
## 2. 阶段 1:计划模式 — 需求梳理与工作流规划
### 2.1 计划模式状态
- 用户发出第一条需求消息起进入**计划模式**,Header 显示徽章「📋 计划模式」,输入框提示语切换为「梳理需求中,你可以随时补充或纠正…」。
- 计划模式在**工作流确认冻结**时结束;执行期大改可通过 replan 回到计划模式(见 §5.4)。
### 2.2 规划师对话(LLM,替换现行规则路由)
- 必须调用独立内置智能体 `report-collaboration-intent` 的真实 LangGraph thread,并原样消费 `messages-tuple` SSE;禁止用 `ainvoke()` 等待完整 JSON 后再包装成一话。
- 流式正文、思考内容和工具步骤直接进入工作台现有 `MessageList`,与普通智能体问答采用同一套步骤条 / Reasoning 展示。
- 模型:`report_collaboration.planner_model`(回退 `coordinator_model` → 默认模型,复用现有 model_resolver 冻结机制)。
- 提问是领域化的:写市场分析就问「关注整车还是供应链 / 要不要竞品对比 / 给谁看 / 篇幅」,而非固定模板问句。
- 澄清轮次上限沿用 `max_clarification_rounds`;用户随时可以说「直接开始」按默认假设推进。
### 2.3 需求摘要卡
信息足够后,规划师在对话中输出一张**结构化需求摘要卡**(业务卡插槽渲染):主题 / 目的 / 读者 / 角度清单 / 篇幅与重点 / 默认假设。用户逐项看,可在对话里继续改(「读者改成投资人」→ 卡片更新出新版)。
### 2.4 工作流生成与迭代
- 规划师判断意图信息足够后,立即按已确认意图自动生成**一份**工作流方案(不是三选一),不再要求用户额外点击“确认意图/生成方案”;方案以工作流卡(成员构成、各节点使命、预计时长/成本)+ 右侧流程图预览呈现。
- 生成约束(保证可执行):**节点类型受词表约束** — `research / verify / analyze / write / review`;自由的是结构(几条线、并行还是串行、核验深度)与每个节点的使命/角度/工具。
- 对话式修改:「把价格策略单独拆一条线」「加一个政策风险分析」→ 规划师修订后出新版工作流卡(带变更说明),再确认。
- 「开始执行」→ 冻结需求 + 工作流(含模型/预算快照,沿用现有 freeze 机制)。
---
## 3. 阶段 2:执行 — 过程在流程图上可见
### 3.1 流程图动画规格(执行状态的可视化主载体)
节点状态 → 样式(`ReportFlowCanvas` / `ReportFlowNode`,状态源 `selectNodeStatusMap`,事件源 `node.status.changed` SSE):
| 状态 | 节点样式 | 连入边 |
|------|----------|--------|
| planned / queued | 灰色常态 | 静态 |
| **running** | 主色描边 + **呼吸脉冲动画**(ring)+ 角标 spinner | **流动动画**(@xyflow 内置 `animated: true` 的 dash 流动) |
| **completed** | 绿色 + ✓ 角标,常驻 | 恢复静态实线 |
| failed | 红色 + 错误角标 | 红色虚线 |
| awaiting_input | 橙色 + ?角标 | 橙色静态 |
| interrupted / superseded | 半透明降级 | 灰 |
- 语义:**边流动 = 数据正在流入该节点**(该节点执行中);节点完成即停动画换完成样式。
- 实现成本:@xyflow 原生支持 animated 边与自定义节点 className,纯 CSS 补呼吸动画即可,无需新依赖。
### 3.2 聊天区(保留现有 Tab 切换)
- 并行成员沿用 **AgentStageSelector Tab 切换**(维持现状,不改交错时间线)。
- 每个 Tab 内是该成员的完整流式输出:思考、工具调用卡、检索过程、正文 —— 与普通智能体问答同观感(message-adapter 已具备映射能力)。
- 协调者播报(「研究线 A 完成,核心发现 X,开始交叉核验」)进入总时间线 Tab。
---
## 4. 阶段 3:干预 — 对话即干预 + 指定重跑
### 4.1 输入框常开,协调者智能体分流
执行期输入框永远可用,所有输入先到协调者(LLM 分类,规则高精度直判兜底——复用 RC-BE-014 IntentRouter 骨架,把「规则优先」反转为「LLM 优先 + 规则快路径」):
| 用户意图 | 协调者行为 | 生效时机 |
|----------|-----------|----------|
| 提问(「查到什么了?」) | 直接回答 | 立即,不打断 |
| 修正方向(「重点看下沉市场」) | 小改:在安全点注入 overlay,并回复说明 | 下个安全点 |
| **指定重跑**(见 §4.2) | 出确认卡 → 重开节点 attempt | 确认后 |
| 大改(砍线/加线) | 提议 replan → 回计划模式 | 用户确认 |
| 停止 | 直接取消 | 立即 |
### 4.2 指定智能体重跑(新增核心交互)
**场景**:用户发现某节点传给下游的信息不对(如研究线 B 的结论有误)。
两个入口,同一后端动作:
1. **对话**:「研究线 B 的结果不对,重新查,重点看价格战」→ 协调者解析出 `node_id` + 新指令;
2. **画布**:点节点 → 抽屉(NodeInspectorDrawer)加「↻ 重新执行」按钮。
**级联规则**(复用并补全 ledger 的 targeted rework):
```
重跑节点 N(attempt+1,带用户新指令作为 mission overlay)
→ N 完成 → 所有直接/间接依赖 N 产物的下游节点标记 superseded
→ 按 DAG 依赖序逐波重跑(边动画重新流动,用户全程可见)
→ 受影响章节重新生成
```
- 确认卡必须展示**影响面**:「将级联重跑 核验B / 分析 / 写作 共 3 个节点,预计 +8 分钟」。
- 成本保护:级联重跑计入 run 预算(现有 budget 机制),预算不足时明确报因。
---
## 5. 阶段 4:报告成稿与对话式修改
### 5.1 成稿
审稿通过 → 报告卡出现在时间线(点开 ReportPreviewDialog 全文),协调者播报总结(各线核心发现 → 结论如何形成)。
### 5.2 修改意图分流(协调者 LLM 判定)
| 用户说 | 判定 | 动作 |
|--------|------|------|
| 「把第二章结论写得更笃定」 | 文字润色 | 改写候选(现有 rewrite API)→ 对比预览 → apply → 新版本 |
| 「这个数据不对 / 重新收集一下」 | **事实性问题** | 定位来源节点 → **触发该节点重跑(可带新角度)→ 级联 §4.2 → 受影响章节重生成 → 新版本** |
| 「再从供应链角度补一节」 | 结构性新增 | replan:回计划模式改工作流(只增不删已完成部分) |
| 「恢复到上一版」 | 版本回退 | 现有 restore API |
- 版本链:每次 apply / restore / 级联重跑产出均产生新 head,历史完整保留(现有机制)。
- 修改对话里协调者必须先**复述理解**(「你是想让研究线 B 换个角度重查,然后重写第三章,对吗?」)再执行,避免误触发昂贵的级联。
---
## 6. 工程改造清单
### 6.1 保留(本次联调已验证)
- 持久化 13 张表 + durable SSE + `after_seq` 断线续传;幂等键 / 乐观锁 / 预算框架。
- MessageList 复用链路(message-adapter → LangGraph Message → 业务卡插槽)。
- AgentStageSelector Tab、commands 安全点骨架(RC-BE-014)、报告版本三件套(RC-BE-015)、前端 hooks/reducer/selectors 主体。
- 「清空历史」「生成候选方案」等本次新增入口。
### 6.2 后端重做 / 新建
| 项 | 说明 |
|----|------|
| 规划师智能体 | LLM 多轮澄清(替换 `requirement/intent_router.py` 规则路由)+ 需求摘要结构化 + **工作流 LLM 生成**(受 §2.4 词表约束,替换 `planning/controller.py` 三模板)+ 对话式工作流修订 |
| 协调者智能体升级 | 干预分类 LLM 优先;修改意图分流(§5.2);执行前复述确认 |
| 指定重跑 + 级联 | ledger targeted rework 补全级联 superseded/重跑波次;确认卡带影响面计算 |
| AgentScope 适配器修复 | `count_tokens` 协议缺失(上下文压缩必崩);attempt 耗尽终态化(防僵尸 run) |
| 事件补齐 | 边流动所需的节点状态事件已有;补「级联影响预告」事件 |
### 6.3 前端重做 / 新建
| 项 | 说明 |
|----|------|
| 计划模式 UI | Header 徽章、composer 提示切换、需求摘要卡、工作流卡(替换现三张模板卡) |
| 流程图动画 | running 节点呼吸动画 + 入边 `animated`、completed ✓ 样式、级联重跑时边动画重流(§3.1) |
| 节点抽屉 | 加「重新执行」按钮 → 确认卡 |
| 干预交互 | 执行期输入框常开;意图确认卡(含影响面);修改对话复述确认 |
| 布局 | 聊天为主、画布为辅的默认比例微调(不动整体两栏结构) |
---
## 7. 实施分期
| 期 | 内容 | 验收 |
|----|------|------|
| **P0 地基** | AgentScope `count_tokens` 修复;attempt 耗尽终态化;级联重跑的 ledger 补全 | 执行阶段不崩、无僵尸 run、单节点重跑级联正确(后端测试) |
| **P1 计划模式 + 执行可视化** | 规划师 LLM 对话、需求摘要卡、工作流 LLM 生成与迭代、计划模式 UI、流程图动画 | 从新会话到确认工作流全程对话完成;执行时动画正确随状态流转 |
| **P2 干预 + 成稿修改** | 协调者 LLM 分类、指定重跑(两入口 + 影响面确认卡)、报告修改意图分流、上游级联重跑 | 四类干预各自走通;「数据不对→重跑→新章节→新版本」全链路 |
每期结束跑 RC 全量测试(当前基线 233 通过)+ 前端 26 单测,不引入对其他模块的影响。
@@ -229,27 +229,23 @@ sequenceDiagram
```http ```http
POST /api/report-collaboration/sessions POST /api/report-collaboration/sessions
POST /api/intent/init POST /api/report-collaboration/sessions/{sessionId}/messages
POST /api/intent/stream Content-Type: application/json
POST /api/report-collaboration/sessions/{sessionId}/intent-turns
{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null }
``` ```
- 报告页面固定调用内置 `report-collaboration-intent` 智能体;`/api/intent/stream` 原样透传 LangGraph `messages-tuple`,不是等待模型完成后包装响应。 - `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。
- 正文 token、provider reasoning 和工具调用步骤实时写入当前 `MessageList` 气泡;终态后 `/intent-turns` 只负责持久化镜像与状态交接,不再调用模型识别意图。
- 旧 `/sessions/{sessionId}/messages` 在 `empty/clarifying` 阶段返回 `409 USE_STREAMING_INTENT`,防止退回阻塞式 `ainvoke` 路径。
- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。 - 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。
- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。 - 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。
- 意图智能体输出 `INTENT_READY` 后,`POST /intent-turns` 自动创建并完成 `plan_request`;前端无需再展示“确认意图”或“生成候选方案”按钮。
### 5.3 阶段 2:候选方案 ### 5.3 阶段 2:候选方案
```http ```http
# 正常路径:由最后一轮 POST /messages 自动触发规划 POST /api/report-collaboration/sessions/{sessionId}/plan-requests
POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select
``` ```
- `POST /plan-requests` 仅保留给“重新生成”与兼容调用,不是首次规划的必经步骤。
- 规划师尚未判定 `ready` 时直接调用该兼容接口会返回 `422 REQUIREMENT_NOT_READY`,防止跳过意图确认。
- **预览方案**(`previewPlanId`):纯前端,不调 API。 - **预览方案**(`previewPlanId`):纯前端,不调 API。
- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。 - **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。
- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。 - 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。
-3
View File
@@ -59,9 +59,6 @@
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。 # 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。 # 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A # VITE_COMPACT_MENU_TYPE=A
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
# VITE_TURBO_MODE_VISIBLE=false
# VITE_GITHUB_OAUTH_TOKEN= # VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)--- # --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
+7 -10
View File
@@ -1,12 +1,12 @@
# 我的项目 # 项目空间
入口:全量与简洁导航中的「我的项目」,路由 `/page/strategy/qa/projects`。旧地址 `/page/workspace/projects` 仅保留兼容跳转,不再展示独立入口或旧页面。 入口:全量与简洁导航中的「项目空间」,路由 `/page/workspace/projects`。
## 使用流程 ## 使用流程
1. 新建项目,选择普通问答、策略优选或方案生成。三类项目独立保存和展示,方案生成不会复用或标记策略优选项目。 1. 新建项目,填写名称,在问答、策略智能、方案生成中选择一种模式。
2. 选择项目默认智能体,填写目标、约束等项目说明。新建项目对话时仍可换用另一个可访问的智能体。 2. 选择项目默认智能体,填写目标、约束等项目说明。新建项目对话时仍可换用另一个可访问的智能体。
3. 创建后进入项目工作区。普通问答项目可包含多条独立问答;方案项目可包含多条方案记录。最近对话根据 `analysis_project_mode` 回到普通问答分组或方案项目。 3. 创建后进入嵌入式问答工作区,左侧保留普通聊天记录;项目对话也会出现在聊天记录中并返回所属项目。
4. 方案生成可从「工作流配置 · 报告结构」选择、启停、编辑、删除结构,选择后自动填充项目报告结构配置;也可直接编辑大纲及固定/弹性约束。 4. 方案生成可从「工作流配置 · 报告结构」选择、启停、编辑、删除结构,选择后自动填充项目报告结构配置;也可直接编辑大纲及固定/弹性约束。
5. 策略智能项目保存默认 Strategy Lens;每轮依次进行问题重构、各 Lens 独立分析、策略辩论和方案生成,并分为「意图理解」「多视角策略分析」「完整策略方案」三栏。右栏以文档形式给出执行摘要、目标原则、分阶段行动、资源配置、风险应对、评估复盘、形成依据与备选策略切换条件;原始对话和引用仍可切换查看。 5. 策略智能项目保存默认 Strategy Lens;每轮依次进行问题重构、各 Lens 独立分析、策略辩论和方案生成,并分为「意图理解」「多视角策略分析」「完整策略方案」三栏。右栏以文档形式给出执行摘要、目标原则、分阶段行动、资源配置、风险应对、评估复盘、形成依据与备选策略切换条件;原始对话和引用仍可切换查看。
6. 策略智能对话底部提供改变约束、增删视角、重新推演和挑战当前策略快捷动作;项目主页面可直接添加或删除 Lens。 6. 策略智能对话底部提供改变约束、增删视角、重新推演和挑战当前策略快捷动作;项目主页面可直接添加或删除 Lens。
@@ -15,8 +15,7 @@
## 代码与运行 ## 代码与运行
- `src/pages/AnalysisProjectsPage.tsx`:普通问答与方案项目的新建、设置、历史和工作台布局。 - `src/pages/AnalysisProjectsPage.tsx`:项目列表、新建、设置、Lens 配置、历史与工作台布局。
- `src/strategy-selection/StrategyProjectsPage.tsx`:「我的项目」三类项目列表及创建入口。
- `src/pages/analysis-projects/ProjectConversation.tsx`:现有聊天、研究进度、方案画布的项目级组合。 - `src/pages/analysis-projects/ProjectConversation.tsx`:现有聊天、研究进度、方案画布的项目级组合。
- `src/core/analysis-projects/api.ts`:Gateway 项目 API 与按账号隔离的缓存。 - `src/core/analysis-projects/api.ts`:Gateway 项目 API 与按账号隔离的缓存。
- 后端:`/api/analysis-projects`,对话仍通过现有 `/api/threads/{thread_id}/runs/*` 流式接口发送;服务端从不可变归属读取项目配置。 - 后端:`/api/analysis-projects`,对话仍通过现有 `/api/threads/{thread_id}/runs/*` 流式接口发送;服务端从不可变归属读取项目配置。
@@ -41,11 +40,9 @@ python -m pytest tests/test_analysis_projects.py tests/test_deep_research_report
- 简洁模式的「我的项目」列表、策略工作台和普通问答项目恢复工作区功能导航;窄屏提供展开菜单按钮。 - 简洁模式的「我的项目」列表、策略工作台和普通问答项目恢复工作区功能导航;窄屏提供展开菜单按钮。
- 普通问答先创建具名项目,再在项目内创建多个对话。列表读取 `/api/analysis-projects` 的 `qa` 项目,不再读取、标记或自动归入全局普通聊天。 - 普通问答先创建具名项目,再在项目内创建多个对话。列表读取 `/api/analysis-projects` 的 `qa` 项目,不再读取、标记或自动归入全局普通聊天。
- 项目设置更新保留既有知识库绑定。旧「项目空间」入口已移除并跳转「我的项目」;普通问答路由为 `qa/projects/groups/*`,方案生成路由为 `qa/projects/solutions/*`。 - 项目设置更新保留既有知识库绑定。原「项目空间」与官方入口继续可用;我的项目下问答路由为 `qa/projects/groups/*`。
- 策略生成卡片统一先显示思考/执行过程,再显示正文;评判、方案及展开视图沿用相同顺序。 - 策略生成卡片统一先显示思考/执行过程,再显示正文;评判、方案及展开视图沿用相同顺序。
- 策略优选工作台拆为三步:意图与任务定义、策略生成与智能评判、综合方案生成。第二步只保留候选策略和评判选择,第三步集中完成方案生成、Markdown 编辑、项目保存以及 Markdown/Word 直接下载;`?module=solution` 直接进入第三步,历史方案即使任务已修改仍可查看下载。 - 完成评判但尚未勾选策略的项目可以从方案列表进入,在工作台选择后生成;已保存方案在任务修改后仍可打开。旧列表请求不再覆盖新请求状态。
- 资料库把已完成的策略优选综合方案作为独立「策略生成」来源展示,并标注具体策略项目名称;条目可回到策略项目第三步,也可直接下载 Markdown/Word。普通策略项目线程生成的文件沿用原文件下载链路并显示同类来源标识。
- 方案生成使用独立项目和固定报告结构,通过 `deep_research_report` 直接生成并打开方案画布;策略优选项目不再出现在方案生成分类中。
- 知识库管理页读取上游最新目录,避免远端删除后命中旧缓存;检索调用仍共享短期目录缓存。 - 知识库管理页读取上游最新目录,避免远端删除后命中旧缓存;检索调用仍共享短期目录缓存。
- 普通用户访问尚未初始化的助手知识库时显示明确空态及刷新按钮。 - 普通用户访问尚未初始化的助手知识库时显示明确空态及刷新按钮。
+133 -135
View File
@@ -1,135 +1,133 @@
{ {
"name": "deer-flow-frontend-vite", "name": "deer-flow-frontend-vite",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build", "build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit", "build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
"build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test", "build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test",
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts", "test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
"test:answer-feedback": "node --experimental-strip-types --test src/components/workspace/agents/agent-feedback-visibility.test.ts src/components/workspace/messages/answer-feedback-eligibility.test.ts src/strategy-components/api/answer-feedback-batching.test.ts", "test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
"test:retrieval-debug": "node --experimental-strip-types --test src/core/llmwiki/retrieval-debug.test.ts", "test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs", "test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts", "check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs", "check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
"check:chrome109-css": "node scripts/check-chrome109-css.mjs", },
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs" "dependencies": {
}, "@codemirror/lang-css": "^6.3.1",
"dependencies": { "@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/language-data": "^6.5.2",
"@codemirror/lang-python": "^6.2.1", "@dnd-kit/core": "^6.3.1",
"@codemirror/language-data": "^6.5.2", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2",
"@dnd-kit/sortable": "^10.0.0", "@fontsource/noto-sans-sc": "^5.2.9",
"@dnd-kit/utilities": "^3.2.2", "@langchain/core": "^1.1.15",
"@fontsource/noto-sans-sc": "^5.2.9", "@langchain/langgraph-sdk": "^1.5.3",
"@langchain/core": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11",
"@langchain/langgraph-sdk": "^1.5.3", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-scroll-area": "1.2.18", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-slot": "1.3.3", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-use-controllable-state": "^1.2.2",
"@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.17",
"@radix-ui/react-use-controllable-state": "^1.2.2", "@tiptap/core": "^3.23.4",
"@tanstack/react-query": "^5.90.17", "@tiptap/extension-bubble-menu": "^3.23.4",
"@tiptap/core": "^3.23.4", "@tiptap/extension-placeholder": "^3.23.4",
"@tiptap/extension-bubble-menu": "^3.23.4", "@tiptap/extension-underline": "^3.23.4",
"@tiptap/extension-placeholder": "^3.23.4", "@tiptap/pm": "^3.23.4",
"@tiptap/extension-underline": "^3.23.4", "@tiptap/react": "^3.23.4",
"@tiptap/pm": "^3.23.4", "@tiptap/starter-kit": "^3.23.4",
"@tiptap/react": "^3.23.4", "@types/hast": "^3.0.4",
"@tiptap/starter-kit": "^3.23.4", "@uiw/codemirror-theme-basic": "^4.25.4",
"@types/hast": "^3.0.4", "@uiw/codemirror-theme-monokai": "^4.25.4",
"@uiw/codemirror-theme-basic": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4", "@xyflow/react": "^12.10.0",
"@uiw/react-codemirror": "^4.25.4", "ai": "^6.0.33",
"@xyflow/react": "^12.10.0", "axios": "1.16.1",
"ai": "^6.0.33", "best-effort-json-parser": "^1.2.1",
"axios": "1.16.1", "canvas-confetti": "^1.9.4",
"best-effort-json-parser": "^1.2.1", "class-variance-authority": "^0.7.1",
"canvas-confetti": "^1.9.4", "clsx": "^2.1.1",
"class-variance-authority": "^0.7.1", "cmdk": "^1.1.1",
"clsx": "^2.1.1", "codemirror": "^6.0.2",
"cmdk": "^1.1.1", "date-fns": "^4.1.0",
"codemirror": "^6.0.2", "dayjs": "^1.11.21",
"date-fns": "^4.1.0", "dnd-kit": "^0.0.2",
"dayjs": "^1.11.21", "docx": "^9.5.1",
"dnd-kit": "^0.0.2", "dotenv": "^17.2.3",
"docx": "^9.5.1", "embla-carousel-react": "^8.6.0",
"dotenv": "^17.2.3", "file-saver": "^2.0.5",
"embla-carousel-react": "^8.6.0", "gsap": "^3.13.0",
"file-saver": "^2.0.5", "hast": "^1.0.0",
"gsap": "^3.13.0", "katex": "^0.16.28",
"hast": "^1.0.0", "leaflet": "^1.9.4",
"katex": "^0.16.28", "lucide-react": "^0.562.0",
"leaflet": "^1.9.4", "maplibre-gl": "^6.9.0",
"lucide-react": "^0.562.0", "mermaid": "^11.15.0",
"maplibre-gl": "^6.9.0", "motion": "^12.26.2",
"mermaid": "^11.15.0", "nanoid": "^5.1.6",
"motion": "^12.26.2", "next-themes": "^0.4.6",
"nanoid": "^5.1.6", "ogl": "^1.0.11",
"next-themes": "^0.4.6", "react": "^19.0.0",
"ogl": "^1.0.11", "react-dom": "^19.0.0",
"react": "^19.0.0", "react-markdown": "^10.1.0",
"react-dom": "^19.0.0", "react-resizable-panels": "^4.4.1",
"react-markdown": "^10.1.0", "react-router-dom": "^7.9.4",
"react-resizable-panels": "^4.4.1", "react-syntax-highlighter": "^16.1.1",
"react-router-dom": "^7.9.4", "rehype-katex": "^7.0.1",
"react-syntax-highlighter": "^16.1.1", "rehype-raw": "^7.0.0",
"rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1",
"rehype-raw": "^7.0.0", "remark-math": "^6.0.0",
"remark-gfm": "^4.0.1", "shiki": "3.15.0",
"remark-math": "^6.0.0", "sonner": "^2.0.7",
"shiki": "3.15.0", "streamdown": "1.4.0",
"sonner": "^2.0.7", "tailwind-merge": "^3.4.0",
"streamdown": "1.4.0", "tdesign-react": "^1.12.3",
"tailwind-merge": "^3.4.0", "tiptap-markdown": "^0.9.0",
"tdesign-react": "^1.12.3", "tokenlens": "^1.3.1",
"tiptap-markdown": "^0.9.0", "unist-util-visit": "^5.0.0",
"tokenlens": "^1.3.1", "use-stick-to-bottom": "^1.1.1",
"unist-util-visit": "^5.0.0", "uuid": "^14.0.0",
"use-stick-to-bottom": "^1.1.1", "zod": "^3.24.2"
"uuid": "^14.0.0", },
"zod": "^3.24.2" "devDependencies": {
}, "@tailwindcss/postcss": "^4.0.15",
"devDependencies": { "@types/file-saver": "^2.0.7",
"@tailwindcss/postcss": "^4.0.15", "@types/gsap": "^3.0.0",
"@types/file-saver": "^2.0.7", "@types/node": "^20.14.10",
"@types/gsap": "^3.0.0", "@types/react": "^19.0.0",
"@types/node": "^20.14.10", "@types/react-dom": "^19.0.0",
"@types/react": "^19.0.0", "@vitejs/plugin-react": "^5.0.4",
"@types/react-dom": "^19.0.0", "lightningcss": "^1.30.2",
"@vitejs/plugin-react": "^5.0.4", "postcss": "^8.5.3",
"lightningcss": "^1.30.2", "tailwindcss": "^4.0.15",
"postcss": "^8.5.3", "tw-animate-css": "^1.4.0",
"tailwindcss": "^4.0.15", "typescript": "^5.8.2",
"tw-animate-css": "^1.4.0", "vite": "^6.0.0",
"typescript": "^5.8.2", "vite-plugin-source-identifier": "^1.1.2"
"vite": "^6.0.0", },
"vite-plugin-source-identifier": "^1.1.2" "overrides": {
}, "@radix-ui/react-slot": "1.3.3"
"overrides": { },
"@radix-ui/react-slot": "1.3.3" "packageManager": "pnpm@10.26.2"
}, }
"packageManager": "pnpm@10.26.2"
}
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52">
<defs>
<linearGradient id="ap-add" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#f5222d"/>
<stop offset="1" stop-color="#a8071a"/>
</linearGradient>
</defs>
<rect width="52" height="52" rx="8" fill="#ffcfd1"/>
<path fill="url(#ap-add)" d="M26 14a2 2 0 0 1 2 2v8h8a2 2 0 1 1 0 4h-8v8a2 2 0 1 1-4 0v-8h-8a2 2 0 1 1 0-4h8v-8a2 2 0 0 1 2-2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 503 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="52" height="52" viewBox="0 0 52 52"><defs><style>.a{fill:#ffcfd1;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f5222d"/><stop offset="1" stop-color="#a8071a"/></linearGradient></defs><g transform="translate(0 -116.002)"><rect class="a" width="52" height="52" rx="8" transform="translate(0 116.002)"/><g transform="translate(2.236 118.236)"><path class="b" d="M9204.336-22218.762a6.4,6.4,0,0,1-2.489-.691,6.242,6.242,0,0,1-2-1.615,6.363,6.363,0,0,1-1.206-2.283,6.357,6.357,0,0,1-.246-1.863c-.047-.033-.094-.066-.139-.1a6.4,6.4,0,0,1-2.272-3.4,6.418,6.418,0,0,1,.268-4.076,6.336,6.336,0,0,1,1.233-1.912,6.383,6.383,0,0,1-.4-1.973,6.41,6.41,0,0,1,.53-2.809,6.441,6.441,0,0,1,1.7-2.289,6.424,6.424,0,0,1,1.751-1.057,4.969,4.969,0,0,1,.343-1.289,4.965,4.965,0,0,1,1.334-1.859,5.038,5.038,0,0,1,2.016-1.082,5.037,5.037,0,0,1,2.289-.074,5.024,5.024,0,0,1,2.089.943,4.947,4.947,0,0,1,.871.861,5.147,5.147,0,0,1,.876-.861,5,5,0,0,1,2.083-.943,5.035,5.035,0,0,1,2.289.074,5.06,5.06,0,0,1,2.021,1.082,5.031,5.031,0,0,1,1.334,1.859,5.008,5.008,0,0,1,.343,1.289,6.424,6.424,0,0,1,1.751,1.057,6.441,6.441,0,0,1,1.7,2.289,6.405,6.405,0,0,1,.524,2.809,6.434,6.434,0,0,1-.4,1.973,6.315,6.315,0,0,1,1.234,1.912,6.418,6.418,0,0,1,.268,4.076,6.4,6.4,0,0,1-2.272,3.4l-.143.105a6.214,6.214,0,0,1-.248,1.859,6.26,6.26,0,0,1-1.2,2.283,6.271,6.271,0,0,1-2.01,1.615,6.354,6.354,0,0,1-2.485.691c-.14.006-.278.012-.418.012a6.213,6.213,0,0,1-2.139-.375,6.394,6.394,0,0,1-2.2-1.34,6.4,6.4,0,0,1-.915-1.066,6.415,6.415,0,0,1-.91,1.066,6.427,6.427,0,0,1-2.206,1.34,6.283,6.283,0,0,1-2.143.377C9204.609-22218.748,9204.473-22218.752,9204.336-22218.762Zm6.768-6.342a4.232,4.232,0,0,0,.346,1.652,4.123,4.123,0,0,0,.972,1.379,4.116,4.116,0,0,0,1.44.877,4.062,4.062,0,0,0,1.67.234,4.159,4.159,0,0,0,1.63-.447,4.159,4.159,0,0,0,1.313-1.061,4.227,4.227,0,0,0,.787-1.49c.02-.07.037-.139.054-.209a6.32,6.32,0,0,1-1.422.164,1.1,1.1,0,0,1-1.1-1.1,1.1,1.1,0,0,1,1.1-1.1,4.178,4.178,0,0,0,2.529-.859,4.138,4.138,0,0,0,1.485-2.221,4.216,4.216,0,0,0-.173-2.67,4.2,4.2,0,0,0-1.765-2.01,1.1,1.1,0,0,1-.4-1.5,1.1,1.1,0,0,1,1.063-.543,4.16,4.16,0,0,0,.1-.758,4.249,4.249,0,0,0-.346-1.838,4.156,4.156,0,0,0-1.117-1.5,4.078,4.078,0,0,0-.569-.408,4.928,4.928,0,0,1-.385.838,1.1,1.1,0,0,1-.955.553,1.08,1.08,0,0,1-.547-.15,1.093,1.093,0,0,1-.4-1.5,2.906,2.906,0,0,0,.374-1.229,1.022,1.022,0,0,1,.006-.145,2.841,2.841,0,0,0-.218-1.133,2.836,2.836,0,0,0-.754-1.051,2.794,2.794,0,0,0-1.139-.607,2.8,2.8,0,0,0-1.29-.045,2.782,2.782,0,0,0-1.173.535,2.84,2.84,0,0,0-.82.994,2.912,2.912,0,0,0-.3,1.262v5.254a4.426,4.426,0,0,0,.877,2.625,4.4,4.4,0,0,0,2.277,1.58,1.1,1.1,0,0,1,.748,1.361,1.1,1.1,0,0,1-1.367.748,6.6,6.6,0,0,1-2.535-1.41v6.928Zm-10.352,1.145a4.119,4.119,0,0,0,.787,1.49,4.148,4.148,0,0,0,1.313,1.061,4.105,4.105,0,0,0,1.624.447,4.076,4.076,0,0,0,1.676-.234,4.125,4.125,0,0,0,1.44-.877,4.208,4.208,0,0,0,.972-1.379,4.226,4.226,0,0,0,.34-1.652v-6.924a6.628,6.628,0,0,1-2.534,1.406,1.117,1.117,0,0,1-.308.045,1.1,1.1,0,0,1-1.055-.793,1.093,1.093,0,0,1,.748-1.361,4.393,4.393,0,0,0,2.272-1.58,4.354,4.354,0,0,0,.876-2.625v-5.254a2.906,2.906,0,0,0-.29-1.262,2.84,2.84,0,0,0-.82-.994,2.778,2.778,0,0,0-1.179-.535,2.8,2.8,0,0,0-1.29.045,2.832,2.832,0,0,0-1.139.607,2.821,2.821,0,0,0-.748,1.051,2.819,2.819,0,0,0-.212,1.271,2.821,2.821,0,0,0,.374,1.234,1.1,1.1,0,0,1-.4,1.5,1.1,1.1,0,0,1-1.5-.4,5.009,5.009,0,0,1-.386-.838,4.127,4.127,0,0,0-.568.408,4.156,4.156,0,0,0-1.117,1.5,4.249,4.249,0,0,0-.346,1.838,4.246,4.246,0,0,0,.1.758,1.106,1.106,0,0,1,1.064.543,1.1,1.1,0,0,1-.4,1.5,4.2,4.2,0,0,0-1.765,2.01,4.216,4.216,0,0,0-.173,2.67,4.138,4.138,0,0,0,1.485,2.221,4.178,4.178,0,0,0,2.529.859,1.1,1.1,0,0,1,1.1,1.1,1.1,1.1,0,0,1-1.1,1.1,6.32,6.32,0,0,1-1.422-.164C9200.714-22224.1,9200.732-22224.029,9200.752-22223.959Z" transform="translate(-9186.242 22256.754)"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="52" height="52" viewBox="0 0 52 52"><defs><style>.a{fill:#ffcfd1;}.b{opacity:0;}.b,.c{fill:url(#a);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f5222d"/><stop offset="1" stop-color="#a8071a"/></linearGradient></defs><g transform="translate(0 -116.002)"><rect class="a" width="52" height="52" rx="8" transform="translate(0 116.002)"/><g transform="translate(18559 4825)"><rect class="b" width="42" height="42" transform="translate(-18554 -4704)"/><path class="c" d="M-9736.837-26950.139a1.008,1.008,0,0,1-.542-.889v-3.371h-.752a4.506,4.506,0,0,1-4.5-4.5v-2.629c-1.89-.08-3.366-2-3.366-4.367s1.477-4.287,3.366-4.369v-2.633a4.5,4.5,0,0,1,4.5-4.5h15.658v.129a3.676,3.676,0,0,0,.494,1.869h-16.152a2.5,2.5,0,0,0-2.5,2.5v14a2.5,2.5,0,0,0,2.5,2.5h1.741a1.006,1.006,0,0,1,1.009,1v2.432l4.544-3.244a.97.97,0,0,1,.576-.189h13.124a2.5,2.5,0,0,0,2.506-2.5v-9.441a3.524,3.524,0,0,0,1.118.189h.033a3.634,3.634,0,0,0,2.764-1.293,4.793,4.793,0,0,1,1.456,3.549,5.014,5.014,0,0,1-.982,3.041,3.23,3.23,0,0,1-2.391,1.334v2.621a4.507,4.507,0,0,1-4.5,4.5h-12.806l-5.858,4.184a1.021,1.021,0,0,1-.582.191A1.007,1.007,0,0,1-9736.837-26950.139Zm1.334-10.389a1.876,1.876,0,0,1-1.876-1.877v-7.869a1.876,1.876,0,0,1,1.876-1.877h15.739a1.876,1.876,0,0,1,1.876,1.877v7.869a1.876,1.876,0,0,1-1.876,1.877Zm10.931-5.811a1.752,1.752,0,0,0,1.754,1.746,1.751,1.751,0,0,0,1.747-1.746,1.754,1.754,0,0,0-1.747-1.756A1.755,1.755,0,0,0-9724.572-26966.338Zm-9.617,0a1.752,1.752,0,0,0,1.748,1.746,1.751,1.751,0,0,0,1.747-1.746,1.755,1.755,0,0,0-1.747-1.756A1.755,1.755,0,0,0-9734.189-26966.338Zm20.1-5.277-.453-1.551a4.433,4.433,0,0,0-2.946-3.006l-1.537-.467a.649.649,0,0,1-.447-.611.524.524,0,0,1,.021-.148.616.616,0,0,1,.217-.346.624.624,0,0,1,.217-.115l.284-.088,1.232-.365a4.417,4.417,0,0,0,2.96-3.02l.46-1.551a.625.625,0,0,1,.42-.434v.006a.538.538,0,0,1,.183-.033.574.574,0,0,1,.292.08.581.581,0,0,1,.3.381l.46,1.557a4.422,4.422,0,0,0,2.96,3.014l1.171.346.346.107a.647.647,0,0,1,.454.609.641.641,0,0,1-.447.611l-1.537.467a4.434,4.434,0,0,0-2.946,3.006l-.453,1.551a.631.631,0,0,1-.6.461A.636.636,0,0,1-9714.089-26971.615Zm-4.571-7.34-.183-.623a2.7,2.7,0,0,0-1.829-1.83l-.623-.189a.29.29,0,0,1-.2-.277.284.284,0,0,1,.2-.277l.61-.184a2.7,2.7,0,0,0,1.842-1.842l.183-.615a.32.32,0,0,1,.2-.2.327.327,0,0,1,.224.027.275.275,0,0,1,.143.17l.183.615a2.713,2.713,0,0,0,1.842,1.842l.609.184a.289.289,0,0,1,.21.277.3.3,0,0,1-.21.277l-.257.076-.366.113a2.725,2.725,0,0,0-1.828,1.83l-.183.623a.293.293,0,0,1-.278.2A.3.3,0,0,1-9718.66-26978.955Z" transform="translate(-8805.37 22282.029)"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080" viewBox="0 0 1920 1080"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 3840 2160"><image width="3840" height="2160" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 1.7 MiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="582" height="667" viewBox="0 0 582 667"><image width="582" height="667" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 664 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="139" height="765" viewBox="0 0 139 765"><defs><style>.a{fill:url(#a);}</style><pattern id="a" width="1" height="1" patternTransform="matrix(-1, 0, 0, 1, 278, 0)" viewBox="0 0 139 765"><image preserveAspectRatio="xMidYMid slice" width="139" height="765" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 51 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="139" height="765" viewBox="0 0 139 765"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 278 1530"><image width="278" height="1530" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 51 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="273" height="323" viewBox="0 0 273 323"><image width="273" height="323" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 193 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="727" height="727" viewBox="0 0 727 727"><defs><style>.a{fill:url(#a);}.b{fill:url(#d);}.c,.d,.f{fill:none;}.c{stroke:rgba(32,194,255,0);stroke-width:8px;}.d{stroke:rgba(255,173,32,0.15);stroke-width:5px;stroke-dasharray:7 5;}.e{stroke:none;}.g{filter:url(#e);}.h{filter:url(#b);}</style><linearGradient id="a" x1="0.761" y1="0.205" x2="0.284" y2="0.581" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#ffd2ab"/><stop offset="1" stop-color="#ff7855"/></linearGradient><filter id="b" x="88.068" y="60.324" width="217.004" height="181.59" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="7.5" result="c"/><feFlood flood-color="#ff941a" flood-opacity="0.4"/><feComposite operator="in" in2="c"/><feComposite in="SourceGraphic"/></filter><linearGradient id="d" x1="0.237" y1="0.761" x2="0.718" y2="0.394" xlink:href="#a"/><filter id="e" x="424.041" y="484.359" width="215.309" height="181.689" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="7.5" result="f"/><feFlood flood-color="#ff941a" flood-opacity="0.4"/><feComposite operator="in" in2="f"/><feComposite in="SourceGraphic"/></filter></defs><g transform="translate(4 4)"><g transform="translate(-126.998 -127.002)"><g class="h" transform="matrix(1, 0, 0, 1, 123, 123)"><path class="a" d="M18115.566-21172.494q4.919-8.443,10.389-16.543a294.531,294.531,0,0,1,35.713-43.289,294.545,294.545,0,0,1,43.293-35.717,292.5,292.5,0,0,1,49.682-26.969,290.614,290.614,0,0,1,31.271-11.162l1.656,5.506a288.092,288.092,0,0,0-166.973,131.084Z" transform="translate(-18005 21389)"/></g><g class="g" transform="matrix(1, 0, 0, 1, 123, 123)"><path class="b" d="M18451.539-20750.967a288.209,288.209,0,0,0,165.279-131.172l5.029,2.92q-5.1,8.815-10.8,17.258a294.659,294.659,0,0,1-35.717,43.287,294.647,294.647,0,0,1-43.293,35.717,292.2,292.2,0,0,1-49.686,26.971,291.1,291.1,0,0,1-29.178,10.537Z" transform="translate(-18005 21389)"/></g><circle class="c" cx="359.5" cy="359.5" r="359.5" transform="translate(126.998 127.002)"/></g><g class="d" transform="translate(98 98)"><circle class="e" cx="261.5" cy="261.5" r="261.5"/><circle class="f" cx="261.5" cy="261.5" r="259"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="727" height="727" viewBox="0 0 727 727"><defs><style>.a{fill:url(#a);}.b{fill:url(#d);}.c,.d,.f{fill:none;}.c{stroke:rgba(32,194,255,0);stroke-width:8px;}.d{stroke:rgba(255,173,32,0.15);stroke-width:5px;stroke-dasharray:7 5;}.e{stroke:none;}.g{filter:url(#e);}.h{filter:url(#b);}</style><linearGradient id="a" x1="0.761" y1="0.205" x2="0.284" y2="0.581" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#ffd2ab"/><stop offset="1" stop-color="#ff7855"/></linearGradient><filter id="b" x="88.068" y="60.324" width="217.004" height="181.59" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="7.5" result="c"/><feFlood flood-color="#ff941a" flood-opacity="0.4"/><feComposite operator="in" in2="c"/><feComposite in="SourceGraphic"/></filter><linearGradient id="d" x1="0.237" y1="0.761" x2="0.718" y2="0.394" xlink:href="#a"/><filter id="e" x="424.041" y="484.359" width="215.309" height="181.689" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="7.5" result="f"/><feFlood flood-color="#ff941a" flood-opacity="0.4"/><feComposite operator="in" in2="f"/><feComposite in="SourceGraphic"/></filter></defs><g transform="translate(4 4)"><g transform="translate(-126.998 -127.002)"><g class="h" transform="matrix(1, 0, 0, 1, 123, 123)"><path class="a" d="M18115.566-21172.494q4.919-8.443,10.389-16.543a294.531,294.531,0,0,1,35.713-43.289,294.545,294.545,0,0,1,43.293-35.717,292.5,292.5,0,0,1,49.682-26.969,290.614,290.614,0,0,1,31.271-11.162l1.656,5.506a288.092,288.092,0,0,0-166.973,131.084Z" transform="translate(-18005 21389)"/></g><g class="g" transform="matrix(1, 0, 0, 1, 123, 123)"><path class="b" d="M18451.539-20750.967a288.209,288.209,0,0,0,165.279-131.172l5.029,2.92q-5.1,8.815-10.8,17.258a294.659,294.659,0,0,1-35.717,43.287,294.647,294.647,0,0,1-43.293,35.717,292.2,292.2,0,0,1-49.686,26.971,291.1,291.1,0,0,1-29.178,10.537Z" transform="translate(-18005 21389)"/></g><circle class="c" cx="359.5" cy="359.5" r="359.5" transform="translate(126.998 127.002)"/></g><g class="d" transform="translate(98 98)"><circle class="e" cx="261.5" cy="261.5" r="261.5"/><circle class="f" cx="261.5" cy="261.5" r="259"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="458" height="113.588" viewBox="0 0 458 113.588"><defs><style>.a{opacity:0.78;}.b{fill:url(#a);}.c{fill:#fff;}.d{filter:url(#d);}.e{filter:url(#b);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff9c1"/></linearGradient><filter id="b" x="104.221" y="19.443" width="226.559" height="61.289" filterUnits="userSpaceOnUse"><feOffset dy="1" input="SourceAlpha"/><feGaussianBlur stdDeviation="5.5" result="c"/><feFlood flood-color="#fe0" flood-opacity="0.71"/><feComposite operator="in" in2="c"/><feComposite in="SourceGraphic"/></filter><filter id="d" x="104.221" y="19.443" width="226.559" height="61.289" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="2" result="e"/><feFlood flood-color="#fff" result="f"/><feComposite operator="out" in="SourceGraphic" in2="e"/><feComposite operator="in" in="f"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(-731 -564.412)"><image class="a" width="458" height="113.588" transform="translate(731 564.412)" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 625 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{opacity:0;fill:url(#a);}.b{fill:url(#b);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient><linearGradient id="b" x1="0.405" x2="1" xlink:href="#a"/></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="M110.023,105.37H98.2a1.721,1.721,0,0,1-1.716-1.716v-1.918h10.189a1.721,1.721,0,0,0,1.716-1.716V72.8a1.721,1.721,0,0,0-1.716-1.716H98.2V67.437A3.444,3.444,0,0,0,94.76,64H87.83a3.444,3.444,0,0,0-3.437,3.437v3.652H75.625A1.721,1.721,0,0,0,73.91,72.8v27.21a1.721,1.721,0,0,0,1.716,1.716h10.49v1.918a1.721,1.721,0,0,1-1.716,1.716H72.3a1.721,1.721,0,0,0-1.716,1.716v10.1A1.721,1.721,0,0,0,72.3,118.9h37.724a1.721,1.721,0,0,0,1.716-1.716v-10.1A1.726,1.726,0,0,0,110.023,105.37Zm-8.406-19.1a3.437,3.437,0,1,1-3.437-3.437A3.436,3.436,0,0,1,101.616,86.266ZM87.83,67.437h6.924v3.646H87.83ZM81.121,86.266A3.437,3.437,0,1,1,84.558,89.7,3.436,3.436,0,0,1,81.121,86.266Zm32.48-7.089V93.355a1.716,1.716,0,0,1-3.431,0V79.177a1.716,1.716,0,0,1,3.431,0Zm5.122,3.548v6.881a1.716,1.716,0,0,1-3.431,0V82.724a1.716,1.716,0,0,1,3.431,0ZM70.644,95.07a1.717,1.717,0,0,1-1.716-1.716V79.177a1.716,1.716,0,0,1,3.431,0V93.355A1.713,1.713,0,0,1,70.644,95.07ZM67.231,82.724v6.881a1.716,1.716,0,1,1-3.431,0V82.724a1.716,1.716,0,0,1,3.431,0Zm0,0" transform="translate(-14540.154 22987.455)"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -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,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 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

+1 -6
View File
@@ -40,7 +40,7 @@ window.__CM_RUNTIME_CONFIG__ = {
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。 // Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "", VITE_CANVAS_LANGGRAPH_BASE_URL: "",
// 地图瓦片地址模板:仅供普通问答消息列表中的交互地图使用。 // 地图瓦片地址模板:普通问答地图和生成的 HTML 网页地图共同使用。
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。 // 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13", VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
@@ -155,11 +155,6 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。 // GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "", VITE_GITHUB_OAUTH_TOKEN: "",
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
VITE_TURBO_MODE_VISIBLE: "true",
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。 // 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。 // 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
VITE_SENTIMENT_AGENT_VISIBLE: "true", VITE_SENTIMENT_AGENT_VISIBLE: "true",
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52">
<defs>
<linearGradient id="ap-add" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#f5222d"/>
<stop offset="1" stop-color="#a8071a"/>
</linearGradient>
</defs>
<rect width="52" height="52" rx="8" fill="#ffcfd1"/>
<path fill="url(#ap-add)" d="M26 14a2 2 0 0 1 2 2v8h8a2 2 0 1 1 0 4h-8v8a2 2 0 1 1-4 0v-8h-8a2 2 0 1 1 0-4h8v-8a2 2 0 0 1 2-2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 503 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="52" height="52" viewBox="0 0 52 52"><defs><style>.a{fill:#ffcfd1;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f5222d"/><stop offset="1" stop-color="#a8071a"/></linearGradient></defs><g transform="translate(0 -116.002)"><rect class="a" width="52" height="52" rx="8" transform="translate(0 116.002)"/><g transform="translate(2.236 118.236)"><path class="b" d="M9204.336-22218.762a6.4,6.4,0,0,1-2.489-.691,6.242,6.242,0,0,1-2-1.615,6.363,6.363,0,0,1-1.206-2.283,6.357,6.357,0,0,1-.246-1.863c-.047-.033-.094-.066-.139-.1a6.4,6.4,0,0,1-2.272-3.4,6.418,6.418,0,0,1,.268-4.076,6.336,6.336,0,0,1,1.233-1.912,6.383,6.383,0,0,1-.4-1.973,6.41,6.41,0,0,1,.53-2.809,6.441,6.441,0,0,1,1.7-2.289,6.424,6.424,0,0,1,1.751-1.057,4.969,4.969,0,0,1,.343-1.289,4.965,4.965,0,0,1,1.334-1.859,5.038,5.038,0,0,1,2.016-1.082,5.037,5.037,0,0,1,2.289-.074,5.024,5.024,0,0,1,2.089.943,4.947,4.947,0,0,1,.871.861,5.147,5.147,0,0,1,.876-.861,5,5,0,0,1,2.083-.943,5.035,5.035,0,0,1,2.289.074,5.06,5.06,0,0,1,2.021,1.082,5.031,5.031,0,0,1,1.334,1.859,5.008,5.008,0,0,1,.343,1.289,6.424,6.424,0,0,1,1.751,1.057,6.441,6.441,0,0,1,1.7,2.289,6.405,6.405,0,0,1,.524,2.809,6.434,6.434,0,0,1-.4,1.973,6.315,6.315,0,0,1,1.234,1.912,6.418,6.418,0,0,1,.268,4.076,6.4,6.4,0,0,1-2.272,3.4l-.143.105a6.214,6.214,0,0,1-.248,1.859,6.26,6.26,0,0,1-1.2,2.283,6.271,6.271,0,0,1-2.01,1.615,6.354,6.354,0,0,1-2.485.691c-.14.006-.278.012-.418.012a6.213,6.213,0,0,1-2.139-.375,6.394,6.394,0,0,1-2.2-1.34,6.4,6.4,0,0,1-.915-1.066,6.415,6.415,0,0,1-.91,1.066,6.427,6.427,0,0,1-2.206,1.34,6.283,6.283,0,0,1-2.143.377C9204.609-22218.748,9204.473-22218.752,9204.336-22218.762Zm6.768-6.342a4.232,4.232,0,0,0,.346,1.652,4.123,4.123,0,0,0,.972,1.379,4.116,4.116,0,0,0,1.44.877,4.062,4.062,0,0,0,1.67.234,4.159,4.159,0,0,0,1.63-.447,4.159,4.159,0,0,0,1.313-1.061,4.227,4.227,0,0,0,.787-1.49c.02-.07.037-.139.054-.209a6.32,6.32,0,0,1-1.422.164,1.1,1.1,0,0,1-1.1-1.1,1.1,1.1,0,0,1,1.1-1.1,4.178,4.178,0,0,0,2.529-.859,4.138,4.138,0,0,0,1.485-2.221,4.216,4.216,0,0,0-.173-2.67,4.2,4.2,0,0,0-1.765-2.01,1.1,1.1,0,0,1-.4-1.5,1.1,1.1,0,0,1,1.063-.543,4.16,4.16,0,0,0,.1-.758,4.249,4.249,0,0,0-.346-1.838,4.156,4.156,0,0,0-1.117-1.5,4.078,4.078,0,0,0-.569-.408,4.928,4.928,0,0,1-.385.838,1.1,1.1,0,0,1-.955.553,1.08,1.08,0,0,1-.547-.15,1.093,1.093,0,0,1-.4-1.5,2.906,2.906,0,0,0,.374-1.229,1.022,1.022,0,0,1,.006-.145,2.841,2.841,0,0,0-.218-1.133,2.836,2.836,0,0,0-.754-1.051,2.794,2.794,0,0,0-1.139-.607,2.8,2.8,0,0,0-1.29-.045,2.782,2.782,0,0,0-1.173.535,2.84,2.84,0,0,0-.82.994,2.912,2.912,0,0,0-.3,1.262v5.254a4.426,4.426,0,0,0,.877,2.625,4.4,4.4,0,0,0,2.277,1.58,1.1,1.1,0,0,1,.748,1.361,1.1,1.1,0,0,1-1.367.748,6.6,6.6,0,0,1-2.535-1.41v6.928Zm-10.352,1.145a4.119,4.119,0,0,0,.787,1.49,4.148,4.148,0,0,0,1.313,1.061,4.105,4.105,0,0,0,1.624.447,4.076,4.076,0,0,0,1.676-.234,4.125,4.125,0,0,0,1.44-.877,4.208,4.208,0,0,0,.972-1.379,4.226,4.226,0,0,0,.34-1.652v-6.924a6.628,6.628,0,0,1-2.534,1.406,1.117,1.117,0,0,1-.308.045,1.1,1.1,0,0,1-1.055-.793,1.093,1.093,0,0,1,.748-1.361,4.393,4.393,0,0,0,2.272-1.58,4.354,4.354,0,0,0,.876-2.625v-5.254a2.906,2.906,0,0,0-.29-1.262,2.84,2.84,0,0,0-.82-.994,2.778,2.778,0,0,0-1.179-.535,2.8,2.8,0,0,0-1.29.045,2.832,2.832,0,0,0-1.139.607,2.821,2.821,0,0,0-.748,1.051,2.819,2.819,0,0,0-.212,1.271,2.821,2.821,0,0,0,.374,1.234,1.1,1.1,0,0,1-.4,1.5,1.1,1.1,0,0,1-1.5-.4,5.009,5.009,0,0,1-.386-.838,4.127,4.127,0,0,0-.568.408,4.156,4.156,0,0,0-1.117,1.5,4.249,4.249,0,0,0-.346,1.838,4.246,4.246,0,0,0,.1.758,1.106,1.106,0,0,1,1.064.543,1.1,1.1,0,0,1-.4,1.5,4.2,4.2,0,0,0-1.765,2.01,4.216,4.216,0,0,0-.173,2.67,4.138,4.138,0,0,0,1.485,2.221,4.178,4.178,0,0,0,2.529.859,1.1,1.1,0,0,1,1.1,1.1,1.1,1.1,0,0,1-1.1,1.1,6.32,6.32,0,0,1-1.422-.164C9200.714-22224.1,9200.732-22224.029,9200.752-22223.959Z" transform="translate(-9186.242 22256.754)"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="52" height="52" viewBox="0 0 52 52"><defs><style>.a{fill:#ffcfd1;}.b{opacity:0;}.b,.c{fill:url(#a);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f5222d"/><stop offset="1" stop-color="#a8071a"/></linearGradient></defs><g transform="translate(0 -116.002)"><rect class="a" width="52" height="52" rx="8" transform="translate(0 116.002)"/><g transform="translate(18559 4825)"><rect class="b" width="42" height="42" transform="translate(-18554 -4704)"/><path class="c" d="M-9736.837-26950.139a1.008,1.008,0,0,1-.542-.889v-3.371h-.752a4.506,4.506,0,0,1-4.5-4.5v-2.629c-1.89-.08-3.366-2-3.366-4.367s1.477-4.287,3.366-4.369v-2.633a4.5,4.5,0,0,1,4.5-4.5h15.658v.129a3.676,3.676,0,0,0,.494,1.869h-16.152a2.5,2.5,0,0,0-2.5,2.5v14a2.5,2.5,0,0,0,2.5,2.5h1.741a1.006,1.006,0,0,1,1.009,1v2.432l4.544-3.244a.97.97,0,0,1,.576-.189h13.124a2.5,2.5,0,0,0,2.506-2.5v-9.441a3.524,3.524,0,0,0,1.118.189h.033a3.634,3.634,0,0,0,2.764-1.293,4.793,4.793,0,0,1,1.456,3.549,5.014,5.014,0,0,1-.982,3.041,3.23,3.23,0,0,1-2.391,1.334v2.621a4.507,4.507,0,0,1-4.5,4.5h-12.806l-5.858,4.184a1.021,1.021,0,0,1-.582.191A1.007,1.007,0,0,1-9736.837-26950.139Zm1.334-10.389a1.876,1.876,0,0,1-1.876-1.877v-7.869a1.876,1.876,0,0,1,1.876-1.877h15.739a1.876,1.876,0,0,1,1.876,1.877v7.869a1.876,1.876,0,0,1-1.876,1.877Zm10.931-5.811a1.752,1.752,0,0,0,1.754,1.746,1.751,1.751,0,0,0,1.747-1.746,1.754,1.754,0,0,0-1.747-1.756A1.755,1.755,0,0,0-9724.572-26966.338Zm-9.617,0a1.752,1.752,0,0,0,1.748,1.746,1.751,1.751,0,0,0,1.747-1.746,1.755,1.755,0,0,0-1.747-1.756A1.755,1.755,0,0,0-9734.189-26966.338Zm20.1-5.277-.453-1.551a4.433,4.433,0,0,0-2.946-3.006l-1.537-.467a.649.649,0,0,1-.447-.611.524.524,0,0,1,.021-.148.616.616,0,0,1,.217-.346.624.624,0,0,1,.217-.115l.284-.088,1.232-.365a4.417,4.417,0,0,0,2.96-3.02l.46-1.551a.625.625,0,0,1,.42-.434v.006a.538.538,0,0,1,.183-.033.574.574,0,0,1,.292.08.581.581,0,0,1,.3.381l.46,1.557a4.422,4.422,0,0,0,2.96,3.014l1.171.346.346.107a.647.647,0,0,1,.454.609.641.641,0,0,1-.447.611l-1.537.467a4.434,4.434,0,0,0-2.946,3.006l-.453,1.551a.631.631,0,0,1-.6.461A.636.636,0,0,1-9714.089-26971.615Zm-4.571-7.34-.183-.623a2.7,2.7,0,0,0-1.829-1.83l-.623-.189a.29.29,0,0,1-.2-.277.284.284,0,0,1,.2-.277l.61-.184a2.7,2.7,0,0,0,1.842-1.842l.183-.615a.32.32,0,0,1,.2-.2.327.327,0,0,1,.224.027.275.275,0,0,1,.143.17l.183.615a2.713,2.713,0,0,0,1.842,1.842l.609.184a.289.289,0,0,1,.21.277.3.3,0,0,1-.21.277l-.257.076-.366.113a2.725,2.725,0,0,0-1.828,1.83l-.183.623a.293.293,0,0,1-.278.2A.3.3,0,0,1-9718.66-26978.955Z" transform="translate(-8805.37 22282.029)"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080" viewBox="0 0 1920 1080"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 3840 2160"><image width="3840" height="2160" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 1.7 MiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="582" height="667" viewBox="0 0 582 667"><image width="582" height="667" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 664 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="139" height="765" viewBox="0 0 139 765"><defs><style>.a{fill:url(#a);}</style><pattern id="a" width="1" height="1" patternTransform="matrix(-1, 0, 0, 1, 278, 0)" viewBox="0 0 139 765"><image preserveAspectRatio="xMidYMid slice" width="139" height="765" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 51 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="139" height="765" viewBox="0 0 139 765"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 278 1530"><image width="278" height="1530" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARYAAAX6CAYAAAA2hEelAAAgAElEQVR4nOy9+ZMs2XXfd25mVi/vvdkxAAYgIBAESYugaEdYpG3BlIIMOUL2P2uFf7MIibQIYggMIcomTYogAVIkQCwz5Axmea+7KvMeRS636uatu2ZlVeXy/QQK/Xqp6p7uylPfe5bvEfzON2kQQoTupb6ArZ9l+4fPxiW/H3f/d+H/RACmQoa/BABgbIrBj6cUQLxyOXoE/DUBWCZQLACA0RmuWBTh3EVQ0piPeNLPAwC4OqcHluGEAg4CDAAz5dyBRQxQLC4QaACYCddULC7MQKQCir98DQCYDHpgGftoon/9ENUijLeu749AA8DEmKJicRETaBBkAJgARYKaSFMd3JWLDn0uY+VaTFyBBkEGgCtxScUydo7EFqgEAgwA16fYX3jMYysK4fj3uUD5GoCJcCnFYruoz9Xqb8vFQMUAcEH0wGKWdceB9625+uNfU83UP4c03gcAjMg1FYuNmIa61EBgPh6qSgCcGVtgMS+usRWFTcHQhZWMPnyp/zxQMgCMwLX6WHxqwaUw9PfHysPYPgYlA8CJxASWSykYMhSDUjBZINiMGWRMJcOO5C8AwMM4imW8y85evRHeY5It0A39iczHVUFFWI5wCDYAOBgSWLj/r9H7X8zvI9t/CdFd9qaKsQ0tjvkzmUcvXcVIz/0AWC1TUyz2R+9nPw65mPbj2f79w9uYo1LMT60exzwiSUPJQMUAoDFGYDl3DkYpI9YeWXYfF0SCtaOSzWrzHB3F6vuYORhpvA/AKpnTdPPRIYxEpxp4H1i4u+jZclQao5qksD2m67hkfg6AxXOOwOK6iM6tZFwdvpkjqIw1UnB8BOOjipJEcAFrYl6KxQX3LtqqeysM9SIcKkYxxoVvPq4vF4NAAxbLJQPL+XMxx99PVW2EcQv1xoyFnoshi4JBVQkskmUoFjf6BW1TLb73x5iINqtKKrBkXVAROCaBJXLNwDK2gonJmah/Vw4VQ45j0ljYAhkhuIClsXTFQh7Xfz3Jm2lKwpeHGfNn0pPKyLmARTGlwNK/sETyBT20GqXnOqqudJ0ZSuacfr0ZggtYGmtQLDGwoWD0nAxbjjDkUUKx348sgaUK3A+AWTDlwDJWP4zLjsH16OZRSXYfNVWMOZM0RNXojye1QAbArIFiORDKxbBR3XFVlWyPEfq+mRasoFrA7JljYBlaTUpTLv371Rkf2VMxaqiAKI/0jXGhvibv7oEKEZg9UCxufD4vpk9LpiWB9bJ1bBAzK1NQLWDWLCGw2F/d3XuShioX22MwkZDaEaZWNpmhZGICjK1pD6oFzBYolmGYU816EJCG+sgTghhyLWARLDmwhHIxscrF1eLffVztTdo/jDoSyS6omB29oe+Td2+RawGzxWaMBE6HjQCjBwhfgMmMGwCzZE1HoVNzKy7l4nt4PbjEdPHarB6gWsDswKvi+ZGJ9giXGCUA4KwgsJw+o9Ov+tQpl/266r1iqSK/h/oalfRNSfwCMBkQWM7PkFyL3o2LwAJmxxrLzanVINf9UocOTQ+Y0M+Qd/crE74PAJNgmYqFJ5nvNFVLSLFAtYDZsizFogeU04PLGB26+mMpxZLyO1cdvLajFACTBYrlcrhyLT501QLAbFhzS78rZ+LKf7i+zv7+viHX2teiDzG60DtxOaGyBMDVwSvhZWGjryU0oIhOXDBLFrKwjPtvR3rU7m1IuaQ+ZqWpkJjKVKb1tUC1gFkw71fBfjPaXBiSZ0GuBcyKeSoWM5hcN7ik5lqUatFniFzYOnFRHQKTB4rlOsjEGSKhBReoFjB5YPR0UBYu5eEiPtdSq5U6APZVi9RMoWKCRR6hcACYBPMJLOM2v02BSjN1CgUp9Xm9ExfHITBZ5hFYXFWfaQSY8EyRUiv9qedKq/KkeOLmWo4GwQVMknkplmUoFYXZiRtzxNGTuKfaPQBwNpAIPOC6UMe5gO2BkSPVh66C8u4FAbkWMFmmH1h4MTkVG9IwggoFC70yhBcFMFmmfRTa51boGqo/dbrZnmvx31vPtcS4xellZ3jigskywVe97uTBxtvlYvazhP4mAvNDYOpMTLHwQZ0I28qeWWKUkntCSPfE1YOL679YaEcm+LSAyTJxxbIK2OjEjfkPz7oXBbjLgUmCzttjxnKOS5mC1nMtvv3Nx9Uh5t2JPycAozMRxcLabZXIzjS7HGCngDwLmBxXVixsqfhMLriYP5DLYS7k4m+fej58rNTyJqG/izCa5cwcDQBX5QqBxRZEkHs8wQ9X9bWgExdMhgsHFlOhLOo6SHOWs3+1NKaeY6pDRad2UB0Ck+EK5/PV51N8DKkOocUfTA5UhdyYPi0moeqRKwfj+zK9OqQnZkPVIex4BpPiAoqFDwN4y++iPRUeWB3K0dMCpsR5FQtbjj3XmfsZAzZyGy5iTZtck9SVUeWJXcWaa/cF4KqcMbBoQWU9XbRjoOdYUnxaChhAgalwnsCy3AHCWNWiE8i1WFM1TGIfYHz5E3N2KNeOUQBcjTPmWJBPOQFpWFeGUIqlQCcumAJ4EoYZGiFD61P7nxe9m80T1/ZYqA6BSXJaYDmqlKJHZSRss0OhIKXPDiG4gKsyTo7FrP7Mt/IzFUzFQoHgIgzVkiGJC67JsMBiruNoelQwA3canTA5hI7ULlzSWvx3GEoE12S4YpGy7zxvKykvU5CH+lNiP95/nLrBV3SzVIeNibq7XOjYykaLf2ziF4DRGa5Y6psMvCjiaZ3AkWIhI9dSaPuEXAitOgTVAq4GZoXiiXWWi5wROvr6UCeuuY7V9jgCZWcwBeKffEqhmEcgcE702SFKqA5hdghclTjFooKKL58CQugXeewvUAUWszrkwpwdQnUIXAV3YDETs3pgAefjOHywMQOUMjukci34w4GLYg8sNoWCoGKi/0JsasT0xg19ffs71r+6Xx0qDUMn1xZE3Vku145RAFyMYl/Z0QMHFMr1sB949OpQHpEbMxULrBTARSmosjznoFBScamRFDStwm1fy+FhXZ24NoShWJDABRenCPaigCkw1FlO35aIVwpwMdDHkk5sP4tJ6Ot9F75eHUpxltNnh3AcAhcDTVTzgQfODqGnBVycAk+3kxkjv2LhaG6ItCRuFVEdIiOJi9khcDGgWOaF6dMSAi3+4CrgyTYusRe7fnN93obp4m8+lnnf+hi06W74W4OLgSfbvJDG7FAod5IZCVwALgKqQsNxbUo0Vcvwaeg6v9J04wr9c7bZIV9PS25xlgPgrCCwzI+DsxwnzQ6pwAJTYnB2CnTYXo3wtLP5t1HKhWvFwqWWZ3GhHkCvDmHaGZwdKJY5cQgHba6Fm2NRyKOFjMBS4jgEzg0SeuNxyu4TW3A4fIxZ23zQzHFxF1TM9SCu6lCmVYfQuQTODhTLnDAVyyGwZNrHbWBTIrgoCCynY5sd8iVVfR63sS5zqdUhFUz0iWfkWcDZwKvXbOjFAdZUS2y+RGirQfKIrwdgMKgKnY/YUnCkSulEhtjfRV8cXwXUp606hNkhcDagWGaDNQZIi52CD8wOgYuAJ9f4mNvxXZUifyXI/zGFzQDKVyHKMDsELgGeXPNGrw6pNay+vymqQ+AiwI/lvMTmWSigSlzIhOqQ+vgGCVxwbvCqNW/YUCwhbJaVAIwOnljj4cqlhLpxTYXh+Vpr2sZM4Ib6X8g4EkGzgtFBYJk/pvlTDCqwIIkLzgI6b6eHS730O3b9C81CXb/119x0y8y2cPAHY1OgRWqGHIcN7oJEqSVnRWB2aIPqEDgXBZovz4apNkLOcjYnOceO5+7LD5+VXWCpb7cBT13CUQicGyiWOSI0E6jW/GlIdUi3rMRQIhgVvFqNj60KNJZHS/dofHhE3hu1VANMnDJj4hmAUUBgmS1Hi/vZGEqMac5D2RmcBVSFLscpO5/d1aE+kpjVkSgL5FmEsXcIlpVgNKBYloU5OxTyw4VlJTgLUCznR1cWMYE8vjpER07+Uis76wrJlfPBtDM4C3gyLQszsMTkWHL0s4CxgWK5LL48iyt34supmKiy807Ll8TkWXQrBeRZwMngVWp5mJUhRSjXgrIzGA0olvOhZnps7v0xF7zL9Z/7H9Y/u79LZUng+r5P1s0OwQsXjAIUyzIx9w6FQIs/GBU4yF2eUD+LLaeiPiYDX0fa16nZoSKgTFmrDt0Q88MUf2lgXuDVaZlIRxLXBaadwaggx3I5UvtZTDz9LHV7fy91oysW/X4+L1x1FIIXLjgZvDpdj5ETpEdzQ+a0s0+x2KadARgMFMv5aa94ZpvaCF3s5vupmxJLEta8jOPO+9mhIiHxC8ARUCzX4RQbBUdwOHpIdqwH8aGqQzdQLeAUsLv5ergUSOiCdk87H2dSpOZre6vlT3yzQzedatmiCxcMBYplXqSqCD2JGxMkDmVnPDfACSDHcj2G+rP4p537yE556NUhn8l2rikWBBYwGASWJaEfa4WWwD1eGu8Cu53BKCCwXJ5T/VmOczN721tDyAihrwWJrQ7pwSXHziEwBASWa3AcFlKOQ3GmT2L/ZYfKEEdVhoQx7Rxa2wrAEQgsl6O7rJP7WWz054T6uxGPgwDXqoNVi/9G+56ugKE7y1VQLSAV7BW6Bv3VY+fpF+n/XLine truncated

Before

Width:  |  Height:  |  Size: 51 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="273" height="323" viewBox="0 0 273 323"><image width="273" height="323" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 193 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="727" height="727" viewBox="0 0 727 727"><defs><style>.a{fill:url(#a);}.b{fill:url(#d);}.c,.d,.f{fill:none;}.c{stroke:rgba(32,194,255,0);stroke-width:8px;}.d{stroke:rgba(255,173,32,0.15);stroke-width:5px;stroke-dasharray:7 5;}.e{stroke:none;}.g{filter:url(#e);}.h{filter:url(#b);}</style><linearGradient id="a" x1="0.761" y1="0.205" x2="0.284" y2="0.581" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#ffd2ab"/><stop offset="1" stop-color="#ff7855"/></linearGradient><filter id="b" x="88.068" y="60.324" width="217.004" height="181.59" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="7.5" result="c"/><feFlood flood-color="#ff941a" flood-opacity="0.4"/><feComposite operator="in" in2="c"/><feComposite in="SourceGraphic"/></filter><linearGradient id="d" x1="0.237" y1="0.761" x2="0.718" y2="0.394" xlink:href="#a"/><filter id="e" x="424.041" y="484.359" width="215.309" height="181.689" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="7.5" result="f"/><feFlood flood-color="#ff941a" flood-opacity="0.4"/><feComposite operator="in" in2="f"/><feComposite in="SourceGraphic"/></filter></defs><g transform="translate(4 4)"><g transform="translate(-126.998 -127.002)"><g class="h" transform="matrix(1, 0, 0, 1, 123, 123)"><path class="a" d="M18115.566-21172.494q4.919-8.443,10.389-16.543a294.531,294.531,0,0,1,35.713-43.289,294.545,294.545,0,0,1,43.293-35.717,292.5,292.5,0,0,1,49.682-26.969,290.614,290.614,0,0,1,31.271-11.162l1.656,5.506a288.092,288.092,0,0,0-166.973,131.084Z" transform="translate(-18005 21389)"/></g><g class="g" transform="matrix(1, 0, 0, 1, 123, 123)"><path class="b" d="M18451.539-20750.967a288.209,288.209,0,0,0,165.279-131.172l5.029,2.92q-5.1,8.815-10.8,17.258a294.659,294.659,0,0,1-35.717,43.287,294.647,294.647,0,0,1-43.293,35.717,292.2,292.2,0,0,1-49.686,26.971,291.1,291.1,0,0,1-29.178,10.537Z" transform="translate(-18005 21389)"/></g><circle class="c" cx="359.5" cy="359.5" r="359.5" transform="translate(126.998 127.002)"/></g><g class="d" transform="translate(98 98)"><circle class="e" cx="261.5" cy="261.5" r="261.5"/><circle class="f" cx="261.5" cy="261.5" r="259"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="727" height="727" viewBox="0 0 727 727"><defs><style>.a{fill:url(#a);}.b{fill:url(#d);}.c,.d,.f{fill:none;}.c{stroke:rgba(32,194,255,0);stroke-width:8px;}.d{stroke:rgba(255,173,32,0.15);stroke-width:5px;stroke-dasharray:7 5;}.e{stroke:none;}.g{filter:url(#e);}.h{filter:url(#b);}</style><linearGradient id="a" x1="0.761" y1="0.205" x2="0.284" y2="0.581" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#ffd2ab"/><stop offset="1" stop-color="#ff7855"/></linearGradient><filter id="b" x="88.068" y="60.324" width="217.004" height="181.59" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="7.5" result="c"/><feFlood flood-color="#ff941a" flood-opacity="0.4"/><feComposite operator="in" in2="c"/><feComposite in="SourceGraphic"/></filter><linearGradient id="d" x1="0.237" y1="0.761" x2="0.718" y2="0.394" xlink:href="#a"/><filter id="e" x="424.041" y="484.359" width="215.309" height="181.689" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="7.5" result="f"/><feFlood flood-color="#ff941a" flood-opacity="0.4"/><feComposite operator="in" in2="f"/><feComposite in="SourceGraphic"/></filter></defs><g transform="translate(4 4)"><g transform="translate(-126.998 -127.002)"><g class="h" transform="matrix(1, 0, 0, 1, 123, 123)"><path class="a" d="M18115.566-21172.494q4.919-8.443,10.389-16.543a294.531,294.531,0,0,1,35.713-43.289,294.545,294.545,0,0,1,43.293-35.717,292.5,292.5,0,0,1,49.682-26.969,290.614,290.614,0,0,1,31.271-11.162l1.656,5.506a288.092,288.092,0,0,0-166.973,131.084Z" transform="translate(-18005 21389)"/></g><g class="g" transform="matrix(1, 0, 0, 1, 123, 123)"><path class="b" d="M18451.539-20750.967a288.209,288.209,0,0,0,165.279-131.172l5.029,2.92q-5.1,8.815-10.8,17.258a294.659,294.659,0,0,1-35.717,43.287,294.647,294.647,0,0,1-43.293,35.717,292.2,292.2,0,0,1-49.686,26.971,291.1,291.1,0,0,1-29.178,10.537Z" transform="translate(-18005 21389)"/></g><circle class="c" cx="359.5" cy="359.5" r="359.5" transform="translate(126.998 127.002)"/></g><g class="d" transform="translate(98 98)"><circle class="e" cx="261.5" cy="261.5" r="261.5"/><circle class="f" cx="261.5" cy="261.5" r="259"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="458" height="113.588" viewBox="0 0 458 113.588"><defs><style>.a{opacity:0.78;}.b{fill:url(#a);}.c{fill:#fff;}.d{filter:url(#d);}.e{filter:url(#b);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff9c1"/></linearGradient><filter id="b" x="104.221" y="19.443" width="226.559" height="61.289" filterUnits="userSpaceOnUse"><feOffset dy="1" input="SourceAlpha"/><feGaussianBlur stdDeviation="5.5" result="c"/><feFlood flood-color="#fe0" flood-opacity="0.71"/><feComposite operator="in" in2="c"/><feComposite in="SourceGraphic"/></filter><filter id="d" x="104.221" y="19.443" width="226.559" height="61.289" filterUnits="userSpaceOnUse"><feOffset input="SourceAlpha"/><feGaussianBlur stdDeviation="2" result="e"/><feFlood flood-color="#fff" result="f"/><feComposite operator="out" in="SourceGraphic" in2="e"/><feComposite operator="in" in="f"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(-731 -564.412)"><image class="a" width="458" height="113.588" transform="translate(731 564.412)" xlink:href="data:image/png;base64,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 truncated

Before

Width:  |  Height:  |  Size: 625 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{opacity:0;fill:url(#a);}.b{fill:url(#b);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient><linearGradient id="b" x1="0.405" x2="1" xlink:href="#a"/></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="M110.023,105.37H98.2a1.721,1.721,0,0,1-1.716-1.716v-1.918h10.189a1.721,1.721,0,0,0,1.716-1.716V72.8a1.721,1.721,0,0,0-1.716-1.716H98.2V67.437A3.444,3.444,0,0,0,94.76,64H87.83a3.444,3.444,0,0,0-3.437,3.437v3.652H75.625A1.721,1.721,0,0,0,73.91,72.8v27.21a1.721,1.721,0,0,0,1.716,1.716h10.49v1.918a1.721,1.721,0,0,1-1.716,1.716H72.3a1.721,1.721,0,0,0-1.716,1.716v10.1A1.721,1.721,0,0,0,72.3,118.9h37.724a1.721,1.721,0,0,0,1.716-1.716v-10.1A1.726,1.726,0,0,0,110.023,105.37Zm-8.406-19.1a3.437,3.437,0,1,1-3.437-3.437A3.436,3.436,0,0,1,101.616,86.266ZM87.83,67.437h6.924v3.646H87.83ZM81.121,86.266A3.437,3.437,0,1,1,84.558,89.7,3.436,3.436,0,0,1,81.121,86.266Zm32.48-7.089V93.355a1.716,1.716,0,0,1-3.431,0V79.177a1.716,1.716,0,0,1,3.431,0Zm5.122,3.548v6.881a1.716,1.716,0,0,1-3.431,0V82.724a1.716,1.716,0,0,1,3.431,0ZM70.644,95.07a1.717,1.717,0,0,1-1.716-1.716V79.177a1.716,1.716,0,0,1,3.431,0V93.355A1.713,1.713,0,0,1,70.644,95.07ZM67.231,82.724v6.881a1.716,1.716,0,1,1-3.431,0V82.724a1.716,1.716,0,0,1,3.431,0Zm0,0" transform="translate(-14540.154 22987.455)"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -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,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 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

@@ -34,13 +34,8 @@ 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,
splitUnsupportedUploadFiles,
useUploadPolicy,
validateChatUploadFiles,
} from "@/core/uploads";
import { isIMEComposing } from "@/lib/ime"; import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
@@ -148,94 +143,73 @@ const useOptionalProviderAttachments = () =>
useContext(ProviderAttachmentsContext); useContext(ProviderAttachmentsContext);
const usePromptInputValidation = () => useContext(PromptInputValidationContext); const usePromptInputValidation = () => useContext(PromptInputValidationContext);
export type PromptInputProviderProps = PropsWithChildren<{ export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string; initialInput?: string;
uploadPolicy?: UploadPolicy; }>;
}>;
/** /**
* Optional global provider that lifts PromptInput state outside of PromptInput. * Optional global provider that lifts PromptInput state outside of PromptInput.
* If you don't use it, PromptInput stays fully self-managed. * If you don't use it, PromptInput stays fully self-managed.
*/ */
export function PromptInputProvider({ export function PromptInputProvider({
initialInput: initialTextInput = "", initialInput: initialTextInput = "",
uploadPolicy, children,
children, }: PromptInputProviderProps) {
}: PromptInputProviderProps) { // ----- textInput state
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput); const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []); const clearInput = useCallback(() => setTextInput(""), []);
// ----- attachments state (global when wrapped) // ----- attachments state (global when wrapped)
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>(() => {});
const add = useCallback((files: File[] | FileList) => {
const add = useCallback((files: File[] | FileList) => { const incoming = Array.from(files);
const incoming = Array.from(files);
if (incoming.length === 0) { if (incoming.length === 0) {
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, id: nanoid(),
type: item.mediaType ?? "", type: "file" as const,
})); url: URL.createObjectURL(file),
const validation = validateChatUploadFiles( mediaType: file.type,
incoming, filename: file.name,
existing, file,
effectiveUploadPolicy, })),
); ),
for (const message of new Set( );
validation.issues.map((issue) => issue.message), }, []);
)) {
toast.error(message); const remove = useCallback((id: string) => {
} setAttachmentFiles((prev) => {
if (validation.accepted.length === 0) return; const found = prev.find((f) => f.id === id);
if (found?.url) {
const next = current.concat( URL.revokeObjectURL(found.url);
validation.accepted.map((file) => ({ }
id: nanoid(), return prev.filter((f) => f.id !== id);
type: "file" as const, });
url: URL.createObjectURL(file), }, []);
mediaType: file.type,
filename: file.name, const clear = useCallback(() => {
file, setAttachmentFiles((prev) => {
})), for (const f of prev) {
); if (f.url) {
attachmentsRef.current = next; URL.revokeObjectURL(f.url);
setAttachmentFiles(next); }
}, [effectiveUploadPolicy]); }
return [];
const remove = useCallback((id: string) => { });
const current = attachmentsRef.current; }, []);
const found = current.find((f) => f.id === id);
if (found?.url) URL.revokeObjectURL(found.url); // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
const next = current.filter((f) => f.id !== id); const attachmentsRef = useRef(attachmentFiles);
attachmentsRef.current = next; attachmentsRef.current = attachmentFiles;
setAttachmentFiles(next);
}, []);
const clear = useCallback(() => {
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
attachmentsRef.current = [];
setAttachmentFiles([]);
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
attachmentsRef.current = attachmentFiles;
// Cleanup blob URLs on unmount to prevent memory leaks // Cleanup blob URLs on unmount to prevent memory leaks
useEffect(() => { useEffect(() => {
@@ -483,17 +457,10 @@ export type PromptInputProps = Omit<
// Render a hidden input with given name and keep it in sync for native form posts. Default false. // Render a hidden input with given name and keep it in sync for native form posts. Default false.
syncHiddenInput?: boolean; syncHiddenInput?: boolean;
// Minimal constraints // Minimal constraints
maxFiles?: number; maxFiles?: number;
maxFileSize?: number; // bytes maxFileSize?: number; // bytes
uploadPolicy?: UploadPolicy; onError?: (err: {
onError?: (err: { code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
code:
| "max_files"
| "max_file_size"
| "max_total_size"
| "unsupported_type"
| "accept"
| "unsupported_package";
message: string; message: string;
}) => void; }) => void;
onSubmit: ( onSubmit: (
@@ -509,25 +476,14 @@ export const PromptInput = ({
multiple, multiple,
globalDrop, globalDrop,
syncHiddenInput, syncHiddenInput,
maxFiles, maxFiles,
maxFileSize, maxFileSize,
uploadPolicy,
onError, onError,
onSubmit, onSubmit,
children, children,
...props ...props
}: PromptInputProps) => { }: PromptInputProps) => {
const { data: fetchedUploadPolicy } = useUploadPolicy(); // Try to use a provider controller if present
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
const controller = useOptionalPromptInputController(); const controller = useOptionalPromptInputController();
const usingProvider = !!controller; const usingProvider = !!controller;
@@ -560,11 +516,8 @@ export const PromptInput = ({
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
return patterns.some((pattern) => { return patterns.some((pattern) => {
if (pattern.startsWith(".")) { if (pattern.endsWith("/*")) {
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
}
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);
} }
@@ -574,72 +527,57 @@ export const PromptInput = ({
[accept], [accept],
); );
const addLocal = useCallback( const addLocal = useCallback(
(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 withinSize = (f: File) =>
const localPolicy: UploadPolicy = { maxFileSize ? f.size <= maxFileSize : true;
...effectiveUploadPolicy, const sized = accepted.filter(withinSize);
max_files_per_message: if (accepted.length > 0 && sized.length === 0) {
typeof maxFiles === "number" onError?.({
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles) code: "max_file_size",
: effectiveUploadPolicy.max_files_per_message, message: "All files exceed the maximum size.",
groups: Object.fromEntries( });
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [ return;
key, }
{
...group, setItems((prev) => {
max_bytes: const capacity =
typeof maxFileSize === "number" typeof maxFiles === "number"
? Math.min(group.max_bytes, maxFileSize) ? Math.max(0, maxFiles - prev.length)
: group.max_bytes, : undefined;
}, const capped =
]), typeof capacity === "number" ? sized.slice(0, capacity) : sized;
) as UploadPolicy["groups"], if (typeof capacity === "number" && sized.length > capacity) {
}; onError?.({
const existing = items.map((item) => ({ code: "max_files",
name: item.filename ?? "attachment", message: "Too many files. Some were not added.",
size: item.file?.size ?? 0, });
type: item.mediaType ?? "", }
})); const next: (PromptInputFilePart & { id: string })[] = [];
const validation = validateChatUploadFiles( for (const file of capped) {
accepted, next.push({
existing, id: nanoid(),
localPolicy, type: "file",
); url: URL.createObjectURL(file),
for (const issue of Array.from( mediaType: file.type,
new Map( filename: file.name,
validation.issues.map((item) => [item.message, item]), file,
).values(), });
)) { }
onError?.({ code: issue.code, message: issue.message }); return prev.concat(next);
if (!onError) toast.error(issue.message); });
} },
if (validation.accepted.length === 0) return; [matchesAccept, maxFiles, maxFileSize, onError],
);
setItems((prev) =>
prev.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
),
);
},
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
);
const removeLocal = useCallback( const removeLocal = useCallback(
(id: string) => (id: string) =>
@@ -894,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}
+14 -10
View File
@@ -222,12 +222,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
<User className="size-4" /> <User className="size-4" />
个人信息 个人信息
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem {isAdmin && (
onClick={() => navigate("/page/strategy/admin/menu-management")} <DropdownMenuItem
> onClick={() => navigate("/page/strategy/admin/menu-management")}
<ListTree className="size-4" /> >
{isAdmin ? "默认菜单管理" : "我的菜单"} <ListTree className="size-4" />
</DropdownMenuItem> 菜单管理
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/task-buttons")} onClick={() => navigate("/page/strategy/admin/task-buttons")}
@@ -559,10 +561,12 @@ function LegacyAccordionSidebar({
<User className="size-4" /> <User className="size-4" />
个人信息 个人信息
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}> {isAdmin && (
<ListTree className="size-4" /> <DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
{isAdmin ? "默认菜单管理" : "我的菜单"} <ListTree className="size-4" />
</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" />
@@ -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 = (
@@ -960,9 +965,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
已选 {selectedSkills.length} 个 已选 {selectedSkills.length} 个
</span> </span>
</div> </div>
<SkillTransferSelector <SkillTransferSelector
skills={skills} skills={enabledSkills}
selected={selectedSkills} selected={selectedSkills}
onChange={setSelectedSkills} onChange={setSelectedSkills}
isLoading={isSkillsLoading} isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName} onViewDetail={setDetailSkillName}
@@ -73,8 +73,7 @@ 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 { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import { import {
bootstrapResumePath, bootstrapResumePath,
isBootstrapThread, isBootstrapThread,
@@ -385,7 +384,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 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : ( ) : (
filteredThreads.map((thread) => { filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread( const projectType = myProjectConversationTypeOfThread(thread);
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 +498,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 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectInfo ? ( ) : projectType ? (
<MyProjectThreadIcon type={projectInfo.type} /> <MyProjectThreadIcon type={projectType} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -530,23 +524,15 @@ 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="min-w-0 flex-1 truncate">
className={cn( {desensitize(titleOfThread(thread, t.pages.newChat))}
"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))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{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" />
)} )}
@@ -153,7 +153,6 @@ export function AgentDetailPanel({
skillsIcon, skillsIcon,
skillsTitle, skillsTitle,
belowSkillsSlot, belowSkillsSlot,
feedbackScoreSlot,
showCommonQuestions = false, showCommonQuestions = false,
jobResponsibilities, jobResponsibilities,
panelTitle, panelTitle,
@@ -185,8 +184,6 @@ export function AgentDetailPanel({
skillsTitle?: string; skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */ /** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode; belowSkillsSlot?: ReactNode;
/** Optional answer-feedback summary rendered near the agent identity. */
feedbackScoreSlot?: ReactNode;
showCommonQuestions?: boolean; showCommonQuestions?: boolean;
/** /**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示; * 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -750,8 +747,6 @@ export function AgentDetailPanel({
)} )}
</section> </section>
{!editing && feedbackScoreSlot}
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */} {/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
{jobResponsibilities !== undefined && ( {jobResponsibilities !== undefined && (
@@ -913,7 +908,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 ? (
@@ -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,13 +169,11 @@ export function AgentGallery({
(activeTab !== "private" || privateUnlocked); (activeTab !== "private" || privateUnlocked);
const activeTotal = const activeTotal =
wxPlaceholderTab activeTab === "sentiment"
? 0 ? 1
: activeTab === "sentiment" : activeTab === "private"
? 1 ? privateTotal
: activeTab === "private" : mainTotal;
? privateTotal
: mainTotal;
const setTabPage = (tab: string, page: number) => const setTabPage = (tab: string, page: number) =>
setPages((prev) => ({ ...prev, [tab]: page })); setPages((prev) => ({ ...prev, [tab]: page }));
@@ -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"
@@ -9,8 +9,7 @@ import {
MyProjectThreadIcon, MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator"; } 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 { myProjectConversationTypeOfThread } 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 +31,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 +50,7 @@ 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( const projectType = myProjectConversationTypeOfThread(thread);
thread,
projectThreadNames,
);
return ( return (
<Link <Link
key={thread.thread_id} key={thread.thread_id}
@@ -63,14 +58,13 @@ 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 ? ( {projectType ? (
<MyProjectThreadIcon type={projectInfo.type} /> <MyProjectThreadIcon type={projectType} />
) : ( ) : (
<MessageSquareIcon <MessageSquareIcon
className={cn( className={cn(
@@ -79,22 +73,10 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
)} )}
/> />
)} )}
<span <span className="min-w-0 flex-1 truncate">
className={cn( {desensitize(titleOfThread(thread, "新对话"))}
"min-w-0 flex-1",
projectInfo && "flex flex-col items-start gap-0.5",
)}
>
<span className="w-full truncate">
{desensitize(titleOfThread(thread, "新对话"))}
</span>
{projectInfo && (
<MyProjectThreadBadge
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span> </span>
{projectType && <MyProjectThreadBadge type={projectType} />}
</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)}
@@ -1,5 +1,5 @@
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, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
@@ -27,8 +27,6 @@ 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";
@@ -43,6 +41,7 @@ import {
import { isWordDocumentFile } from "@/core/utils/files"; import { isWordDocumentFile } from "@/core/utils/files";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { import {
ArtifactFileDetail, ArtifactFileDetail,
@@ -51,7 +50,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 };
@@ -91,29 +89,16 @@ const ChatBox: React.FC<{
threadId: string; threadId: string;
disableArtifacts?: boolean; disableArtifacts?: boolean;
writingMode?: 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, 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();
@@ -123,22 +108,19 @@ 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);
const manualReferenceBatchRef = useRef(false); const manualReferenceBatchRef = useRef(false);
const referenceDebugSignatureRef = useRef<string>(""); const referenceDebugSignatureRef = useRef<string>("");
const referenceBatchesRef = useRef<BackendReferenceBatch[]>([]);
const [sideTab, setSideTab] = useState("document"); const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true); const [referencesOpen, setReferencesOpen] = useState(true);
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null); const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null); const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [wikiCitationSource, setWikiCitationSource] = useState<BackendReferenceSource | 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 = writingMode ?? !!settings.context.writing_mode;
@@ -150,8 +132,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.
@@ -168,7 +149,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 &&
@@ -177,8 +158,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(
@@ -188,7 +168,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(
@@ -201,28 +181,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;
@@ -287,41 +248,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);
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([]);
@@ -373,7 +299,7 @@ const ChatBox: React.FC<{
), ),
[backendReferenceBatches, skillDisplayMap], [backendReferenceBatches, skillDisplayMap],
); );
const resolvedReferenceBatches = useMemo( const referenceBatches = useMemo(
() => { () => {
if ( if (
shouldFetchBackendReferenceBatches && shouldFetchBackendReferenceBatches &&
@@ -389,18 +315,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;
@@ -446,6 +360,9 @@ const ChatBox: React.FC<{
latestReferenceBatch; latestReferenceBatch;
const referenceSources = activeReferenceBatch?.sources ?? []; const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0; const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
referenceBatchesRef.current = referenceBatches;
}, [referenceBatches]);
useEffect(() => { useEffect(() => {
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) { if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
return; return;
@@ -491,6 +408,16 @@ const ChatBox: React.FC<{
window.setTimeout(() => { window.setTimeout(() => {
setPinnedReferenceId(detail?.referenceId ?? null); setPinnedReferenceId(detail?.referenceId ?? null);
}, 0); }, 0);
const referenceIndex = Number(detail?.referenceId);
const batch = referenceBatchesRef.current.find((item) => item.id === detail?.batchId)
?? referenceBatchesRef.current.at(-1);
const source = Number.isInteger(referenceIndex) && referenceIndex > 0
? batch?.sources.find((item) => item.index === referenceIndex)
?? batch?.sources[referenceIndex - 1]
: undefined;
if (source?.skillName?.startsWith("llmwiki_") || source?.type?.startsWith("llmwiki_")) {
setWikiCitationSource(source);
}
setReferencesOpen(true); setReferencesOpen(true);
setSideTab("references"); setSideTab("references");
}; };
@@ -531,20 +458,9 @@ const ChatBox: React.FC<{
((artifactDirectoryModeEnabled && threadChanged) || ((artifactDirectoryModeEnabled && threadChanged) ||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact)) ((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]!;
if (
shouldAutoOpenWritingArtifact(
automaticTarget,
thread.messages,
writingModeEnabled,
)
) {
selectArtifact(automaticTarget);
setArtifactsOpen(true); setArtifactsOpen(true);
setSideTab("document"); setSideTab("document");
} }
@@ -583,7 +499,6 @@ const ChatBox: React.FC<{
artifacts, artifacts,
setArtifacts, setArtifacts,
availableArtifacts, availableArtifacts,
availablePreferredArtifact,
disableArtifacts, disableArtifacts,
thread.messages, thread.messages,
writingModeEnabled, writingModeEnabled,
@@ -796,7 +711,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}
> >
@@ -807,8 +722,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",
)} )}
/> />
@@ -830,9 +743,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"
@@ -848,19 +759,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 ? ( <ReferencePanel
<KnowledgeWikiPanel highlightedReferenceId={pinnedReferenceId}
highlightedReferenceId={pinnedReferenceId} sources={referenceSources}
selection={knowledgeReferences} />
sources={referenceSources}
/>
) : (
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) : artifactPanelOpen ? ( ) : artifactPanelOpen ? (
@@ -879,23 +782,26 @@ const ChatBox: React.FC<{
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
{knowledgeReferences ? ( <ReferencePanel
<KnowledgeWikiPanel highlightedReferenceId={pinnedReferenceId}
highlightedReferenceId={pinnedReferenceId} sources={referenceSources}
selection={knowledgeReferences} />
sources={referenceSources}
/>
) : (
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
)}
</div> </div>
) : null} ) : null}
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
<WeKnoraSourceDrawer
open={wikiCitationSource !== null}
target={null}
title={wikiCitationSource?.title || "Wiki 文档"}
quote={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
inlineContent={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
inlineKnowledgeBaseId={String(wikiCitationSource?.raw?.knowledge_base_id || "")}
onOpenChange={(open) => {
if (!open) setWikiCitationSource(null);
}}
/>
</ReferenceBatchesContext.Provider> </ReferenceBatchesContext.Provider>
); );
}; };
@@ -908,19 +814,23 @@ function ReferencePanel({
sources: Parameters<typeof SkillDisplaySources>[0]["sources"]; sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
}) { }) {
const orderedSources = useMemo(() => { const orderedSources = useMemo(() => {
if (!highlightedReferenceId) { // This is presentation-only. Keep every source's original `index`, since
return sources; // inline citations resolve through that index (for example [3] must still
} // open the third source returned by retrieval even when its card is shown
const index = Number(highlightedReferenceId); // below a higher-scoring result).
if (!Number.isFinite(index)) { return sources
return sources; .map((source, originalOrder) => ({ source, originalOrder }))
} .sort((left, right) => {
const selected = sources.find((source) => source.index === index); const leftScore = typeof left.source.score === "number" && Number.isFinite(left.source.score)
if (!selected) { ? left.source.score
return sources; : Number.NEGATIVE_INFINITY;
} const rightScore = typeof right.source.score === "number" && Number.isFinite(right.source.score)
return [selected, ...sources.filter((source) => source.index !== index)]; ? right.source.score
}, [highlightedReferenceId, sources]); : Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }) => source);
}, [sources]);
return ( return (
<div className="flex size-full flex-col overflow-hidden"> <div className="flex size-full flex-col overflow-hidden">
@@ -22,7 +22,6 @@ import {
Download, Download,
FileJson, FileJson,
FileText, FileText,
Loader2,
MessageSquare, MessageSquare,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
@@ -30,20 +29,18 @@ import {
PinOff, PinOff,
Plus, Plus,
Search, Search,
Settings,
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, useEffect, 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,
@@ -69,6 +66,7 @@ import {
MyProjectThreadIcon, MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator"; } from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents"; import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -83,8 +81,7 @@ 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 { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -97,17 +94,17 @@ 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 {
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config"; usePublishSquares,
useUpdateWxCommonAgents,
useWxCommonAgents,
} from "@/core/system-settings/hooks";
import { SENTIMENT_AGENT_ID, isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
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";
@@ -124,11 +121,13 @@ function SectionLabel({
count, count,
collapsed, collapsed,
onToggle, onToggle,
onOpenSettings,
}: { }: {
label: string; label: string;
count?: number; count?: number;
collapsed: boolean; collapsed: boolean;
onToggle: () => void; onToggle: () => void;
onOpenSettings?: () => 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"> <div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
@@ -152,45 +151,111 @@ function SectionLabel({
)} )}
/> />
</button> </button>
{onOpenSettings && (
<button
type="button"
onClick={onOpenSettings}
className="ml-1 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
aria-label="设置智能体类型"
title="设置智能体类型"
>
<Settings className="size-3.5" />
</button>
)}
</div> </div>
); );
} }
/** Gallery category values persisted for the dedicated wx full-chat route. */
const WX_AGENT_TYPE_MINE = "mine";
const WX_AGENT_TYPE_SENTIMENT = "sentiment";
const WX_AGENT_TYPE_SQUARE_PREFIX = "square:";
const WX_AGENT_TYPE_DEFAULT = "__default__";
type CommonAgent = Pick<Agent, "id" | "name" | "description">; type CommonAgent = Pick<Agent, "id" | "name" | "description">;
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */ type WxAgentTypeOption = {
const WX_AGENT_TYPE_MINE = "mine"; value: string;
const WX_AGENT_TYPE_DEFAULT_SQUARE = "default_square"; label: string;
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 = [ function wxSquareAgentType(squareId: string) {
{ value: WX_AGENT_TYPE_MINE, label: "我的" }, return `${WX_AGENT_TYPE_SQUARE_PREFIX}${squareId}`;
{ 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 { function wxSquareIdOf(agentType: string): string | undefined {
if (typeof window === "undefined") return WX_AGENT_TYPE_MINE; return agentType.startsWith(WX_AGENT_TYPE_SQUARE_PREFIX)
const stored = window.localStorage.getItem(WX_AGENT_TYPE_STORAGE_KEY); ? agentType.slice(WX_AGENT_TYPE_SQUARE_PREFIX.length)
return isWxAgentType(stored) ? stored : WX_AGENT_TYPE_MINE; : undefined;
} }
function resolveWxSquareId( function WxAgentTypeSettingsDialog({
agentType: WxAgentType, open,
squares: Array<{ id: string; name: string }> | undefined, onOpenChange,
defaultSquareId: string | undefined, agentType,
): string | undefined { options,
if (agentType === WX_AGENT_TYPE_MINE) return undefined; }: {
if (agentType === WX_AGENT_TYPE_DEFAULT_SQUARE) return defaultSquareId; open: boolean;
return findNanjingDbSquareId(squares); onOpenChange: (open: boolean) => void;
agentType: string | null;
options: WxAgentTypeOption[];
}) {
const [selectedType, setSelectedType] = useState(
agentType ?? WX_AGENT_TYPE_DEFAULT,
);
const { mutateAsync: updateWxCommonAgents, isPending } =
useUpdateWxCommonAgents();
useEffect(() => {
if (open) setSelectedType(agentType ?? WX_AGENT_TYPE_DEFAULT);
}, [agentType, open]);
const handleSave = async () => {
try {
await updateWxCommonAgents(
selectedType === WX_AGENT_TYPE_DEFAULT ? null : selectedType,
);
toast.success("常用智能体类型已更新");
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存智能体类型失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>设置常用智能体类型</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<label htmlFor="wx-common-agent-type" className="text-sm font-medium">
智能体类型
</label>
<select
id="wx-common-agent-type"
value={selectedType}
onChange={(event) => setSelectedType(event.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={isPending}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
} }
export function GeneralQaSidebar({ export function GeneralQaSidebar({
@@ -221,67 +286,96 @@ export function GeneralQaSidebar({
return !v; return !v;
}); });
const { data: publishSquares, isLoading: wxSquaresLoading } = usePublishSquares({ const [agentTypeSettingsOpen, setAgentTypeSettingsOpen] = useState(false);
const { data: wxCommonAgentSettings } = useWxCommonAgents({
enabled: !!wxCommonAgents, enabled: !!wxCommonAgents,
}); });
const [wxAgentType, setWxAgentTypeState] = useState<WxAgentType>( const { data: publishSquares } = usePublishSquares({
readStoredWxAgentType, enabled: !!wxCommonAgents,
});
const isWxManager =
wxCommonAgents &&
getAccountDisplayName().toLowerCase() === "wx";
const sentimentVisible = isSentimentAgentVisible();
const wxCategoryOptions = useMemo<WxAgentTypeOption[]>(() => {
const options: WxAgentTypeOption[] = [
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
...(publishSquares?.squares ?? []).map((square) => ({
value: wxSquareAgentType(square.id),
label: square.name,
})),
];
if (sentimentVisible) {
options.push({ value: WX_AGENT_TYPE_SENTIMENT, label: "舆情分析" });
}
return options;
}, [publishSquares?.squares, sentimentVisible]);
const wxAgentTypeOptions = useMemo<WxAgentTypeOption[]>(
() => [
{ value: WX_AGENT_TYPE_DEFAULT, label: "默认推荐(未设置)" },
...wxCategoryOptions,
],
[wxCategoryOptions],
);
const configuredWxAgentType = wxCommonAgentSettings?.agent_type ?? null;
const wxUsesCategoryFilter = !!configuredWxAgentType && wxCategoryOptions.some(
(option) => option.value === configuredWxAgentType,
);
const wxSquareId =
wxCommonAgents && wxUsesCategoryFilter && configuredWxAgentType
? wxSquareIdOf(configuredWxAgentType)
: undefined;
const wxShowsSentiment =
wxCommonAgents &&
wxUsesCategoryFilter &&
configuredWxAgentType === WX_AGENT_TYPE_SENTIMENT;
const [sentimentProfile, setSentimentProfile] = useState(() =>
readSentimentAgentProfile(),
); );
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(() => { useEffect(() => {
const timer = window.setTimeout(() => setWxSearch(wxSearchInput.trim()), 300); const refresh = () => setSentimentProfile(readSentimentAgentProfile());
return () => window.clearTimeout(timer); window.addEventListener("sentiment-agent-profile-changed", refresh);
}, [wxSearchInput]); return () =>
const wxSquareId = wxCommonAgents window.removeEventListener("sentiment-agent-profile-changed", refresh);
? 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", scope: "mine",
page: 1, page: 1,
pageSize: 20, pageSize: 20,
enabled: !wxCommonAgents, enabled: !wxUsesCategoryFilter,
}); });
const { agents: square } = useAgentsPaginated({ const { agents: square } = useAgentsPaginated({
scope: "square", scope: "square",
page: 1, page: 1,
pageSize: 20, pageSize: 20,
enabled: !wxCommonAgents, enabled: !wxUsesCategoryFilter,
}); });
const { const { agents: wxAgents } = useAgentsPaginated({
agents: wxAgents, scope: wxSquareId ? "square" : "mine",
total: wxTotal,
isLoading: wxAgentsLoading,
isFetching: wxAgentsFetching,
} = useAgentsPaginated({
scope: wxAgentType === WX_AGENT_TYPE_MINE ? "mine" : "square",
page: 1, page: 1,
pageSize: WX_AGENT_LIST_PAGE_SIZE, pageSize: MAX_FREQUENT,
square: wxSquareId, square: wxSquareId,
search: wxSearch || undefined, enabled: wxUsesCategoryFilter && !wxShowsSentiment,
enabled: wxFetchEnabled,
}); });
const frequent = useMemo<CommonAgent[]>(() => { const frequent = useMemo<CommonAgent[]>(() => {
if (wxCommonAgents) { if (wxUsesCategoryFilter) {
return wxFetchEnabled ? wxAgents : []; if (wxShowsSentiment) {
return [
{
id: SENTIMENT_AGENT_ID,
name: sentimentProfile.name,
description: sentimentProfile.description,
},
];
}
return wxAgents.slice(0, MAX_FREQUENT);
} }
const out: Agent[] = []; const out: Agent[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -294,15 +388,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, sentimentProfile, square, wxAgents, wxShowsSentiment, wxUsesCategoryFilter]);
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 +401,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,
@@ -352,7 +432,6 @@ export function GeneralQaSidebar({
// ── 最近的对话取数 + 搜索 ── // ── 最近的对话取数 + 搜索 ──
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 +458,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()
.includes(q),
) )
: recentItems; : visibleThreads;
// 未搜索时与旧版一致:最多展示 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,81 +571,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 || isWxManager) && (
<div className="pt-2"> <div className="pt-2">
<SectionLabel <SectionLabel
label="常用智能体" label="常用智能体"
count={wxCommonAgents ? frequent.length : undefined} count={wxUsesCategoryFilter ? frequent.length : undefined}
collapsed={agentsCollapsed} collapsed={agentsCollapsed}
onToggle={toggleAgents} onToggle={toggleAgents}
onOpenSettings={isWxManager ? () => setAgentTypeSettingsOpen(true) : undefined}
/> />
{!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 ||
@@ -657,50 +661,13 @@ export function GeneralQaSidebar({
</button> </button>
); );
})} })}
</div> <Link
)} href={moreAgentsHref}
{wxCommonAgents ? ( 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"
<div className="grid grid-cols-2 gap-1.5 px-1 pt-0.5"> >
<Link <span>{t.common.more}</span>
href={moreAgentsHref} <ChevronRight className="size-3.5 shrink-0" />
className={cn( </Link>
"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
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"
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
)}
</div> </div>
)} )}
</div> </div>
@@ -710,7 +677,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 +695,30 @@ 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( const projectType = myProjectConversationTypeOfThread(thread);
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 +732,8 @@ export function GeneralQaSidebar({
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectInfo ? ( ) : projectType ? (
<MyProjectThreadIcon type={projectInfo.type} /> <MyProjectThreadIcon type={projectType} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -823,23 +745,15 @@ 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="min-w-0 flex-1 truncate">
className={cn( {desensitize(titleOfThread(thread, t.pages.newChat))}
"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))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-4 max-w-[calc(100%_-_1rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{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" />
)} )}
@@ -970,6 +884,14 @@ export function GeneralQaSidebar({
if (!o) setShareThreadId(null); if (!o) setShareThreadId(null);
}} }}
/> />
{wxCommonAgents && isWxManager && (
<WxAgentTypeSettingsDialog
open={agentTypeSettingsOpen}
onOpenChange={setAgentTypeSettingsOpen}
agentType={wxUsesCategoryFilter ? configuredWxAgentType : null}
options={wxAgentTypeOptions}
/>
)}
</div> </div>
); );
} }
@@ -114,11 +114,10 @@ export function InputBox({
extraHeader, extraHeader,
isNewThread, isNewThread,
threadId, threadId,
initialValue, initialValue,
onContextChange, onContextChange,
onFollowupsPresenceChange, onFollowupsVisibilityChange,
followupsDisplayAllowed = true, onSubmit,
onSubmit,
onStop, onStop,
promptPrefix, promptPrefix,
agentSelector, agentSelector,
@@ -164,15 +163,8 @@ export function InputBox({
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: 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;
/** /**
@@ -501,27 +493,26 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0); setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]); }, [pendingSuggestion, requestFormSubmit, textInput]);
const followupsPresent = const showFollowups =
!disabled && !disabled &&
!isNewThread && !isNewThread &&
!selectedSingleRoundRag && !selectedSingleRoundRag &&
!followupsHidden && !followupsHidden &&
(followupsLoading || followups.length > 0); (followupsLoading || followups.length > 0);
const showFollowups = followupsDisplayAllowed && followupsPresent;
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
useEffect(() => {
useEffect(() => { followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
followupsPresenceChangeRef.current = onFollowupsPresenceChange; }, [onFollowupsVisibilityChange]);
}, [onFollowupsPresenceChange]);
useEffect(() => {
useEffect(() => { followupsVisibilityChangeRef.current?.(showFollowups);
followupsPresenceChangeRef.current?.(followupsPresent); }, [showFollowups]);
}, [followupsPresent]);
useEffect(() => {
useEffect(() => { return () => followupsVisibilityChangeRef.current?.(false);
return () => followupsPresenceChangeRef.current?.(false); }, []);
}, []);
useEffect(() => { useEffect(() => {
const streaming = status === "streaming"; const streaming = status === "streaming";
@@ -1,5 +1,5 @@
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";
@@ -50,21 +50,16 @@ export function KnowledgeProductNav() {
toast.success(`默认知识库体验已切换为 ${value}`); toast.success(`默认知识库体验已切换为 ${value}`);
}, },
}); });
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
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>
<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>
<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">
普通知识库默认页面 普通知识库默认页面
@@ -1,413 +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 {
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 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={openContent}
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>
);
}
@@ -476,19 +476,9 @@ export function LlmWikiKnowledgeSelector({
() => [...selectedNames, ...selectedAssistantNames], () => [...selectedNames, ...selectedAssistantNames],
[selectedAssistantNames, selectedNames], [selectedAssistantNames, selectedNames],
); );
const selectedConfiguredQuestions = useMemo(
() => value.flatMap((id) => rows.find((item) => item.id === id)?.test_questions ?? []),
[rows, value],
);
const suggestedQuestions = useMemo( const suggestedQuestions = useMemo(
() => { () => buildKnowledgeSpaceQuestions(allSelectedNames),
if (value.length === 1 && assistantValue.length === 0 && selectedConfiguredQuestions.length > 0) { [allSelectedNames],
return selectedConfiguredQuestions.slice(0, 12);
}
const general = buildKnowledgeSpaceQuestions(allSelectedNames);
return Array.from(new Set([...selectedConfiguredQuestions, ...general])).slice(0, 12);
},
[allSelectedNames, assistantValue.length, selectedConfiguredQuestions, value.length],
); );
useEffect(() => { useEffect(() => {
@@ -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,8 +1,7 @@
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";
export interface ThreadContextType { export interface ThreadContextType {
@@ -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
@@ -32,13 +32,13 @@ import {
CollapsibleContent, CollapsibleContent,
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, hasCompleteArtifactExtension,
@@ -82,7 +82,6 @@ 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";
export function MessageGroup({ export function MessageGroup({
className, className,
@@ -138,19 +137,6 @@ export function MessageGroup({
} }
}, [lastToolCallStep, steps]); }, [lastToolCallStep, steps]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(null);
const handleCitationClick = useCallback(
(referenceId: string) => {
const referenceIndex = Number(referenceId);
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
const source =
displaySources.find((item) => item.index === referenceIndex) ??
displaySources[referenceIndex - 1];
if (source) setSelectedCitation(source);
},
[displaySources],
);
const reasoningId = const reasoningId =
lastReasoningStep?.id ?? lastReasoningStep?.id ??
messages.find((message) => message.type === "ai")?.id; messages.find((message) => message.type === "ai")?.id;
@@ -200,7 +186,6 @@ export function MessageGroup({
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -208,8 +193,9 @@ export function MessageGroup({
<ToolCall <ToolCall
key={step.id} key={step.id}
{...step} {...step}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
), ),
@@ -220,8 +206,9 @@ export function MessageGroup({
key={lastToolCallStep.id} key={lastToolCallStep.id}
{...lastToolCallStep} {...lastToolCallStep}
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
@@ -262,7 +249,6 @@ export function MessageGroup({
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -271,12 +257,6 @@ export function MessageGroup({
</> </>
)} )}
</ChainOfThought> </ChainOfThought>
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
</> </>
); );
} }
@@ -286,14 +266,12 @@ function StepReasoningMarkdown({
displaySources, displaySources,
isLoading, isLoading,
rehypePlugins, rehypePlugins,
onCitationClick, }: {
}: {
content: string; content: string;
displaySources: RuntimeDisplaySource[]; displaySources: RuntimeDisplaySource[];
isLoading: boolean; isLoading: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>; rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onCitationClick: (referenceId: string) => void; }) {
}) {
const markdown = useMemo( const markdown = useMemo(
() => () =>
applyInlineCitations(content, displaySources, { appendSummary: false }), applyInlineCitations(content, displaySources, { appendSummary: false }),
@@ -303,19 +281,18 @@ function StepReasoningMarkdown({
if (displaySources.length === 0) return undefined; if (displaySources.length === 0) return undefined;
return { return {
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => { a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) { if (href?.startsWith("#reference-")) {
return ( return (
<CitationLink <CitationLink
href={href} href={href}
{...props} {...props}
onReferenceClick={onCitationClick} />
/> );
);
} }
return <a href={href} {...props} />; return <a href={href} {...props} />;
}, },
}; };
}, [displaySources.length, onCitationClick]); }, [displaySources.length]);
return ( return (
<MarkdownContent <MarkdownContent
@@ -335,7 +312,8 @@ function ToolCall({
result, result,
isLast = false, isLast = false,
isLoading = false, isLoading = false,
skillDisplayMap = new Map(), skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false, ragModeEnabled = false,
}: { }: {
id?: string; id?: string;
@@ -345,7 +323,8 @@ function ToolCall({
result?: string | Record<string, unknown>; result?: string | Record<string, unknown>;
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();
@@ -354,21 +333,28 @@ function ToolCall({
? null ? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ?? : (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null); null);
const displayItems = const displayItems =
displayMatch && result !== undefined displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ ? syncDisplaySourceIndexes(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 wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : ""; const isLlmWikiTool = name === "llmwiki_search" || name === "llmwiki_read_wiki_page" || name === "llmwiki_browse_wiki_index";
const wikiSearchLabel = name === "llmwiki_search" const wikiQuery = isLlmWikiTool ? resolveSearchQuery(args, result) : "";
? wikiQuery const wikiSearchLabel = name === "llmwiki_search"
? `Wiki 文档检索 "${wikiQuery}"` ? wikiQuery
: "Wiki 文档检索" ? `Wiki 文档检索 "${wikiQuery}"`
: null; : "Wiki 文档检索"
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) { if (displayItems.length > 0) {
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
@@ -394,12 +380,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 = normalizeDefaultDisplaySources(result).map((item, index) => ({ const defaultItems = syncDisplaySourceIndexes(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} />
@@ -859,9 +845,21 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
type CoTStep = CoTReasoningStep | CoTToolCallStep; type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] { function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = []; const steps: CoTStep[] = [];
for (const message of messages) { // A streamed ToolMessage normally carries tool_call_id. If a provider
// omits it, the legacy name-based fallback is safe only for a unique tool
// name. Reusing one llmwiki_search result for several parallel calls makes
// every card appear to come from the same knowledge base.
const toolCallNameCounts = new Map<string, number>();
for (const message of messages) {
if (message.type !== "ai") continue;
for (const toolCall of message.tool_calls ?? []) {
const key = toolCall.name.toLowerCase();
toolCallNameCounts.set(key, (toolCallNameCounts.get(key) ?? 0) + 1);
}
}
for (const message of messages) {
if (message.type === "ai") { if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message); const reasoning = extractReasoningContentFromMessage(message);
if (reasoning) { if (reasoning) {
@@ -902,11 +900,12 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
}; };
const toolCallId = tool_call.id; const toolCallId = tool_call.id;
if (toolCallId) { if (toolCallId) {
const toolCallResult = findToolCallResult( const toolCallResult = findToolCallResult(
toolCallId, toolCallId,
messages, messages,
tool_call.name, tool_call.name,
); { allowNameFallback: toolCallNameCounts.get(tool_call.name.toLowerCase()) === 1 },
);
if (toolCallResult !== undefined) { if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") { if (typeof toolCallResult === "string") {
try { try {
@@ -951,9 +950,27 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
); );
} }
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) { function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( const orderedResults = useMemo(
() =>
results
.map((item, originalOrder) => ({ item, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.item.score === "number" && Number.isFinite(left.item.score)
? left.item.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.item.score === "number" && Number.isFinite(right.item.score)
? right.item.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ item }) => item),
[results],
);
return (
<Collapsible open={open} onOpenChange={setOpen} className="w-full"> <Collapsible open={open} onOpenChange={setOpen} className="w-full">
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"> <CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
<ChevronDownIcon <ChevronDownIcon
@@ -965,13 +982,52 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
<span>{results.length} 条真实返回结果</span> <span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2"> <CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => ( {orderedResults.map((item) => (
<SourceCard key={`${item.skillName}-${item.index}`} source={item} /> <SourceCard
))} key={`${item.skillName}-${item.index}`}
source={item}
/>
))}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
); );
} }
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 id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
return id ? [[id, source.index] as const] : [];
}),
);
return sources.map((source) => {
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
const index = id ? indexByCitationId.get(id) : 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);
@@ -1010,46 +1066,32 @@ 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">
> {item.title ?? "检索结果"}
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left"> </DialogTitle>
<SheetTitle className="text-base leading-relaxed"> {(sourceLabel || date) && (
{item.title ?? "检索结果"} <DialogDescription className="flex items-center gap-2 text-xs">
</SheetTitle> {sourceLabel && <span>{sourceLabel}</span>}
{(sourceLabel || date) && ( {sourceLabel && date && <span>·</span>}
<SheetDescription className="flex items-center gap-2 text-xs"> {date && <span>{date}</span>}
{sourceLabel && <span>{sourceLabel}</span>} </DialogDescription>
{sourceLabel && date && <span>·</span>} )}
{date && <span>{date}</span>} </DialogHeader>
</SheetDescription> {content && (
)} <div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{!sourceLabel && !date && ( {content}
<SheetDescription className="sr-only">检索结果详情</SheetDescription> </div>
)} )}
</SheetHeader> {item.url && (
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6"> <div className="text-muted-foreground border-t pt-3 text-xs break-all">
<div className="mx-auto max-w-3xl space-y-4"> {item.url}
{content ? ( </div>
<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} </DialogContent>
</article> </Dialog>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
</> </>
); );
} }
@@ -1,10 +1,9 @@
import type { Message } from '@langchain/langgraph-sdk'; import type { Message } from '@langchain/langgraph-sdk';
import { import {
ArrowLeftIcon, BookMarkedIcon,
BookMarkedIcon, ChevronDownIcon,
ChevronRightIcon, ClockIcon,
ClockIcon, ExternalLinkIcon,
ExternalLinkIcon,
FileIcon, FileIcon,
Loader2Icon, Loader2Icon,
PencilIcon, PencilIcon,
@@ -35,15 +34,20 @@ import {
ReasoningTrigger, ReasoningTrigger,
} from '@/components/ai-elements/reasoning'; } from '@/components/ai-elements/reasoning';
import { Task, TaskTrigger } from '@/components/ai-elements/task'; 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,
@@ -51,11 +55,10 @@ import {
} from '@/components/workspace/messages/uploaded-file-preview-dialog'; } from '@/components/workspace/messages/uploaded-file-preview-dialog';
import { resolveArtifactURL } from '@/core/artifacts/utils'; import { resolveArtifactURL } from '@/core/artifacts/utils';
import { useChatAskTimes } from '@/core/chat-timestamps'; 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,
parseUploadedFiles, parseUploadedFiles,
@@ -74,16 +77,14 @@ import {
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 { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
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 { DeleteMessageButton } from './delete-message-button';
import { isEligibleAnswerFeedbackMessage } from './answer-feedback-eligibility';
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';
import { MessageTokenUsage } from './message-token-usage'; import { MessageTokenUsage } from './message-token-usage';
@@ -166,11 +167,9 @@ export function MessageListItem({
extraRehypePlugins, extraRehypePlugins,
onEditResubmit, onEditResubmit,
onEditSave, onEditSave,
onMessageDeleted, onMessageDeleted,
turnTime, turnTime,
answerFeedbackEnabled = false, }: {
answerFeedbackContext,
}: {
className?: string; className?: string;
fitRowToContent?: boolean; fitRowToContent?: boolean;
/** /**
@@ -195,12 +194,8 @@ export function MessageListItem({
* timestamp of its own — without it the answer would fall back to the current * timestamp of its own — without it the answer would fall back to the current
* 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';
const messageMarkdown = const messageMarkdown =
@@ -217,16 +212,13 @@ export function MessageListItem({
} }
}, [assistantEditing, messageMarkdown]); }, [assistantEditing, messageMarkdown]);
const canSaveAssistantEdit = const canSaveAssistantEdit =
!isLoading && !isLoading &&
!isHuman && !isHuman &&
!!onEditSave && !!onEditSave &&
!!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) {
@@ -334,16 +326,9 @@ export function MessageListItem({
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100', '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 && ( {messageMarkdown && (
<AnswerFeedbackControl
threadId={threadId}
messageId={message.id}
context={answerFeedbackContext}
/>
)}
{messageMarkdown && (
<DownloadWordButton <DownloadWordButton
markdown={messageMarkdown} markdown={messageMarkdown}
fileName="message" fileName="message"
@@ -702,25 +687,22 @@ function MessageContent_({
return files as FileInMessage[]; return files as FileInMessage[];
}, [message.additional_kwargs?.files, rawContent]); }, [message.additional_kwargs?.files, rawContent]);
const contentToDisplay = useMemo(() => { const contentToDisplay = useMemo(() => {
if (isHuman) { if (isHuman) {
return rawContent return rawContent
? stripArtifactTag(stripUploadedFilesTag(rawContent)) ? stripArtifactTag(stripUploadedFilesTag(rawContent))
: ''; : '';
} }
const sanitizedContent = stripKnowledgeRetrievalDiagnostics(rawContent ?? ''); if (isMultiRoundRag) {
if (isMultiRoundRag) { return stripIncompleteCitationMarker(rawContent ?? '');
return stripIncompleteCitationMarker(sanitizedContent); }
} const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
const withCitations = applyInlineCitations(sanitizedContent, displaySources); return stripIncompleteCitationMarker(withCitations);
return stripIncompleteCitationMarker(withCitations); }, [rawContent, isHuman, isMultiRoundRag, displaySources]);
}, [rawContent, isHuman, isMultiRoundRag, displaySources]); const markdownRehypePlugins = useMemo(
const markdownRehypePlugins = useMemo< () => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
NonNullable<MarkdownContentProps['rehypePlugins']> [rehypePlugins],
>( );
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const parsedHumanContent = useMemo( const parsedHumanContent = useMemo(
() => () =>
isHuman isHuman
@@ -826,8 +808,11 @@ 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}
@@ -839,7 +824,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);
@@ -850,176 +835,130 @@ function MessageContent_({
); );
} }
/** /**
* 单轮 RAG 的参考文献入口。先打开右侧抽屉查看列表, * 单轮 RAG 专属的「引用来源」折叠面板。
* 选中条目后在同一抽屉中阅读详情,避免跳出居中弹框打断问答。 *
*/ * 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
function FrontendRagSourcesPanel({ sources }: { sources: RuntimeDisplaySource[] }) { */
const [open, setOpen] = useState(false); function FrontendRagSourcesPanel({
const [selectedSource, setSelectedSource] = useState<RuntimeDisplaySource | null>(null); sources,
if (sources.length === 0) return null; onSelect,
}: {
const handleOpenChange = (nextOpen: boolean) => { sources: RuntimeDisplaySource[];
setOpen(nextOpen); onSelect: (source: RuntimeDisplaySource) => void;
if (!nextOpen) setSelectedSource(null); }) {
}; const [open, setOpen] = useState(false);
if (sources.length === 0) return null;
return ( return (
<> <Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<button <CollapsibleTrigger asChild>
type="button" <button
onClick={() => setOpen(true)} type="button"
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" className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs transition-colors"
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> <ChevronDownIcon
<span>查看</span> className={cn(
<ChevronRightIcon className="size-3.5" /> 'size-3.5 transition-transform duration-200',
</button> !open && '-rotate-90',
)}
<Sheet open={open} onOpenChange={handleOpenChange}> />
<SheetContent </button>
side="right" </CollapsibleTrigger>
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none" <CollapsibleContent className="mt-2 grid gap-2">
> {sources.map((source, idx) => (
<div className="flex h-full min-h-0 flex-col"> <button
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left"> key={source.id ?? `${source.title}-${idx}`}
<div className="flex items-start gap-3"> type="button"
{selectedSource && ( onClick={() => onSelect(source)}
<Button className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
type="button" >
size="icon-sm" <div className="flex items-center gap-2">
variant="ghost" <span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
className="mt-0.5 shrink-0" {source.index}
onClick={() => setSelectedSource(null)} </span>
aria-label="返回参考文献列表" <span
> className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
<ArrowLeftIcon className="size-4" /> title={source.title}
</Button> >
)} {source.title}
<div className="min-w-0"> </span>
<SheetTitle className="truncate text-base"> {source.type && (
{selectedSource?.title || "参考文献"} <span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
</SheetTitle> {source.type}
<SheetDescription className="mt-1 text-xs"> </span>
{selectedSource )}
? "引用详情;可返回列表继续查看其他文献。" </div>
: `本次回答共引用 ${sources.length} 条资料,点击条目查看具体内容。`} {(source.content || source.snippet) && (
</SheetDescription> <p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
</div> {source.content || source.snippet}
</div> </p>
</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) => (
<button
key={source.id ?? `${source.title}-${idx}`}
type="button"
onClick={() => setSelectedSource(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"
>
<div className="flex items-start gap-2.5">
<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">
{source.index}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-foreground line-clamp-2 min-w-0 flex-1 text-sm font-medium">
{source.title}
</span>
{source.type && (
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[11px]">
{source.type}
</span>
)}
</div>
{(source.source || source.time) && (
<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">
{source.snippet || source.content}
</p>
)}
</div>
<ChevronRightIcon className="text-muted-foreground mt-1 size-4 shrink-0 transition-transform group-hover:translate-x-0.5" />
</div>
</button>
))}
</div>
</div>
)} )}
</div> </button>
</SheetContent> ))}
</Sheet> </CollapsibleContent>
</> </Collapsible>
); );
} }
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 ( const isLlmWikiSource =
<Sheet open={!!source} onOpenChange={onOpenChange}> source?.skillName?.startsWith('llmwiki_') ||
<SheetContent source?.type?.startsWith('llmwiki_') ||
side="right" source?.raw?.type === 'llmwiki_wiki_index_intro';
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none" if (isLlmWikiSource) {
> const content = source?.content || source?.snippet || '';
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left"> return (
<SheetTitle className="leading-relaxed"> <WeKnoraSourceDrawer
{source?.title || "引用资料"} open={!!source}
</SheetTitle> target={null}
<SheetDescription className="space-y-1 text-xs"> title={source?.title || 'Wiki 文档'}
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span> quote={content}
{source?.time && <span className="block">时间:{source.time}</span>} inlineContent={content}
</SheetDescription> inlineKnowledgeBaseId={String(source?.raw?.knowledge_base_id || '')}
</SheetHeader> onOpenChange={onOpenChange}
{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">
{sourceUrl && ( <DialogHeader className="pr-8">
<a <DialogTitle className="leading-relaxed">
href={sourceUrl} {source?.title || "引用资料"}
target="_blank" </DialogTitle>
rel="noopener noreferrer" <DialogDescription className="space-y-1 text-xs">
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" <span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
> {source?.time && <span className="block">时间:{source.time}</span>}
<ExternalLinkIcon className="size-4" /> </DialogDescription>
查看原文链接 </DialogHeader>
</a> {sourceUrl && (
)} <a
<article className="bg-background rounded-xl border px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6"> href={sourceUrl}
{source.content || source.snippet || "暂无引用内容"} target="_blank"
</article> rel="noopener noreferrer"
{sourceUrl && ( 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"
<p className="text-muted-foreground border-t pt-3 text-xs break-all"> >
{sourceUrl} <ExternalLinkIcon className="size-4" />
</p> 查看原文链接
)} </a>
</div> )}
</div> <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 || "暂无引用内容"}
} </div>
</DialogContent>
</Dialog>
);
}
function resolveSingleRoundRagSourceUrl( function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null, source: RuntimeDisplaySource | null,
@@ -1240,12 +1179,10 @@ function RichFileCard({
if (!fileUrl) return null; if (!fileUrl) return null;
const preview = ( const preview = (
<UploadedFilePreviewDialog <UploadedFilePreviewDialog
filename={file.filename} filename={file.filename}
fileUrl={fileUrl} fileUrl={fileUrl}
threadId={threadId} open={previewOpen}
virtualPath={file.path}
open={previewOpen}
onOpenChange={setPreviewOpen} onOpenChange={setPreviewOpen}
/> />
); );
@@ -42,16 +42,14 @@ import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
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 { 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 { DocumentRewriteTimeline } from "./document-rewrite-timeline"; import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group"; import { MessageGroup } from "./message-group";
@@ -67,7 +65,7 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80; export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */ /** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
function ScrollToBottomOnUserMessage({ function ScrollToBottomOnUserMessage({
threadId, threadId,
messages, messages,
}: { }: {
@@ -111,54 +109,10 @@ function ScrollToBottomOnUserMessage({
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted); return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]); }, [scrollToBottom, threadId]);
return null; return null;
} }
/** export function MessageList({
* 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({
className, className,
contentClassName, contentClassName,
fitMessageRowsToContent = false, fitMessageRowsToContent = false,
@@ -166,10 +120,9 @@ export function MessageList({
thread, thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false, tokenUsageEnabled = false,
extraRehypePlugins, extraRehypePlugins,
scrollButtonClassName, scrollButtonClassName,
onAtBottomChange, onClarificationSubmit,
onClarificationSubmit,
onEditResubmit, onEditResubmit,
onEditSave, onEditSave,
messageContentOverrides, messageContentOverrides,
@@ -186,10 +139,9 @@ export function MessageList({
businessCardSlot, businessCardSlot,
speakerShell, speakerShell,
onOpenArtifact, onOpenArtifact,
onDownloadArtifact, onDownloadArtifact,
afterDocumentRewriteItems, afterDocumentRewriteItems,
answerFeedbackEnabled = false, }: {
}: {
className?: string; className?: string;
contentClassName?: string; contentClassName?: string;
fitMessageRowsToContent?: boolean; fitMessageRowsToContent?: boolean;
@@ -197,10 +149,8 @@ export function MessageList({
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
paddingBottom?: number; paddingBottom?: number;
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;
@@ -244,15 +194,9 @@ export function MessageList({
onOpenArtifact?: (filepath: string) => void; onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */ /** Host override for downloading virtual/session-owned files in a present-files card. */
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(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins), () => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -299,7 +243,7 @@ export function MessageList({
: ({ ...message, content: override } as Message); : ({ ...message, content: override } as Message);
}); });
}, [messageContentOverrides, thread.messages]); }, [messageContentOverrides, thread.messages]);
const messages = useMemo( const messages = useMemo(
() => { () => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>(); const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0 return hiddenMessageIds.size === 0 && externalHidden.size === 0
@@ -311,41 +255,8 @@ export function MessageList({
); );
}, },
[allMessages, externallyHiddenMessageIds, hiddenMessageIds], [allMessages, externallyHiddenMessageIds, hiddenMessageIds],
); );
const feedbackAvailable = answerFeedbackEnabled && allowDelete; const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
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 documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes(); const askTimes = useChatAskTimes();
@@ -409,9 +320,8 @@ export function MessageList({
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
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) ||
@@ -612,10 +522,9 @@ export function MessageList({
return ( return (
<Conversation <Conversation
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)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12", hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
@@ -675,14 +584,8 @@ export function MessageList({
onMessageDeleted={ onMessageDeleted={
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
}
/>
); );
}); });
if (speakerShell && group.type === "assistant") { if (speakerShell && group.type === "assistant") {
@@ -1053,9 +956,8 @@ export type SetupWritingApprovalPayload = {
outline?: string; outline?: string;
}; };
export type SetupReportStructureApprovalPayload = { export type SetupReportStructureApprovalPayload = {
kind?: "report_structure" | "gongwen_template"; title?: string;
title?: string;
name?: string; name?: string;
content?: string; content?: string;
structure_mode?: string; structure_mode?: string;
@@ -1158,8 +1060,8 @@ function SetupAgentApprovalCard({
return null; return null;
} }
const approvalPayload = payload; const approvalPayload = payload;
const busy = disabled || submitting !== null; const busy = disabled || submitting !== null;
const skills = approvalPayload.skills ?? []; const skills = approvalPayload.skills ?? [];
async function run(action: "approve" | "reject") { async function run(action: "approve" | "reject") {
@@ -1455,7 +1357,7 @@ const STRUCTURE_MODE_LABELS: Record<string, string> = {
adaptive: "弹性大纲", adaptive: "弹性大纲",
}; };
function SetupReportStructureApprovalCard({ function SetupReportStructureApprovalCard({
disabled, disabled,
message, message,
onApprove, onApprove,
@@ -1473,8 +1375,7 @@ function SetupReportStructureApprovalCard({
return null; return null;
} }
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 ?? [];
@@ -1499,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 ? (
@@ -1542,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>
@@ -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,101 +56,14 @@ 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 —
@@ -211,31 +122,16 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
export function UploadedFilePreviewDialog({ export function UploadedFilePreviewDialog({
filename, filename,
fileUrl, fileUrl,
threadId,
virtualPath,
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;
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]);
@@ -245,34 +141,11 @@ export function UploadedFilePreviewDialog({
useAuthedObjectUrl(fileUrl, blobEnabled); 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);
@@ -282,71 +155,14 @@ export function UploadedFilePreviewDialog({
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);
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 apiFetch(textUrl, {
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));
@@ -357,7 +173,7 @@ export function UploadedFilePreviewDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId]); }, [open, kind, textUrl]);
const renderAsMarkdown = const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown"; kind === "office" || ext === "md" || ext === "markdown";
@@ -426,41 +242,18 @@ export function UploadedFilePreviewDialog({
loadingView loadingView
) : textError ? ( ) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} /> <BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : ( ) : (
<div className="space-y-3"> <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">
{previewMeta && {text ?? ""}
(previewMeta.truncated || </pre>
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
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<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 ?? ""}
</pre>
)}
</div>
) )
) : ( ) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} /> <BinaryFallback fileUrl={fileUrl} label={t.common.download} />
@@ -1,9 +1,4 @@
import { import { FolderKanban } from "lucide-react";
FileText,
MessageSquareText,
Sparkles,
type LucideIcon,
} from "lucide-react";
import { import {
MY_PROJECT_CONVERSATION_LABELS, MY_PROJECT_CONVERSATION_LABELS,
@@ -19,20 +14,6 @@ const BADGE_STYLES: Record<MyProjectConversationType, string> = {
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300", "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({ export function MyProjectThreadIcon({
type, type,
className, className,
@@ -40,47 +21,31 @@ export function MyProjectThreadIcon({
type: MyProjectConversationType; type: MyProjectConversationType;
className?: string; className?: string;
}) { }) {
const Icon = ICONS[type];
const label = `我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`;
return ( return (
<span <FolderKanban
aria-label={label} aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
className={cn( className={cn("size-3.5 shrink-0 text-violet-500", className)}
"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({ export function MyProjectThreadBadge({
type, type,
projectName,
className, className,
}: { }: {
type: MyProjectConversationType; type: MyProjectConversationType;
projectName?: string | null;
className?: string; className?: string;
}) { }) {
const typeLabel = `${MY_PROJECT_CONVERSATION_LABELS[type]}项目`;
const ownerLabel = projectName?.trim() || "我的项目";
return ( return (
<span <span
className={cn( 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", "shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
BADGE_STYLES[type], BADGE_STYLES[type],
className, className,
)} )}
title={`来自我的项目 · ${typeLabel} · ${ownerLabel}`} title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
> >
<span className="shrink-0">{typeLabel}</span> {MY_PROJECT_CONVERSATION_LABELS[type]}
<span aria-hidden="true" className="opacity-55">·</span>
<span className="min-w-0 truncate">{ownerLabel}</span>
</span> </span>
); );
} }
@@ -69,8 +69,7 @@ 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 { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -82,14 +81,9 @@ import {
titleOfThread, titleOfThread,
} from "@/core/threads/utils"; } from "@/core/threads/utils";
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";
export function RecentChatList({ export function RecentChatList({
compact = false, compact = false,
@@ -122,10 +116,9 @@ export function RecentChatList({
useParams<{ useParams<{
thread_id: string; thread_id: string;
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();
const { toggle: toggleFavorite } = useToggleThreadFavorite(); const { toggle: toggleFavorite } = useToggleThreadFavorite();
@@ -209,19 +202,15 @@ export function RecentChatList({
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页); // 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。 // 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
const visibleThreads = sortThreadsPinnedFirst( const visibleThreads = sortThreadsPinnedFirst(
threads.filter((thread) => !isHiddenFromRecentChats(thread)), threads.filter((thread) => !isHiddenFromRecentChats(thread)),
); );
const projectThreadNames = useProjectThreadNames(visibleThreads);
const recentItems = mergeRecentWorkspaceItems( if (visibleThreads.length === 0) {
visibleThreads, return null;
recentStrategyProjects, }
); const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
if (recentItems.length === 0) {
return null;
}
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems;
return ( return (
<> <>
<SidebarGroup <SidebarGroup
@@ -242,9 +231,9 @@ export function RecentChatList({
: t.sidebar.demoChats} : t.sidebar.demoChats}
</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>
{!compact && ( {!compact && (
@@ -260,79 +249,29 @@ 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 { 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 href = listPathOfThread(thread, pathname);
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectInfo = myProjectConversationInfoOfThread( const projectType = myProjectConversationTypeOfThread(thread);
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"
@@ -349,8 +288,8 @@ export function RecentChatList({
} }
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectInfo ? ( ) : projectType ? (
<MyProjectThreadIcon type={projectInfo.type} /> <MyProjectThreadIcon type={projectType} />
) : ( ) : (
compact && ( compact && (
<MessageSquare <MessageSquare
@@ -366,23 +305,15 @@ 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="min-w-0 flex-1 truncate">
className={cn( {desensitize(titleOfThread(thread, t.pages.newChat))}
"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))}
</span>
{projectInfo && (
<MyProjectThreadBadge
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
projectName={projectInfo.projectName}
type={projectInfo.type}
/>
)}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-3"
type={projectType}
/>
)}
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -1,6 +1,6 @@
"use client"; "use client";
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UploadCloudIcon, UserIcon, WrenchIcon } from "lucide-react"; import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
@@ -15,7 +15,6 @@ import { AccountSettingsPage } from "@/components/workspace/settings/account-set
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-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 { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -25,7 +24,6 @@ type SettingsSection =
| "account" | "account"
| "appearance" | "appearance"
| "memory" | "memory"
| "uploads"
| "tools" | "tools"
| "encoder" | "encoder"
| "about"; | "about";
@@ -64,10 +62,7 @@ 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 ...(isAdmin
? [ ? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
]
: []), : []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin ...(isAdmin
@@ -137,9 +132,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "memory" && isAdmin && ( {activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage /> <MemoryRuntimeSettingsPage />
)} )}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />} {activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />} {activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />} {activeSection === "about" && <AboutSettingsPage />}
@@ -19,8 +19,6 @@ import {
LockIcon, LockIcon,
PinIcon, PinIcon,
PlusIcon, PlusIcon,
Power,
PowerOff,
RefreshCwIcon, RefreshCwIcon,
SparklesIcon, SparklesIcon,
TagsIcon, TagsIcon,
@@ -73,11 +71,10 @@ 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 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,
@@ -90,25 +87,13 @@ 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";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
@@ -117,28 +102,21 @@ import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet"; 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。 */ const { t } = useI18n();
restrictWxTypes?: boolean; if (embedded) return <SkillSettingsList onClose={onClose} />;
} = {}) { return (
const { t } = useI18n();
if (embedded) {
return (
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
);
}
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>
); );
} }
@@ -187,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();
@@ -215,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).
@@ -285,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;
@@ -301,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
@@ -338,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 &&
@@ -369,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],
@@ -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>
<> {squareTabs.map((sq) => (
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger> <TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger> {sq.name}
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger> </TabsTrigger>
</> ))}
) : (
<>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
</>
)}
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button <Button
@@ -555,11 +479,11 @@ function SkillSettingsList({
</Button> </Button>
</div> </div>
</header> </header>
<TagSearchBar <TagSearchBar
targetType="skill" targetType="skill"
onSearchChange={setSearch} onSearchChange={setSearch}
tagIds={tagIds} tagIds={tagIds}
onTagIdsChange={setTagIds} onTagIdsChange={setTagIds}
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ViewModeToggle value={viewMode} onChange={setViewMode} /> <ViewModeToggle value={viewMode} onChange={setViewMode} />
@@ -582,16 +506,16 @@ function SkillSettingsList({
<SelectItem value="name_desc">名称 Z-A</SelectItem> <SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
} }
/> />
{!isLoading && !error && ( {!isLoading && !error && (
<div className="text-muted-foreground flex justify-end text-sm tabular-nums"> <div className="text-muted-foreground flex justify-end text-sm tabular-nums">
共 {orderedSkills.length} 个技能 共 {orderedSkills.length} 个技能
</div> </div>
)} )}
{publisher && ( {publisher && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span> <span className="text-muted-foreground text-xs">筛选发布者:</span>
<button <button
type="button" type="button"
@@ -623,10 +547,9 @@ function SkillSettingsList({
<SkillRow <SkillRow
key={skill.name} key={skill.name}
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}
/> />
))} ))}
@@ -681,26 +604,23 @@ function SkillSettingsList({
} }
export function SkillRow({ export function SkillRow({
skill, skill,
currentUserId, currentUserId,
isAdmin, isAdmin,
availabilityControl,
layout = "row", layout = "row",
onPublisherClick, onPublisherClick,
knowledgeBindings = [], knowledgeBindings = [],
}: { }: {
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
* owner_user_id + its desensitized display label. */ * owner_user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void; onPublisherClick?: (publisher: { id: string; label: string }) => void;
/** Admin list can inject its single batched binding query here (never N+1). */ /** Admin list can inject its single batched binding query here (never N+1). */
knowledgeBindings?: SkillKnowledgeBinding[]; knowledgeBindings?: SkillKnowledgeBinding[];
}) { }) {
const isCard = layout === "card"; const isCard = layout === "card";
const { t } = useI18n(); const { t } = useI18n();
@@ -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
@@ -729,33 +647,12 @@ export function SkillRow({
const isOwned = const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId; isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned; const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can manage custom skills from any owner; non-admin only their own. // Admin can manage custom skills from any owner; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin); const canManage = isCustom && (isOwned || isAdmin);
const canDelete = isAdmin || isOwned; const canDelete = isAdmin || isOwned;
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 {
@@ -803,15 +700,15 @@ export function SkillRow({
} }
} }
async function handleDelete() { async function handleDelete() {
try { try {
if (isBuiltin) { if (isBuiltin) {
await deleteBuiltin.mutateAsync(skill.name); await deleteBuiltin.mutateAsync(skill.name);
} else { } else {
await deleteCustom.mutateAsync(skill.name); await deleteCustom.mutateAsync(skill.name);
} }
toast.success(t.settings.skills.deleteSuccess); toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false); setConfirmDeleteOpen(false);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} }
@@ -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"
@@ -1100,7 +950,7 @@ export function SkillRow({
> >
<TagsIcon className="size-3.5" /> <TagsIcon className="size-3.5" />
</Button> </Button>
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -1121,12 +971,12 @@ export function SkillRow({
/> />
</Button> </Button>
)} )}
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700", skill.pinned && "text-emerald-600 hover:text-emerald-700",
)} )}
disabled={setPinned.isPending} disabled={setPinned.isPending}
@@ -1193,11 +1043,11 @@ export function SkillRow({
> >
<CopyIcon className="size-3.5" /> <CopyIcon className="size-3.5" />
</Button> </Button>
{canDelete && ( {canDelete && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0" className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)} onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction} title={t.settings.skills.deleteAction}
> >
@@ -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)}
@@ -1302,7 +1141,7 @@ export function SkillRow({
<Button <Button
variant="outline" variant="outline"
onClick={() => setConfirmDeleteOpen(false)} onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteCustom.isPending || deleteBuiltin.isPending} disabled={deleteCustom.isPending || deleteBuiltin.isPending}
> >
{t.common.cancel} {t.common.cancel}
</Button> </Button>
@@ -1314,37 +1153,6 @@ export function SkillRow({
{deleteCustom.isPending || deleteBuiltin.isPending {deleteCustom.isPending || deleteBuiltin.isPending
? t.common.loading ? t.common.loading
: t.common.delete} : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</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> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1418,24 +1226,24 @@ function InstallSkillDialog({
const validateMutation = useValidateSkillUpload(); const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload(); const installMutation = useInstallSkillUpload();
const setNameZhMutation = useSetSkillNameZh(); const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh(); const generateNameZhMutation = useGenerateSkillNameZh();
const validateFile = useCallback( const validateFile = useCallback(
async (selected: File, category: "custom" | "public") => { async (selected: File, category: "custom" | "public") => {
setValidation(null); setValidation(null);
setValidationError(null); setValidationError(null);
try { try {
const result = await validateMutation.mutateAsync({ const result = await validateMutation.mutateAsync({
file: selected, file: selected,
targetCategory: category, targetCategory: category,
}); });
setValidation(result); setValidation(result);
} catch (err) { } catch (err) {
setValidationError(err instanceof Error ? err.message : String(err)); setValidationError(err instanceof Error ? err.message : String(err));
} }
}, },
[validateMutation], [validateMutation],
); );
const reset = useCallback(() => { const reset = useCallback(() => {
setFile(null); setFile(null);
@@ -1474,12 +1282,12 @@ function InstallSkillDialog({
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) { if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error(dialog.rejectInvalidExt); toast.error(dialog.rejectInvalidExt);
return; return;
} }
setFile(selected); setFile(selected);
await validateFile(selected, targetCategory); await validateFile(selected, targetCategory);
}, },
[dialog.rejectInvalidExt, targetCategory, validateFile], [dialog.rejectInvalidExt, targetCategory, validateFile],
); );
const onDrop = useCallback( const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => { (e: React.DragEvent<HTMLDivElement>) => {
@@ -1556,34 +1364,29 @@ function InstallSkillDialog({
} }
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]); }, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public"; const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom"; const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill. // Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict; const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && validation.can_install_as_is; const canInstall = !!validation && !validationError && validation.can_install_as_is;
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 customConflictMessage = !validation
const unclaimedCustomConflict = ? ""
!!isCustomConflict && !validation?.owned_by_me && !validation?.owned_by_other && canOverwrite; : otherCustomConflict
const customConflictMessage = !validation ? dialog.conflictOwnedByOther
? "" .replace("{name}", validation.skill_name)
: otherCustomConflict .replace(
? dialog.conflictOwnedByOther "{owner}",
.replace("{name}", validation.skill_name) validation.owner_username ?? validation.owner_user_id ?? "—",
.replace( )
"{owner}", : validation.owned_by_me
validation.owner_username ?? validation.owner_user_id ?? "—", ? (ownCustomCanOverwrite
) ? dialog.conflictOwnedByMe
: validation.owned_by_me : dialog.conflictOwnedByMeWrongTarget
? (ownCustomCanOverwrite ).replace("{name}", validation.skill_name)
? dialog.conflictOwnedByMe : dialog.conflictCustom.replace("{name}", validation.skill_name);
: dialog.conflictOwnedByMeWrongTarget
).replace("{name}", validation.skill_name)
: unclaimedCustomConflict
? dialog.conflictUnclaimed.replace("{name}", validation.skill_name)
: dialog.conflictCustom.replace("{name}", validation.skill_name);
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}> <Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
@@ -1702,12 +1505,12 @@ function InstallSkillDialog({
<input <input
type="radio" type="radio"
name="targetCategory" name="targetCategory"
value={cat} value={cat}
checked={targetCategory === cat} checked={targetCategory === cat}
onChange={() => { onChange={() => {
setTargetCategory(cat); setTargetCategory(cat);
if (file) void validateFile(file, cat); if (file) void validateFile(file, cat);
}} }}
className="accent-primary" className="accent-primary"
/> />
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span> <span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
@@ -1765,18 +1568,18 @@ function InstallSkillDialog({
</ul> </ul>
</Field> </Field>
)} )}
{isCustomConflict && ( {isCustomConflict && (
<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" />
<span> <span>
{desensitize(customConflictMessage)} {desensitize(customConflictMessage)}
</span> </span>
</div> </div>
)} )}
{isPublicConflict && ( {isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}> <div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
@@ -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>
);
}
@@ -2,14 +2,17 @@ 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 { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links"; import {
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";
@@ -56,6 +59,7 @@ 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({
@@ -65,7 +69,11 @@ 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 =
source.type === "llmwiki_wiki_index_intro" ||
source.raw?.type === "llmwiki_wiki_index_intro";
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki"; const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
const opensWikiDrawer = Boolean(previewTarget) || isWikiIndexSource;
const matchedHeading = isWikiSource const matchedHeading = isWikiSource
? String( ? String(
(source.raw?.matched_heading as string | undefined) || (source.raw?.matched_heading as string | undefined) ||
@@ -78,17 +86,15 @@ 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(summary || content).slice(0, 280); // A retrieval card represents the matched passage. A document/Wiki summary
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string" // is useful only when the result did not include the actual passage.
? source.raw.rank_score_semantics const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
: ""; const score =
const scoreMeta = typeof source.score !== "number" typeof source.score === "number"
? null ? source.score <= 1
: scoreSemantics === "rrf" ? `${Math.round(source.score * 100)}%`
? `融合排序分 ${source.score.toFixed(4)}` : String(source.score)
: scoreSemantics === "provider_relevance" : null;
? `上游通道分 ${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,
@@ -96,12 +102,16 @@ 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; // Tool-trace cards are evidence previews, not the answer's reference list.
// Only the right-side reference panel receives `referenceTarget`, so keep
// citation numbers there and avoid presenting a second, misleading number
// in the middle retrieval trace.
const showReferenceNumber = isCitation && referenceTarget;
const openSource = () => { const openSource = () => {
if (previewTarget) { if (opensWikiDrawer) {
setDetailOpen(true); setDetailOpen(true);
} else { } else {
setRelatedOpen(true); setRelatedOpen(true);
@@ -178,21 +188,20 @@ export function SourceCard({
</div> </div>
)} )}
</div> </div>
{previewTarget && ( {(opensWikiDrawer || linkedTarget) && (
<WeKnoraSourceDrawer <WeKnoraSourceDrawer
open={detailOpen} open={detailOpen}
target={previewTarget} target={linkedTarget ?? previewTarget}
title={source.title} title={source.title}
quote={content} quote={content}
inlineContent={isWikiIndexSource ? content : ""}
inlineKnowledgeBaseId={String(source.raw?.knowledge_base_id || "")}
onOpenChange={setDetailOpen} onOpenChange={setDetailOpen}
/> />
)} )}
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}> {!opensWikiDrawer && <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">
@@ -201,19 +210,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 && (
@@ -229,39 +235,45 @@ 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" /> 本次检索命中的 Wiki 文档内容
本次检索命中的 Wiki 文档内容 </div>
</div> )}
)} <article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7"> <MarkdownContent
<MarkdownContent className="max-w-none"
className="max-w-none" content={content}
content={content} isLoading={false}
isLoading={false} remarkPlugins={reasoningPlugins.remarkPlugins}
remarkPlugins={reasoningPlugins.remarkPlugins} rehypePlugins={reasoningPlugins.rehypePlugins}
rehypePlugins={reasoningPlugins.rehypePlugins} linkClassName={(href) =>
/> resolveWeKnoraSourceTarget({ url: href })?.wikiSlug
</article> ? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
</> : ""
) : ( }
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground"> onLinkClick={(event, href) => {
暂无可展示的引用内容 const target = resolveWeKnoraSourceTarget({ url: href });
</div> if (!target) return;
)} event.preventDefault();
{source.url && !previewTarget && ( setLinkedTarget(target);
<div className="text-muted-foreground mt-4 border-t pt-3 text-xs break-all"> setDetailOpen(true);
{source.url} }}
</div> />
)} </article>
</div> </div>
</SheetContent> ) : null}
</Sheet>} {source.url && !previewTarget && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{source.url}
</div>
)}
</DialogContent>
</Dialog>}
</> </>
); );
} }
@@ -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} />
</>
);
}
@@ -3,13 +3,10 @@ import { AlertTriangle, Loader2 } from "lucide-react";
import { import {
parseVisualizationSpec, parseVisualizationSpec,
type MapSpec,
type MindmapSpec,
type VisualizationLanguage, type VisualizationLanguage,
} from "./visualization-spec"; } from "./visualization-spec";
const MapRenderer = lazy(() => import("./map-renderer")); const MapRenderer = lazy(() => import("./map-renderer"));
const MindmapRenderer = lazy(() => import("./mindmap-renderer"));
type VisualizationBlockProps = { type VisualizationBlockProps = {
language: VisualizationLanguage; language: VisualizationLanguage;
@@ -18,7 +15,7 @@ type VisualizationBlockProps = {
isLoading: boolean; isLoading: boolean;
}; };
function LoadingCard({ label = "正在生成可视化内容…" }: { label?: string }) { function LoadingCard({ label = "正在生成地图内容…" }: { label?: string }) {
return ( return (
<div <div
className="my-4 flex min-h-32 w-full max-w-none items-center justify-center rounded-xl border bg-muted/20 text-muted-foreground" className="my-4 flex min-h-32 w-full max-w-none items-center justify-center rounded-xl border bg-muted/20 text-muted-foreground"
@@ -30,15 +27,7 @@ function LoadingCard({ label = "正在生成可视化内容…" }: { label?: str
); );
} }
function ErrorCard({ function ErrorCard({ message, source }: { message: string; source?: string }) {
message,
source,
title = "可视化内容暂时无法显示",
}: {
message: string;
source?: string;
title?: string;
}) {
return ( return (
<div <div
className="my-4 w-full max-w-none rounded-xl border border-destructive/30 bg-destructive/5 p-4" className="my-4 w-full max-w-none rounded-xl border border-destructive/30 bg-destructive/5 p-4"
@@ -47,7 +36,7 @@ function ErrorCard({
<div className="flex items-start gap-2 text-sm text-destructive"> <div className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> <AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<div> <div>
<div className="font-medium">{title}</div> <div className="font-medium">地图内容暂时无法显示</div>
<div className="mt-1 text-xs opacity-80">{message}</div> <div className="mt-1 text-xs opacity-80">{message}</div>
</div> </div>
</div> </div>
@@ -70,11 +59,11 @@ class VisualizationErrorBoundary extends Component<
state = { error: null as string | null }; state = { error: null as string | null };
static getDerivedStateFromError(error: unknown) { static getDerivedStateFromError(error: unknown) {
return { error: error instanceof Error ? error.message : "可视化渲染失败" }; return { error: error instanceof Error ? error.message : "地图渲染失败" };
} }
componentDidCatch(error: unknown, info: ErrorInfo) { componentDidCatch(error: unknown, info: ErrorInfo) {
console.error("Visualization renderer failed", error, info.componentStack); console.error("Map renderer failed", error, info.componentStack);
} }
render() { render() {
@@ -97,32 +86,14 @@ export function VisualizationBlock({
); );
if (!result.ok) { if (!result.ok) {
if (isLoading && !complete) { if (isLoading && !complete) return <LoadingCard />;
return <LoadingCard label={language === "mindmap-json" ? "正在整理思维导图…" : undefined} />; return <ErrorCard message={result.error} source={source} />;
}
return (
<ErrorCard
title={language === "mindmap-json" ? "思维导图暂时无法显示" : "地图内容暂时无法显示"}
message={result.error}
source={source}
/>
);
} }
return ( return (
<VisualizationErrorBoundary key={`${language}-${source.length}`} source={source}> <VisualizationErrorBoundary key={`${language}-${source.length}`} source={source}>
<Suspense <Suspense fallback={<LoadingCard label="正在加载本地地图组件…" />}>
fallback={ <MapRenderer spec={result.spec} />
<LoadingCard
label={language === "mindmap-json" ? "正在加载思维导图组件…" : "正在加载本地地图组件…"}
/>
}
>
{language === "mindmap-json" ? (
<MindmapRenderer spec={result.spec as MindmapSpec} />
) : (
<MapRenderer spec={result.spec as MapSpec} />
)}
</Suspense> </Suspense>
</VisualizationErrorBoundary> </VisualizationErrorBoundary>
); );
@@ -32,99 +32,6 @@ test("accepts the map contract", () => {
assert.equal(map.ok, true); assert.equal(map.ok, true);
}); });
test("accepts a dedicated mindmap contract", () => {
const result = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
title: "形势分析",
rootId: "root",
nodes: [
{ id: "root", parentId: null, label: "形势分析", kind: "topic" },
{ id: "risk", parentId: "root", label: "主要风险", kind: "risk" },
{ id: "action", parentId: "root", label: "处置建议", kind: "action" },
],
}),
);
assert.equal(result.ok, true);
assert.equal(result.ok && result.spec.type, "mindmap");
});
test("renders a completed legacy mindmap fence as a mindmap", () => {
const content = [
"```mindmap",
'{"rootId":"root","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
"```",
].join("\n");
const visual = splitVisualizationContent(content).find((segment) => segment.type === "visualization");
assert.ok(visual && visual.type === "visualization");
assert.equal(visual.language, "mindmap-json");
assert.equal(visual.complete, true);
});
test("rejects mindmaps with a cycle or missing parent", () => {
const cycle = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "b", label: "A" },
{ id: "b", parentId: "a", label: "B" },
],
}),
);
assert.equal(cycle.ok, false);
const missingParent = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "missing", label: "A" },
],
}),
);
assert.equal(missingParent.ok, false);
});
test("rejects dense mindmaps before they can overload the canvas", () => {
const tooManyChildren = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "a", parentId: "root", label: "维度 A" },
{ id: "b", parentId: "root", label: "维度 B" },
{ id: "c", parentId: "root", label: "维度 C" },
{ id: "d", parentId: "root", label: "维度 D" },
{ id: "e", parentId: "root", label: "维度 E" },
{ id: "f", parentId: "root", label: "维度 F" },
{ id: "g", parentId: "root", label: "维度 G" },
{ id: "h", parentId: "root", label: "维度 H" },
],
}),
);
assert.equal(tooManyChildren.ok, false);
const tooManyNodes = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
...Array.from({ length: 36 }, (_, index) => ({
id: `node-${index}`,
parentId: "root",
label: `节点 ${index + 1}`,
})),
],
}),
);
assert.equal(tooManyNodes.ok, false);
assert.equal(
tooManyNodes.ok ? "" : tooManyNodes.error,
"思维导图节点过多(37 个);请将同类信息合并后控制在 36 个以内",
);
});
test("renders a valid map contract from a generic json fence", () => { test("renders a valid map contract from a generic json fence", () => {
const content = [ const content = [
"下面是地图:", "下面是地图:",
@@ -143,15 +50,6 @@ test("keeps non-map json fences on the ordinary Markdown path", () => {
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]); assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
}); });
test("keeps generic mindmap-shaped JSON on the ordinary Markdown path", () => {
const content = [
"```json",
'{"type":"mindmap","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
"```",
].join("\n");
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("keeps unfinished generic json fences on the ordinary Markdown path", () => { test("keeps unfinished generic json fences on the ordinary Markdown path", () => {
const content = '```json\n{"markers":['; const content = '```json\n{"markers":[';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]); assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
@@ -176,8 +74,3 @@ test("keeps an unfinished map fence for streaming fallback", () => {
assert.ok(visual && visual.type === "visualization"); assert.ok(visual && visual.type === "visualization");
assert.equal(visual.complete, false); assert.equal(visual.complete, false);
}); });
test("shows unfinished dedicated mindmap JSON before mounting the renderer", () => {
const content = "说明\n```mindmap-json\n{\"title\":\"分析导图\"";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
export const VISUALIZATION_LANGUAGES = ["map-json", "mindmap-json"] as const; export const VISUALIZATION_LANGUAGES = ["map-json"] as const;
export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number]; export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number];
@@ -48,43 +48,8 @@ const mapSchema = z.object({
export type MapSpec = z.infer<typeof mapSchema>; export type MapSpec = z.infer<typeof mapSchema>;
export const MINDMAP_NODE_KINDS = [
"topic",
"dimension",
"finding",
"evidence",
"risk",
"action",
] as const;
export type MindmapNodeKind = (typeof MINDMAP_NODE_KINDS)[number];
const mindmapNodeSchema = z.object({
id: z.string().trim().min(1).max(80),
parentId: z.string().trim().min(1).max(80).nullable(),
label: z.string().trim().min(1).max(120),
kind: z.enum(MINDMAP_NODE_KINDS).default("finding"),
detail: z.string().trim().max(1_200).optional(),
sourceRefs: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
});
const mindmapSchema = z.object({
type: z.literal("mindmap").default("mindmap"),
title: z.string().trim().max(200).default("分析思维导图"),
summary: z.string().trim().max(800).optional(),
rootId: z.string().trim().min(1).max(80).optional(),
// A mind map is an overview, not a transcription of every paragraph in a
// report. Keeping this small is also important because every node becomes a
// rendered React Flow element in the message list and full-screen canvas.
nodes: z.array(mindmapNodeSchema).min(1).max(36),
});
export type MindmapSpec = z.infer<typeof mindmapSchema>;
type MindmapNode = MindmapSpec["nodes"][number];
export type VisualizationSpec = MapSpec | MindmapSpec;
export type VisualizationParseResult = export type VisualizationParseResult =
| { ok: true; spec: VisualizationSpec } | { ok: true; spec: MapSpec }
| { ok: false; error: string }; | { ok: false; error: string };
function normalizeLanguageType( function normalizeLanguageType(
@@ -94,77 +59,16 @@ function normalizeLanguageType(
if (!value || typeof value !== "object" || Array.isArray(value)) return value; if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = { ...(value as Record<string, unknown>) }; const record = { ...(value as Record<string, unknown>) };
if (language === "map-json") record.type = "map"; if (language === "map-json") record.type = "map";
if (language === "mindmap-json") record.type = "mindmap";
return record; return record;
} }
function formatZodError(error: z.ZodError): string { function formatZodError(error: z.ZodError): string {
const issue = error.issues[0]; const issue = error.issues[0];
if (!issue) return "可视化数据格式不正确"; if (!issue) return "地图数据格式不正确";
const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : ""; const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : "";
return `${path}${issue.message}`; return `${path}${issue.message}`;
} }
function validateMindmap(spec: MindmapSpec): string | null {
const nodesById = new Map<string, MindmapSpec["nodes"][number]>();
for (const node of spec.nodes) {
if (nodesById.has(node.id)) return `节点 id 重复:${node.id}`;
nodesById.set(node.id, node);
}
const roots = spec.nodes.filter((node) => node.parentId === null);
if (roots.length !== 1) return "思维导图必须且只能有一个根节点";
const root = roots[0]!;
if (spec.rootId && spec.rootId !== root.id) return "rootId 必须指向唯一的根节点";
for (const node of spec.nodes) {
if (node.parentId === null) continue;
if (node.parentId === node.id) return `节点不能以自身为父节点:${node.id}`;
if (!nodesById.has(node.parentId)) return `节点 ${node.id} 的父节点不存在:${node.parentId}`;
}
const children = new Map<string, MindmapNode[]>();
for (const node of spec.nodes) {
if (node.parentId === null) continue;
const siblings = children.get(node.parentId) ?? [];
siblings.push(node);
children.set(node.parentId, siblings);
}
for (const [parentId, siblings] of children) {
const limit = parentId === root.id ? 6 : 3;
if (siblings.length > limit) {
const parentLabel = nodesById.get(parentId)?.label ?? parentId;
return parentId === root.id
? `中心主题最多保留 6 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
: `节点“${parentLabel}”最多保留 3 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
}
}
for (const node of spec.nodes) {
let depth = 0;
let cursor: MindmapSpec["nodes"][number] | undefined = node;
const visited = new Set<string>();
while (cursor && cursor.parentId !== null) {
if (visited.has(cursor.id)) return "思维导图不能包含循环父子关系";
visited.add(cursor.id);
cursor = nodesById.get(cursor.parentId);
depth += 1;
if (depth > 3) return "思维导图最多支持 4 层层级";
}
}
return null;
}
function mindmapShapeError(value: unknown): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const nodes = (value as Record<string, unknown>).nodes;
if (Array.isArray(nodes) && nodes.length > 36) {
return `思维导图节点过多(${nodes.length} 个);请将同类信息合并后控制在 36 个以内`;
}
return null;
}
export function parseVisualizationSpec( export function parseVisualizationSpec(
language: VisualizationLanguage, language: VisualizationLanguage,
source: string, source: string,
@@ -181,18 +85,8 @@ export function parseVisualizationSpec(
return { ok: false, error: "地图数据必须是 JSON 对象" }; return { ok: false, error: "地图数据必须是 JSON 对象" };
} }
if (language === "mindmap-json") { const parsed = mapSchema.safeParse(normalized);
const error = mindmapShapeError(normalized);
if (error) return { ok: false, error };
}
const schema = language === "map-json" ? mapSchema : mindmapSchema;
const parsed = schema.safeParse(normalized);
if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) }; if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) };
if (language === "mindmap-json") {
const error = validateMindmap(parsed.data as MindmapSpec);
if (error) return { ok: false, error };
}
return { ok: true, spec: parsed.data }; return { ok: true, spec: parsed.data };
} }
@@ -205,9 +99,7 @@ export type VisualizationSegment =
complete: boolean; complete: boolean;
}; };
// `mindmap-json` is the contract. `mindmap` is retained as a compatibility const FENCE_PATTERN = /(^|\n)```(map-json|json)[ \t]*\r?\n/gi;
// alias because existing model turns have already emitted that fence name.
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
function isMapJsonSource(source: string): boolean { function isMapJsonSource(source: string): boolean {
let raw: unknown; let raw: unknown;
@@ -241,13 +133,6 @@ export function splitVisualizationContent(content: string): VisualizationSegment
const complete = closeIndex >= 0; const complete = closeIndex >= 0;
const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim(); const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim();
const matchedLanguage = match[2].toLowerCase(); const matchedLanguage = match[2].toLowerCase();
const isMindmapFence = matchedLanguage === "mindmap-json" || matchedLanguage === "mindmap";
// Dedicated mind-map JSON is shown as an ordinary streaming code block
// until the closing fence arrives. That lets people see the model writing
// and validate the raw structure before the expensive renderer mounts.
if (isMindmapFence && !complete) {
return [{ type: "markdown", content }];
}
if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) { if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) {
if (!complete) break; if (!complete) break;
FENCE_PATTERN.lastIndex = closeIndex + 3; FENCE_PATTERN.lastIndex = closeIndex + 3;
@@ -255,7 +140,7 @@ export function splitVisualizationContent(content: string): VisualizationSegment
} }
segments.push({ segments.push({
type: "visualization", type: "visualization",
language: isMindmapFence ? "mindmap-json" : "map-json", language: "map-json",
source, source,
complete, complete,
}); });
@@ -54,6 +54,13 @@ export type WorkspaceCompactNavItem = MenuItem & {
}; };
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
{
id: "workspace-projects",
label: "项目空间",
icon: BookOpen,
path: "/page/workspace/projects",
match: (pathname) => pathname.startsWith("/page/workspace/projects"),
},
{ {
id: "workspace-new-chat", id: "workspace-new-chat",
label: "新对话", label: "新对话",
@@ -162,8 +169,7 @@ export function useWorkspaceCompactNavItems(): {
settingsItems: EffectiveWorkspaceCompactNavItem[]; settingsItems: EffectiveWorkspaceCompactNavItem[];
toolboxItems: EffectiveWorkspaceCompactNavItem[]; toolboxItems: EffectiveWorkspaceCompactNavItem[];
} { } {
const { data: overrideConfig } = useCompactMenuOverrides(); const { data: overrides = [] } = useCompactMenuOverrides();
const overrides = overrideConfig?.overrides ?? [];
return useMemo(() => { return useMemo(() => {
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides); const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
return { return {
@@ -223,7 +223,7 @@ export function WorkspaceNavMenu() {
{isAdmin ? ( {isAdmin ? (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
navigate("/page/workspace/admin/user-leaderboard") navigate("/page/strategy/admin/user-leaderboard")
} }
> >
<TrophyIcon /> <TrophyIcon />
@@ -273,15 +273,16 @@ export function WorkspaceNavMenu() {
模型配置 模型配置
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口: {/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar; 简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */} {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/menu-management")} onClick={() => navigate("/page/strategy/admin/menu-management")}
> >
<ListTreeIcon /> <ListTreeIcon />
{isAdmin ? "默认菜单管理" : "我的菜单"} 菜单管理
</DropdownMenuItem> </DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")} onClick={() => navigate("/page/workspace/admin/tags")}
> >
@@ -327,7 +328,7 @@ export function WorkspaceNavMenu() {
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
navigate("/page/workspace/admin/fixed-questions") navigate("/page/strategy/admin/fixed-questions")
} }
> >
<MessageSquareTextIcon /> <MessageSquareTextIcon />
@@ -39,6 +39,7 @@ export function WorkspaceSidebar({
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = const isGeneralQa =
wxCommonAgents || wxCommonAgents ||
pathname.startsWith("/page/workspace/projects") ||
pathname.startsWith("/page/workspace/chats") || pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats"); pathname.startsWith("/page/strategy/chats");
@@ -1,79 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mergeConversationAgentOptions } from "./conversation-options";
import type { Agent } from "./types";
function agent(
id: string,
options: Partial<Agent> = {},
): Agent {
return {
id,
name: options.name ?? id,
description: "",
user_id: options.user_id ?? null,
published: options.published ?? true,
is_private: options.is_private ?? false,
builtin: options.builtin ?? options.user_id == null,
...options,
};
}
test("keeps curated built-ins and appends every visible custom agent", () => {
const featured = [
agent("featured-builtin", { builtin: true }),
agent("featured-custom", { builtin: false, user_id: "owner-1" }),
];
const visible = [
agent("internal-worker", { builtin: true }),
agent("featured-custom", { builtin: false, user_id: "owner-1" }),
agent("owned-private", {
builtin: false,
user_id: "owner-1",
published: false,
}),
agent("published-custom", {
builtin: false,
user_id: "owner-2",
}),
];
assert.deepEqual(
mergeConversationAgentOptions(featured, visible).map(({ id }) => id),
[
"featured-builtin",
"featured-custom",
"owned-private",
"published-custom",
],
);
});
test("deduplicates ids and ignores malformed unavailable rows", () => {
const duplicate = agent("agent-1", {
name: "精选名称",
builtin: false,
user_id: "owner-1",
});
const result = mergeConversationAgentOptions(
[duplicate],
[
agent("agent-1", {
name: "列表名称",
builtin: false,
user_id: "owner-1",
}),
agent("", { builtin: false, user_id: "owner-1" }),
agent("missing-name", {
name: " ",
builtin: false,
user_id: "owner-1",
}),
],
);
assert.equal(result.length, 1);
assert.equal(result[0]?.name, "精选名称");
});
@@ -1,34 +0,0 @@
import type { Agent } from "./types";
/**
* Build the agent list used by ordinary conversation entry points.
*
* The homepage selector is intentionally admin-curated and therefore may
* contain only one or two featured agents. Project conversations need a
* wider catalogue: every custom agent visible to the current user should be
* selectable as well. Unfeatured built-ins are omitted because that bucket
* also contains internal workflow workers (AI writing, roundtable, strategy
* selection, and so on) that are not standalone conversation agents.
*/
export function mergeConversationAgentOptions(
featuredAgents: Agent[],
visibleAgents: Agent[],
): Agent[] {
const result: Agent[] = [];
const seen = new Set<string>();
const append = (agent: Agent) => {
const id = agent.id?.trim();
const name = agent.name?.trim();
if (!id || !name || seen.has(id)) return;
seen.add(id);
result.push(agent);
};
// Preserve the administrator's ordering for curated built-ins/custom
// agents, then append the rest of the user's visible custom catalogue.
featuredAgents.forEach(append);
visibleAgents.filter((agent) => !agent.builtin).forEach(append);
return result;
}
+4 -22
View File
@@ -25,14 +25,13 @@ import {
unpinAgent, unpinAgent,
updateAgent, updateAgent,
} from "./api"; } from "./api";
import type { import type {
Agent, Agent,
AgentSort, AgentSort,
CreateAgentRequest, CreateAgentRequest,
PaginatedAgents, PaginatedAgents,
UpdateAgentRequest, UpdateAgentRequest,
} from "./types"; } from "./types";
import { mergeConversationAgentOptions } from "./conversation-options";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``) /** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */ * or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
@@ -243,7 +242,7 @@ export function useToggleAgentPin() {
}); });
} }
export function useAgentSelector() { export function useAgentSelector() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"], queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(), queryFn: () => getAgentSelector(),
@@ -254,24 +253,7 @@ export function useAgentSelector() {
isLoading, isLoading,
error, error,
}; };
} }
/**
* Agent catalogue for project/ordinary conversations.
*
* It combines the admin-curated homepage list with every custom agent the
* current user can access. This deliberately does not expose unfeatured
* built-in workflow workers as standalone chat agents.
*/
export function useConversationAgentOptions() {
const featured = useAgentSelector();
const visible = useAgents();
return {
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
isLoading: featured.isLoading || visible.isLoading,
error: featured.error ?? visible.error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) { function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] }); void queryClient.invalidateQueries({ queryKey: ["agents"] });
@@ -83,13 +83,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
export function useAnalysisProjects( export function useAnalysisProjects() {
options: { enabled?: boolean } = {},
) {
return useQuery({ return useQuery({
queryKey: projectKeys(), queryKey: projectKeys(),
queryFn: () => request<{ projects: AnalysisProject[] }>(""), queryFn: () => request<{ projects: AnalysisProject[] }>(""),
enabled: options.enabled ?? true,
}); });
} }
export function useAnalysisProject(id: string) { export function useAnalysisProject(id: string) {
@@ -1,5 +1,4 @@
import { apiFetch } from "../api"; import { apiFetch } from "../api";
import { getBackendBaseURL } from "../config";
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
import { prepareGeneratedHtml } from "../utils/legacy-html-preview"; import { prepareGeneratedHtml } from "../utils/legacy-html-preview";
import { getFileName } from "../utils/files"; import { getFileName } from "../utils/files";
@@ -107,54 +106,6 @@ export async function downloadArtifactMarkdownAsWord({
const title = fileName.replace(/\.(md|markdown)$/i, ""); const title = fileName.replace(/\.(md|markdown)$/i, "");
await downloadMarkdownAsDocx(await res.text(), fileName, title); await downloadMarkdownAsDocx(await res.text(), fileName, title);
} }
async function fetchStrategyProjectMarkdown(projectId: string): Promise<Response> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/strategy-project-artifacts/${encodeURIComponent(projectId)}/download`,
);
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
return res;
}
export async function downloadStrategyProjectMarkdown({
projectId,
}: {
projectId: string;
}): Promise<void> {
const res = await fetchStrategyProjectMarkdown(projectId);
const filename =
filenameFromContentDisposition(res.headers.get("Content-Disposition")) ??
"策略优选方案.md";
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
export async function downloadStrategyProjectMarkdownAsWord({
projectId,
}: {
projectId: string;
}): Promise<void> {
const res = await fetchStrategyProjectMarkdown(projectId);
const markdownName =
filenameFromContentDisposition(res.headers.get("Content-Disposition")) ??
"策略优选方案.md";
const title = markdownName.replace(/\.(md|markdown)$/i, "");
await downloadMarkdownAsDocx(
await res.text(),
title || "策略优选方案",
title || "策略优选方案",
);
}
export async function openArtifactInNewTab({ export async function openArtifactInNewTab({
filepath, filepath,
+2 -19
View File
@@ -13,11 +13,7 @@ import {
isSandboxStreamPerfEnabled, isSandboxStreamPerfEnabled,
} from "./sandbox-stream-perf"; } from "./sandbox-stream-perf";
import { isArtifactApiPath } from "./streaming-write-preview"; import { isArtifactApiPath } from "./streaming-write-preview";
import { import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight";
isWriteFileInFlight,
isWriteFileStreamingUrl,
latestCompletedArtifactMutationKey,
} from "./write-file-in-flight";
import { updateArtifactContent } from "./api"; import { updateArtifactContent } from "./api";
function hasToolResult( function hasToolResult(
@@ -139,12 +135,6 @@ export function useArtifactContent({
: undefined; : undefined;
const writeFileInFlight = const writeFileInFlight =
useWriteFileStreaming && isWriteFileInFlight(thread, filepath); useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
const completedArtifactMutationKey = useMemo(() => {
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
return null;
}
return latestCompletedArtifactMutationKey(thread, filepath);
}, [filepath, thread, useWriteFileStreaming]);
const displayedWriteFileContent = useThrottledWriteFileDisplay({ const displayedWriteFileContent = useThrottledWriteFileDisplay({
filepath, filepath,
liveContent: liveWriteFileContent, liveContent: liveWriteFileContent,
@@ -160,14 +150,7 @@ export function useArtifactContent({
}, [filepath, thread, useWriteFileStreaming]); }, [filepath, thread, useWriteFileStreaming]);
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: [ queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
"artifact",
filepath,
threadId,
isMock,
publicShareCode,
completedArtifactMutationKey,
],
queryFn: () => { queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode }); return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
}, },
+1 -21
View File
@@ -9,15 +9,7 @@ export type ArtifactLibraryKind =
| "archive" | "archive"
| "other"; | "other";
export type ArtifactLibrarySource = export type ArtifactLibrarySource = "all" | "general_qa" | "agent";
| "all"
| "general_qa"
| "agent"
| "strategy";
export type ArtifactStrategySourceKind =
| "strategy_selection"
| "analysis_project";
export interface ArtifactLibraryItem { export interface ArtifactLibraryItem {
threadId: string; threadId: string;
@@ -26,10 +18,6 @@ export interface ArtifactLibraryItem {
agentName: string | null; agentName: string | null;
threadType: string | null; threadType: string | null;
sourceType: Exclude<ArtifactLibrarySource, "all">; sourceType: Exclude<ArtifactLibrarySource, "all">;
strategyProjectId: string | null;
strategyName: string | null;
strategySourceKind: ArtifactStrategySourceKind | null;
strategyStale: boolean;
path: string; path: string;
name: string; name: string;
sizeBytes: number; sizeBytes: number;
@@ -94,10 +82,6 @@ export async function fetchArtifactLibrary({
agent_name?: string | null; agent_name?: string | null;
thread_type?: string | null; thread_type?: string | null;
source_type?: Exclude<ArtifactLibrarySource, "all">; source_type?: Exclude<ArtifactLibrarySource, "all">;
strategy_project_id?: string | null;
strategy_name?: string | null;
strategy_source_kind?: ArtifactStrategySourceKind | null;
strategy_stale?: boolean;
path: string; path: string;
name: string; name: string;
size_bytes: number; size_bytes: number;
@@ -118,10 +102,6 @@ export async function fetchArtifactLibrary({
agentName: item.agent_name ?? item.agent_id ?? null, agentName: item.agent_name ?? item.agent_id ?? null,
threadType: item.thread_type ?? null, threadType: item.thread_type ?? null,
sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"), sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"),
strategyProjectId: item.strategy_project_id ?? null,
strategyName: item.strategy_name ?? null,
strategySourceKind: item.strategy_source_kind ?? null,
strategyStale: item.strategy_stale === true,
path: item.path, path: item.path,
name: item.name, name: item.name,
sizeBytes: item.size_bytes, sizeBytes: item.size_bytes,
@@ -3,7 +3,6 @@ import test from "node:test";
import { import {
isWriteFileInFlight, isWriteFileInFlight,
latestCompletedArtifactMutationKey,
latestInFlightWriteFilePreview, latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact, latestPreparedHtmlArtifact,
resolveWriteFilePath, resolveWriteFilePath,
@@ -65,80 +64,6 @@ test("latestPreparedHtmlArtifact ignores workspace and failed prepares", () => {
); );
}); });
test("latestPreparedHtmlArtifact yields to a later final HTML write", () => {
const preview = latestPreparedHtmlArtifact([
{
id: "m-template",
type: "ai",
tool_calls: [
{ id: "tpl1", name: "page_report_template", args: { action: "prepare" } },
],
},
{
type: "tool",
tool_call_id: "tpl1",
content: JSON.stringify({
ok: true,
working_copy: "/mnt/user-data/outputs/report-page.html",
}),
},
{
id: "m-final",
type: "ai",
tool_calls: [
{
id: "write1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/美国服务网络大屏.html",
content: "<!doctype html><html></html>",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestCompletedArtifactMutationKey revisions completed writes and replacements", () => {
const thread = {
messages: [
{
type: "ai",
tool_calls: [
{
id: "write1",
name: "write_file",
args: { path: "/mnt/user-data/outputs/report-page.html" },
},
],
},
{ type: "tool", tool_call_id: "write1" },
{
type: "ai",
tool_calls: [
{
id: "replace2",
name: "str_replace",
args: { path: "/mnt/user-data/outputs/report-page.html" },
},
],
},
{ type: "tool", tool_call_id: "replace2" },
],
};
assert.equal(
latestCompletedArtifactMutationKey(thread, "/mnt/user-data/outputs/report-page.html"),
"str_replace:replace2",
);
assert.equal(
latestCompletedArtifactMutationKey(thread, "/mnt/user-data/outputs/other.html"),
null,
);
});
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => { test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
const preview = latestInFlightWriteFilePreview([ const preview = latestInFlightWriteFilePreview([
{ {
@@ -75,40 +75,11 @@ function parseToolJson(content: unknown): Record<string, unknown> | null {
: null; : null;
} }
function hasLaterOutputHtmlWrite(
messages: WriteFileThreadLike["messages"],
preparedMessageIndex: number,
): boolean {
for (
let messageIndex = preparedMessageIndex + 1;
messageIndex < messages.length;
messageIndex += 1
) {
const message = messages[messageIndex];
if (message?.type !== "ai" || !message.tool_calls) continue;
for (const toolCall of message.tool_calls) {
if (toolCall?.name !== "write_file") continue;
const args = toolCall.args as { path?: unknown } | undefined;
const path =
typeof args?.path === "string"
? normalizeStreamingArtifactPath(args.path)
: "";
if (/^\/mnt\/user-data\/outputs\/.+\.html?$/i.test(path)) {
return true;
}
}
}
return false;
}
/** /**
* A successful page-template prepare already creates a previewable HTML file * A successful page-template prepare already creates a previewable HTML file
* under outputs. Open it before the first edit arrives so the user sees the * under outputs. Open it before the first edit arrives so the user sees the
* sandbox as soon as page generation starts when the model edits it in place. * sandbox as soon as page generation starts, even when the model edits via
* * str_replace or emits a complete write_file call in one stream frame.
* A later write_file to outputs is a new final artifact, not a reason to keep
* the prepared template selected. Let the write preview take over instead;
* otherwise the template's cached content can reappear until a manual refresh.
*/ */
export function latestPreparedHtmlArtifact( export function latestPreparedHtmlArtifact(
messages: WriteFileThreadLike["messages"], messages: WriteFileThreadLike["messages"],
@@ -135,7 +106,6 @@ export function latestPreparedHtmlArtifact(
: ""; : "";
const artifactPath = candidate.trim(); const artifactPath = candidate.trim();
if (!/^\/mnt\/user-data\/outputs\/.+\.html?$/i.test(artifactPath)) continue; if (!/^\/mnt\/user-data\/outputs\/.+\.html?$/i.test(artifactPath)) continue;
if (hasLaterOutputHtmlWrite(messages, messageIndex)) continue;
return { return {
artifactPath, artifactPath,
previewKey: `page-template:${toolCall.id}`, previewKey: `page-template:${toolCall.id}`,
@@ -154,43 +124,6 @@ function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
); );
} }
/**
* A completed write_file or str_replace changes the server-side artifact even
* when its path stays the same. The id becomes a React Query revision key so a
* previously prepared template cannot remain cached after the final HTML lands.
*/
export function latestCompletedArtifactMutationKey(
thread: WriteFileThreadLike,
filepath: string,
): string | null {
const targetPath = normalizeStreamingArtifactPath(filepath);
if (!targetPath) return null;
for (let messageIndex = thread.messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = thread.messages[messageIndex];
if (message?.type !== "ai" || !message.tool_calls) continue;
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (
(toolCall?.name !== "write_file" && toolCall?.name !== "str_replace") ||
!toolCall.id ||
!hasToolResult(thread, toolCall.id)
) {
continue;
}
const args = toolCall.args as { path?: unknown } | undefined;
const path =
typeof args?.path === "string"
? normalizeStreamingArtifactPath(args.path)
: "";
if (path === targetPath) {
return `${toolCall.name}:${toolCall.id}`;
}
}
}
return null;
}
/** True only while THIS file's write_file call has not yet returned a tool result. */ /** True only while THIS file's write_file call has not yet returned a tool result. */
export function isWriteFileInFlight( export function isWriteFileInFlight(
thread: WriteFileThreadLike, thread: WriteFileThreadLike,
@@ -492,8 +492,6 @@ export const enUS: Translations = {
badgePublishedTitle: "Visible to all users", badgePublishedTitle: "Visible to all users",
badgeUnpublished: "Unpublished", badgeUnpublished: "Unpublished",
badgeUnpublishedTitle: "Not published yet — only visible to its owner", badgeUnpublishedTitle: "Not published yet — only visible to its owner",
badgeOffline: "Offline",
badgeOfflineTitle: "Retained in the system but unavailable to models",
badgeStale: "Stale", badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator", badgeStaleTitle: "Idle for a long time — flagged by the curator",
badgeArchived: "Archived", badgeArchived: "Archived",
@@ -516,13 +514,6 @@ export const enUS: Translations = {
unpublishAction: "Unpublish", unpublishAction: "Unpublish",
publishSuccess: "Skill published", publishSuccess: "Skill published",
unpublishSuccess: "Skill unpublished", unpublishSuccess: "Skill unpublished",
onlineAction: "Bring online",
offlineAction: "Take offline",
onlineSuccess: "Skill is online and available to models again",
offlineSuccess: "Skill is offline and unavailable to models",
offlineConfirmTitle: "Take this skill offline?",
offlineConfirmDescription:
"Take \"{name}\" offline? Its files and configuration will be retained, but models cannot use it and new configurations cannot select it. You can bring it online again later.",
pinAction: "Pin (prevent auto-archive)", pinAction: "Pin (prevent auto-archive)",
unpinAction: "Unpin", unpinAction: "Unpin",
pinSuccess: "Pinned", pinSuccess: "Pinned",
@@ -616,8 +607,6 @@ export const enUS: Translations = {
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.", "Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
conflictCustom: conflictCustom:
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.", "A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
conflictUnclaimed:
"A leftover copy of skill '{name}' exists on the server with no owning user — it is invisible in every list and cannot be managed. Click 'Replace' to remove the leftover and install your upload.",
conflictPublic: conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.", "A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint: conflictPublicAdminHint:
@@ -425,8 +425,6 @@ export interface Translations {
badgePublishedTitle: string; badgePublishedTitle: string;
badgeUnpublished: string; badgeUnpublished: string;
badgeUnpublishedTitle: string; badgeUnpublishedTitle: string;
badgeOffline: string;
badgeOfflineTitle: string;
badgeStale: string; badgeStale: string;
badgeStaleTitle: string; badgeStaleTitle: string;
badgeArchived: string; badgeArchived: string;
@@ -449,12 +447,6 @@ export interface Translations {
unpublishAction: string; unpublishAction: string;
publishSuccess: string; publishSuccess: string;
unpublishSuccess: string; unpublishSuccess: string;
onlineAction: string;
offlineAction: string;
onlineSuccess: string;
offlineSuccess: string;
offlineConfirmTitle: string;
offlineConfirmDescription: string;
pinAction: string; pinAction: string;
unpinAction: string; unpinAction: string;
pinSuccess: string; pinSuccess: string;
@@ -539,7 +531,6 @@ export interface Translations {
conflictOwnedByMeWrongTarget: string; conflictOwnedByMeWrongTarget: string;
conflictOwnedByOther: string; conflictOwnedByOther: string;
conflictCustom: string; conflictCustom: string;
conflictUnclaimed: string;
conflictPublic: string; conflictPublic: string;
conflictPublicAdminHint: string; conflictPublicAdminHint: string;
installSuccess: string; installSuccess: string;
@@ -472,8 +472,6 @@ export const zhCN: Translations = {
badgePublishedTitle: "该技能已对所有用户可见", badgePublishedTitle: "该技能已对所有用户可见",
badgeUnpublished: "未发布", badgeUnpublished: "未发布",
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见", badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
badgeOffline: "已下线",
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
badgeStale: "过时", badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记", badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档", badgeArchived: "归档",
@@ -496,13 +494,6 @@ export const zhCN: Translations = {
unpublishAction: "取消发布", unpublishAction: "取消发布",
publishSuccess: "已发布", publishSuccess: "已发布",
unpublishSuccess: "已取消发布", unpublishSuccess: "已取消发布",
onlineAction: "上线",
offlineAction: "下线",
onlineSuccess: "技能已上线,模型可以再次调用",
offlineSuccess: "技能已下线,模型将不再调用",
offlineConfirmTitle: "确认下线技能?",
offlineConfirmDescription:
"下线技能“{name}”吗?技能文件和配置会保留,但模型无法调用它,新配置也不能选择它。之后可随时重新上线。",
pinAction: "钉选(防止被自动归档)", pinAction: "钉选(防止被自动归档)",
unpinAction: "取消钉选", unpinAction: "取消钉选",
pinSuccess: "已钉选", pinSuccess: "已钉选",
@@ -591,8 +582,6 @@ export const zhCN: Translations = {
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。", "技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
conflictCustom: conflictCustom:
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。", "已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
conflictUnclaimed:
"服务器上存在同名技能 “{name}” 的残留副本(无归属用户,列表中不可见、无法管理)。点击「替换」将清除残留副本并安装您上传的版本。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`, conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`, conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
installSuccess: `技能 '{name}' 已安装。`, installSuccess: `技能 '{name}' 已安装。`,
@@ -1,44 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import * as answerDisplay from "./answer-display.ts";
const { stripKnowledgeRetrievalDiagnostics } = answerDisplay;
test("removes the inline partial-retrieval notice from a historical answer", () => {
const content = [
"## 回答",
"这是基于 Wiki 的结论。[1]",
"",
"**资料不足与检索局限说明:** 本轮仅有部分检索通道成功(VECTOR_UNAVAILABLE),检索结果可能不完整。",
].join("\n");
assert.equal(
stripKnowledgeRetrievalDiagnostics(content),
"## 回答\n这是基于 Wiki 的结论。[1]",
);
});
test("removes a multi-line diagnostic section without changing the answer", () => {
const content = [
"正文第一段。",
"",
"### 资料不足与检索局限说明",
"本轮只有部分检索通道成功。",
"- 内部错误码:VECTOR_UNAVAILABLE",
"",
"正文第二段。",
].join("\n");
assert.equal(
stripKnowledgeRetrievalDiagnostics(content),
"正文第一段。\n\n正文第二段。",
);
});
test("leaves ordinary assistant content unchanged", () => {
const content = "向量检索不可用时,可以继续使用关键词检索。";
assert.equal(stripKnowledgeRetrievalDiagnostics(content), content);
});
@@ -1,29 +0,0 @@
const RETRIEVAL_DIAGNOSTIC_START =
/(?:资料不足与检索局限说明|本轮(?:仅有|只有)部分检索通道成功)/;
/**
* Hide internal retrieval diagnostics from assistant answers, including
* historical messages that already persisted the model-generated notice.
* Retrieval traces remain available to developers and are not modified.
*/
export function stripKnowledgeRetrievalDiagnostics(content: string): string {
if (!RETRIEVAL_DIAGNOSTIC_START.test(content)) return content;
const output: string[] = [];
let skippingDiagnosticBlock = false;
for (const line of content.split(/\r?\n/)) {
if (RETRIEVAL_DIAGNOSTIC_START.test(line)) {
skippingDiagnosticBlock = true;
continue;
}
if (skippingDiagnosticBlock) {
if (!line.trim()) {
skippingDiagnosticBlock = false;
}
continue;
}
output.push(line);
}
return output.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
@@ -1,83 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import * as knowledgeReference from "./knowledge-reference.ts";
const {
classifyKnowledgeWikiSource,
createKnowledgeWikiReferenceSelection,
filterKnowledgeWikiSources,
} = knowledgeReference;
test("knowledge Wiki selection excludes generic and unselected citations", () => {
const selection = createKnowledgeWikiReferenceSelection(
["wiki-a", "wiki-a"],
["assistant-a"],
);
assert.ok(selection);
const sources = filterKnowledgeWikiSources(
[
{ type: "llmwiki_wiki", raw: { knowledge_base_id: "wiki-a" } },
{
type: "llmwiki",
raw: { knowledge_base_id: "wiki-a", retrieval_mode: "raw_chunk" },
},
{ type: "assistant_knowledge", raw: { base_id: "assistant-a" } },
{ type: "llmwiki_wiki", raw: { knowledge_base_id: "wiki-other" } },
{ type: "web", raw: { url: "https://example.com" } },
],
selection,
);
assert.deepEqual(
sources.map((source) => source.type),
["llmwiki_wiki", "llmwiki", "assistant_knowledge"],
);
assert.deepEqual(selection.knowledgeBaseIds, ["wiki-a"]);
});
test("empty selection does not enable the Wiki reference view", () => {
assert.equal(createKnowledgeWikiReferenceSelection([], []), undefined);
});
test("formatted Wiki pages and original chunks are classified separately", () => {
assert.equal(
classifyKnowledgeWikiSource({
type: "llmwiki",
raw: { knowledge_base_id: "wiki-a", retrieval_mode: "raw_chunk" },
}),
"raw",
);
assert.equal(
classifyKnowledgeWikiSource({
type: "assistant_knowledge",
raw: {
base_id: "assistant-a",
page_type: "chunk",
slug: "sources/manual/chunk-1",
},
}),
"raw",
);
assert.equal(
classifyKnowledgeWikiSource({
type: "assistant_knowledge",
raw: {
base_id: "assistant-a",
page_type: "article",
slug: "wiki/manual-overview",
},
}),
"wiki",
);
assert.equal(
classifyKnowledgeWikiSource({
type: "llmwiki_wiki",
raw: { knowledge_base_id: "wiki-a", wiki_slug: "wiki/overview" },
}),
"wiki",
);
});
@@ -1,91 +0,0 @@
export type KnowledgeWikiReferenceSelection = {
kind: "wiki";
knowledgeBaseIds: string[];
assistantKnowledgeBaseIds: string[];
};
type KnowledgeReferenceSourceLike = {
type?: string;
retrieval_mode?: unknown;
raw?: Record<string, unknown>;
};
export type KnowledgeWikiSourceKind = "wiki" | "raw";
function normalizedString(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
/** Distinguish generated/formatted Wiki pages from original document chunks. */
export function classifyKnowledgeWikiSource(
source: KnowledgeReferenceSourceLike,
): KnowledgeWikiSourceKind {
const raw = source.raw ?? {};
const retrievalMode = normalizedString(
raw.retrieval_mode ?? source.retrieval_mode,
);
const pageType = normalizedString(raw.page_type);
const rawKind = normalizedString(raw.kind);
if (
retrievalMode === "raw_chunk" ||
pageType === "chunk" ||
rawKind === "chunk"
) {
return "raw";
}
const hasWikiPageIdentity = Boolean(
normalizedString(raw.wiki_slug) ||
normalizedString(raw.wikiSlug) ||
normalizedString(raw.slug),
);
return source.type === "llmwiki_wiki" || hasWikiPageIdentity
? "wiki"
: "raw";
}
function normalizedIds(values: readonly string[] | null | undefined): string[] {
return Array.from(
new Set((values ?? []).map((value) => value.trim()).filter(Boolean)),
);
}
export function createKnowledgeWikiReferenceSelection(
knowledgeBaseIds: readonly string[] | null | undefined,
assistantKnowledgeBaseIds: readonly string[] | null | undefined,
): KnowledgeWikiReferenceSelection | undefined {
const ordinary = normalizedIds(knowledgeBaseIds);
const assistant = normalizedIds(assistantKnowledgeBaseIds);
if (ordinary.length === 0 && assistant.length === 0) return undefined;
return {
kind: "wiki",
knowledgeBaseIds: ordinary,
assistantKnowledgeBaseIds: assistant,
};
}
/**
* Knowledge-space Q&A never reuses unrelated Web/tool citations. Keep only
* sources whose server-authored knowledge-space id belongs to the active
* selection; sources without an auditable space id are deliberately omitted.
*/
export function filterKnowledgeWikiSources<T extends KnowledgeReferenceSourceLike>(
sources: readonly T[],
selection: KnowledgeWikiReferenceSelection,
): T[] {
const ordinary = new Set(selection.knowledgeBaseIds);
const assistant = new Set(selection.assistantKnowledgeBaseIds);
return sources.filter((source) => {
const raw = source.raw ?? {};
const ordinaryId = String(
raw.knowledge_base_id ?? raw.mapping_id ?? "",
).trim();
const assistantId = String(raw.base_id ?? "").trim();
const assistantSource =
source.type === "assistant_knowledge" ||
String(raw.kind ?? "").toLowerCase() === "assistant";
if (assistantSource) {
return assistant.has(assistantId || ordinaryId);
}
return ordinary.has(ordinaryId || assistantId);
});
}
@@ -1,78 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import { retrievalCandidateCount, retrievalChannelScores, retrievalDiagnosticNumber, retrievalModeLabel, retrievalRankScoreLabel } from "./retrieval-debug.ts";
test("labels every retrieval mode returned by Wiki and assistant search", () => {
assert.equal(retrievalModeLabel("vector"), "向量检索");
assert.equal(retrievalModeLabel("wiki_api"), "Wiki 接口检索");
assert.equal(retrievalModeLabel("hybrid"), "向量 + 关键词混合检索");
assert.equal(retrievalModeLabel("keyword"), "关键词检索");
assert.equal(retrievalModeLabel("overview"), "知识库概览检索");
assert.equal(retrievalModeLabel("raw_chunk"), "原文分块检索");
assert.equal(retrievalModeLabel("wiki_raw_hybrid"), "Wiki + 原文分块混合检索");
assert.equal(retrievalModeLabel("graph_expansion"), "Wiki 图关联扩展");
assert.equal(retrievalModeLabel("wiki_graph_hybrid"), "Wiki 混合召回 + 图扩展");
assert.equal(retrievalModeLabel("wiki_graph_raw_hybrid"), "Wiki 混合召回 + 图扩展 + 原文分块");
assert.equal(retrievalModeLabel("assistant_graph_hybrid"), "助手知识库检索 + 图扩展");
assert.equal(retrievalModeLabel("mixed"), "多通道混合检索");
assert.equal(retrievalModeLabel("empty"), "未命中");
});
test("labels rank scores only with their declared semantics", () => {
assert.equal(retrievalRankScoreLabel("rrf"), "融合排序分");
assert.equal(retrievalRankScoreLabel("graph_decay"), "图扩展分");
assert.equal(retrievalRankScoreLabel("provider_relevance"), "上游相关分");
assert.equal(retrievalRankScoreLabel(undefined, false), "检索排序分");
assert.equal(retrievalRankScoreLabel(undefined, true), null);
});
test("keeps fusion rank separate from channel and vector scores", () => {
const scores = retrievalChannelScores({
score: 0.03125,
retrieval_mode: "hybrid",
channel_score: 0.82,
matched_sections: [{ score: 0.74 }, { score: 0.91 }],
});
assert.deepEqual(scores, [{ label: "通道分", value: 0.82 }]);
assert.deepEqual(
retrievalChannelScores({
retrieval_mode: "vector",
matched_sections: [{ score: 0.74 }, { score: 0.91 }],
}),
[{ label: "向量分", value: 0.91 }],
);
assert.deepEqual(
retrievalChannelScores({ retrieval_mode: "graph_expansion", vector_score: 0.77, graph_score: 0.42 }),
[
{ label: "向量分", value: 0.77 },
{ label: "图扩展分", value: 0.42 },
],
);
assert.deepEqual(
retrievalChannelScores({
retrieval_mode: "graph_expansion",
rank_score: 0.42,
score: 0.99,
rank_score_semantics: "graph_decay",
graph_score: 0.42,
}),
[],
);
});
test("reads current and nested diagnostic shapes", () => {
assert.equal(retrievalCandidateCount({ vector_candidates: 12 }, "vector"), 12);
assert.equal(retrievalCandidateCount({ raw_chunk_candidates: 3 }, "raw_chunk"), 3);
assert.equal(retrievalCandidateCount({ graph_candidates: 5 }, "graph"), 5);
assert.equal(
retrievalCandidateCount({ candidates: { assistant: "4" } }, "assistant"),
4,
);
assert.equal(
retrievalDiagnosticNumber({ top_raw_vector_score: 0.66 }, ["raw_top", "top_raw_vector_score"]),
0.66,
);
});
@@ -1,177 +0,0 @@
export type LlmWikiRetrievalMode =
| "empty"
| "vector"
| "keyword"
| "hybrid"
| "overview"
| "wiki_api"
| "raw_chunk"
| "wiki_raw_hybrid"
| "graph"
| "graph_expansion"
| "wiki_graph_hybrid"
| "wiki_graph_raw_hybrid"
| "assistant_graph_hybrid"
| "assistant"
| "mixed";
type UnknownRecord = Record<string, unknown>;
function asRecord(value: unknown): UnknownRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as UnknownRecord)
: null;
}
function finiteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
export function retrievalModeLabel(mode: unknown): string {
const normalized = typeof mode === "string" ? mode.trim().toLowerCase() : "";
switch (normalized) {
case "vector":
return "向量检索";
case "keyword":
return "关键词检索";
case "hybrid":
return "向量 + 关键词混合检索";
case "overview":
return "知识库概览检索";
case "wiki_api":
return "Wiki 接口检索";
case "raw_chunk":
return "原文分块检索";
case "wiki_raw_hybrid":
return "Wiki + 原文分块混合检索";
case "graph":
case "graph_expansion":
return "Wiki 图关联扩展";
case "wiki_graph_hybrid":
return "Wiki 混合召回 + 图扩展";
case "wiki_graph_raw_hybrid":
return "Wiki 混合召回 + 图扩展 + 原文分块";
case "assistant_graph_hybrid":
return "助手知识库检索 + 图扩展";
case "assistant":
return "助手知识库检索";
case "mixed":
return "多通道混合检索";
case "empty":
return "未命中";
default:
return normalized ? `未知模式(${normalized})` : "未报告";
}
}
export function formatRetrievalScore(value: unknown): string | null {
const score = finiteNumber(value);
if (score === null) return null;
return score.toFixed(4);
}
export function retrievalRankScoreLabel(
semantics: unknown,
hasChannelScores = false,
): string | null {
const normalized = typeof semantics === "string" ? semantics.trim().toLowerCase() : "";
switch (normalized) {
case "rrf":
return "融合排序分";
case "graph_decay":
return "图扩展分";
case "provider_relevance":
return "上游相关分";
default:
// A pure vector result commonly exposes the same raw value as both the
// top-level score and vector_score. Do not relabel or duplicate it as a
// fusion score when the backend did not declare score semantics.
return hasChannelScores ? null : "检索排序分";
}
}
export interface RetrievalChannelScore {
label: "通道分" | "向量分" | "图扩展分";
value: number;
}
/**
* A Wiki result's top-level score may be an RRF fusion rank. Channel scores
* are deliberately extracted separately so callers never present the RRF
* value as a cosine-similarity percentage.
*/
export function retrievalChannelScores(result: unknown): RetrievalChannelScore[] {
const row = asRecord(result);
if (!row) return [];
const scores: RetrievalChannelScore[] = [];
const channelScore = finiteNumber(row.channel_score);
if (channelScore !== null) scores.push({ label: "通道分", value: channelScore });
const retrievalMode = typeof row.retrieval_mode === "string"
? row.retrieval_mode.trim().toLowerCase()
: "";
const sections = retrievalMode === "vector" && Array.isArray(row.matched_sections)
? row.matched_sections
: [];
const vectorScores = sections
.map((section) => finiteNumber(asRecord(section)?.score))
.filter((value): value is number => value !== null);
const explicitVectorScore = finiteNumber(row.vector_score);
const vectorScore = explicitVectorScore ?? (vectorScores.length > 0 ? Math.max(...vectorScores) : null);
if (vectorScore !== null) scores.push({ label: "向量分", value: vectorScore });
const graphScore = finiteNumber(row.graph_score);
const rankSemantics = typeof row.rank_score_semantics === "string"
? row.rank_score_semantics.trim().toLowerCase()
: "";
const rankScore = finiteNumber(row.rank_score ?? row.score);
const graphScoreAlreadyRenderedAsRank = rankSemantics === "graph_decay"
&& rankScore !== null
&& graphScore !== null
&& Math.abs(rankScore - graphScore) < Number.EPSILON;
if (graphScore !== null && !graphScoreAlreadyRenderedAsRank) {
scores.push({ label: "图扩展分", value: graphScore });
}
return scores;
}
export type RetrievalCandidateChannel = "vector" | "keyword" | "wiki_api" | "graph" | "raw_chunk" | "assistant";
export function retrievalCandidateCount(
diagnostics: unknown,
channel: RetrievalCandidateChannel,
): number | null {
const row = asRecord(diagnostics);
if (!row) return null;
const candidates = asRecord(row.candidates);
const channelDetails = asRecord(row[channel]);
const values = [
row[`${channel}_candidates`],
candidates?.[channel],
channelDetails?.candidates,
channelDetails?.candidate_count,
channelDetails?.count,
];
for (const value of values) {
const parsed = finiteNumber(value);
if (parsed !== null) return parsed;
}
return null;
}
export function retrievalDiagnosticNumber(
diagnostics: unknown,
keys: string[],
): number | null {
const row = asRecord(diagnostics);
if (!row) return null;
for (const key of keys) {
const direct = finiteNumber(row[key]);
if (direct !== null) return direct;
}
return null;
}
@@ -1,41 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-ignore Node's type-stripping runner requires the explicit TypeScript suffix.
import { resolveRetrievedChunkExcerpt } from "./source-excerpt.ts";
test("returns the exact focus chunk instead of a document-level fallback", () => {
const excerpt = resolveRetrievedChunkExcerpt(
{
focus_chunk_id: "chunk-2",
chunks: [
{ id: "chunk-1", content: "特朗普人物介绍" },
{ id: "chunk-2", content: "海湖庄园位于佛罗里达州。", is_focus: true },
],
},
"每个引用都相同的文档摘要",
);
assert.equal(excerpt, "海湖庄园位于佛罗里达州。");
});
test("matches focus_chunk_id when providers omit is_focus", () => {
const excerpt = resolveRetrievedChunkExcerpt({
focus_chunk_id: "chunk-b",
chunks: [
{ id: "chunk-a", content: "第一段" },
{ id: "chunk-b", content: "第二段" },
],
});
assert.equal(excerpt, "第二段");
});
test("uses the citation excerpt when no focus chunk is available", () => {
const excerpt = resolveRetrievedChunkExcerpt(
{ focus_chunk_id: "missing", chunks: [] },
" 本次召回内容\r\n\r\n\r\n下一段 ",
);
assert.equal(excerpt, "本次召回内容\n\n下一段");
});
Loaded 100 of 810 files, more files were not shown because too many files have changed in this diff. Show more