Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b638b541d0 | ||
|
|
34474bd5fc |
No files matched your search
-191
@@ -1,191 +0,0 @@
|
|||||||
# Workspace/editor
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
!.vscode/settings.json
|
|
||||||
frontend-web/.vscode/
|
|
||||||
node_modules/
|
|
||||||
# Claude Code local-only settings (per-user permissions, not shared)
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|
||||||
# Local runtime logs and pid files
|
|
||||||
.runtime/
|
|
||||||
logs/
|
|
||||||
offline-backend-20260512/backend/logs/
|
|
||||||
|
|
||||||
# Frontend local/runtime artifacts
|
|
||||||
frontend-web/.env
|
|
||||||
frontend-web/.env.local
|
|
||||||
frontend-web/.env.*.local
|
|
||||||
frontend-web/node_modules/
|
|
||||||
frontend-web/.pnpm-store/
|
|
||||||
frontend-web/tsconfig.tsbuildinfo
|
|
||||||
frontend-web/vite-dev.log
|
|
||||||
frontend-web/vite-dev.err.log
|
|
||||||
|
|
||||||
# Frontend local packages/archives/docs (non-source)
|
|
||||||
frontend-web/pnpm-lock.yaml
|
|
||||||
frontend-web/README.md
|
|
||||||
frontend-web/doc.md
|
|
||||||
frontend-web/src.zip
|
|
||||||
frontend-web/src9.zip
|
|
||||||
frontend-web/src-v1.zip
|
|
||||||
frontend-web/src-*.rar
|
|
||||||
frontend-web/dist
|
|
||||||
# Frontend build artifacts
|
|
||||||
frontend-web/dist/
|
|
||||||
frontend-web/dist-scheduled-check/
|
|
||||||
|
|
||||||
# Offline backend bundles and local deployment artifacts
|
|
||||||
offline-backend-20260505.zip
|
|
||||||
offline-backend-20260505/output/
|
|
||||||
offline-backend-20260505/bundle/.env
|
|
||||||
offline-backend-20260505/bundle/.env.example
|
|
||||||
offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
|
|
||||||
offline-backend-20260505/bundle/SHA256SUMS
|
|
||||||
offline-backend-20260505/bundle/config.yaml
|
|
||||||
offline-backend-20260505/bundle/config.yaml.example
|
|
||||||
offline-backend-20260505/bundle/data/
|
|
||||||
offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar
|
|
||||||
offline-backend-20260505/bundle/extensions_config.json
|
|
||||||
offline-backend-20260505/bundle/frontend-vite.zip
|
|
||||||
offline-backend-20260505/bundle/skills/
|
|
||||||
offline-backend-20260505/bundle/start_backend.0509.sh
|
|
||||||
offline-backend-20260505/bundle/start_backend.sh
|
|
||||||
offline-backend-20260505/bundle/stop_backend.sh
|
|
||||||
|
|
||||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
|
||||||
offline-backend-20260512/.env.example
|
|
||||||
offline-backend-20260512/.env
|
|
||||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
|
||||||
offline-backend-20260512/SHA256SUMS
|
|
||||||
offline-backend-20260512/config.yaml.example
|
|
||||||
offline-backend-20260512/data/
|
|
||||||
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
|
||||||
offline-backend-20260512/extensions_config.json
|
|
||||||
offline-backend-20260512/skills/
|
|
||||||
offline-backend-20260512/wheelhouse/
|
|
||||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
|
||||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
|
||||||
offline-backend-20260512/backend/.deer-flow/*
|
|
||||||
offline-backend-20260512/backend/.env
|
|
||||||
offline-backend-20260512/backend/.env.local
|
|
||||||
offline-backend-20260512/start_backend.sh
|
|
||||||
offline-backend-20260512/stop_backend.sh
|
|
||||||
offline-backend-20260512/check_backend.sh
|
|
||||||
|
|
||||||
hermes-agent-main/
|
|
||||||
|
|
||||||
|
|
||||||
skills/*
|
|
||||||
|
|
||||||
offline-backend-20260512/backend/.deer-flow/*
|
|
||||||
|
|
||||||
# Per-user skill_evolution overrides — runtime data written by the API
|
|
||||||
offline-backend-20260512/backend/skill_evolution.json
|
|
||||||
skills/
|
|
||||||
hermes-agent-main/
|
|
||||||
.gitignore
|
|
||||||
offline-backend-20260512/backend/tests/*
|
|
||||||
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
|
||||||
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
|
||||||
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
|
||||||
!offline-backend-20260512/backend/tests/test_word_export.py
|
|
||||||
# Keep generated-file library aggregation and filtering regression coverage versioned.
|
|
||||||
!offline-backend-20260512/backend/tests/test_artifact_library.py
|
|
||||||
# Keep the model configuration regression coverage versioned even though
|
|
||||||
# this deployment repository otherwise ignores newly added backend tests.
|
|
||||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
|
||||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
|
||||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
|
||||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
|
||||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
|
||||||
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
|
||||||
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
|
|
||||||
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
|
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
|
||||||
# Deep-research reliability regression coverage.
|
|
||||||
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
|
||||||
# Independent enterprise-research persistence, HTTP and race regressions.
|
|
||||||
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
|
|
||||||
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
|
|
||||||
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
|
|
||||||
# Roundtable coordinator/seat capability isolation regressions.
|
|
||||||
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
|
|
||||||
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
|
|
||||||
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
|
|
||||||
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
|
|
||||||
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_workflow_engine.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_workflow_runs.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
|
|
||||||
# Writing-mode first-report pipeline enforcement regressions.
|
|
||||||
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
|
|
||||||
# LLM call timeout retry classification regression coverage.
|
|
||||||
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
|
|
||||||
# Writing-mode pipeline conversation-material regressions.
|
|
||||||
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
|
|
||||||
# Report collaboration (RC-BE-000–018) regression coverage.
|
|
||||||
!offline-backend-20260512/backend/tests/test_report_collaboration_*.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py
|
|
||||||
deploy
|
|
||||||
.history
|
|
||||||
|
|
||||||
# Cloned third-party repos (local only)
|
|
||||||
git-clone/
|
|
||||||
|
|
||||||
# Repo-root offline dependency pack (local only)
|
|
||||||
/dist/
|
|
||||||
|
|
||||||
# Frontend offline pack working directories
|
|
||||||
frontend-web/.offline-pack-build/
|
|
||||||
frontend-web/.offline-extract-test/
|
|
||||||
frontend-web-node_modules-offline-*.tar.gz
|
|
||||||
|
|
||||||
# Local dependency caches and backend scratch archives
|
|
||||||
/.pnpm-store/
|
|
||||||
/offline-backend-20260512/backend/backend*.zip
|
|
||||||
|
|
||||||
# Local offline bundles and scratch tooling
|
|
||||||
/*.tar.gz
|
|
||||||
/offline-backend-20260512/*.zip
|
|
||||||
/tmp/
|
|
||||||
|
|
||||||
.codegraph
|
|
||||||
codegraph.mdc
|
|
||||||
|
|
||||||
docs/aidev-docs
|
|
||||||
docs/backups
|
|
||||||
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
|
||||||
!frontend-web/src/components/workspace/skills/
|
|
||||||
|
|
||||||
# uv lockfile (not tracked per project preference)
|
|
||||||
offline-backend-20260512/backend/uv.lock
|
|
||||||
|
|
||||||
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
|
|
||||||
|
|
||||||
# Local-only scratch directory (not committed)
|
|
||||||
0622/
|
|
||||||
|
|
||||||
# Python bytecode cache
|
|
||||||
__pycache__/
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
@@ -96,7 +96,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
|||||||
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
||||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
||||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||||
|
|||||||
Submodule _skills_src/ai-skills deleted from cde1c8385e.
Submodule _skills_src/anthropics-skills deleted from 5754626092.
Submodule _skills_src/awesome-claude-skills deleted from 92568c1eda.
Submodule _skills_src/ce-kit deleted from d4973c4827.
Submodule _skills_src/csm deleted from 3fa16a94e0.
Submodule _skills_src/superpowers deleted from 6fd4507659.
Submodule _skills_src/tapestry-skills deleted from 80e1dc56df.
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="188" height="241" viewBox="0 0 188 241"><defs><style>.a{fill:url(#a);}.b{fill:#fff;opacity:0.47;}</style><pattern id="a" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 376 482"><image width="376" height="482" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXgAAAHiCAYAAADiYNeDAAAgAElEQVR4nOy993csOZYmdoFIw6R73pav6qrqrvY9flatmdldrWa1+nv1m46OjrSr7ZnZdtW2ulxX1Xt8ho+eTBcB6CACQFzcQEQaMskkeb938iUzvP3w4cPFhdDpGK41hGg+ez3jxZm0veg69r85Vj0zzHqe/thnOmgJAIn9xh8zrQsAqwBwAwA2ybf7rNqPWbaH1nG/V+zfCZRX02y/BQBtO90gAwDz4KcAoOw0bacPAWAAAH3794n9xr/NZx99DiD8dutkdvv4k6F9Toae88Zc+LOk53umZj3fSec57/W7Imhd67NnnCc6loAdETsy7iKiXgOADQBYtyTvvjft9B4i9hW7Tfy7a6dhghcNNKBR0eYIfoRI3hH7iPw2n0NL7OZzhL7N9GNE8kO0PVdwDOw2GYyFggmesWiYZ+wmADwEgHsAcB8AbgPAHft9Cyl2R9DtyHfbbqtlCRx/5JznQMk/sftdt7+d4saf1H7G9jOKfA+Rot8FgB0AeGW/XwDASwB4BgB7dlsMxkLABM84K6zZzyr53LREPongO0t4J5yN1J5j3dEUBL9jSf6EfI7th8E4FdiDZw++wPwefMcS9+sA8BoAPAaABwDwyJL4LUT4MYsmmWIvlxlZjUXjSH3Xkv8WADwHgKcA8AQAvskLAq3ns3LYg59ve1cMrOAZs2DN2hfryCu/awk9RvC3llSZnycSVMDFMLIkHyN483sbeftH9sPqnjEVWMGzgi/QfJ7SEvYbAPCW/X7TEvl9a7GsEZume34HfyXgInSO0efAWjqG6L8CgK8B4M/2e6sxGocV/Hzbu2JggmeCLxCe5xoJVbxrSR0T/BuW4Ncu7qCvBY4twX9NCP5rq+5xiGap7Jng59veFQMTPBN8Ae2P5JFV5+8BwLcA4B077S6yZ9xn3ugVxmxQyJ5xn1fWzvkCAD4FgM+syt/K7yYT/Hzbu2JggmeCv5V/dB7tctcq9LcIwd+5kCNjTMIrQvB/tp9tEHl0zq79nC+Y4JcGTPDXl+DbVpl/GwC+CwAf2obSB6hz0Q0b9cJYXvSRRXNg7RzTQPtHAPgtAPzBqvrze9GZ4JcGTPDXi+BvothzZ8Vggn/AkVWXHqkleUzwzrrZRbH3iwMT/NKACf56ELywZP4hIvN3bPz6Lfu5sbC9My4C+8iieWmtHEf6f7Skvxj2Y4JfGjDBX12C71jv/I5V5h8AwHcswX9gY9YvsimOcX7QNrb+E0vwv7d/P7c+/qszzY3DBL80YIK/mgTfsw2kP7Af02D6trVnbrNav7bYtxaN+XxpG2Z/bT+fWj//9GCCXxowwV8dgu/ZKJh71o75DiL4tzhenUFwbCNuHMH/3to2L218/fxkzwS/NGCCvxoEv2oJ/ScA8GOr3h8ji2aeZFmMq48xsmieWhX/CwD4uW2cnS8lAhP80oAJ/vISfBcpdmO//NAS/I9suCN3QmLMAmXDK39pCf7XtmHWKfrh1Ntigl8aMMFfToJfsQ2lP7GfD23qgHuW9BmMebFtSf1rG23zc6vqP5natmGCXxowwV8egpcoIuZt6607S+ataNrd6/1sM6ZF/LnLrEf/C/txit5F3tQnOmOCXxowwV8OgheW1I398jcA8D0bx37XfkI7RgOzO2NGRJ8/ZRX9tiX33wDAz6yN82XtQ8YEvzTgXovLjRWr2E1UzPcB4C8swX9rqnS8zPGMaVBPktKmg76PQm3v2V7QH1sb55kdwISxhGAFv7wKftX67H8PAH9pOyi5wTTCqBiq2JnYGfMgeP6iin6MBiUxHab+DQD+P+vVn/ilWMEvDVjBLx82LIl/aP31v7fWzKPokeo5XyQGo/IsRX6EgqVtI7Ret436D2270GuW5J/ZkacYSwJW8Mul4Dcsmf8tAPydTQT2wPY+Jcely+Njz51x5rAK3j3O8ed6xyp6EzP/362a/xVok9WSFfwygBX8cmAzt190Tuh/Z332H+fT6QOMiR34AWYsCo6g3QMYVfS3Qefi4zXUqe6u7RW7ZVMjMC4QrOAvXsHfsSGPPwWRe+3fRjnZ64+HiZ1xnnDPdf3jfWgtmj+Chn8FgP/XhlZuz3SUrODPFKzgLw43rPL5nlXsPwUNH03MGcPEzrgI+OeullE37McFAty0ETcf20yWrOYvAKzgL0bB37Ahj39vPx/YRtTV6v6Z0BlLjPjzfuLVPMB/s5//MRXJs4I/U7CCP1/0rMIxqv1/tuT+gyixMxiXF+Z5ftdG2bgBZTZtR6knZ5aWmDERrODPT8EbcjcWzD9aYv++Jfu4JcPKnXGZUP/cH9sG199YJf9/2b/jnaNYwZ8pWMEvHm3bE/A7ltj/yWZ+vHXVT5zBsALmW2h4yFXrz5tImxfnOhj4NQQr+MUq+Jbt4m3i2v+97ZH6ejxChhU74yoh+l4d2vQGxo//P21em0/tQOEFWMGfKVjBLw63rQ9pyP0frHp/fFVPlsGYAhvWprxlLctNO+0z22mKccZgBb8YBX/bhj6ahtR/Z9MO3A+36zqOzLh9BuMywUdXVh70FzbH/H8FgP8bAP4lT0PMCv5MwQr+bNG2se0m3cB/sOT+0VSZHxmM64X7Nlx43fr0Kzbv/BP25c8OTPBnB5E3Jmn9H6wl82NryYTk7hUHSTnAYFwHhM971/bc3rD9QF6z3vwf+M04GzDBnw3MA/q+tWT+2XZiah46j6T4YDCuJCZbkCs2EMGp+Zb15//EmSlPDyb402PNhj3+bzYE8sNoCKSoYXL24BnXAqTNSVcefCOI/toSvbFv/g9r2Rzz8zE/mOBPh7vIb/9fbeclck01p/NlMCg0SpNd4pYNJe7aGSt2eMDZEpYxPJjg54fx1/8KAP5LniisGM6suJ5epWjQ+YAcPCgHgxGiYPg8uMZF2BTvSMt2CuzZTKumhvyvdhQpxoxggp8dLlLGhEH+R9uB6d18K1iMeGLX1z5Ui8GYCCFwu9SKbXztICX/M46wmR1M8LOhZcn8761y/+to56VAuTO5MxhRmPfETi/cmoov/5a1Pl2Kg/9mO0WlfEGnAxP89FixNoyJlPlfAOB/snmvS/jRlpjcGYypEH1HPNEnduzXjjVw2nbml7XJyhgBmOCnQ9fmbP+pVe4/CcjdKRGtUXw7kzuDMRWC90f44WCRon9g3701S/L/j42VH/EFbgYT/GSs2Tjdf7Ke+18FMe4Vtc7EzmDMDCyIzN9+iMCA5I0lmiHe+ozDKJvBBN+Mnm3R/0fbgemHlNwDr51j2hmMU0KXkcVVb/6OTd7Xs9xlrJvf8gAi9WCCr8ea7Z36T9Zz/4s8j7UAFNfOXjuDsRBUkvFZ30bnAsu8iwoApFX0n7CSj4MJPo5VG6b1TyDyHqo/tIMUWCDlDqzcGYwzBcrX5HnedYoq5t20HaI6dkHTGPs7OxYsA4EJvooVOwLNP1rl/qOA3B2xK0XqkWy/MxhnAvw+5danBpCymFbaNbfsuzm0S46skufoGgQm+BBda8uYbJD/2WaEDMndELvSIJxRKJjYGYwzBX6f3N+ZApCiIPqS5G/YoIcEEf0n9u9rD2CCD2Cqe++gOPcf2YE7CuTkbj9Bi/8FHzWDcdXh2ryU/ZZRJX9ifXnT0/VzDqEswARfoG3J/ac2K+Rf2Rb7Aki5c4Mqg3FBcO8hVJS8i65JLLFrS/LXPq0BE3xhsjy2oy/9J9tCf8/P9cpdlR0xGAzGxUAboSUsyQdK/p59d09sZI3x4r+67nVsJniAh1ax/2er4MuxU51iMP4fcKw7g3HhcDXoLCuiJEMlf9++wwYHVs1vXeebdt0J/o4l9/9ke8kV6Qe854eVO1szDMZSwJG8t2vAhVAK1ON11xL8f80H876muM4Ev5E3zmj9v9tG1df8HPP8qKwgeCDkzh48g7EEsFFsRsmb0aFkgg/pNftOg+0A9S/Xdfi/60rwa7bl3WWFfNvPCTx3YOXOYCwrvJK3bWMmjLKwaxL7Tpt3e8cq+Z9fx96u15HgE9tL9b/YiJl3/RyOlmEwLhmsko9H17xr33Gwja+/tA2w1wbXjeDbNjPkP9gcM98JrkHeS1UxuTMYlw35eyuKT0nwXfuOm2RkL62C/+w6hU9eN4J/3Q6Q/c8BueOqHm1QrY4yw2AwlgW006GyqQ3Kd9eN8frPtoer+XxxXe5f60p6zP6UfHIiYSNmfmzHUP2RbWQtkVfx1AUdMIPBOBsom2fS5q4psGHf+UMbNnloI2t00M52BbVc68q2IebtLi5fjLhtwyEdud8LlnPWjELqnZU7g7H8cO+pH5fBkLu1a0Bg0r5r3/0ta9n8DLQuwievcE6pq6vg89PKb/CKbWwx2SH/znZsssshYqfZIbnTKoNxORAMpqZRVI0uOkOFo0IZDjjynryGQTX3/NXBVffguzbHzN/aVAQf2lzvtvXd9lJVqmyNd+AskQzG5YEm728eHy+LmDkhHXuvWg4wETXfWKL//Cpnn7zqBH8HtOnVpv+xGDRbrBWTrcTPQyKzOJGzgmcwLgfo++uVfFa8w17BO5LXH+Q1ep178SalwZOreqevLsELWAWtv1tEzYh/VzSy2ichL+1tT1WqLine truncated
|
|
||||||
|
Before Width: | Height: | Size: 68 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68"><defs><style>.a{fill:#fff;stroke:#707070;stroke-width:3.75px;opacity:0;}.b{fill:url(#a);}.c{stroke:none;}.d{fill:none;}</style><linearGradient id="a" x1="0.431" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient></defs><g transform="translate(-71.077 -67.746)"><g class="a" transform="translate(71.077 67.746)"><rect class="c" width="68" height="68"/><rect class="d" x="1.875" y="1.875" width="64.25" height="64.25"/></g><path class="b" d="M114.406,106.791a7.707,7.707,0,0,0-4.354,1.353L98.7,99.937a13.3,13.3,0,0,0,1.742-6.583c0-.421-.03-.842-.06-1.263L112,85.207a4.687,4.687,0,1,0-1.952-2.345L99.691,88.995a13.678,13.678,0,0,0-6.1-7.365l2.072-10.341c.15,0,.3.03.451.03a4.66,4.66,0,1,0-4.655-4.659,4.727,4.727,0,0,0,1.412,3.367l-2.1,10.462A13.479,13.479,0,0,0,80.05,81.87l-6.4-5.712A6.129,6.129,0,1,0,71.7,78.443L77.677,83.7a13.456,13.456,0,0,0,.691,19.991l-5.316,5.622a5.948,5.948,0,0,0-3.394-1.022,6.163,6.163,0,1,0,6.156,6.162,6.343,6.343,0,0,0-.721-2.916l5.826-6.193a13.464,13.464,0,0,0,16.1-2.946l11.022,7.966a7.77,7.77,0,0,0-1.2,4.118,7.658,7.658,0,1,0,15.316,0A7.817,7.817,0,0,0,114.406,106.791Zm0,0" transform="translate(13 9.671)"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,13 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="ap-idea" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox">
|
|
||||||
<stop offset="0" stop-color="#f3212c"/>
|
|
||||||
<stop offset="1" stop-color="#aa071a"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g fill="url(#ap-idea)" transform="translate(12 10)">
|
|
||||||
<path d="M22 2.2A12.8 12.8 0 0 0 9.2 15c0 5.1 2.9 9.5 7.1 11.6v3.8a2.4 2.4 0 0 0 2.4 2.4h6.6a2.4 2.4 0 0 0 2.4-2.4v-3.8A12.8 12.8 0 0 0 34.8 15 12.8 12.8 0 0 0 22 2.2Zm3.3 26.8h-6.6v-2.1h6.6Zm-3.3-26.8A10.4 10.4 0 0 1 32.4 15a10.4 10.4 0 0 1-6.3 9.5 2.4 2.4 0 0 0-1.3 2.1v.3h-5.6v-.3a2.4 2.4 0 0 0-1.3-2.1A10.4 10.4 0 0 1 11.6 15 10.4 10.4 0 0 1 22 2.2Z"/>
|
|
||||||
<path d="M17.6 39.2a2 2 0 0 0 2 2h4.8a2 2 0 0 0 2-2v-1.4h-8.8Zm-1.8-3.8h12.4a1.6 1.6 0 1 0 0-3.2H15.8a1.6 1.6 0 1 0 0 3.2Z"/>
|
|
||||||
<path d="M8.4 22.6a1.6 1.6 0 0 0 0-3.2H3.8a1.6 1.6 0 1 0 0 3.2Zm31.8 0a1.6 1.6 0 0 0 0-3.2h-4.6a1.6 1.6 0 1 0 0 3.2ZM7.2 10.8a1.6 1.6 0 0 0 2.3-2.3L6.2 5.3A1.6 1.6 0 0 0 4 7.6Zm29.3-2.3a1.6 1.6 0 0 0 2.3 2.3l3.2-3.2A1.6 1.6 0 1 0 39.7 5.3Z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient></defs><g transform="translate(14482.893 -23044.904)"><rect class="a" width="68" height="68" transform="translate(-14482.893 23044.904)"/><path class="b" d="M43.013,57.336h-.255l-.074,0a14.064,14.064,0,0,1-8.911-3.467,6.216,6.216,0,0,1,6.1-5.029h6.278a6.218,6.218,0,0,1,6.1,5.03A14.073,14.073,0,0,1,43.6,57.324h-.009l-.067,0H43.5l-.063,0h-.424ZM30.159,54.9H8.5a1.717,1.717,0,0,1-1.715-1.716v-10.1A1.717,1.717,0,0,1,8.5,41.363H20.6a1.717,1.717,0,0,0,1.716-1.715V37.73H11.824a1.717,1.717,0,0,1-1.716-1.715V8.8a1.718,1.718,0,0,1,1.716-1.716h8.768V3.437A3.44,3.44,0,0,1,24.029,0h6.93A3.441,3.441,0,0,1,34.4,3.437V7.089H42.87A1.718,1.718,0,0,1,44.586,8.8V26.022c-.515-.046-1.044-.07-1.574-.07A17.327,17.327,0,0,0,30.157,54.9Zm4.219-36.07a3.437,3.437,0,1,0,3.438,3.437A3.441,3.441,0,0,0,34.378,18.828Zm-13.619,0A3.437,3.437,0,1,0,24.2,22.265,3.441,3.441,0,0,0,20.758,18.828ZM24.029,3.437V7.083h6.924V3.437Zm7.256,47.59v0a14.059,14.059,0,1,1,23.456,0,9.508,9.508,0,0,0-8.589-5.455H44.686a7.193,7.193,0,1,0-3.347,0H39.873a9.5,9.5,0,0,0-8.587,5.457ZM43.013,42.5a3.923,3.923,0,1,1,3.923-3.924A3.928,3.928,0,0,1,43.013,42.5ZM6.844,31.07a1.717,1.717,0,0,1-1.716-1.715V15.176a1.715,1.715,0,1,1,3.431,0V29.355A1.717,1.717,0,0,1,6.844,31.07ZM49.8,27.327h0a17.187,17.187,0,0,0-3.429-1.051v-11.1a1.715,1.715,0,1,1,3.431,0v12.15Zm3.406-.007a1.717,1.717,0,0,1-1.715-1.715V18.724a1.715,1.715,0,0,1,3.431,0v6.881A1.717,1.717,0,0,1,53.206,27.32Zm-51.491,0A1.717,1.717,0,0,1,0,25.605V18.724a1.715,1.715,0,0,1,3.431,0v6.881A1.717,1.717,0,0,1,1.715,27.32Z" transform="translate(-14477.664 23050.15)"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,14 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="ap-task" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox">
|
|
||||||
<stop offset="0" stop-color="#f3212c"/>
|
|
||||||
<stop offset="1" stop-color="#aa071a"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g fill="url(#ap-task)" transform="translate(14 12)">
|
|
||||||
<path d="M28.4 2.2H15.6A3.4 3.4 0 0 0 12.2 5.6v2.8H8.6A3.4 3.4 0 0 0 5.2 11.8v28.8A3.4 3.4 0 0 0 8.6 44h22.8a3.4 3.4 0 0 0 3.4-3.4V11.8a3.4 3.4 0 0 0-3.4-3.4h-3.6V5.6A3.4 3.4 0 0 0 28.4 2.2Zm-12.8 3.4h12.8v2.8H15.6Zm19.2 35.8H8.6V11.8h3.6v2.2a1.6 1.6 0 0 0 1.6 1.6h12.8a1.6 1.6 0 0 0 1.6-1.6v-2.2h3.6Z"/>
|
|
||||||
<rect x="11.4" y="24.2" width="3.6" height="11.6" rx="1.2"/>
|
|
||||||
<rect x="18.2" y="20.4" width="3.6" height="15.4" rx="1.2"/>
|
|
||||||
<rect x="25" y="27.2" width="3.6" height="8.6" rx="1.2"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 924 B |
@@ -18,9 +18,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||||
import type { StreamdownProps } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
import { SafeStreamdown } from "@/core/streamdown";
|
|
||||||
|
|
||||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
from: UIMessage["role"];
|
from: UIMessage["role"];
|
||||||
@@ -304,11 +302,11 @@ export const MessageBranchPage = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageResponseProps = StreamdownProps;
|
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||||
|
|
||||||
export const MessageResponse = memo(
|
export const MessageResponse = memo(
|
||||||
({ className, ...props }: MessageResponseProps) => (
|
({ className, ...props }: MessageResponseProps) => (
|
||||||
<SafeStreamdown
|
<Streamdown
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -34,14 +34,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
|
import type { PromptInputFilePart } from "@/core/uploads";
|
||||||
import {
|
import { splitUnsupportedUploadFiles } from "@/core/uploads";
|
||||||
DEFAULT_UPLOAD_POLICY,
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
splitUnsupportedUploadFiles,
|
|
||||||
useUploadPolicy,
|
|
||||||
validateChatUploadFiles,
|
|
||||||
} from "@/core/uploads";
|
|
||||||
import { imeEnterAction } from "@/lib/ime";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
@@ -62,7 +57,6 @@ import {
|
|||||||
Children,
|
Children,
|
||||||
type ClipboardEventHandler,
|
type ClipboardEventHandler,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type CompositionEventHandler,
|
|
||||||
createContext,
|
createContext,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type FormEventHandler,
|
type FormEventHandler,
|
||||||
@@ -151,7 +145,6 @@ const usePromptInputValidation = () => useContext(PromptInputValidationContext);
|
|||||||
|
|
||||||
export type PromptInputProviderProps = PropsWithChildren<{
|
export type PromptInputProviderProps = PropsWithChildren<{
|
||||||
initialInput?: string;
|
initialInput?: string;
|
||||||
uploadPolicy?: UploadPolicy;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,12 +153,8 @@ export type PromptInputProviderProps = PropsWithChildren<{
|
|||||||
*/
|
*/
|
||||||
export function PromptInputProvider({
|
export function PromptInputProvider({
|
||||||
initialInput: initialTextInput = "",
|
initialInput: initialTextInput = "",
|
||||||
uploadPolicy,
|
|
||||||
children,
|
children,
|
||||||
}: PromptInputProviderProps) {
|
}: PromptInputProviderProps) {
|
||||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
|
||||||
const effectiveUploadPolicy =
|
|
||||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
|
||||||
// ----- textInput state
|
// ----- textInput state
|
||||||
const [textInput, setTextInput] = useState(initialTextInput);
|
const [textInput, setTextInput] = useState(initialTextInput);
|
||||||
const clearInput = useCallback(() => setTextInput(""), []);
|
const clearInput = useCallback(() => setTextInput(""), []);
|
||||||
@@ -174,7 +163,6 @@ export function PromptInputProvider({
|
|||||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||||
(PromptInputFilePart & { id: string })[]
|
(PromptInputFilePart & { id: string })[]
|
||||||
>([]);
|
>([]);
|
||||||
const attachmentsRef = useRef(attachmentFiles);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const openRef = useRef<() => void>(() => {});
|
const openRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
@@ -184,26 +172,9 @@ export function PromptInputProvider({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = attachmentsRef.current;
|
setAttachmentFiles((prev) =>
|
||||||
const existing = current.map((item) => ({
|
prev.concat(
|
||||||
name: item.filename ?? "attachment",
|
incoming.map((file) => ({
|
||||||
size: item.file?.size ?? 0,
|
|
||||||
type: item.mediaType ?? "",
|
|
||||||
}));
|
|
||||||
const validation = validateChatUploadFiles(
|
|
||||||
incoming,
|
|
||||||
existing,
|
|
||||||
effectiveUploadPolicy,
|
|
||||||
);
|
|
||||||
for (const message of new Set(
|
|
||||||
validation.issues.map((issue) => issue.message),
|
|
||||||
)) {
|
|
||||||
toast.error(message);
|
|
||||||
}
|
|
||||||
if (validation.accepted.length === 0) return;
|
|
||||||
|
|
||||||
const next = current.concat(
|
|
||||||
validation.accepted.map((file) => ({
|
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
@@ -211,31 +182,33 @@ export function PromptInputProvider({
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
file,
|
file,
|
||||||
})),
|
})),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
attachmentsRef.current = next;
|
}, []);
|
||||||
setAttachmentFiles(next);
|
|
||||||
}, [effectiveUploadPolicy]);
|
|
||||||
|
|
||||||
const remove = useCallback((id: string) => {
|
const remove = useCallback((id: string) => {
|
||||||
const current = attachmentsRef.current;
|
setAttachmentFiles((prev) => {
|
||||||
const found = current.find((f) => f.id === id);
|
const found = prev.find((f) => f.id === id);
|
||||||
if (found?.url) URL.revokeObjectURL(found.url);
|
if (found?.url) {
|
||||||
const next = current.filter((f) => f.id !== id);
|
URL.revokeObjectURL(found.url);
|
||||||
attachmentsRef.current = next;
|
}
|
||||||
setAttachmentFiles(next);
|
return prev.filter((f) => f.id !== id);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
for (const f of attachmentsRef.current) {
|
setAttachmentFiles((prev) => {
|
||||||
if (f.url) {
|
for (const f of prev) {
|
||||||
URL.revokeObjectURL(f.url);
|
if (f.url) {
|
||||||
|
URL.revokeObjectURL(f.url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return [];
|
||||||
attachmentsRef.current = [];
|
});
|
||||||
setAttachmentFiles([]);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||||
|
const attachmentsRef = useRef(attachmentFiles);
|
||||||
attachmentsRef.current = attachmentFiles;
|
attachmentsRef.current = attachmentFiles;
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
// Cleanup blob URLs on unmount to prevent memory leaks
|
||||||
@@ -486,15 +459,8 @@ export type PromptInputProps = Omit<
|
|||||||
// Minimal constraints
|
// Minimal constraints
|
||||||
maxFiles?: number;
|
maxFiles?: number;
|
||||||
maxFileSize?: number; // bytes
|
maxFileSize?: number; // bytes
|
||||||
uploadPolicy?: UploadPolicy;
|
|
||||||
onError?: (err: {
|
onError?: (err: {
|
||||||
code:
|
code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
|
||||||
| "max_files"
|
|
||||||
| "max_file_size"
|
|
||||||
| "max_total_size"
|
|
||||||
| "unsupported_type"
|
|
||||||
| "accept"
|
|
||||||
| "unsupported_package";
|
|
||||||
message: string;
|
message: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
@@ -512,22 +478,11 @@ export const PromptInput = ({
|
|||||||
syncHiddenInput,
|
syncHiddenInput,
|
||||||
maxFiles,
|
maxFiles,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
uploadPolicy,
|
|
||||||
onError,
|
onError,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: PromptInputProps) => {
|
}: PromptInputProps) => {
|
||||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
|
||||||
const effectiveUploadPolicy =
|
|
||||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
|
||||||
const policyAccept = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(effectiveUploadPolicy.groups)
|
|
||||||
.flatMap((group) => group.extensions)
|
|
||||||
.join(","),
|
|
||||||
[effectiveUploadPolicy],
|
|
||||||
);
|
|
||||||
// Try to use a provider controller if present
|
// Try to use a provider controller if present
|
||||||
const controller = useOptionalPromptInputController();
|
const controller = useOptionalPromptInputController();
|
||||||
const usingProvider = !!controller;
|
const usingProvider = !!controller;
|
||||||
@@ -562,9 +517,6 @@ export const PromptInput = ({
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return patterns.some((pattern) => {
|
return patterns.some((pattern) => {
|
||||||
if (pattern.startsWith(".")) {
|
|
||||||
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
|
|
||||||
}
|
|
||||||
if (pattern.endsWith("/*")) {
|
if (pattern.endsWith("/*")) {
|
||||||
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
||||||
return f.type.startsWith(prefix);
|
return f.type.startsWith(prefix);
|
||||||
@@ -579,67 +531,52 @@ export const PromptInput = ({
|
|||||||
(fileList: File[] | FileList) => {
|
(fileList: File[] | FileList) => {
|
||||||
const incoming = Array.from(fileList);
|
const incoming = Array.from(fileList);
|
||||||
const accepted = incoming.filter((f) => matchesAccept(f));
|
const accepted = incoming.filter((f) => matchesAccept(f));
|
||||||
if (accepted.length < incoming.length) {
|
if (incoming.length && accepted.length === 0) {
|
||||||
const error = {
|
onError?.({
|
||||||
code: "accept",
|
code: "accept",
|
||||||
message: "部分文件不符合当前输入框允许的格式。",
|
message: "No files match the accepted types.",
|
||||||
} as const;
|
});
|
||||||
onError?.(error);
|
return;
|
||||||
if (!onError) toast.error(error.message);
|
|
||||||
}
|
}
|
||||||
const localPolicy: UploadPolicy = {
|
const withinSize = (f: File) =>
|
||||||
...effectiveUploadPolicy,
|
maxFileSize ? f.size <= maxFileSize : true;
|
||||||
max_files_per_message:
|
const sized = accepted.filter(withinSize);
|
||||||
typeof maxFiles === "number"
|
if (accepted.length > 0 && sized.length === 0) {
|
||||||
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
|
onError?.({
|
||||||
: effectiveUploadPolicy.max_files_per_message,
|
code: "max_file_size",
|
||||||
groups: Object.fromEntries(
|
message: "All files exceed the maximum size.",
|
||||||
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
|
});
|
||||||
key,
|
return;
|
||||||
{
|
|
||||||
...group,
|
|
||||||
max_bytes:
|
|
||||||
typeof maxFileSize === "number"
|
|
||||||
? Math.min(group.max_bytes, maxFileSize)
|
|
||||||
: group.max_bytes,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
) as UploadPolicy["groups"],
|
|
||||||
};
|
|
||||||
const existing = items.map((item) => ({
|
|
||||||
name: item.filename ?? "attachment",
|
|
||||||
size: item.file?.size ?? 0,
|
|
||||||
type: item.mediaType ?? "",
|
|
||||||
}));
|
|
||||||
const validation = validateChatUploadFiles(
|
|
||||||
accepted,
|
|
||||||
existing,
|
|
||||||
localPolicy,
|
|
||||||
);
|
|
||||||
for (const issue of Array.from(
|
|
||||||
new Map(
|
|
||||||
validation.issues.map((item) => [item.message, item]),
|
|
||||||
).values(),
|
|
||||||
)) {
|
|
||||||
onError?.({ code: issue.code, message: issue.message });
|
|
||||||
if (!onError) toast.error(issue.message);
|
|
||||||
}
|
}
|
||||||
if (validation.accepted.length === 0) return;
|
|
||||||
|
|
||||||
setItems((prev) =>
|
setItems((prev) => {
|
||||||
prev.concat(
|
const capacity =
|
||||||
validation.accepted.map((file) => ({
|
typeof maxFiles === "number"
|
||||||
|
? Math.max(0, maxFiles - prev.length)
|
||||||
|
: undefined;
|
||||||
|
const capped =
|
||||||
|
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
|
||||||
|
if (typeof capacity === "number" && sized.length > capacity) {
|
||||||
|
onError?.({
|
||||||
|
code: "max_files",
|
||||||
|
message: "Too many files. Some were not added.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const next: (PromptInputFilePart & { id: string })[] = [];
|
||||||
|
for (const file of capped) {
|
||||||
|
next.push({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
type: "file" as const,
|
type: "file",
|
||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
mediaType: file.type,
|
mediaType: file.type,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
file,
|
file,
|
||||||
})),
|
});
|
||||||
),
|
}
|
||||||
);
|
return prev.concat(next);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
|
[matchesAccept, maxFiles, maxFileSize, onError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeLocal = useCallback(
|
const removeLocal = useCallback(
|
||||||
@@ -828,17 +765,11 @@ export const PromptInput = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const form = event.currentTarget;
|
const form = event.currentTarget;
|
||||||
// Prefer the live textarea. Firefox 102 applies the chosen candidate to
|
|
||||||
// the DOM before React state catches up, and a send click in that gap
|
|
||||||
// would otherwise submit the previous pinyin-less value.
|
|
||||||
const liveText = form.querySelector<HTMLTextAreaElement>(
|
|
||||||
"textarea[name='message']",
|
|
||||||
)?.value;
|
|
||||||
const text = usingProvider
|
const text = usingProvider
|
||||||
? (liveText ?? controller.textInput.value)
|
? controller.textInput.value
|
||||||
: (() => {
|
: (() => {
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
return (formData.get("message") as string) || liveText || "";
|
return (formData.get("message") as string) || "";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Reset form immediately after capturing text to avoid race condition
|
// Reset form immediately after capturing text to avoid race condition
|
||||||
@@ -901,7 +832,7 @@ export const PromptInput = ({
|
|||||||
const inner = (
|
const inner = (
|
||||||
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
||||||
<input
|
<input
|
||||||
accept={accept ?? policyAccept}
|
accept={accept}
|
||||||
aria-label="Upload files"
|
aria-label="Upload files"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
@@ -916,12 +847,10 @@ export const PromptInput = ({
|
|||||||
ref={formRef}
|
ref={formRef}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions
|
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
|
||||||
before 105 ignore them, keep the horizontal flex layout, and
|
versions before 105 ignore :has(), retain h-9, and clip the whole
|
||||||
squeeze the textarea beside the footer controls. */}
|
multi-line composer to a single row. */}
|
||||||
<InputGroup className="h-auto min-h-16 flex-col items-stretch">
|
<InputGroup className="h-auto min-h-16">{children}</InputGroup>
|
||||||
{children}
|
|
||||||
</InputGroup>
|
|
||||||
</form>
|
</form>
|
||||||
</PromptInputValidationContext.Provider>
|
</PromptInputValidationContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -951,43 +880,17 @@ export type PromptInputTextareaProps = ComponentProps<
|
|||||||
export const PromptInputTextarea = ({
|
export const PromptInputTextarea = ({
|
||||||
onChange,
|
onChange,
|
||||||
onInput,
|
onInput,
|
||||||
onKeyDown,
|
|
||||||
onCompositionStart,
|
|
||||||
onCompositionEnd,
|
|
||||||
className,
|
className,
|
||||||
placeholder = "What would you like to know?",
|
placeholder = "What would you like to know?",
|
||||||
defaultValue,
|
|
||||||
...props
|
...props
|
||||||
}: PromptInputTextareaProps) => {
|
}: PromptInputTextareaProps) => {
|
||||||
const controller = useOptionalPromptInputController();
|
const controller = useOptionalPromptInputController();
|
||||||
const attachments = usePromptInputAttachments();
|
const attachments = usePromptInputAttachments();
|
||||||
const sanitizeIncomingFiles = usePromptInputValidation();
|
const sanitizeIncomingFiles = usePromptInputValidation();
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const composingRef = useRef(false);
|
|
||||||
const compositionEndedAtRef = useRef(0);
|
|
||||||
const onChangeRef = useRef(onChange);
|
|
||||||
const onInputRef = useRef(onInput);
|
|
||||||
const onKeyDownRef = useRef(onKeyDown);
|
|
||||||
const onCompositionStartRef = useRef(onCompositionStart);
|
|
||||||
const onCompositionEndRef = useRef(onCompositionEnd);
|
|
||||||
const setInputRef = useRef(controller?.textInput.setInput);
|
|
||||||
const attachmentsRef = useRef(attachments);
|
|
||||||
onChangeRef.current = onChange;
|
|
||||||
onInputRef.current = onInput;
|
|
||||||
onKeyDownRef.current = onKeyDown;
|
|
||||||
onCompositionStartRef.current = onCompositionStart;
|
|
||||||
onCompositionEndRef.current = onCompositionEnd;
|
|
||||||
setInputRef.current = controller?.textInput.setInput;
|
|
||||||
attachmentsRef.current = attachments;
|
|
||||||
|
|
||||||
const publishValue = useCallback((value: string) => {
|
|
||||||
setInputRef.current?.(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
|
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
|
||||||
// Firefox 102 commits an open IME composition as raw pinyin when the
|
|
||||||
// textarea's height or overflow changes mid-composition.
|
|
||||||
if (composingRef.current) return;
|
|
||||||
if (
|
if (
|
||||||
typeof CSS !== "undefined" &&
|
typeof CSS !== "undefined" &&
|
||||||
typeof CSS.supports === "function" &&
|
typeof CSS.supports === "function" &&
|
||||||
@@ -1008,7 +911,6 @@ export const PromptInputTextarea = ({
|
|||||||
// onInput does not run for a controlled value filled by a suggested question
|
// onInput does not run for a controlled value filled by a suggested question
|
||||||
// or restored draft. Resize once after every render in legacy Chrome too.
|
// or restored draft. Resize once after every render in legacy Chrome too.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (composingRef.current) return;
|
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
@@ -1020,28 +922,13 @@ export const PromptInputTextarea = ({
|
|||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
const action = imeEnterAction({
|
if (isIMEComposing(e, isComposing)) {
|
||||||
isComposing: composingRef.current,
|
|
||||||
nativeIsComposing: e.nativeEvent.isComposing || e.key === "Process",
|
|
||||||
keyCode: e.keyCode,
|
|
||||||
compositionEndedAt: compositionEndedAtRef.current,
|
|
||||||
now: performance.now(),
|
|
||||||
});
|
|
||||||
// Firefox 102 confirms the candidate on this key. preventDefault here
|
|
||||||
// cancels that confirmation and leaves the pinyin in the box.
|
|
||||||
if (action === "ignore") {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onKeyDownRef.current?.(e);
|
if (e.shiftKey) {
|
||||||
if (e.defaultPrevented || e.shiftKey) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The confirming Enter already finished the candidate. Swallow it so
|
|
||||||
// Firefox does not also insert a newline or send the message.
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (action === "swallow") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the submit button is disabled before submitting
|
// Check if the submit button is disabled before submitting
|
||||||
const form = e.currentTarget.form;
|
const form = e.currentTarget.form;
|
||||||
@@ -1053,59 +940,22 @@ export const PromptInputTextarea = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
form?.requestSubmit();
|
form?.requestSubmit();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyDownRef.current?.(e);
|
|
||||||
if (e.defaultPrevented) return;
|
|
||||||
|
|
||||||
if (composingRef.current) return;
|
|
||||||
|
|
||||||
// Remove last attachment when Backspace is pressed and textarea is empty
|
// Remove last attachment when Backspace is pressed and textarea is empty
|
||||||
if (
|
if (
|
||||||
e.key === "Backspace" &&
|
e.key === "Backspace" &&
|
||||||
e.currentTarget.value === "" &&
|
e.currentTarget.value === "" &&
|
||||||
attachmentsRef.current.files.length > 0
|
attachments.files.length > 0
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const lastAttachment = attachmentsRef.current.files.at(-1);
|
const lastAttachment = attachments.files.at(-1);
|
||||||
if (lastAttachment) {
|
if (lastAttachment) {
|
||||||
attachmentsRef.current.remove(lastAttachment.id);
|
attachments.remove(lastAttachment.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompositionStart: CompositionEventHandler<HTMLTextAreaElement> = (
|
|
||||||
event,
|
|
||||||
) => {
|
|
||||||
composingRef.current = true;
|
|
||||||
compositionEndedAtRef.current = 0;
|
|
||||||
onCompositionStartRef.current?.(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCompositionEnd: CompositionEventHandler<HTMLTextAreaElement> = (
|
|
||||||
event,
|
|
||||||
) => {
|
|
||||||
onCompositionEndRef.current?.(event);
|
|
||||||
composingRef.current = false;
|
|
||||||
compositionEndedAtRef.current = performance.now();
|
|
||||||
const el = event.currentTarget;
|
|
||||||
// Firefox 102 dispatches compositionend before the committed candidate is
|
|
||||||
// visible, and before the input event that carries it. Syncing the
|
|
||||||
// compositionend value here would write the raw pinyin back over 汉字.
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (el.isConnected) publishValue(el.value);
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
|
|
||||||
// Keep the controlled value identical to the DOM, including the pinyin
|
|
||||||
// still being composed. Otherwise React restores the previous value at
|
|
||||||
// the end of the input event and Firefox 102 cannot replace it.
|
|
||||||
publishValue(event.currentTarget.value);
|
|
||||||
onChangeRef.current?.(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
|
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
|
||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
|
|
||||||
@@ -1135,28 +985,35 @@ export const PromptInputTextarea = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const controlledProps = controller
|
||||||
|
? {
|
||||||
|
value: controller.textInput.value,
|
||||||
|
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
controller.textInput.setInput(e.currentTarget.value);
|
||||||
|
onChange?.(e);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
onChange,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputGroupTextarea
|
<InputGroupTextarea
|
||||||
{...props}
|
|
||||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||||
name="message"
|
name="message"
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={3}
|
rows={3}
|
||||||
onChange={handleChange}
|
onCompositionEnd={() => setIsComposing(false)}
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onCompositionStart={() => setIsComposing(true)}
|
||||||
onCompositionStart={handleCompositionStart}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
if (!composingRef.current) {
|
resizeForLegacyChrome(event.currentTarget);
|
||||||
resizeForLegacyChrome(event.currentTarget);
|
onInput?.(event);
|
||||||
}
|
|
||||||
onInputRef.current?.(event);
|
|
||||||
}}
|
}}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
{...(controller
|
{...props}
|
||||||
? { value: controller.textInput.value }
|
{...controlledProps}
|
||||||
: { defaultValue })}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1276,22 +1133,18 @@ export const PromptInputSubmit = ({
|
|||||||
...props
|
...props
|
||||||
}: PromptInputSubmitProps) => {
|
}: PromptInputSubmitProps) => {
|
||||||
let Icon = <ArrowUpIcon className="size-4" />;
|
let Icon = <ArrowUpIcon className="size-4" />;
|
||||||
let ariaLabel = "发送消息";
|
|
||||||
|
|
||||||
if (status === "submitted") {
|
if (status === "submitted") {
|
||||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||||
ariaLabel = "正在发送";
|
|
||||||
} else if (status === "streaming") {
|
} else if (status === "streaming") {
|
||||||
Icon = <SquareIcon className="size-4" />;
|
Icon = <SquareIcon className="size-4" />;
|
||||||
ariaLabel = "停止生成";
|
|
||||||
} else if (status === "error") {
|
} else if (status === "error") {
|
||||||
Icon = <XIcon className="size-4" />;
|
Icon = <XIcon className="size-4" />;
|
||||||
ariaLabel = "重新发送";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputGroupButton
|
<InputGroupButton
|
||||||
aria-label={ariaLabel}
|
aria-label="Submit"
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300",
|
"transition-all duration-300",
|
||||||
status === "streaming" &&
|
status === "streaming" &&
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||||
import { SafeStreamdown } from "@/core/streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
import { reasoningPlugins } from "@/core/streamdown/plugins";
|
import { reasoningPlugins } from "@/core/streamdown/plugins";
|
||||||
import { Shimmer } from "./shimmer";
|
import { Shimmer } from "./shimmer";
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export const ReasoningContent = memo(
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{typeof children === "string" ? (
|
{typeof children === "string" ? (
|
||||||
<SafeStreamdown {...reasoningPlugins}>{children}</SafeStreamdown>
|
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ import { queryClient } from "@/components/query-client-provider";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import Empty from "./empty";
|
import Empty from "./empty";
|
||||||
import { NetworkActor } from "./network-actor";
|
import { NetworkActor } from "./network-actor";
|
||||||
import {
|
|
||||||
normalizeV2UploadSources,
|
|
||||||
type StudioUploadSource,
|
|
||||||
type V2UploadResponse,
|
|
||||||
} from "@/core/studio/document-upload";
|
|
||||||
|
|
||||||
// ── 适配层 ─────────────────────────────────────────────────
|
// ── 适配层 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -91,7 +86,22 @@ interface DocumentUploadDialogContextType {
|
|||||||
data?: { query: string; mode: string }
|
data?: { query: string; mode: string }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
// 上传成功回调,返回上传的来源信息
|
// 上传成功回调,返回上传的来源信息
|
||||||
onUploadComplete?: (sources: StudioUploadSource[]) => void;
|
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 任务轮询类型 ─────────────────────────────────────────────
|
||||||
|
interface TaskSource {
|
||||||
|
task_id: string;
|
||||||
|
title: string;
|
||||||
|
status: 'processing' | 'success' | 'error';
|
||||||
|
source_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadResponse {
|
||||||
|
status: string;
|
||||||
|
uploaded: number;
|
||||||
|
skipped: number;
|
||||||
|
sources: TaskSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface listDataItem {
|
interface listDataItem {
|
||||||
@@ -214,7 +224,7 @@ export const DocumentUploadDialogProvider: FC<{
|
|||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
searchSpace: { id: number | string;[key: string]: unknown };
|
searchSpace: { id: number | string;[key: string]: unknown };
|
||||||
onUploadSuccess?: () => void;
|
onUploadSuccess?: () => void;
|
||||||
onUploadComplete?: (sources: StudioUploadSource[]) => void;
|
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||||||
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
|
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [initialSearchText, setInitialSearchText] = useState("");
|
const [initialSearchText, setInitialSearchText] = useState("");
|
||||||
@@ -343,23 +353,27 @@ const DocumentUploadPopupContent: FC<{
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
formData.append("files", file);
|
formData.append("files", file);
|
||||||
}
|
}
|
||||||
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload-v2`, {
|
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json() as V2UploadResponse;
|
const data = await res.json() as UploadResponse;
|
||||||
// 调用上传完成回调,传递来源信息
|
// 调用上传完成回调,传递来源信息
|
||||||
if (data.sources && data.sources.length > 0) {
|
if (data.sources && data.sources.length > 0) {
|
||||||
onUploadComplete?.(normalizeV2UploadSources(data));
|
onUploadComplete?.(data.sources.map(s => ({
|
||||||
|
id: undefined, // 后端返回的数据中暂时没有 id
|
||||||
|
title: s.title,
|
||||||
|
task_id: s.task_id,
|
||||||
|
status: s.status,
|
||||||
|
})));
|
||||||
}
|
}
|
||||||
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
|
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
|
||||||
// 只关闭上传弹窗
|
// 只关闭上传弹窗
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} else {
|
} else {
|
||||||
const payload = await res.json().catch(() => null) as { detail?: string } | null;
|
toast.error("上传失败,请重试");
|
||||||
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("上传失败,请重试");
|
toast.error("上传失败,请重试");
|
||||||
@@ -396,6 +410,7 @@ const DocumentUploadPopupContent: FC<{
|
|||||||
selectedSearchSources.map((s) => ({
|
selectedSearchSources.map((s) => ({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
|
task_id: undefined,
|
||||||
status: "success" as const,
|
status: "success" as const,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useLayoutEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import {
|
|
||||||
applyFontScaleToDocument,
|
|
||||||
normalizeFontScale,
|
|
||||||
normalizeFontScaleMode,
|
|
||||||
} from "@/core/ui/page-zoom";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
|
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
|
||||||
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
|
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
|
||||||
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
|
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
|
||||||
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
|
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
|
||||||
* `fontScale === 1` 即默认 16px。
|
* `fontScale === 1` 即默认 16px。
|
||||||
*/
|
*/
|
||||||
export const BASE_FONT_PX = 16;
|
export const BASE_FONT_PX = 16;
|
||||||
@@ -26,36 +21,25 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
|
|||||||
{ value: 1.25, label: "特大", px: 20 },
|
{ value: 1.25, label: "特大", px: 20 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FONT_SCALE_MODE_OPTIONS: {
|
|
||||||
value: "text" | "page";
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
value: "text",
|
|
||||||
label: "仅文字",
|
|
||||||
description: "只放大文字,间距和布局保持原样。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "page",
|
|
||||||
label: "整页缩放",
|
|
||||||
description: "文字、间距、图标和写死的像素一起放大。",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。
|
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
|
||||||
|
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
|
||||||
|
* 不渲染任何 DOM。
|
||||||
*/
|
*/
|
||||||
export function FontScaleApplier() {
|
export function FontScaleApplier() {
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const { fontScale, fontScaleMode } = settings.display;
|
const { fontScale } = settings.display;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
applyFontScaleToDocument(
|
const scale =
|
||||||
normalizeFontScaleMode(fontScaleMode),
|
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
|
||||||
normalizeFontScale(fontScale),
|
? fontScale
|
||||||
);
|
: 1;
|
||||||
}, [fontScale, fontScaleMode]);
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty("--ui-font-scale", String(scale));
|
||||||
|
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
|
||||||
|
root.style.removeProperty("font-size");
|
||||||
|
}, [fontScale]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
|||||||
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
|
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
|
||||||
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
|
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
|
||||||
* a real document URL, so fragment links scroll within the frame as expected —
|
* a real document URL, so fragment links scroll within the frame as expected —
|
||||||
* The sandbox permits scripts so the preview can use the app-bundled HTML
|
* all without enabling scripts (the sandbox stays script-free for security; the
|
||||||
* visualization runtimes, while still withholding same-origin privileges.
|
* server already sanitizes script vectors and this is defense-in-depth).
|
||||||
*
|
*
|
||||||
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
||||||
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
|
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
|
||||||
@@ -26,9 +26,7 @@ export function HtmlPreviewFrame({
|
|||||||
const [url, setUrl] = useState<string>();
|
const [url, setUrl] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const blob = new Blob([
|
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
||||||
prepareGeneratedHtml(html ?? ""),
|
|
||||||
], {
|
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { toCssPx } from "@/core/ui/page-zoom";
|
|
||||||
|
|
||||||
import type { TooltipState } from "./graphTypes";
|
import type { TooltipState } from "./graphTypes";
|
||||||
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
|
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
|
||||||
|
|
||||||
@@ -11,8 +9,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
|
|||||||
if (!tooltip) return null;
|
if (!tooltip) return null;
|
||||||
const meta = NODE_KIND_META[tooltip.kind];
|
const meta = NODE_KIND_META[tooltip.kind];
|
||||||
// Keep the tooltip inside the viewport.
|
// Keep the tooltip inside the viewport.
|
||||||
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
|
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
|
||||||
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
|
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kg-tooltip" style={{ left, top }}>
|
<div className="kg-tooltip" style={{ left, top }}>
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
top: 4rem;
|
top: 4rem;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: 98px;
|
width: 98px;
|
||||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
height: calc(100vh - 4rem);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
|
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
|
||||||
.sidebar-contnet--header-hidden {
|
.sidebar-contnet--header-hidden {
|
||||||
top: 0;
|
top: 0;
|
||||||
height: calc(100vh / var(--df-page-zoom, 1));
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-contnet--header-hidden .item-nav-content {
|
.sidebar-contnet--header-hidden .item-nav-content {
|
||||||
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
height: calc(100vh - 20px);
|
||||||
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
min-height: calc(100vh - 20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-contents {
|
.sidebar-contents {
|
||||||
@@ -160,8 +160,8 @@
|
|||||||
|
|
||||||
.item-nav-content {
|
.item-nav-content {
|
||||||
width: 155px;
|
width: 155px;
|
||||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
height: calc(100vh - 4rem - 20px);
|
||||||
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
min-height: calc(100vh - 4rem - 20px);
|
||||||
background: rgba(3, 38, 80, 0.8);
|
background: rgba(3, 38, 80, 0.8);
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
border-color: rgba(43, 143, 254, 0.6);
|
border-color: rgba(43, 143, 254, 0.6);
|
||||||
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
|||||||
top: 4rem;
|
top: 4rem;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: 96px;
|
width: 96px;
|
||||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
height: calc(100vh - 4rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -653,7 +653,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
|||||||
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
|
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
|
||||||
.light-sidebar--header-hidden {
|
.light-sidebar--header-hidden {
|
||||||
top: 0;
|
top: 0;
|
||||||
height: calc(100vh / var(--df-page-zoom, 1));
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function PageSidebar({
|
|||||||
onOpenChange={setNotificationOpen}
|
onOpenChange={setNotificationOpen}
|
||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh/var(--df-page-zoom,1)-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||||
isDark
|
isDark
|
||||||
? "border-r border-blue-800/30 bg-slate-900"
|
? "border-r border-blue-800/30 bg-slate-900"
|
||||||
: "border-r border-red-100 bg-white"
|
: "border-r border-red-100 bg-white"
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ScrollText,
|
|
||||||
Timer,
|
|
||||||
User,
|
User,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -224,12 +222,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
个人信息
|
个人信息
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<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")}
|
||||||
@@ -270,22 +270,6 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate("/page/workspace/admin/skill-timing")}
|
|
||||||
>
|
|
||||||
<Timer className="size-4" />
|
|
||||||
技能耗时排查
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
|
|
||||||
>
|
|
||||||
<ScrollText className="size-4" />
|
|
||||||
模型收发记录
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||||
<Cpu className="size-4" />
|
<Cpu className="size-4" />
|
||||||
@@ -500,9 +484,7 @@ function LegacyAccordionSidebar({
|
|||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||||
headerHidden
|
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
|
||||||
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
|
|
||||||
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
|
|
||||||
} ${
|
} ${
|
||||||
isDark
|
isDark
|
||||||
? "border-r border-blue-800/30 bg-slate-900"
|
? "border-r border-blue-800/30 bg-slate-900"
|
||||||
@@ -579,10 +561,12 @@ function LegacyAccordionSidebar({
|
|||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
个人信息
|
个人信息
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<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" />
|
||||||
@@ -607,22 +591,6 @@ function LegacyAccordionSidebar({
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate("/page/workspace/admin/skill-timing")}
|
|
||||||
>
|
|
||||||
<Timer className="size-4" />
|
|
||||||
技能耗时排查
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
|
|
||||||
>
|
|
||||||
<ScrollText className="size-4" />
|
|
||||||
模型收发记录
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||||
<Cpu className="size-4" />
|
<Cpu className="size-4" />
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { useLayoutEffect, useState } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
|
|
||||||
import { ReactUiErrorBoundary } from "./react-ui-error-boundary";
|
|
||||||
|
|
||||||
// Offline browser QA: real React production render/commit recursion, not a
|
|
||||||
// fabricated #185 thrown by a unit-test stub. A manual button click reloads
|
|
||||||
// this same page without submitting any conversation or model request.
|
|
||||||
const key = "deerflow.boundary-browser-check";
|
|
||||||
let mounts = 0;
|
|
||||||
function InfiniteCommitLoop() {
|
|
||||||
const [, update] = useState(0);
|
|
||||||
useState(() => { mounts += 1; return 0; });
|
|
||||||
useLayoutEffect(() => { update((value) => value + 1); });
|
|
||||||
return <div>Loop fixture</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function result(passed: boolean, details: Record<string, unknown>) {
|
|
||||||
const output = document.createElement("pre");
|
|
||||||
output.id = "boundary-check-result";
|
|
||||||
output.textContent = JSON.stringify({ passed, ...details });
|
|
||||||
document.body.append(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resumed = sessionStorage.getItem(key) === "manual-reload";
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<ReactUiErrorBoundary>
|
|
||||||
{resumed ? <div id="healthy">Existing page restored</div> : <InfiniteCommitLoop />}
|
|
||||||
</ReactUiErrorBoundary>,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resumed) {
|
|
||||||
sessionStorage.removeItem(key);
|
|
||||||
window.setTimeout(() => result(Boolean(document.getElementById("healthy")), {
|
|
||||||
phase: "manual-reload-restored", renderLoopCaught: true, noAutomaticRemount: true,
|
|
||||||
}), 100);
|
|
||||||
} else {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
const alert = document.querySelector('[role="alert"]');
|
|
||||||
const before = mounts;
|
|
||||||
if (!alert || /#185|react\.dev|Maximum update depth/.test(alert.textContent ?? "")) {
|
|
||||||
result(false, { phase: "friendly-fallback", hasAlert: Boolean(alert) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (mounts !== before || before !== 1) {
|
|
||||||
result(false, { phase: "unexpected-automatic-remount", mounts, before });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sessionStorage.setItem(key, "manual-reload");
|
|
||||||
(alert.querySelector("button") as HTMLButtonElement).click();
|
|
||||||
}, 250);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
||||||
|
|
||||||
import { isReactMaximumUpdateDepthError } from "@/core/threads/stream-error-classification";
|
|
||||||
import { REACT_UI_ERROR_MESSAGE } from "@/core/threads/react-ui-error";
|
|
||||||
|
|
||||||
/** Last-resort render/commit guard. It deliberately never auto-remounts a broken
|
|
||||||
* tree, which could otherwise repeatedly resume the same run and crash again. */
|
|
||||||
export class ReactUiErrorBoundary extends Component<
|
|
||||||
{ children: ReactNode },
|
|
||||||
{ failed: boolean }
|
|
||||||
> {
|
|
||||||
state = { failed: false };
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: unknown) {
|
|
||||||
if (!isReactMaximumUpdateDepthError(error)) throw error;
|
|
||||||
return { failed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(_error: unknown, info: ErrorInfo) {
|
|
||||||
console.warn("[deer-flow] react_ui_render_error", {
|
|
||||||
errorType: "react_maximum_update_depth",
|
|
||||||
componentStack: info.componentStack,
|
|
||||||
recovery: "manual_reload",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (!this.state.failed) return this.props.children;
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen items-center justify-center p-6">
|
|
||||||
<section role="alert" className="max-w-lg rounded-lg border p-6 text-center">
|
|
||||||
<h1 className="mb-3 text-lg font-medium">页面暂时无法显示</h1>
|
|
||||||
<p className="mb-5 text-sm text-muted-foreground">{REACT_UI_ERROR_MESSAGE}</p>
|
|
||||||
<button
|
|
||||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
重新载入页面
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/** Lightweight, dependency-free fallback used while a route chunk loads. */
|
|
||||||
export function RouteLoadingFallback() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="text-muted-foreground flex size-full min-h-32 items-center justify-center gap-2 bg-background"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="border-muted-foreground/30 border-t-foreground size-4 animate-spin rounded-full border-2"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">正在加载页面…</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"legacy-dialog-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[200] bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -82,31 +82,26 @@ const DialogContent = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay className={overlayClassName} />
|
<DialogOverlay className={overlayClassName} />
|
||||||
<div
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-viewport"
|
ref={ref}
|
||||||
className={cn("legacy-dialog-viewport", overlayClassName)}
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<DialogPrimitive.Content
|
{children}
|
||||||
ref={ref}
|
{showCloseButton && (
|
||||||
data-slot="dialog-content"
|
<DialogPrimitive.Close
|
||||||
className={cn(
|
data-slot="dialog-close"
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-[200] grid min-h-0 max-h-full w-full max-w-[calc(100%-2rem)] pointer-events-auto gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
className,
|
>
|
||||||
)}
|
<XIcon />
|
||||||
{...props}
|
<span className="sr-only">Close</span>
|
||||||
>
|
</DialogPrimitive.Close>
|
||||||
{children}
|
)}
|
||||||
{showCloseButton && (
|
</DialogPrimitive.Content>
|
||||||
<DialogPrimitive.Close
|
|
||||||
data-slot="dialog-close"
|
|
||||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</div>
|
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -240,7 +240,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const SelectContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -20,12 +21,12 @@ export function Breadcrumb({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{segments.map((segment, i) => (
|
{segments.map((segment, i) => (
|
||||||
<div className="contents" key={`${segment}-${i}`}>
|
<Fragment key={`${segment}-${i}`}>
|
||||||
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||||
{segment}
|
{segment}
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||||
</div>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
<span
|
<span
|
||||||
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||||
import { useAllUsers } from "@/core/auth/hooks";
|
import { useAllUsers } from "@/core/auth/hooks";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -19,11 +17,6 @@ export function UserManagementPage() {
|
|||||||
title={t.adminUsers.title}
|
title={t.adminUsers.title}
|
||||||
description={t.adminUsers.description}
|
description={t.adminUsers.description}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex justify-end">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to="/page/strategy/admin/login-whitelist">管理密码与登录口令</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -534,6 +534,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enabledSkills = useMemo(
|
||||||
|
() => skills.filter((skill) => skill.enabled),
|
||||||
|
[skills],
|
||||||
|
);
|
||||||
|
|
||||||
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||||
// both the card header and the compact list row.
|
// both the card header and the compact list row.
|
||||||
const statusBadges = (
|
const statusBadges = (
|
||||||
@@ -961,7 +966,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<SkillTransferSelector
|
<SkillTransferSelector
|
||||||
skills={skills}
|
skills={enabledSkills}
|
||||||
selected={selectedSkills}
|
selected={selectedSkills}
|
||||||
onChange={setSelectedSkills}
|
onChange={setSelectedSkills}
|
||||||
isLoading={isSkillsLoading}
|
isLoading={isSkillsLoading}
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
import {
|
|
||||||
MyProjectThreadBadge,
|
|
||||||
MyProjectThreadIcon,
|
|
||||||
} from "@/components/workspace/my-project-thread-indicator";
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
@@ -73,8 +69,6 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
|
||||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
|
||||||
import {
|
import {
|
||||||
bootstrapResumePath,
|
bootstrapResumePath,
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
@@ -385,7 +379,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||||
[threads],
|
[threads],
|
||||||
);
|
);
|
||||||
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
|
||||||
|
|
||||||
const filteredThreads = useMemo(() => {
|
const filteredThreads = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -486,10 +479,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
) : (
|
) : (
|
||||||
filteredThreads.map((thread) => {
|
filteredThreads.map((thread) => {
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
const projectInfo = myProjectConversationInfoOfThread(
|
|
||||||
thread,
|
|
||||||
projectThreadNames,
|
|
||||||
);
|
|
||||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
@@ -503,7 +492,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||||
projectInfo && "h-auto min-h-12 py-1.5",
|
|
||||||
isActive
|
isActive
|
||||||
? "border border-primary/40 bg-primary/5 font-medium text-primary"
|
? "border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||||
@@ -517,8 +505,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
) : projectInfo ? (
|
|
||||||
<MyProjectThreadIcon type={projectInfo.type} />
|
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -530,22 +516,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span
|
<span className="truncate">
|
||||||
className={cn(
|
{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>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||||
|
|||||||
@@ -152,8 +152,6 @@ export function AgentDetailPanel({
|
|||||||
positionName,
|
positionName,
|
||||||
skillsIcon,
|
skillsIcon,
|
||||||
skillsTitle,
|
skillsTitle,
|
||||||
belowSkillsSlot,
|
|
||||||
feedbackScoreSlot,
|
|
||||||
showCommonQuestions = false,
|
showCommonQuestions = false,
|
||||||
jobResponsibilities,
|
jobResponsibilities,
|
||||||
panelTitle,
|
panelTitle,
|
||||||
@@ -183,10 +181,6 @@ export function AgentDetailPanel({
|
|||||||
positionName?: string;
|
positionName?: string;
|
||||||
skillsIcon?: ReactNode;
|
skillsIcon?: ReactNode;
|
||||||
skillsTitle?: string;
|
skillsTitle?: string;
|
||||||
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
|
|
||||||
belowSkillsSlot?: ReactNode;
|
|
||||||
/** Optional answer-feedback summary rendered near the agent identity. */
|
|
||||||
feedbackScoreSlot?: ReactNode;
|
|
||||||
showCommonQuestions?: boolean;
|
showCommonQuestions?: boolean;
|
||||||
/**
|
/**
|
||||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||||
@@ -750,8 +744,6 @@ export function AgentDetailPanel({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!editing && feedbackScoreSlot}
|
|
||||||
|
|
||||||
|
|
||||||
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
||||||
{jobResponsibilities !== undefined && (
|
{jobResponsibilities !== undefined && (
|
||||||
@@ -913,7 +905,7 @@ export function AgentDetailPanel({
|
|||||||
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
||||||
<Section
|
<Section
|
||||||
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
|
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
|
||||||
title={skillsTitle ?? `已配置技能 ${skillNamesShown.length} 个`}
|
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
|
||||||
className="position-roundtable-skills-section"
|
className="position-roundtable-skills-section"
|
||||||
extra={
|
extra={
|
||||||
editing ? (
|
editing ? (
|
||||||
@@ -948,9 +940,6 @@ export function AgentDetailPanel({
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
|
||||||
{belowSkillsSlot}
|
|
||||||
|
|
||||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { BarChart3Icon, MessageSquareTextIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
useAgentFeedbackScore,
|
|
||||||
type AnswerFeedbackReason,
|
|
||||||
type FeedbackDimensionKey,
|
|
||||||
} from "@/strategy-components/api/answer-feedback";
|
|
||||||
|
|
||||||
const DIMENSIONS: Array<{
|
|
||||||
key: FeedbackDimensionKey;
|
|
||||||
label: string;
|
|
||||||
fallbackWeight: number;
|
|
||||||
}> = [
|
|
||||||
{ key: "correctness", label: "正确性", fallbackWeight: 30 },
|
|
||||||
{ key: "completeness", label: "完整性", fallbackWeight: 20 },
|
|
||||||
{ key: "evidence_grounding", label: "证据与依据", fallbackWeight: 20 },
|
|
||||||
{ key: "relevance", label: "相关性", fallbackWeight: 15 },
|
|
||||||
{ key: "actionability", label: "可操作性", fallbackWeight: 15 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const REASON_LABELS: Record<AnswerFeedbackReason, string> = {
|
|
||||||
incorrect_or_incomplete: "不正确或不完整",
|
|
||||||
unmet_expectations: "与期望不符",
|
|
||||||
slow_or_problematic: "速度慢或存在问题",
|
|
||||||
style_or_tone: "风格或语气",
|
|
||||||
safety_or_legal: "安全或法律疑虑",
|
|
||||||
other: "其他",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONFIDENCE_LABELS = {
|
|
||||||
none: "暂无置信度",
|
|
||||||
low: "低置信度",
|
|
||||||
medium: "中等置信度",
|
|
||||||
high: "高置信度",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function scoreText(value: number | null | undefined) {
|
|
||||||
return typeof value === "number" && Number.isFinite(value)
|
|
||||||
? value.toFixed(1)
|
|
||||||
: "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function percent(value: number | null | undefined) {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
||||||
const normalized = value <= 1 ? value * 100 : value;
|
|
||||||
return Math.max(0, Math.min(100, normalized));
|
|
||||||
}
|
|
||||||
|
|
||||||
function weightPercent(value: number | undefined, fallback: number) {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
||||||
return value <= 1 ? Math.round(value * 100) : Math.round(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentFeedbackScoreCard({ agentId }: { agentId: string }) {
|
|
||||||
const { data, isLoading, isError } = useAgentFeedbackScore(agentId);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<section className="bg-card/60 rounded-xl border p-4" aria-label="回答评价加载中">
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
|
||||||
<div className="bg-muted mt-4 h-12 w-28 animate-pulse rounded-lg" />
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
{DIMENSIONS.map((item) => (
|
|
||||||
<div key={item.key} className="bg-muted h-3 animate-pulse rounded" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !data) {
|
|
||||||
return (
|
|
||||||
<section className="bg-card/60 rounded-xl border p-4">
|
|
||||||
<div className="flex items-center gap-1.5 text-sm font-semibold">
|
|
||||||
<BarChart3Icon className="size-3.5" />
|
|
||||||
回答评价
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">评价数据暂不可用</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const positiveRate = data.sample_count
|
|
||||||
? (data.positive_count / data.sample_count) * 100
|
|
||||||
: null;
|
|
||||||
const taskSuccessRate = percent(data.task_success_rate);
|
|
||||||
const topReasons = Object.entries(data.reason_breakdown ?? {})
|
|
||||||
.filter((entry): entry is [AnswerFeedbackReason, { count: number; rate: number }] =>
|
|
||||||
Boolean(entry[1]?.count),
|
|
||||||
)
|
|
||||||
.sort((left, right) => right[1].count - left[1].count)
|
|
||||||
.slice(0, 3);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-card/60 rounded-xl border p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
|
||||||
<BarChart3Icon className="size-3.5" />
|
|
||||||
回答评价
|
|
||||||
</h4>
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">用户评价分</p>
|
|
||||||
</div>
|
|
||||||
{data.score_band && data.score !== null && (
|
|
||||||
<span className="border-primary/20 bg-primary/5 text-primary rounded-full border px-2 py-1 text-[11px] font-medium">
|
|
||||||
{data.score_band.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex items-end gap-2">
|
|
||||||
{data.score === null ? (
|
|
||||||
<span className="text-muted-foreground text-xl font-semibold">暂无评价</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="text-3xl font-bold tracking-tight tabular-nums">
|
|
||||||
{scoreText(data.score)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground mb-1 text-xs">/ 100</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px]">
|
|
||||||
<span>{data.sample_count} 条评价</span>
|
|
||||||
<span>
|
|
||||||
{CONFIDENCE_LABELS[data.confidence_level]}
|
|
||||||
{` ${Math.round(data.confidence * 100)}%`}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
好评率 {positiveRate === null ? "—" : `${positiveRate.toFixed(0)}%`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.sample_count > 0 && (
|
|
||||||
<div className="mt-4 space-y-3" aria-label="回答评价维度">
|
|
||||||
{DIMENSIONS.map((item) => {
|
|
||||||
const dimension = data.dimensions[item.key];
|
|
||||||
const value = percent(dimension?.score);
|
|
||||||
return (
|
|
||||||
<div key={item.key}>
|
|
||||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
|
||||||
<span className="text-foreground">
|
|
||||||
{item.label}
|
|
||||||
<span className="text-muted-foreground ml-1">
|
|
||||||
{weightPercent(dimension?.weight, item.fallbackWeight)}%
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground tabular-nums">
|
|
||||||
{value === null ? "—" : value.toFixed(1)}
|
|
||||||
{dimension?.evidence_count
|
|
||||||
? ` · ${dimension.evidence_count} 条依据`
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-full rounded-full transition-[width]",
|
|
||||||
value !== null && value >= 80
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: value !== null && value >= 60
|
|
||||||
? "bg-amber-500"
|
|
||||||
: "bg-rose-500",
|
|
||||||
)}
|
|
||||||
style={{ width: `${value ?? 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(data.task_score !== null || taskSuccessRate !== null) && (
|
|
||||||
<div className="bg-muted/50 mt-4 flex items-center justify-between rounded-lg px-3 py-2 text-xs">
|
|
||||||
<span className="flex items-center gap-1.5 font-medium">
|
|
||||||
<MessageSquareTextIcon className="size-3.5" />
|
|
||||||
任务成功与稳定性
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground tabular-nums">
|
|
||||||
{data.task_score !== null && `评分 ${scoreText(data.task_score)}`}
|
|
||||||
{data.task_score !== null && taskSuccessRate !== null && " · "}
|
|
||||||
{taskSuccessRate !== null && `成功率 ${taskSuccessRate.toFixed(0)}%`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{topReasons.length > 0 && (
|
|
||||||
<div className="mt-4 border-t pt-3">
|
|
||||||
<p className="text-muted-foreground mb-2 text-[11px] font-medium">主要改进反馈</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{topReasons.map(([reason, breakdown]) => (
|
|
||||||
<span
|
|
||||||
key={reason}
|
|
||||||
className="bg-muted text-muted-foreground rounded-full px-2 py-1 text-[11px]"
|
|
||||||
>
|
|
||||||
{REASON_LABELS[reason]} · {breakdown.count}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-muted-foreground/80 mt-4 border-t pt-3 text-[10px] leading-relaxed">
|
|
||||||
该分数仅反映用户对回答结果的评价,不代表完整任务轨迹、工具调用或技能能力。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
|
||||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
|
||||||
import { canViewAgentFeedbackScore } from "./agent-feedback-visibility.ts";
|
|
||||||
|
|
||||||
test("allows only the canonical admin role to view agent feedback scores", () => {
|
|
||||||
assert.equal(canViewAgentFeedbackScore({ system_role: "admin" }), true);
|
|
||||||
assert.equal(canViewAgentFeedbackScore({ system_role: "user" }), false);
|
|
||||||
assert.equal(canViewAgentFeedbackScore({ system_role: "Admin" }), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fails closed when authentication or its role is unavailable", () => {
|
|
||||||
assert.equal(canViewAgentFeedbackScore(null), false);
|
|
||||||
assert.equal(canViewAgentFeedbackScore(undefined), false);
|
|
||||||
assert.equal(canViewAgentFeedbackScore({ system_role: "" }), false);
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { LoginByUsernameResponse } from "@/core/auth";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Agent-level answer scores are an administrative quality-control view.
|
|
||||||
* Keep the role check strict so missing, stale or unknown auth roles fail
|
|
||||||
* closed instead of exposing aggregate evaluation data.
|
|
||||||
*/
|
|
||||||
export function canViewAgentFeedbackScore(
|
|
||||||
auth: Pick<LoginByUsernameResponse, "system_role"> | null | undefined,
|
|
||||||
): boolean {
|
|
||||||
return auth?.system_role === "admin";
|
|
||||||
}
|
|
||||||
@@ -16,16 +16,8 @@ import { getStoredAuth } from "@/core/auth";
|
|||||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||||
import type { Agent, AgentSort } from "@/core/agents";
|
import type { Agent, AgentSort } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
|
||||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||||
import {
|
|
||||||
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
|
|
||||||
WX_RESTRICTED_TAB_NANJING_DB,
|
|
||||||
isWxRestrictedPlaceholderTab,
|
|
||||||
wxRestrictedDefaultSquareTabValue,
|
|
||||||
wxRestrictedNanjingTabValue,
|
|
||||||
} from "@/core/system-settings/wx-type-filter";
|
|
||||||
|
|
||||||
import { AgentCard } from "./agent-card";
|
import { AgentCard } from "./agent-card";
|
||||||
import { SentimentAgentCard } from "./sentiment-agent-card";
|
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||||
@@ -42,15 +34,9 @@ const PAGE_SIZE = 12;
|
|||||||
// password never persists beyond the browsing session.
|
// password never persists beyond the browsing session.
|
||||||
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||||
|
|
||||||
export function AgentGallery({
|
export function AgentGallery() {
|
||||||
restrictWxTypes = false,
|
|
||||||
}: {
|
|
||||||
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
|
|
||||||
restrictWxTypes?: boolean;
|
|
||||||
} = {}) {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const routeBase = useRouteBase();
|
|
||||||
const sentimentAgentVisible = isSentimentAgentVisible();
|
const sentimentAgentVisible = isSentimentAgentVisible();
|
||||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
@@ -66,12 +52,6 @@ export function AgentGallery({
|
|||||||
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
|
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
|
||||||
// 其余自定义广场继续保持后端给出的顺序。
|
// 其余自定义广场继续保持后端给出的顺序。
|
||||||
const [defaultSquare, ...additionalSquares] = squareTabs;
|
const [defaultSquare, ...additionalSquares] = squareTabs;
|
||||||
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
|
|
||||||
publishSquares?.default_square_id,
|
|
||||||
);
|
|
||||||
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
|
|
||||||
const wxPlaceholderTab =
|
|
||||||
restrictWxTypes && isWxRestrictedPlaceholderTab(activeTab);
|
|
||||||
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
||||||
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||||
@@ -92,30 +72,6 @@ export function AgentGallery({
|
|||||||
// If the active square tab disappears (admin removed that square), fall back
|
// If the active square tab disappears (admin removed that square), fall back
|
||||||
// to "我的" so the gallery never points at a dead tab.
|
// to "我的" so the gallery never points at a dead tab.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (restrictWxTypes) {
|
|
||||||
if (
|
|
||||||
activeTab === WX_RESTRICTED_TAB_NANJING_DB &&
|
|
||||||
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
|
|
||||||
) {
|
|
||||||
setActiveTab(nanjingTabValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
activeTab === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
|
|
||||||
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
|
|
||||||
) {
|
|
||||||
setActiveTab(defaultSquareTabValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
activeTab !== "mine" &&
|
|
||||||
activeTab !== defaultSquareTabValue &&
|
|
||||||
activeTab !== nanjingTabValue
|
|
||||||
) {
|
|
||||||
setActiveTab("mine");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
|
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
|
||||||
if (activeTab === "builtin" && !isAdmin) {
|
if (activeTab === "builtin" && !isAdmin) {
|
||||||
setActiveTab("mine");
|
setActiveTab("mine");
|
||||||
@@ -131,14 +87,7 @@ export function AgentGallery({
|
|||||||
) {
|
) {
|
||||||
setActiveTab("mine");
|
setActiveTab("mine");
|
||||||
}
|
}
|
||||||
}, [
|
}, [activeTab, squareTabs, isAdmin]);
|
||||||
activeTab,
|
|
||||||
squareTabs,
|
|
||||||
isAdmin,
|
|
||||||
restrictWxTypes,
|
|
||||||
defaultSquareTabValue,
|
|
||||||
nanjingTabValue,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { data: privateStatus } = usePrivateSquareStatus();
|
const { data: privateStatus } = usePrivateSquareStatus();
|
||||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||||
@@ -179,13 +128,10 @@ export function AgentGallery({
|
|||||||
sort,
|
sort,
|
||||||
page: mainPage,
|
page: mainPage,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
enabled:
|
enabled: activeTab !== "private" && activeTab !== "sentiment",
|
||||||
activeTab !== "private" &&
|
|
||||||
activeTab !== "sentiment" &&
|
|
||||||
!wxPlaceholderTab,
|
|
||||||
owner: publisher?.id,
|
owner: publisher?.id,
|
||||||
// Each square tab fetches only that square's agents.
|
// Each square tab fetches only that square's agents.
|
||||||
square: isSquareTab && !wxPlaceholderTab ? activeTab : undefined,
|
square: isSquareTab ? activeTab : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -223,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"
|
||||||
|
|||||||
@@ -278,7 +278,6 @@ export function AgentGuide({
|
|||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [current, setCurrent] = useState(-1);
|
const [current, setCurrent] = useState(-1);
|
||||||
const [appDialogOpen, setAppDialogOpen] = useState(false);
|
|
||||||
const stepsRef = useRef<GuideStep[]>([]);
|
const stepsRef = useRef<GuideStep[]>([]);
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
@@ -318,32 +317,7 @@ export function AgentGuide({
|
|||||||
setCurrent(-1);
|
setCurrent(-1);
|
||||||
}, [stage]);
|
}, [stage]);
|
||||||
|
|
||||||
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200,
|
if (!eligible) return null;
|
||||||
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 Dialog 打开时
|
|
||||||
// 先卸掉引导层,关掉后再从当前步恢复。
|
|
||||||
useEffect(() => {
|
|
||||||
if (!eligible) return;
|
|
||||||
const sync = () => {
|
|
||||||
setAppDialogOpen(
|
|
||||||
Boolean(document.querySelector('[data-slot="dialog-content"]')),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
sync();
|
|
||||||
const observer = new MutationObserver(sync);
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [eligible]);
|
|
||||||
|
|
||||||
// TDesign 1.18 still handles resize when current=-1 and dereferences an
|
|
||||||
// undefined step. Keep its listener mounted only during an active guide.
|
|
||||||
if (
|
|
||||||
!eligible ||
|
|
||||||
appDialogOpen ||
|
|
||||||
current < 0 ||
|
|
||||||
current >= stepsRef.current.length
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Guide
|
<Guide
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ import { MessageSquareIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import {
|
|
||||||
MyProjectThreadBadge,
|
|
||||||
MyProjectThreadIcon,
|
|
||||||
} from "@/components/workspace/my-project-thread-indicator";
|
|
||||||
import { useThreadsPage } from "@/core/threads/hooks";
|
import { useThreadsPage } from "@/core/threads/hooks";
|
||||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
|
||||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
|
||||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -32,7 +26,6 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
const threads = (data?.threads ?? []).filter(
|
const threads = (data?.threads ?? []).filter(
|
||||||
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
|
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
|
||||||
);
|
);
|
||||||
const projectThreadNames = useProjectThreadNames(threads);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-card/60 rounded-xl border p-4">
|
<section className="bg-card/60 rounded-xl border p-4">
|
||||||
@@ -52,10 +45,6 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
{threads.map((thread) => {
|
{threads.map((thread) => {
|
||||||
const href = pathOfThread(thread);
|
const href = pathOfThread(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const projectInfo = myProjectConversationInfoOfThread(
|
|
||||||
thread,
|
|
||||||
projectThreadNames,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
@@ -63,37 +52,19 @@ 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 ? (
|
<MessageSquareIcon
|
||||||
<MyProjectThreadIcon type={projectInfo.type} />
|
|
||||||
) : (
|
|
||||||
<MessageSquareIcon
|
|
||||||
className={cn(
|
|
||||||
"size-3.5 shrink-0",
|
|
||||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1",
|
"size-3.5 shrink-0",
|
||||||
projectInfo && "flex flex-col items-start gap-0.5",
|
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="w-full truncate">
|
|
||||||
{desensitize(titleOfThread(thread, "新对话"))}
|
|
||||||
</span>
|
|
||||||
{projectInfo && (
|
|
||||||
<MyProjectThreadBadge
|
|
||||||
projectName={projectInfo.projectName}
|
|
||||||
type={projectInfo.type}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{desensitize(titleOfThread(thread, "新对话"))}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { useMemo } from "react";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Skill } from "@/core/skills";
|
import type { Skill } from "@/core/skills";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
|
||||||
import { isSkillOffline } from "@/core/skill-availability";
|
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -55,7 +53,6 @@ export function EnabledSkillList({
|
|||||||
onViewDetail?: (name: string) => void;
|
onViewDetail?: (name: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
|
||||||
const byName = useMemo(
|
const byName = useMemo(
|
||||||
() => new Map(skills.map((s) => [s.name, s])),
|
() => new Map(skills.map((s) => [s.name, s])),
|
||||||
[skills],
|
[skills],
|
||||||
@@ -82,14 +79,12 @@ export function EnabledSkillList({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{skillNames.map((name) => {
|
{skillNames.map((name) => {
|
||||||
const skill = byName.get(name);
|
const skill = byName.get(name);
|
||||||
const offline = isSkillOffline(skill);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={name}
|
key={name}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
|
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
|
||||||
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
|
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
|
||||||
offline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
|
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
|
||||||
@@ -113,15 +108,6 @@ export function EnabledSkillList({
|
|||||||
{skillSourceLabel(skill)}
|
{skillSourceLabel(skill)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{offline && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="shrink-0 border-red-500/35 bg-red-500/5 text-[10px] text-red-700 dark:text-red-300"
|
|
||||||
title={t.settings.skills.badgeOfflineTitle}
|
|
||||||
>
|
|
||||||
{t.settings.skills.badgeOffline}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||||
{skill
|
{skill
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import type { Skill } from "@/core/skills";
|
import type { Skill } from "@/core/skills";
|
||||||
import { isSkillOffline } from "@/core/skill-availability";
|
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -29,11 +28,7 @@ const PAGE_SIZE = 5;
|
|||||||
const ALL_CATEGORIES = "__all__";
|
const ALL_CATEGORIES = "__all__";
|
||||||
|
|
||||||
interface SkillTransferSelectorProps {
|
interface SkillTransferSelectorProps {
|
||||||
/**
|
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
|
||||||
* Full visible skill pool. Offline skills are never offered on the left,
|
|
||||||
* but an already-selected offline skill stays visible on the right so it
|
|
||||||
* can be removed without silently changing the persisted whitelist.
|
|
||||||
*/
|
|
||||||
skills: Skill[];
|
skills: Skill[];
|
||||||
/** Currently selected skill names — the persisted `skills: string[]`. */
|
/** Currently selected skill names — the persisted `skills: string[]`. */
|
||||||
selected: string[];
|
selected: string[];
|
||||||
@@ -96,9 +91,7 @@ export function SkillTransferSelector({
|
|||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const skill of skills) {
|
for (const skill of skills) {
|
||||||
if (!isSkillOffline(skill) && skill.category?.trim()) {
|
if (skill.category?.trim()) set.add(skill.category.trim());
|
||||||
set.add(skill.category.trim());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
}, [skills]);
|
}, [skills]);
|
||||||
@@ -109,7 +102,6 @@ export function SkillTransferSelector({
|
|||||||
const availableSkills = useMemo(() => {
|
const availableSkills = useMemo(() => {
|
||||||
return skills.filter(
|
return skills.filter(
|
||||||
(skill) =>
|
(skill) =>
|
||||||
!isSkillOffline(skill) &&
|
|
||||||
!selectedSet.has(skill.name) &&
|
!selectedSet.has(skill.name) &&
|
||||||
matchesQuery(skill, query) &&
|
matchesQuery(skill, query) &&
|
||||||
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
||||||
@@ -392,7 +384,6 @@ function SkillItemCard({
|
|||||||
onPick,
|
onPick,
|
||||||
onViewDetail,
|
onViewDetail,
|
||||||
}: SkillItemCardProps) {
|
}: SkillItemCardProps) {
|
||||||
const isOffline = isSkillOffline(skill);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -407,7 +398,6 @@ function SkillItemCard({
|
|||||||
title={isSelected ? "点击移除" : "点击添加"}
|
title={isSelected ? "点击移除" : "点击添加"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
|
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
|
||||||
isOffline && "border-dashed bg-muted/50",
|
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
|
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
|
||||||
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
|
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
|
||||||
@@ -435,11 +425,6 @@ function SkillItemCard({
|
|||||||
>
|
>
|
||||||
{skillLabel(skill)}
|
{skillLabel(skill)}
|
||||||
</div>
|
</div>
|
||||||
{isOffline && (
|
|
||||||
<div className="text-destructive truncate text-[10px]">
|
|
||||||
已下线,仅可移除
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{skill.description?.trim() && (
|
{skill.description?.trim() && (
|
||||||
<div className="text-muted-foreground truncate text-[11px]">
|
<div className="text-muted-foreground truncate text-[11px]">
|
||||||
{desensitize(skill.description)}
|
{desensitize(skill.description)}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FileType2Icon,
|
FileType2Icon,
|
||||||
GlobeIcon,
|
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
@@ -18,11 +17,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { defaultUrlTransform } from "react-markdown";
|
import { defaultUrlTransform } from "react-markdown";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Artifact,
|
Artifact,
|
||||||
@@ -59,13 +58,17 @@ import { publishArtifactToKnowledge } from "@/core/agents/api";
|
|||||||
import { useAgent } from "@/core/agents/hooks";
|
import { useAgent } from "@/core/agents/hooks";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import { copyTextToClipboard } from "@/core/clipboard";
|
import { copyTextToClipboard } from "@/core/clipboard";
|
||||||
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
|
|
||||||
import {
|
import {
|
||||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||||
useDocumentRewritePresentation,
|
useDocumentRewritePresentation,
|
||||||
} from "@/core/document-rewrite/context";
|
} from "@/core/document-rewrite/context";
|
||||||
|
import {
|
||||||
|
clearArtifactRewriteFallback,
|
||||||
|
completeArtifactRewriteFallback,
|
||||||
|
primeArtifactRewriteFallback,
|
||||||
|
} from "@/core/document-rewrite/local-fallback";
|
||||||
import {
|
import {
|
||||||
downloadArtifactFile,
|
downloadArtifactFile,
|
||||||
} from "@/core/artifacts/download";
|
} from "@/core/artifacts/download";
|
||||||
@@ -82,11 +85,7 @@ import {
|
|||||||
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
|
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
import {
|
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||||
SafeStreamdown,
|
|
||||||
streamdownPlugins,
|
|
||||||
useStreamdownMermaidPngDownload,
|
|
||||||
} from "@/core/streamdown";
|
|
||||||
import {
|
import {
|
||||||
checkCodeFile,
|
checkCodeFile,
|
||||||
getFileName,
|
getFileName,
|
||||||
@@ -210,22 +209,6 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 识别 Web App 站点清单:`outputs/<site>/site.json`(兼容 `write-file:` 虚拟 URL
|
|
||||||
* 与查询串)→ 返回站点目录名;其它文件返回 null(按钮不渲染,零行为变化)。
|
|
||||||
*/
|
|
||||||
function extractSiteStudioTarget(filepath: string): string | null {
|
|
||||||
const normalized = filepath.replace(/^write-file:/, "").split("?")[0];
|
|
||||||
if (!/(^|\/)site\.json$/.test(normalized)) return null;
|
|
||||||
const parts = normalized.split("/").filter(Boolean);
|
|
||||||
const idx = parts.lastIndexOf("site.json");
|
|
||||||
if (idx <= 0) return null;
|
|
||||||
const siteName = parts[idx - 1];
|
|
||||||
if (!siteName || siteName === "outputs") return null;
|
|
||||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(siteName)) return null;
|
|
||||||
return siteName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArtifactFileDetail({
|
export function ArtifactFileDetail({
|
||||||
className,
|
className,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
@@ -283,16 +266,8 @@ export function ArtifactFileDetail({
|
|||||||
headerLeadingActionBare?: boolean;
|
headerLeadingActionBare?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts();
|
const { artifacts, setOpen, select } = useArtifacts();
|
||||||
const { isMock, thread, publicShareCode } = useThread();
|
const { isMock, thread, publicShareCode } = useThread();
|
||||||
const navigate = useNavigate();
|
|
||||||
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
|
||||||
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
|
|
||||||
const siteStudioTarget = useMemo(
|
|
||||||
() => extractSiteStudioTarget(filepathFromProps),
|
|
||||||
[filepathFromProps],
|
|
||||||
);
|
|
||||||
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
|
|
||||||
const isWriteFileVirtual = useMemo(() => {
|
const isWriteFileVirtual = useMemo(() => {
|
||||||
return filepathFromProps.startsWith("write-file:");
|
return filepathFromProps.startsWith("write-file:");
|
||||||
}, [filepathFromProps]);
|
}, [filepathFromProps]);
|
||||||
@@ -439,13 +414,6 @@ export function ArtifactFileDetail({
|
|||||||
thinking: "",
|
thinking: "",
|
||||||
});
|
});
|
||||||
const documentRewriteActive = documentRewrite.isActive;
|
const documentRewriteActive = documentRewrite.isActive;
|
||||||
const generationLockId = useId();
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const owner = `preview:${generationLockId}`;
|
|
||||||
const generating = (writeFileInFlight && thread.isLoading) || documentRewriteActive;
|
|
||||||
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
|
|
||||||
return () => setGeneratingArtifact(owner, null);
|
|
||||||
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
|
|
||||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||||
const displayContent = documentRewriteActive
|
const displayContent = documentRewriteActive
|
||||||
@@ -559,17 +527,10 @@ export function ArtifactFileDetail({
|
|||||||
) => {
|
) => {
|
||||||
if (!rewritePresentation) return;
|
if (!rewritePresentation) return;
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "job_started":
|
|
||||||
rewritePresentation.update(presentationId, {
|
|
||||||
jobId: event.jobId,
|
|
||||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "snapshot_fixed":
|
case "snapshot_fixed":
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
stage: "requirements",
|
stage: "requirements",
|
||||||
fileName: event.fileName || getFileName(filepath),
|
fileName: event.fileName || getFileName(filepath),
|
||||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
|
||||||
originalCharCount: event.originalCharCount,
|
originalCharCount: event.originalCharCount,
|
||||||
requirementsPlan: "",
|
requirementsPlan: "",
|
||||||
});
|
});
|
||||||
@@ -634,7 +595,6 @@ export function ArtifactFileDetail({
|
|||||||
case "committed":
|
case "committed":
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
rewrittenContent: event.content,
|
rewrittenContent: event.content,
|
||||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
|
||||||
versionId: event.versionId ?? null,
|
versionId: event.versionId ?? null,
|
||||||
committedHash: event.committedHash,
|
committedHash: event.committedHash,
|
||||||
});
|
});
|
||||||
@@ -652,11 +612,7 @@ export function ArtifactFileDetail({
|
|||||||
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
rewritePresentation.update(presentationId, {
|
console.error("AI 全文改写历史恢复失败:", event.message);
|
||||||
stage: "error",
|
|
||||||
error: event.message,
|
|
||||||
diagnostic: event.diagnostic ?? null,
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -682,15 +638,7 @@ export function ArtifactFileDetail({
|
|||||||
const historicalPresentationId = rewritePresentation?.create({
|
const historicalPresentationId = rewritePresentation?.create({
|
||||||
threadId,
|
threadId,
|
||||||
source: { kind: "artifact", id: threadId, path: filepath },
|
source: { kind: "artifact", id: threadId, path: filepath },
|
||||||
jobId: historical.id,
|
fileName: getFileName(filepath),
|
||||||
outputPath: typeof historicalResult.outputPath === "string"
|
|
||||||
? historicalResult.outputPath
|
|
||||||
: historical.outputPath,
|
|
||||||
fileName: getFileName(
|
|
||||||
typeof historicalResult.outputPath === "string"
|
|
||||||
? historicalResult.outputPath
|
|
||||||
: historical.outputPath ?? filepath,
|
|
||||||
),
|
|
||||||
instruction: historical.request.instruction,
|
instruction: historical.request.instruction,
|
||||||
style: historical.request.style,
|
style: historical.request.style,
|
||||||
modelName: historical.request.modelName,
|
modelName: historical.request.modelName,
|
||||||
@@ -715,10 +663,7 @@ export function ArtifactFileDetail({
|
|||||||
? "文件在改写期间已变化,未覆盖原文件。"
|
? "文件在改写期间已变化,未覆盖原文件。"
|
||||||
: historical.status === "cancelled"
|
: historical.status === "cancelled"
|
||||||
? "已停止全文改写,原文件未被修改。"
|
? "已停止全文改写,原文件未被修改。"
|
||||||
: historical.status === "failed"
|
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
|
||||||
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
|
|
||||||
: null,
|
|
||||||
diagnostic: historical.diagnostic ?? null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const result = latest.result;
|
const result = latest.result;
|
||||||
@@ -727,16 +672,26 @@ export function ArtifactFileDetail({
|
|||||||
: null;
|
: null;
|
||||||
const originalContent = latest.request.originalContent || draftContent;
|
const originalContent = latest.request.originalContent || draftContent;
|
||||||
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
||||||
|
const versionSnapshotFailed = result.versionSnapshotFailed === true;
|
||||||
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
||||||
const outputPath = typeof result.outputPath === "string"
|
if (!versionSnapshotFailed) {
|
||||||
? result.outputPath
|
clearArtifactRewriteFallback(threadId, filepath);
|
||||||
: latest.outputPath;
|
}
|
||||||
|
const localFallbackAvailable =
|
||||||
|
versionSnapshotFailed &&
|
||||||
|
latest.status === "completed" &&
|
||||||
|
result.status !== "conflict" &&
|
||||||
|
committedHash !== null &&
|
||||||
|
completeArtifactRewriteFallback({
|
||||||
|
threadId,
|
||||||
|
path: filepath,
|
||||||
|
originalContent,
|
||||||
|
committedHash,
|
||||||
|
});
|
||||||
const presentationId = rewritePresentation?.create({
|
const presentationId = rewritePresentation?.create({
|
||||||
threadId,
|
threadId,
|
||||||
source: { kind: "artifact", id: threadId, path: filepath },
|
source: { kind: "artifact", id: threadId, path: filepath },
|
||||||
jobId: latest.id,
|
fileName: getFileName(filepath),
|
||||||
outputPath,
|
|
||||||
fileName: getFileName(outputPath ?? filepath),
|
|
||||||
instruction: latest.request.instruction,
|
instruction: latest.request.instruction,
|
||||||
style: latest.request.style,
|
style: latest.request.style,
|
||||||
modelName: latest.request.modelName,
|
modelName: latest.request.modelName,
|
||||||
@@ -745,6 +700,7 @@ export function ArtifactFileDetail({
|
|||||||
originalContent,
|
originalContent,
|
||||||
rewrittenContent,
|
rewrittenContent,
|
||||||
committedHash,
|
committedHash,
|
||||||
|
localFallbackAvailable,
|
||||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||||
});
|
});
|
||||||
if (!presentationId || !rewritePresentation) return;
|
if (!presentationId || !rewritePresentation) return;
|
||||||
@@ -757,18 +713,28 @@ export function ArtifactFileDetail({
|
|||||||
warnings: summary?.validationWarnings ?? [],
|
warnings: summary?.validationWarnings ?? [],
|
||||||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||||
committedHash,
|
committedHash,
|
||||||
error: result.status === "conflict" ? "改写版文件未生成。" : null,
|
localFallbackAvailable,
|
||||||
|
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
|
||||||
});
|
});
|
||||||
|
if (rewrittenContent && result.status !== "conflict") {
|
||||||
|
setDraftContent(rewrittenContent);
|
||||||
|
setHasLocalEdits(versionSnapshotFailed);
|
||||||
|
if (versionSnapshotFailed) {
|
||||||
|
console.error(
|
||||||
|
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||||
|
{ content: rewrittenContent, url: undefined },
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (latest.status === "failed" || latest.status === "cancelled") {
|
if (latest.status === "failed" || latest.status === "cancelled") {
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
||||||
summary,
|
summary,
|
||||||
error: latest.status === "cancelled"
|
|
||||||
? "已停止全文改写,原文件未被修改。"
|
|
||||||
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
|
|
||||||
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -791,19 +757,32 @@ export function ArtifactFileDetail({
|
|||||||
signal,
|
signal,
|
||||||
),
|
),
|
||||||
onEvent: (event) => updatePresentation(presentationId, event),
|
onEvent: (event) => updatePresentation(presentationId, event),
|
||||||
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||||||
|
setDraftContent(rewritten);
|
||||||
|
setHasLocalEdits(versionSnapshotFailed);
|
||||||
|
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||||||
|
threadId,
|
||||||
|
path: filepath,
|
||||||
|
originalContent,
|
||||||
|
committedHash,
|
||||||
|
});
|
||||||
|
if (!versionSnapshotFailed) {
|
||||||
|
clearArtifactRewriteFallback(threadId, filepath);
|
||||||
|
}
|
||||||
|
if (versionSnapshotFailed) {
|
||||||
|
console.error(
|
||||||
|
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||||
|
);
|
||||||
|
}
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
rewrittenContent: rewritten,
|
rewrittenContent: rewritten,
|
||||||
committedHash,
|
committedHash,
|
||||||
...(outputPath ? { outputPath } : {}),
|
localFallbackAvailable,
|
||||||
});
|
|
||||||
},
|
|
||||||
onFailure: ({ error, diagnostic }) => {
|
|
||||||
rewritePresentation.update(presentationId, {
|
|
||||||
stage: "error",
|
|
||||||
error,
|
|
||||||
diagnostic: diagnostic ?? null,
|
|
||||||
});
|
});
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||||
|
{ content: rewritten, url: undefined },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1136,7 +1115,6 @@ export function ArtifactFileDetail({
|
|||||||
setDocumentRewriteDialogOpen(false);
|
setDocumentRewriteDialogOpen(false);
|
||||||
setAllowAutoViewSwitch(false);
|
setAllowAutoViewSwitch(false);
|
||||||
setAutoScrollEnabled(true);
|
setAutoScrollEnabled(true);
|
||||||
setShowResumeAutoScroll(false);
|
|
||||||
setViewMode("code");
|
setViewMode("code");
|
||||||
|
|
||||||
// Do not make users manually save before using AI rewrite. Persist their
|
// Do not make users manually save before using AI rewrite. Persist their
|
||||||
@@ -1158,6 +1136,11 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
primeArtifactRewriteFallback({
|
||||||
|
threadId,
|
||||||
|
path: filepath,
|
||||||
|
originalContent: draftContent,
|
||||||
|
});
|
||||||
const expectedHash = await sha256Content(draftContent);
|
const expectedHash = await sha256Content(draftContent);
|
||||||
const presentationId = rewritePresentation?.create({
|
const presentationId = rewritePresentation?.create({
|
||||||
threadId,
|
threadId,
|
||||||
@@ -1193,17 +1176,10 @@ export function ArtifactFileDetail({
|
|||||||
onEvent: (event) => {
|
onEvent: (event) => {
|
||||||
if (!presentationId || !rewritePresentation) return;
|
if (!presentationId || !rewritePresentation) return;
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "job_started":
|
|
||||||
rewritePresentation.update(presentationId, {
|
|
||||||
jobId: event.jobId,
|
|
||||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "snapshot_fixed":
|
case "snapshot_fixed":
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
stage: "requirements",
|
stage: "requirements",
|
||||||
fileName: event.fileName || getFileName(filepath),
|
fileName: event.fileName || getFileName(filepath),
|
||||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
|
||||||
originalCharCount: event.originalCharCount,
|
originalCharCount: event.originalCharCount,
|
||||||
requirementsPlan: "",
|
requirementsPlan: "",
|
||||||
});
|
});
|
||||||
@@ -1270,8 +1246,6 @@ export function ArtifactFileDetail({
|
|||||||
break;
|
break;
|
||||||
case "committed":
|
case "committed":
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
rewrittenContent: event.content,
|
|
||||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
|
||||||
versionId: event.versionId ?? null,
|
versionId: event.versionId ?? null,
|
||||||
committedHash: event.committedHash,
|
committedHash: event.committedHash,
|
||||||
});
|
});
|
||||||
@@ -1288,41 +1262,70 @@ export function ArtifactFileDetail({
|
|||||||
error: event.message,
|
error: event.message,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "cancelled":
|
|
||||||
rewritePresentation.update(presentationId, {
|
|
||||||
stage: "cancelled",
|
|
||||||
error: event.message,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "error":
|
case "error":
|
||||||
rewritePresentation.update(presentationId, {
|
// The sandbox retains the visible draft and exposes manual
|
||||||
stage: "error",
|
// save instead of showing an error state to the user.
|
||||||
error: event.message,
|
console.error("AI 全文改写接口失败:", event.message);
|
||||||
diagnostic: event.diagnostic ?? null,
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||||||
|
setDraftContent(rewritten);
|
||||||
|
setHasLocalEdits(versionSnapshotFailed);
|
||||||
|
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||||||
|
threadId,
|
||||||
|
path: filepath,
|
||||||
|
originalContent: draftContent,
|
||||||
|
committedHash,
|
||||||
|
});
|
||||||
|
if (!versionSnapshotFailed) {
|
||||||
|
clearArtifactRewriteFallback(threadId, filepath);
|
||||||
|
}
|
||||||
if (presentationId && rewritePresentation) {
|
if (presentationId && rewritePresentation) {
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
rewrittenContent: rewritten,
|
rewrittenContent: rewritten,
|
||||||
committedHash,
|
committedHash,
|
||||||
...(outputPath ? { outputPath } : {}),
|
localFallbackAvailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。");
|
queryClient.setQueriesData(
|
||||||
|
{
|
||||||
|
queryKey: ["artifact", filepath, threadId, isMock],
|
||||||
|
},
|
||||||
|
{ content: rewritten, url: undefined },
|
||||||
|
);
|
||||||
|
if (versionSnapshotFailed) {
|
||||||
|
console.error(
|
||||||
|
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success("Markdown 全文改写完成,已安全替换原文件。");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onFailure: ({ error, diagnostic }) => {
|
onFailure: ({ content, error }) => {
|
||||||
|
// The rewrite transport can fail after the server has already
|
||||||
|
// streamed text (or before the first token on a 404). Keep that
|
||||||
|
// visible content in the sandbox, make Save actionable, and leave
|
||||||
|
// diagnostics to the developer console only.
|
||||||
|
const retainedContent = content || draftContent;
|
||||||
|
setDraftContent(retainedContent);
|
||||||
|
setHasLocalEdits(true);
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{
|
||||||
|
queryKey: ["artifact", filepath, threadId, isMock],
|
||||||
|
},
|
||||||
|
{ content: retainedContent, url: undefined },
|
||||||
|
);
|
||||||
if (presentationId && rewritePresentation) {
|
if (presentationId && rewritePresentation) {
|
||||||
rewritePresentation.update(presentationId, {
|
rewritePresentation.update(presentationId, {
|
||||||
stage: "error",
|
stage: "completed",
|
||||||
error,
|
rewrittenContent: retainedContent,
|
||||||
diagnostic: diagnostic ?? null,
|
error: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1351,28 +1354,6 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
|
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
|
||||||
|
|
||||||
// The docked conversation composer is outside this artifact pane. Register
|
|
||||||
// the same cancellation path so its standard square stop button can abort
|
|
||||||
// both the local SSE reader and the durable rewrite job.
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!documentRewriteActive ||
|
|
||||||
!documentRewritePresentationId ||
|
|
||||||
!rewritePresentation
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return rewritePresentation.registerCancellation(
|
|
||||||
documentRewritePresentationId,
|
|
||||||
handleCancelDocumentRewrite,
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
documentRewriteActive,
|
|
||||||
documentRewritePresentationId,
|
|
||||||
handleCancelDocumentRewrite,
|
|
||||||
rewritePresentation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
||||||
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
||||||
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
|
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
|
||||||
@@ -1446,9 +1427,7 @@ export function ArtifactFileDetail({
|
|||||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = new Blob([
|
const blob = new Blob([prepareGeneratedHtml(source)], {
|
||||||
prepareGeneratedHtml(source),
|
|
||||||
], {
|
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1475,11 +1454,7 @@ export function ArtifactFileDetail({
|
|||||||
: language === "markdown"
|
: language === "markdown"
|
||||||
? "text/markdown;charset=utf-8"
|
? "text/markdown;charset=utf-8"
|
||||||
: "text/plain;charset=utf-8";
|
: "text/plain;charset=utf-8";
|
||||||
const downloadSource =
|
const blob = new Blob([source], { type: blobType });
|
||||||
language === "html"
|
|
||||||
? prepareGeneratedHtml(source)
|
|
||||||
: source;
|
|
||||||
const blob = new Blob([downloadSource], { type: blobType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -1613,7 +1588,7 @@ export function ArtifactFileDetail({
|
|||||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||||
tooltip={
|
tooltip={
|
||||||
documentRewriteActive
|
documentRewriteActive
|
||||||
? "正在生成独立改写版文件,原文件保持不变;点击停止"
|
? "正在生成临时草稿,原文件尚未被覆盖"
|
||||||
: hasLocalEdits
|
: hasLocalEdits
|
||||||
? "将先保存当前 Markdown 修改,再开始改写"
|
? "将先保存当前 Markdown 修改,再开始改写"
|
||||||
: "使用 AI 重写当前完整 Markdown"
|
: "使用 AI 重写当前完整 Markdown"
|
||||||
@@ -1621,13 +1596,7 @@ export function ArtifactFileDetail({
|
|||||||
// Keep the entry available after a completed rewrite and when
|
// Keep the entry available after a completed rewrite and when
|
||||||
// the user has local edits. Starting with local edits saves
|
// the user has local edits. Starting with local edits saves
|
||||||
// them automatically in handleStartDocumentRewrite.
|
// them automatically in handleStartDocumentRewrite.
|
||||||
onClick={() => {
|
onClick={() => setDocumentRewriteDialogOpen(true)}
|
||||||
if (documentRewriteActive) {
|
|
||||||
handleCancelDocumentRewrite();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDocumentRewriteDialogOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||||
@@ -1678,16 +1647,6 @@ export function ArtifactFileDetail({
|
|||||||
onClick={handleOpenInNewWindow}
|
onClick={handleOpenInNewWindow}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
|
|
||||||
<ArtifactAction
|
|
||||||
icon={GlobeIcon}
|
|
||||||
label="站点预览"
|
|
||||||
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(siteStudioRoute(threadId, siteStudioTarget))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={DownloadIcon}
|
icon={DownloadIcon}
|
||||||
label={t.common.download}
|
label={t.common.download}
|
||||||
@@ -1754,6 +1713,27 @@ export function ArtifactFileDetail({
|
|||||||
正在改写 Markdown
|
正在改写 Markdown
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{documentRewriteActive && (
|
||||||
|
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||||||
|
<LoaderIcon className="size-3.5 animate-spin" />
|
||||||
|
{documentRewrite.run.stage === "requirements"
|
||||||
|
? "正在理解改写需求,原文件未被修改"
|
||||||
|
: documentRewrite.run.stage === "validation"
|
||||||
|
? "正在校验 Markdown"
|
||||||
|
: documentRewrite.run.stage === "committing"
|
||||||
|
? "正在安全替换原文件"
|
||||||
|
: "正在写入临时草稿 · " +
|
||||||
|
documentRewrite.run.writtenCharCount.toLocaleString() +
|
||||||
|
" 字"}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
|
||||||
|
onClick={handleCancelDocumentRewrite}
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showResumeAutoScroll && (
|
{showResumeAutoScroll && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2114,9 +2094,7 @@ export function ArtifactFilePreview({
|
|||||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||||||
// of navigating the sandboxed blob: document (which blanks the frame).
|
// of navigating the sandboxed blob: document (which blanks the frame).
|
||||||
const blob = new Blob([
|
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
|
||||||
prepareGeneratedHtml(content ?? ""),
|
|
||||||
], {
|
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -2169,7 +2147,7 @@ export function ArtifactFilePreview({
|
|||||||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SafeStreamdown
|
<Streamdown
|
||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
// react-markdown's safe default strips unknown protocols before the
|
// react-markdown's safe default strips unknown protocols before the
|
||||||
@@ -2194,7 +2172,7 @@ export function ArtifactFilePreview({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{previewContent}
|
{previewContent}
|
||||||
</SafeStreamdown>
|
</Streamdown>
|
||||||
{activeCursor && (
|
{activeCursor && (
|
||||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
getFileExtensionDisplayName,
|
getFileExtensionDisplayName,
|
||||||
getFileIcon,
|
getFileIcon,
|
||||||
getFileName,
|
getFileName,
|
||||||
isWordDocumentFile,
|
|
||||||
} from "@/core/utils/files";
|
} from "@/core/utils/files";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -97,8 +96,8 @@ export function ArtifactFileList({
|
|||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<Card
|
<Card
|
||||||
key={file}
|
key={file}
|
||||||
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
|
className="relative cursor-pointer p-3"
|
||||||
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)}
|
onClick={() => handleClick(file)}
|
||||||
>
|
>
|
||||||
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
|
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
|
||||||
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
|
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
|
|
||||||
|
|
||||||
export function artifactPreviewKey(artifact: string): string {
|
export function artifactPreviewKey(artifact: string): string {
|
||||||
if (!artifact.startsWith("write-file:")) {
|
if (!artifact.startsWith("write-file:")) {
|
||||||
@@ -40,8 +38,6 @@ export interface ArtifactsContextType {
|
|||||||
select: (artifact: string, autoSelect?: boolean) => void;
|
select: (artifact: string, autoSelect?: boolean) => void;
|
||||||
/** Open/update a streamed write without overriding a manual close of that call. */
|
/** Open/update a streamed write without overriding a manual close of that call. */
|
||||||
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
|
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
|
||||||
/** Register the file being generated; remove this owner's lock on completion. */
|
|
||||||
setGeneratingArtifact: (owner: string, artifact: string | null) => void;
|
|
||||||
deselect: () => void;
|
deselect: () => void;
|
||||||
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -90,26 +86,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
const [preOpen, setPreOpen] = useState(false);
|
const [preOpen, setPreOpen] = useState(false);
|
||||||
const [autoOpen, setAutoOpen] = useState(true);
|
const [autoOpen, setAutoOpen] = useState(true);
|
||||||
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
||||||
const generatingArtifactsRef = useRef(new Map<string, string>());
|
|
||||||
const setGeneratingArtifact = useCallback((owner: string, artifact: string | null) => {
|
|
||||||
if (artifact) generatingArtifactsRef.current.set(owner, artifact);
|
|
||||||
else generatingArtifactsRef.current.delete(owner);
|
|
||||||
}, []);
|
|
||||||
const canSwitchArtifact = useCallback((artifact: string, notify: boolean) => {
|
|
||||||
for (const generating of generatingArtifactsRef.current.values()) {
|
|
||||||
if (
|
|
||||||
artifactPreviewKey(generating) === artifactPreviewKey(artifact) ||
|
|
||||||
resolveWriteFilePath(generating) === resolveWriteFilePath(artifact)
|
|
||||||
) continue;
|
|
||||||
if (notify) {
|
|
||||||
toast.info("当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。", {
|
|
||||||
id: "artifact-generation-switch-locked",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, []);
|
|
||||||
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
|
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
|
||||||
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
||||||
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
||||||
@@ -153,7 +129,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
|
|
||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
(artifact: string, autoSelect = false) => {
|
(artifact: string, autoSelect = false) => {
|
||||||
if (!canSwitchArtifact(artifact, !autoSelect)) return;
|
|
||||||
const previewKey = artifactPreviewKey(artifact);
|
const previewKey = artifactPreviewKey(artifact);
|
||||||
selectedArtifactKeyRef.current = previewKey;
|
selectedArtifactKeyRef.current = previewKey;
|
||||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||||
@@ -165,12 +140,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
setAutoSelect((previous) => (previous ? false : previous));
|
setAutoSelect((previous) => (previous ? false : previous));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[canSwitchArtifact, sidebar],
|
[sidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openStreamedArtifact = useCallback(
|
const openStreamedArtifact = useCallback(
|
||||||
(artifact: string, previewKey: string) => {
|
(artifact: string, previewKey: string) => {
|
||||||
if (!canSwitchArtifact(artifact, false)) return false;
|
|
||||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -206,11 +180,10 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
|
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deselect = useCallback(() => {
|
const deselect = useCallback(() => {
|
||||||
generatingArtifactsRef.current.clear();
|
|
||||||
selectedArtifactKeyRef.current = null;
|
selectedArtifactKeyRef.current = null;
|
||||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||||
@@ -251,7 +224,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
selectedArtifactKey,
|
selectedArtifactKey,
|
||||||
select,
|
select,
|
||||||
openStreamedArtifact,
|
openStreamedArtifact,
|
||||||
setGeneratingArtifact,
|
|
||||||
deselect,
|
deselect,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
|
||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
|
||||||
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
|
|
||||||
import { SubtasksProvider } from "@/core/tasks/context";
|
|
||||||
import { useUploadPolicy } from "@/core/uploads";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared providers required by every full chat surface.
|
|
||||||
*
|
|
||||||
* Keep this in a small standalone module: importing it from a route must not
|
|
||||||
* eagerly evaluate the complete WorkspaceRoutes tree and all of its pages.
|
|
||||||
*/
|
|
||||||
export function ChatRuntime({ children }: { children: ReactNode }) {
|
|
||||||
const { data: uploadPolicy } = useUploadPolicy();
|
|
||||||
return (
|
|
||||||
<SubtasksProvider>
|
|
||||||
<DocumentRewritePresentationProvider>
|
|
||||||
<ArtifactsProvider>
|
|
||||||
<PromptInputProvider uploadPolicy={uploadPolicy}>
|
|
||||||
{children}
|
|
||||||
</PromptInputProvider>
|
|
||||||
</ArtifactsProvider>
|
|
||||||
</DocumentRewritePresentationProvider>
|
|
||||||
</SubtasksProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FilesIcon, XIcon } from "lucide-react";
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
@@ -19,22 +19,11 @@ import {
|
|||||||
listThreadArtifacts,
|
listThreadArtifacts,
|
||||||
mergeArtifactPaths,
|
mergeArtifactPaths,
|
||||||
} from "@/core/artifacts/listing";
|
} from "@/core/artifacts/listing";
|
||||||
import {
|
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
||||||
latestCompletedArtifactMutationKey,
|
|
||||||
latestInFlightWriteFilePreview,
|
|
||||||
latestPreparedHtmlArtifact,
|
|
||||||
isWriteFileInFlight,
|
|
||||||
} from "@/core/artifacts/write-file-in-flight";
|
|
||||||
import {
|
|
||||||
isAutoPreviewableArtifactPath,
|
|
||||||
languageOfStreamingArtifact,
|
|
||||||
} from "@/core/artifacts/streaming-write-preview";
|
|
||||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
|
||||||
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
|
|
||||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||||
@@ -46,7 +35,6 @@ import {
|
|||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
collectRuntimeDisplaySources,
|
collectRuntimeDisplaySources,
|
||||||
} from "@/core/skill-display/runtime";
|
} from "@/core/skill-display/runtime";
|
||||||
import { isWordDocumentFile } from "@/core/utils/files";
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -57,7 +45,6 @@ import {
|
|||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "../artifacts";
|
} from "../artifacts";
|
||||||
import { ReferenceBatchesContext, useThread } from "../messages/context";
|
import { ReferenceBatchesContext, useThread } from "../messages/context";
|
||||||
import { KnowledgeWikiPanel } from "../knowledge-wiki-panel";
|
|
||||||
import { SkillDisplaySources } from "../skill-display-sources";
|
import { SkillDisplaySources } from "../skill-display-sources";
|
||||||
|
|
||||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||||
@@ -96,30 +83,15 @@ const ChatBox: React.FC<{
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
disableArtifacts?: boolean;
|
disableArtifacts?: boolean;
|
||||||
writingMode?: boolean;
|
|
||||||
/** Prefer and automatically reveal a specific artifact once it is available. */
|
|
||||||
preferredArtifact?: string | null;
|
|
||||||
/** Keep scanning until the preferred artifact appears (for delayed file persistence). */
|
|
||||||
pollForPreferredArtifact?: boolean;
|
|
||||||
/** Report the real artifact discovered on disk back to the page state. */
|
|
||||||
onPreferredArtifactAvailabilityChange?: (artifact: string | null) => void;
|
|
||||||
/** Stack the artifact below chat on small screens. */
|
|
||||||
responsiveArtifactLayout?: boolean;
|
|
||||||
}> = ({
|
}> = ({
|
||||||
children,
|
children,
|
||||||
threadId,
|
threadId,
|
||||||
disableArtifacts = false,
|
disableArtifacts = false,
|
||||||
writingMode,
|
|
||||||
preferredArtifact = null,
|
|
||||||
pollForPreferredArtifact = false,
|
|
||||||
onPreferredArtifactAvailabilityChange,
|
|
||||||
responsiveArtifactLayout = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
isMock,
|
isMock,
|
||||||
thread,
|
thread,
|
||||||
knowledgeReferencesEnabled = false,
|
knowledgeReferencesEnabled = false,
|
||||||
knowledgeReferences,
|
|
||||||
} = useThread();
|
} = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -129,8 +101,6 @@ const ChatBox: React.FC<{
|
|||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
const openedRequestedArtifactRef = useRef<string | null>(null);
|
const openedRequestedArtifactRef = useRef<string | null>(null);
|
||||||
const openedPreferredArtifactRef = useRef<string | null>(null);
|
|
||||||
const preferredRunWasLoadingRef = useRef(false);
|
|
||||||
const artifactsCountRef = useRef(0);
|
const artifactsCountRef = useRef(0);
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||||
@@ -142,12 +112,9 @@ const ChatBox: React.FC<{
|
|||||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const stackArtifactPanel = responsiveArtifactLayout && isMobile;
|
|
||||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||||
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
|
const writingModeEnabled = !!settings.context.writing_mode;
|
||||||
const artifactDirectoryModeEnabled =
|
const artifactDirectoryModeEnabled =
|
||||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
@@ -156,8 +123,7 @@ const ChatBox: React.FC<{
|
|||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||||
isThreadRagModeLocked(threadId) ||
|
isThreadRagModeLocked(threadId) ||
|
||||||
knowledgeReferencesEnabled ||
|
knowledgeReferencesEnabled;
|
||||||
!!knowledgeReferences;
|
|
||||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||||
// the existing reference panel, but must not request or write reference
|
// the existing reference panel, but must not request or write reference
|
||||||
// batches through DeerFlow's backend APIs.
|
// batches through DeerFlow's backend APIs.
|
||||||
@@ -174,7 +140,7 @@ const ChatBox: React.FC<{
|
|||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
|
|
||||||
const canScanArtifactDirectory =
|
const canScanArtifactDirectory =
|
||||||
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) &&
|
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
||||||
!disableArtifacts &&
|
!disableArtifacts &&
|
||||||
!isMock &&
|
!isMock &&
|
||||||
!!threadId &&
|
!!threadId &&
|
||||||
@@ -183,8 +149,7 @@ const ChatBox: React.FC<{
|
|||||||
queryKey: ["artifact-directory", threadId],
|
queryKey: ["artifact-directory", threadId],
|
||||||
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||||
enabled: canScanArtifactDirectory,
|
enabled: canScanArtifactDirectory,
|
||||||
refetchInterval:
|
refetchInterval: thread.isLoading ? 1_500 : false,
|
||||||
thread.isLoading || pollForPreferredArtifact ? 1_500 : false,
|
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const checkpointArtifacts = useMemo(
|
const checkpointArtifacts = useMemo(
|
||||||
@@ -194,7 +159,7 @@ const ChatBox: React.FC<{
|
|||||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||||
const availableArtifacts = useMemo(
|
const availableArtifacts = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (!artifactDirectoryModeEnabled && !requestedArtifact && !preferredArtifact) {
|
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
|
||||||
return checkpointArtifacts;
|
return checkpointArtifacts;
|
||||||
}
|
}
|
||||||
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
||||||
@@ -207,28 +172,9 @@ const ChatBox: React.FC<{
|
|||||||
artifactDirectoryModeEnabled,
|
artifactDirectoryModeEnabled,
|
||||||
artifactListingQuery.data?.files,
|
artifactListingQuery.data?.files,
|
||||||
checkpointArtifacts,
|
checkpointArtifacts,
|
||||||
preferredArtifact,
|
|
||||||
requestedArtifact,
|
requestedArtifact,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const availablePreferredArtifact = useMemo(() => {
|
|
||||||
if (!preferredArtifact) return null;
|
|
||||||
const preferredPath = artifactPathname(preferredArtifact);
|
|
||||||
return (
|
|
||||||
[...availableArtifacts]
|
|
||||||
.reverse()
|
|
||||||
.find((artifact) => artifactPathname(artifact) === preferredPath) ?? null
|
|
||||||
);
|
|
||||||
}, [availableArtifacts, preferredArtifact]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!preferredArtifact) return;
|
|
||||||
onPreferredArtifactAvailabilityChange?.(availablePreferredArtifact);
|
|
||||||
}, [
|
|
||||||
availablePreferredArtifact,
|
|
||||||
onPreferredArtifactAvailabilityChange,
|
|
||||||
preferredArtifact,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canScanArtifactDirectory || thread.isLoading) return;
|
if (!canScanArtifactDirectory || thread.isLoading) return;
|
||||||
@@ -253,20 +199,8 @@ const ChatBox: React.FC<{
|
|||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
selectedArtifactKey,
|
selectedArtifactKey,
|
||||||
openStreamedArtifact,
|
openStreamedArtifact,
|
||||||
setGeneratingArtifact,
|
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
|
|
||||||
// Keep the lock even when the user closes the preview while writing.
|
|
||||||
const selectedFileGenerating = Boolean(
|
|
||||||
selectedArtifact && thread.isLoading &&
|
|
||||||
isWriteFileInFlight({ messages: thread.messages ?? [] }, selectedArtifact),
|
|
||||||
);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const owner = `chat:${threadId}`;
|
|
||||||
setGeneratingArtifact(owner, selectedFileGenerating ? selectedArtifact : null);
|
|
||||||
return () => setGeneratingArtifact(owner, null);
|
|
||||||
}, [selectedArtifact, selectedFileGenerating, setGeneratingArtifact, threadId]);
|
|
||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
@@ -305,41 +239,6 @@ const ChatBox: React.FC<{
|
|||||||
threadId,
|
threadId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const completedNow =
|
|
||||||
preferredRunWasLoadingRef.current && !thread.isLoading;
|
|
||||||
preferredRunWasLoadingRef.current = thread.isLoading;
|
|
||||||
if (disableArtifacts || !preferredArtifact) {
|
|
||||||
openedPreferredArtifactRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!availablePreferredArtifact) return;
|
|
||||||
const preferredKey = `${threadId}:${artifactPathname(availablePreferredArtifact)}`;
|
|
||||||
if (openedPreferredArtifactRef.current === preferredKey && !completedNow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openedPreferredArtifactRef.current = preferredKey;
|
|
||||||
selectArtifact(availablePreferredArtifact, true);
|
|
||||||
setArtifactsOpen(true);
|
|
||||||
setSideTab("document");
|
|
||||||
// The same canonical report path may be overwritten by a later run while
|
|
||||||
// its content query is still inside the five-minute cache window. Invalidate
|
|
||||||
// the real selected path before the detail panel mounts/reopens so it never
|
|
||||||
// shows the previous report after the assistant announces completion.
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: ["artifact", availablePreferredArtifact, threadId],
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
availablePreferredArtifact,
|
|
||||||
disableArtifacts,
|
|
||||||
preferredArtifact,
|
|
||||||
queryClient,
|
|
||||||
selectArtifact,
|
|
||||||
setArtifactsOpen,
|
|
||||||
thread.isLoading,
|
|
||||||
threadId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldFetchBackendReferenceBatches) {
|
if (!shouldFetchBackendReferenceBatches) {
|
||||||
setBackendReferenceBatches([]);
|
setBackendReferenceBatches([]);
|
||||||
@@ -391,7 +290,7 @@ const ChatBox: React.FC<{
|
|||||||
),
|
),
|
||||||
[backendReferenceBatches, skillDisplayMap],
|
[backendReferenceBatches, skillDisplayMap],
|
||||||
);
|
);
|
||||||
const resolvedReferenceBatches = useMemo(
|
const referenceBatches = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (
|
if (
|
||||||
shouldFetchBackendReferenceBatches &&
|
shouldFetchBackendReferenceBatches &&
|
||||||
@@ -407,18 +306,6 @@ const ChatBox: React.FC<{
|
|||||||
shouldFetchBackendReferenceBatches,
|
shouldFetchBackendReferenceBatches,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const referenceBatches = useMemo(() => {
|
|
||||||
if (!knowledgeReferences) return resolvedReferenceBatches;
|
|
||||||
return resolvedReferenceBatches
|
|
||||||
.map((batch) => ({
|
|
||||||
...batch,
|
|
||||||
sources: filterKnowledgeWikiSources(
|
|
||||||
batch.sources,
|
|
||||||
knowledgeReferences,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
.filter((batch) => batch.sources.length > 0);
|
|
||||||
}, [knowledgeReferences, resolvedReferenceBatches]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldFetchBackendReferenceBatches) {
|
if (!shouldFetchBackendReferenceBatches) {
|
||||||
return;
|
return;
|
||||||
@@ -547,50 +434,13 @@ const ChatBox: React.FC<{
|
|||||||
!disableArtifacts &&
|
!disableArtifacts &&
|
||||||
nextArtifacts.length > 0 &&
|
nextArtifacts.length > 0 &&
|
||||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||||
(hasNewArtifact || !hadArtifacts))
|
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||||
) {
|
) {
|
||||||
// The preferred deliverable wins over the generic "newest artifact"
|
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
||||||
// heuristic. Both effects can run in the same commit with a stale null
|
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
|
||||||
// selectedArtifact; choosing the preferred path here prevents a sibling
|
selectArtifact(newest);
|
||||||
// file such as research-plan.json from overwriting report.md.
|
setArtifactsOpen(true);
|
||||||
const automaticTarget =
|
setSideTab("document");
|
||||||
availablePreferredArtifact ?? nextArtifacts[nextArtifacts.length - 1]!;
|
|
||||||
const isGeneratedHtml =
|
|
||||||
languageOfStreamingArtifact(automaticTarget) === "html" &&
|
|
||||||
isAutoPreviewableArtifactPath(automaticTarget);
|
|
||||||
if (
|
|
||||||
shouldAutoOpenWritingArtifact(
|
|
||||||
automaticTarget,
|
|
||||||
thread.messages,
|
|
||||||
writingModeEnabled,
|
|
||||||
) &&
|
|
||||||
(isGeneratedHtml || !selectedArtifact)
|
|
||||||
) {
|
|
||||||
if (isGeneratedHtml) {
|
|
||||||
// HTML is the requested end product, so a later webpage should
|
|
||||||
// replace a previously selected document automatically. Preserve a
|
|
||||||
// matching in-flight write key so a user-closed live preview stays
|
|
||||||
// closed after the tool call completes; otherwise a completed
|
|
||||||
// tool-call id makes a later write to the same path a new preview.
|
|
||||||
const revision = latestCompletedArtifactMutationKey(
|
|
||||||
{ messages: thread.messages ?? [] },
|
|
||||||
automaticTarget,
|
|
||||||
);
|
|
||||||
const previewKey =
|
|
||||||
selectedArtifact?.startsWith("write-file:") &&
|
|
||||||
artifactPathname(selectedArtifact) === artifactPathname(automaticTarget) &&
|
|
||||||
selectedArtifactKey
|
|
||||||
? selectedArtifactKey
|
|
||||||
: revision ?? `artifact:${threadId}:${artifactPathname(automaticTarget)}`;
|
|
||||||
openStreamedArtifact(
|
|
||||||
automaticTarget,
|
|
||||||
previewKey,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
selectArtifact(automaticTarget, true);
|
|
||||||
setArtifactsOpen(true);
|
|
||||||
setSideTab("document");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,12 +459,7 @@ const ChatBox: React.FC<{
|
|||||||
) {
|
) {
|
||||||
if (thread?.values?.artifacts?.length > 0) {
|
if (thread?.values?.artifacts?.length > 0) {
|
||||||
setAutoSelectFirstArtifact(false);
|
setAutoSelectFirstArtifact(false);
|
||||||
const firstPreviewableArtifact = thread.values.artifacts.find(
|
selectArtifact(thread.values.artifacts[0]!);
|
||||||
(artifact) => !isWordDocumentFile(artifact),
|
|
||||||
);
|
|
||||||
if (firstPreviewableArtifact) {
|
|
||||||
selectArtifact(firstPreviewableArtifact, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -623,21 +468,17 @@ const ChatBox: React.FC<{
|
|||||||
autoSelectFirstArtifact,
|
autoSelectFirstArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
selectArtifact,
|
selectArtifact,
|
||||||
openStreamedArtifact,
|
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
selectedArtifactKey,
|
|
||||||
artifacts,
|
artifacts,
|
||||||
setArtifacts,
|
setArtifacts,
|
||||||
setArtifactsOpen,
|
|
||||||
availableArtifacts,
|
availableArtifacts,
|
||||||
availablePreferredArtifact,
|
|
||||||
disableArtifacts,
|
disableArtifacts,
|
||||||
thread.messages,
|
thread.messages,
|
||||||
writingModeEnabled,
|
writingModeEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Live sandbox: open when an in-flight write_file has a usable output path.
|
// Live sandbox: open as soon as any in-flight write_file has a path or
|
||||||
// Pathless content stays inline in the CoT, including content-first streams.
|
// the first content bytes — not only when that tool is the last CoT step.
|
||||||
// Agentfx (and other agents) often emit write_file + present_files/bash in
|
// Agentfx (and other agents) often emit write_file + present_files/bash in
|
||||||
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
||||||
const streamingWritePreview = useMemo(
|
const streamingWritePreview = useMemo(
|
||||||
@@ -656,25 +497,6 @@ const ChatBox: React.FC<{
|
|||||||
setSideTab("document");
|
setSideTab("document");
|
||||||
}, [openStreamedArtifact, streamingWritePreview]);
|
}, [openStreamedArtifact, streamingWritePreview]);
|
||||||
|
|
||||||
// page_report_template(prepare) creates the output HTML before content
|
|
||||||
// editing starts. Open that real artifact immediately; this also covers
|
|
||||||
// models that use str_replace or whose write_file arguments arrive at once.
|
|
||||||
const preparedHtmlPreview = useMemo(
|
|
||||||
() =>
|
|
||||||
disableArtifacts || !thread.isLoading
|
|
||||||
? null
|
|
||||||
: latestPreparedHtmlArtifact(thread.messages ?? []),
|
|
||||||
[disableArtifacts, thread.isLoading, thread.messages],
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!preparedHtmlPreview) return;
|
|
||||||
openStreamedArtifact(
|
|
||||||
preparedHtmlPreview.artifactPath,
|
|
||||||
preparedHtmlPreview.previewKey,
|
|
||||||
);
|
|
||||||
setSideTab("document");
|
|
||||||
}, [openStreamedArtifact, preparedHtmlPreview]);
|
|
||||||
|
|
||||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||||
@@ -682,13 +504,12 @@ const ChatBox: React.FC<{
|
|||||||
if (
|
if (
|
||||||
wasEnabled ||
|
wasEnabled ||
|
||||||
!artifactDirectoryModeEnabled ||
|
!artifactDirectoryModeEnabled ||
|
||||||
availableArtifacts.length === 0 ||
|
availableArtifacts.length === 0
|
||||||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedArtifact) {
|
if (!selectedArtifact) {
|
||||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
|
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||||
}
|
}
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
setSideTab("document");
|
setSideTab("document");
|
||||||
@@ -843,7 +664,7 @@ const ChatBox: React.FC<{
|
|||||||
<ReferenceBatchesContext.Provider value={referenceBatches}>
|
<ReferenceBatchesContext.Provider value={referenceBatches}>
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
id={`${resizableIdBase}-panels`}
|
id={`${resizableIdBase}-panels`}
|
||||||
orientation={stackArtifactPanel ? "vertical" : "horizontal"}
|
orientation="horizontal"
|
||||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||||
groupRef={layoutRef}
|
groupRef={layoutRef}
|
||||||
>
|
>
|
||||||
@@ -854,8 +675,6 @@ const ChatBox: React.FC<{
|
|||||||
id={`${resizableIdBase}-separator`}
|
id={`${resizableIdBase}-separator`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"opacity-33 hover:opacity-100",
|
"opacity-33 hover:opacity-100",
|
||||||
stackArtifactPanel &&
|
|
||||||
"h-px w-full after:inset-x-0 after:inset-y-auto after:top-1/2 after:h-4 after:w-full after:-translate-x-0 after:-translate-y-1/2 after:cursor-row-resize",
|
|
||||||
!sidePanelOpen && "pointer-events-none opacity-0",
|
!sidePanelOpen && "pointer-events-none opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -877,9 +696,7 @@ const ChatBox: React.FC<{
|
|||||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
<TabsTrigger value="document">文档</TabsTrigger>
|
<TabsTrigger value="document">文档</TabsTrigger>
|
||||||
<TabsTrigger value="references">
|
<TabsTrigger value="references">参考文献</TabsTrigger>
|
||||||
{knowledgeReferences ? "Wiki" : "参考文献"}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -895,19 +712,11 @@ const ChatBox: React.FC<{
|
|||||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||||
{artifactDocumentPanel}
|
{artifactDocumentPanel}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-hidden p-0">
|
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||||
{knowledgeReferences ? (
|
<ReferencePanel
|
||||||
<KnowledgeWikiPanel
|
highlightedReferenceId={pinnedReferenceId}
|
||||||
highlightedReferenceId={pinnedReferenceId}
|
sources={referenceSources}
|
||||||
selection={knowledgeReferences}
|
/>
|
||||||
sources={referenceSources}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ReferencePanel
|
|
||||||
highlightedReferenceId={pinnedReferenceId}
|
|
||||||
sources={referenceSources}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : artifactPanelOpen ? (
|
) : artifactPanelOpen ? (
|
||||||
@@ -926,18 +735,10 @@ 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>
|
||||||
|
|||||||
@@ -304,9 +304,7 @@ export function CompactModeGuide({
|
|||||||
setCurrent(-1);
|
setCurrent(-1);
|
||||||
}, [clearPollTimer, releasePromptDialog]);
|
}, [clearPollTimer, releasePromptDialog]);
|
||||||
|
|
||||||
// TDesign 1.18 still handles resize when current=-1 and dereferences an
|
if (!eligible) return null;
|
||||||
// undefined step. Keep its listener mounted only during an active guide.
|
|
||||||
if (!eligible || current < 0 || current >= stepsRef.current.length) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Guide
|
<Guide
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
FileJson,
|
FileJson,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -33,17 +32,14 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Select } from "tdesign-react";
|
|
||||||
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { findNanjingDbSquareId } from "@/core/system-settings/wx-type-filter";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -64,10 +60,6 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
import {
|
|
||||||
MyProjectThreadBadge,
|
|
||||||
MyProjectThreadIcon,
|
|
||||||
} from "@/components/workspace/my-project-thread-indicator";
|
|
||||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -83,8 +75,6 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
|
||||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
|
||||||
import {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -97,17 +87,10 @@ import {
|
|||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
|
||||||
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
|
|
||||||
import {
|
|
||||||
mergeRecentWorkspaceItems,
|
|
||||||
useRecentStrategyProjects,
|
|
||||||
} from "@/strategy-selection/recent-projects";
|
|
||||||
|
|
||||||
import { agentColor } from "./agent-icon-color";
|
import { agentColor } from "./agent-icon-color";
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
@@ -131,74 +114,30 @@ function SectionLabel({
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={onToggle}
|
||||||
onClick={onToggle}
|
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
|
||||||
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
|
>
|
||||||
>
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<span>{label}</span>
|
||||||
<span className="truncate">{label}</span>
|
{count != null && (
|
||||||
{count != null && (
|
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
{count}
|
||||||
{count}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</span>
|
||||||
</span>
|
<ChevronDown
|
||||||
<ChevronDown
|
className={cn(
|
||||||
className={cn(
|
"size-3.5 shrink-0 transition-transform duration-200",
|
||||||
"size-3.5 shrink-0 transition-transform duration-200",
|
collapsed && "-rotate-90",
|
||||||
collapsed && "-rotate-90",
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
|
export function GeneralQaSidebar() {
|
||||||
|
|
||||||
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */
|
|
||||||
const WX_AGENT_TYPE_MINE = "mine";
|
|
||||||
const WX_AGENT_TYPE_DEFAULT_SQUARE = "default_square";
|
|
||||||
const WX_AGENT_TYPE_NANJING_DB = "nanjing-db";
|
|
||||||
const WX_AGENT_TYPE_STORAGE_KEY = "gqa-sidebar:wx-agent-type";
|
|
||||||
const WX_AGENT_LIST_PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
const WX_AGENT_TYPE_OPTIONS = [
|
|
||||||
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
|
|
||||||
{ value: WX_AGENT_TYPE_DEFAULT_SQUARE, label: "默认广场" },
|
|
||||||
{ value: WX_AGENT_TYPE_NANJING_DB, label: "南京-db" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type WxAgentType = (typeof WX_AGENT_TYPE_OPTIONS)[number]["value"];
|
|
||||||
|
|
||||||
function isWxAgentType(value: string | null | undefined): value is WxAgentType {
|
|
||||||
return WX_AGENT_TYPE_OPTIONS.some((option) => option.value === value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStoredWxAgentType(): WxAgentType {
|
|
||||||
if (typeof window === "undefined") return WX_AGENT_TYPE_MINE;
|
|
||||||
const stored = window.localStorage.getItem(WX_AGENT_TYPE_STORAGE_KEY);
|
|
||||||
return isWxAgentType(stored) ? stored : WX_AGENT_TYPE_MINE;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveWxSquareId(
|
|
||||||
agentType: WxAgentType,
|
|
||||||
squares: Array<{ id: string; name: string }> | undefined,
|
|
||||||
defaultSquareId: string | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
if (agentType === WX_AGENT_TYPE_MINE) return undefined;
|
|
||||||
if (agentType === WX_AGENT_TYPE_DEFAULT_SQUARE) return defaultSquareId;
|
|
||||||
return findNanjingDbSquareId(squares);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeneralQaSidebar({
|
|
||||||
wxCommonAgents,
|
|
||||||
}: {
|
|
||||||
/** Enables wx's shared Agent Gallery category selection. */
|
|
||||||
wxCommonAgents?: boolean;
|
|
||||||
} = {}) {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||||
@@ -219,70 +158,17 @@ export function GeneralQaSidebar({
|
|||||||
setAgentsCollapsed((v) => {
|
setAgentsCollapsed((v) => {
|
||||||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||||||
return !v;
|
return !v;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: publishSquares, isLoading: wxSquaresLoading } = usePublishSquares({
|
|
||||||
enabled: !!wxCommonAgents,
|
|
||||||
});
|
|
||||||
const [wxAgentType, setWxAgentTypeState] = useState<WxAgentType>(
|
|
||||||
readStoredWxAgentType,
|
|
||||||
);
|
|
||||||
const setWxAgentType = useCallback((next: WxAgentType) => {
|
|
||||||
setWxAgentTypeState(next);
|
|
||||||
window.localStorage.setItem(WX_AGENT_TYPE_STORAGE_KEY, next);
|
|
||||||
}, []);
|
|
||||||
const [wxSearchInput, setWxSearchInput] = useState("");
|
|
||||||
const [wxSearch, setWxSearch] = useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setTimeout(() => setWxSearch(wxSearchInput.trim()), 300);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [wxSearchInput]);
|
|
||||||
const wxSquareId = wxCommonAgents
|
|
||||||
? resolveWxSquareId(
|
|
||||||
wxAgentType,
|
|
||||||
publishSquares?.squares,
|
|
||||||
publishSquares?.default_square_id,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const wxFetchEnabled =
|
|
||||||
!!wxCommonAgents &&
|
|
||||||
(wxAgentType === WX_AGENT_TYPE_MINE || Boolean(wxSquareId));
|
|
||||||
const toggleChats = () =>
|
const toggleChats = () =>
|
||||||
setChatsCollapsed((v) => {
|
setChatsCollapsed((v) => {
|
||||||
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
||||||
return !v;
|
return !v;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 常用智能体取数:wx 走类型筛选;其它页面仍是置顶 → 我的 → 广场 ──
|
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
||||||
const { agents: mine } = useAgentsPaginated({
|
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||||
scope: "mine",
|
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||||
page: 1,
|
const frequent = useMemo<Agent[]>(() => {
|
||||||
pageSize: 20,
|
|
||||||
enabled: !wxCommonAgents,
|
|
||||||
});
|
|
||||||
const { agents: square } = useAgentsPaginated({
|
|
||||||
scope: "square",
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
enabled: !wxCommonAgents,
|
|
||||||
});
|
|
||||||
const {
|
|
||||||
agents: wxAgents,
|
|
||||||
total: wxTotal,
|
|
||||||
isLoading: wxAgentsLoading,
|
|
||||||
isFetching: wxAgentsFetching,
|
|
||||||
} = useAgentsPaginated({
|
|
||||||
scope: wxAgentType === WX_AGENT_TYPE_MINE ? "mine" : "square",
|
|
||||||
page: 1,
|
|
||||||
pageSize: WX_AGENT_LIST_PAGE_SIZE,
|
|
||||||
square: wxSquareId,
|
|
||||||
search: wxSearch || undefined,
|
|
||||||
enabled: wxFetchEnabled,
|
|
||||||
});
|
|
||||||
const frequent = useMemo<CommonAgent[]>(() => {
|
|
||||||
if (wxCommonAgents) {
|
|
||||||
return wxFetchEnabled ? wxAgents : [];
|
|
||||||
}
|
|
||||||
const out: Agent[] = [];
|
const out: Agent[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const add = (agent: Agent | undefined) => {
|
const add = (agent: Agent | undefined) => {
|
||||||
@@ -294,15 +180,7 @@ export function GeneralQaSidebar({
|
|||||||
mine.forEach(add);
|
mine.forEach(add);
|
||||||
square.forEach(add);
|
square.forEach(add);
|
||||||
return out;
|
return out;
|
||||||
}, [mine, square, wxAgents, wxCommonAgents, wxFetchEnabled]);
|
}, [mine, square]);
|
||||||
const wxEmptyHint =
|
|
||||||
wxAgentType === WX_AGENT_TYPE_NANJING_DB && !wxSquareId
|
|
||||||
? "该类型暂无智能体"
|
|
||||||
: wxSearch
|
|
||||||
? "未找到匹配的智能体"
|
|
||||||
: wxAgentType === WX_AGENT_TYPE_MINE
|
|
||||||
? "暂无智能体"
|
|
||||||
: "该广场暂无智能体";
|
|
||||||
|
|
||||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -315,12 +193,6 @@ export function GeneralQaSidebar({
|
|||||||
`${routeBase}/agents`,
|
`${routeBase}/agents`,
|
||||||
currentSearch,
|
currentSearch,
|
||||||
);
|
);
|
||||||
const moreSkillsHref = buildFullChatNavigationPath(
|
|
||||||
`${routeBase}/skills`,
|
|
||||||
currentSearch,
|
|
||||||
);
|
|
||||||
const wxAgentsPageActive = pathname === `${routeBase}/agents`;
|
|
||||||
const wxSkillsPageActive = pathname === `${routeBase}/skills`;
|
|
||||||
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
||||||
|
|
||||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||||
@@ -328,31 +200,21 @@ export function GeneralQaSidebar({
|
|||||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||||
const handleSelectAgent = useCallback(
|
const handleSelectAgent = useCallback(
|
||||||
(agentId: string) => {
|
(agentId: string) => {
|
||||||
if (agentId === SENTIMENT_AGENT_ID) {
|
|
||||||
navigate(
|
|
||||||
buildFullChatNavigationPath(
|
|
||||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
|
|
||||||
currentSearch,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(
|
navigate(
|
||||||
buildFullChatNavigationPath(
|
buildFullChatNavigationPath(
|
||||||
routeBase === "/page/workspace" || wxCommonAgents
|
routeBase === "/page/workspace"
|
||||||
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||||
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||||
currentSearch,
|
currentSearch,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
|
[currentSearch, navigate, newChatPath, routeBase],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 最近的对话取数 + 搜索 ──
|
// ── 最近的对话取数 + 搜索 ──
|
||||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||||
const { data: threads = [] } = useThreads();
|
const { data: threads = [] } = useThreads();
|
||||||
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
|
|
||||||
const { mutate: deleteThread } = useDeleteThread();
|
const { mutate: deleteThread } = useDeleteThread();
|
||||||
const { mutate: renameThread } = useRenameThread();
|
const { mutate: renameThread } = useRenameThread();
|
||||||
const { toggle: togglePin } = useToggleThreadPin();
|
const { toggle: togglePin } = useToggleThreadPin();
|
||||||
@@ -379,28 +241,17 @@ export function GeneralQaSidebar({
|
|||||||
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||||
[threads],
|
[threads],
|
||||||
);
|
);
|
||||||
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
|
||||||
const recentItems = useMemo(
|
|
||||||
() =>
|
|
||||||
mergeRecentWorkspaceItems(visibleThreads, recentStrategyProjects),
|
|
||||||
[recentStrategyProjects, visibleThreads],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredRecentItems = useMemo(() => {
|
const filteredThreads = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const list = q
|
const list = q
|
||||||
? recentItems.filter((item) =>
|
? visibleThreads.filter((thread) =>
|
||||||
(item.kind === "thread"
|
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
|
||||||
? titleOfThread(item.thread, t.pages.newChat)
|
|
||||||
: item.project.title
|
|
||||||
)
|
|
||||||
.toLowerCase()
|
|
||||||
.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,88 +354,17 @@ export function GeneralQaSidebar({
|
|||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||||
{/* ── 常用智能体 ── */}
|
{/* ── 常用智能体 ── */}
|
||||||
{(wxCommonAgents || frequent.length > 0) && (
|
{frequent.length > 0 && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<SectionLabel
|
<SectionLabel
|
||||||
label="常用智能体"
|
label="常用智能体"
|
||||||
count={wxCommonAgents ? frequent.length : undefined}
|
|
||||||
collapsed={agentsCollapsed}
|
collapsed={agentsCollapsed}
|
||||||
onToggle={toggleAgents}
|
onToggle={toggleAgents}
|
||||||
/>
|
/>
|
||||||
{!agentsCollapsed && (
|
{!agentsCollapsed && (
|
||||||
<div className="mt-1 flex flex-col gap-1">
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
{wxCommonAgents && (
|
|
||||||
<div className="space-y-2 px-1 pb-1">
|
|
||||||
<Select
|
|
||||||
size="medium"
|
|
||||||
value={wxAgentType}
|
|
||||||
placeholder="智能体类型"
|
|
||||||
onChange={(value) => {
|
|
||||||
if (isWxAgentType(String(value))) {
|
|
||||||
setWxAgentType(String(value) as WxAgentType);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full [&_.t-input]:!h-9 [&_.t-input]:!rounded-lg [&_.t-input]:!border-border [&_.t-input]:!bg-card [&_.t-input]:!px-2.5 [&_.t-input]:!text-[13px]"
|
|
||||||
popupProps={{
|
|
||||||
overlayClassName: "z-[80]",
|
|
||||||
attach: () => document.body,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{WX_AGENT_TYPE_OPTIONS.map((option) => (
|
|
||||||
<Select.Option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
|
||||||
<Input
|
|
||||||
value={wxSearchInput}
|
|
||||||
onChange={(e) => setWxSearchInput(e.target.value)}
|
|
||||||
placeholder="搜索智能体"
|
|
||||||
className="h-8 rounded-lg border-border bg-card pl-8 pr-8 text-[12px]"
|
|
||||||
aria-label="搜索智能体"
|
|
||||||
/>
|
|
||||||
{wxSearchInput && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setWxSearchInput("")}
|
|
||||||
className="absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
title="清除"
|
|
||||||
aria-label="清除搜索"
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{wxCommonAgents &&
|
|
||||||
(wxAgentsLoading ||
|
|
||||||
(wxAgentType !== WX_AGENT_TYPE_MINE && wxSquaresLoading)) &&
|
|
||||||
frequent.length === 0 ? (
|
|
||||||
<div className="flex h-20 items-center justify-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
加载中…
|
|
||||||
</div>
|
|
||||||
) : frequent.length === 0 ? (
|
|
||||||
<p className="px-3 py-2 text-xs text-muted-foreground/70">
|
|
||||||
{wxCommonAgents ? wxEmptyHint : null}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex max-h-[240px] flex-col gap-1 overflow-y-auto overscroll-contain pr-0.5",
|
|
||||||
wxCommonAgents && wxAgentsFetching && "opacity-60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{frequent.map((agent) => {
|
{frequent.map((agent) => {
|
||||||
const isActive =
|
const isActive = activeAgentId === agent.id;
|
||||||
activeAgentId === agent.id ||
|
|
||||||
(agent.id === SENTIMENT_AGENT_ID &&
|
|
||||||
pathname.startsWith(
|
|
||||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
|
|
||||||
));
|
|
||||||
const { from, to, foreground } = agentColor(agent.id, theme);
|
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -657,50 +437,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 +453,7 @@ export function GeneralQaSidebar({
|
|||||||
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
|
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
|
||||||
<SectionLabel
|
<SectionLabel
|
||||||
label={t.sidebar.recentChats}
|
label={t.sidebar.recentChats}
|
||||||
count={recentItems.length}
|
count={visibleThreads.length}
|
||||||
collapsed={chatsCollapsed}
|
collapsed={chatsCollapsed}
|
||||||
onToggle={toggleChats}
|
onToggle={toggleChats}
|
||||||
/>
|
/>
|
||||||
@@ -728,75 +471,29 @@ export function GeneralQaSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{filteredRecentItems.length === 0 ? (
|
{filteredThreads.length === 0 ? (
|
||||||
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
|
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
|
||||||
{query ? "没有匹配的会话" : t.chats.noChats}
|
{query ? "没有匹配的会话" : t.chats.noChats}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredRecentItems.map((item) => {
|
filteredThreads.map((thread) => {
|
||||||
if (item.kind === "strategy-project") {
|
|
||||||
const { project } = item;
|
|
||||||
const href = strategyProjectWorkbenchPath(
|
|
||||||
"/page/strategy/qa/projects",
|
|
||||||
project.id,
|
|
||||||
);
|
|
||||||
const isActive = href === pathname;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`strategy-project:${project.id}`}
|
|
||||||
className="group/item relative flex items-center"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className={cn(
|
|
||||||
"gq-history-item relative flex min-h-12 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] transition-colors",
|
|
||||||
isActive
|
|
||||||
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
|
||||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MyProjectThreadIcon type="strategy" />
|
|
||||||
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
|
|
||||||
<span className="w-full truncate">
|
|
||||||
{desensitize(project.title)}
|
|
||||||
</span>
|
|
||||||
<MyProjectThreadBadge
|
|
||||||
className="mr-4 max-w-[calc(100%_-_1rem)]"
|
|
||||||
projectName={project.title}
|
|
||||||
type="strategy"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{isActive && (
|
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { thread } = item;
|
|
||||||
const href = buildFullChatNavigationPath(
|
const href = buildFullChatNavigationPath(
|
||||||
pathForThread(thread),
|
pathForThread(thread),
|
||||||
currentSearch,
|
currentSearch,
|
||||||
);
|
);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
const projectInfo = myProjectConversationInfoOfThread(
|
|
||||||
thread,
|
|
||||||
projectThreadNames,
|
|
||||||
);
|
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`thread:${thread.thread_id}`}
|
key={thread.thread_id}
|
||||||
className="group/item relative flex items-center"
|
className="group/item relative flex items-center"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||||
projectInfo && "h-auto min-h-12 py-1.5",
|
|
||||||
isActive
|
isActive
|
||||||
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||||
@@ -810,8 +507,6 @@ export function GeneralQaSidebar({
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
) : projectInfo ? (
|
|
||||||
<MyProjectThreadIcon type={projectInfo.type} />
|
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -823,22 +518,8 @@ export function GeneralQaSidebar({
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
)}
|
)}
|
||||||
<span
|
<span className="truncate">
|
||||||
className={cn(
|
{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>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
|
||||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
|
||||||
import { hasPromptInputPayload } from "./input-box-submit.ts";
|
|
||||||
|
|
||||||
test("allows attachment-only prompt submissions", () => {
|
|
||||||
assert.equal(hasPromptInputPayload({ text: "", files: [{}] }), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects submissions with neither text nor attachments", () => {
|
|
||||||
assert.equal(hasPromptInputPayload({ text: "", files: [] }), false);
|
|
||||||
assert.equal(hasPromptInputPayload({ text: " \n", files: [] }), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows text-only prompt submissions", () => {
|
|
||||||
assert.equal(hasPromptInputPayload({ text: "总结一下", files: [] }), true);
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type PromptInputPayload = {
|
|
||||||
text: string;
|
|
||||||
files: readonly unknown[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A prompt may contain text, attachments, or both. */
|
|
||||||
export function hasPromptInputPayload(message: PromptInputPayload): boolean {
|
|
||||||
return message.text.trim().length > 0 || message.files.length > 0;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
import {
|
||||||
BookMarkedIcon,
|
|
||||||
BrainCircuitIcon,
|
BrainCircuitIcon,
|
||||||
|
BookMarkedIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
@@ -64,22 +63,11 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import {
|
|
||||||
isDocumentRewriteActiveStage,
|
|
||||||
useDocumentRewritePresentation,
|
|
||||||
} from "@/core/document-rewrite/context";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import {
|
|
||||||
REASONING_EFFORTS,
|
|
||||||
resolveModelReasoningEffort,
|
|
||||||
type ReasoningEffort,
|
|
||||||
type StoredReasoningEffort,
|
|
||||||
} from "@/core/models/reasoning-effort";
|
|
||||||
import type { Model } from "@/core/models/types";
|
import type { Model } from "@/core/models/types";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
|
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -113,12 +101,26 @@ import {
|
|||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||||
import { resolveInputMode, type InputMode } from "./input-mode";
|
|
||||||
import { hasPromptInputPayload } from "./input-box-submit";
|
|
||||||
import { useThread } from "./messages/context";
|
import { useThread } from "./messages/context";
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
|
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||||
|
|
||||||
|
|
||||||
|
function getResolvedMode(
|
||||||
|
mode: InputMode | undefined,
|
||||||
|
supportsThinking: boolean,
|
||||||
|
): InputMode {
|
||||||
|
if (!supportsThinking && mode !== "flash") {
|
||||||
|
return "flash";
|
||||||
|
}
|
||||||
|
if (mode) {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return supportsThinking ? "pro" : "flash";
|
||||||
|
}
|
||||||
|
|
||||||
export function InputBox({
|
export function InputBox({
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -130,20 +132,18 @@ export function InputBox({
|
|||||||
threadId,
|
threadId,
|
||||||
initialValue,
|
initialValue,
|
||||||
onContextChange,
|
onContextChange,
|
||||||
onFollowupsPresenceChange,
|
onFollowupsVisibilityChange,
|
||||||
followupsDisplayAllowed = true,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onStop,
|
onStop,
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
agentSelector,
|
agentSelector,
|
||||||
knowledgeSelector,
|
knowledgeSelector,
|
||||||
knowledgeRetrieval,
|
|
||||||
knowledgeSuggestedQuestions = [],
|
|
||||||
extraModels = [],
|
extraModels = [],
|
||||||
assistantId,
|
assistantId,
|
||||||
hideAdminRecommendedQuestions,
|
hideAdminRecommendedQuestions,
|
||||||
compact,
|
compact,
|
||||||
hideContextTools,
|
hideContextTools,
|
||||||
|
hideMemoryTool = true,
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||||
assistantId?: string | null;
|
assistantId?: string | null;
|
||||||
@@ -151,17 +151,23 @@ export function InputBox({
|
|||||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
|
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||||
hideContextTools?: boolean;
|
hideContextTools?: boolean;
|
||||||
|
/**
|
||||||
|
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
||||||
|
* 写作和参考文献开关不受影响。
|
||||||
|
*/
|
||||||
|
hideMemoryTool?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
context: Omit<
|
context: Omit<
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||||
> & {
|
> & {
|
||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: StoredReasoningEffort;
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
|
memory_injection_enabled?: boolean;
|
||||||
};
|
};
|
||||||
extraHeader?: React.ReactNode;
|
extraHeader?: React.ReactNode;
|
||||||
isNewThread?: boolean;
|
isNewThread?: boolean;
|
||||||
@@ -176,18 +182,12 @@ export function InputBox({
|
|||||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||||
> & {
|
> & {
|
||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: StoredReasoningEffort;
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
|
memory_injection_enabled?: boolean;
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
/**
|
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||||
* Whether generated follow-up questions may be rendered. Hosts with a
|
|
||||||
* scrollable transcript disable this while the user is reviewing history;
|
|
||||||
* the generated questions are retained and reappear on returning to bottom.
|
|
||||||
*/
|
|
||||||
followupsDisplayAllowed?: boolean;
|
|
||||||
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
|
|
||||||
onFollowupsPresenceChange?: (present: boolean) => void;
|
|
||||||
onSubmit?: (message: PromptInputMessage) => void;
|
onSubmit?: (message: PromptInputMessage) => void;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
/**
|
/**
|
||||||
@@ -211,17 +211,6 @@ export function InputBox({
|
|||||||
};
|
};
|
||||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||||
knowledgeSelector?: ReactNode;
|
knowledgeSelector?: ReactNode;
|
||||||
/** Per-thread ordinary-knowledge retrieval mode. Omit when unavailable. */
|
|
||||||
knowledgeRetrieval?: {
|
|
||||||
mode: KnowledgeRetrievalMode;
|
|
||||||
smartAvailable: boolean;
|
|
||||||
smartAvailabilityReason: string;
|
|
||||||
/** The administrator policy used only when a new conversation is created. */
|
|
||||||
smartIsDefault: boolean;
|
|
||||||
onModeChange: (mode: KnowledgeRetrievalMode) => void;
|
|
||||||
};
|
|
||||||
/** High-value starter questions derived from the currently selected spaces. */
|
|
||||||
knowledgeSuggestedQuestions?: string[];
|
|
||||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||||
extraModels?: Model[];
|
extraModels?: Model[];
|
||||||
}) {
|
}) {
|
||||||
@@ -239,25 +228,11 @@ export function InputBox({
|
|||||||
return singleRoundRagActive ? extraModels : models;
|
return singleRoundRagActive ? extraModels : models;
|
||||||
}, [extraModels, models, singleRoundRagActive]);
|
}, [extraModels, models, singleRoundRagActive]);
|
||||||
const { thread, isMock } = useThread();
|
const { thread, isMock } = useThread();
|
||||||
const rewritePresentation = useDocumentRewritePresentation();
|
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const referenceModeEntryEnabled =
|
const referenceModeEntryEnabled =
|
||||||
citationDisplay?.reference_mode_enabled ?? false;
|
citationDisplay?.reference_mode_enabled ?? false;
|
||||||
const documentRewriteActive = useMemo(
|
|
||||||
() =>
|
|
||||||
rewritePresentation?.forThread(threadId).some((item) =>
|
|
||||||
isDocumentRewriteActiveStage(item.stage),
|
|
||||||
) ?? false,
|
|
||||||
[rewritePresentation, threadId],
|
|
||||||
);
|
|
||||||
// Keep normal chat generation authoritative when both states overlap. In
|
|
||||||
// the usual rewrite-only case this gives the composer the same square stop
|
|
||||||
// affordance as a normal streamed reply.
|
|
||||||
const composerStatus: ChatStatus = documentRewriteActive
|
|
||||||
? "streaming"
|
|
||||||
: status;
|
|
||||||
|
|
||||||
const [followups, setFollowups] = useState<string[]>([]);
|
const [followups, setFollowups] = useState<string[]>([]);
|
||||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||||
@@ -272,10 +247,8 @@ export function InputBox({
|
|||||||
|
|
||||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||||
const userPreferences = useUserPreferences();
|
const userPreferences = useUserPreferences();
|
||||||
const followupsEnabled = userPreferences.followup_suggestions_enabled;
|
|
||||||
const showAdminRecommendedQuestions =
|
const showAdminRecommendedQuestions =
|
||||||
!hideAdminRecommendedQuestions &&
|
!hideAdminRecommendedQuestions &&
|
||||||
knowledgeSuggestedQuestions.length === 0 &&
|
|
||||||
!userPreferences.hide_admin_prompt_prefix &&
|
!userPreferences.hide_admin_prompt_prefix &&
|
||||||
recommendedQuestions.length > 0;
|
recommendedQuestions.length > 0;
|
||||||
|
|
||||||
@@ -291,20 +264,12 @@ export function InputBox({
|
|||||||
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||||
const supportsReasoningEffort =
|
|
||||||
fallbackModel.supports_reasoning_effort ?? false;
|
|
||||||
const nextModelName = fallbackModel.name;
|
const nextModelName = fallbackModel.name;
|
||||||
const nextMode = resolveInputMode(context.mode, supportsThinking);
|
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||||
const nextReasoningEffort = resolveModelReasoningEffort(
|
|
||||||
supportsReasoningEffort,
|
|
||||||
context.reasoning_effort,
|
|
||||||
context.model_name !== nextModelName,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
context.model_name === nextModelName &&
|
context.model_name === nextModelName &&
|
||||||
context.mode === nextMode &&
|
context.mode === nextMode &&
|
||||||
context.reasoning_effort === nextReasoningEffort &&
|
|
||||||
context.execution_profile === fallbackModel.execution_profile
|
context.execution_profile === fallbackModel.execution_profile
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -314,7 +279,6 @@ export function InputBox({
|
|||||||
...context,
|
...context,
|
||||||
model_name: nextModelName,
|
model_name: nextModelName,
|
||||||
mode: nextMode,
|
mode: nextMode,
|
||||||
reasoning_effort: nextReasoningEffort,
|
|
||||||
execution_profile: fallbackModel.execution_profile,
|
execution_profile: fallbackModel.execution_profile,
|
||||||
});
|
});
|
||||||
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
||||||
@@ -339,31 +303,6 @@ export function InputBox({
|
|||||||
[selectedModel],
|
[selectedModel],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedReasoningEffort = useMemo(
|
|
||||||
() =>
|
|
||||||
resolveModelReasoningEffort(
|
|
||||||
supportReasoningEffort,
|
|
||||||
context.reasoning_effort,
|
|
||||||
),
|
|
||||||
[context.reasoning_effort, supportReasoningEffort],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reasoningEffortIndex = Math.max(
|
|
||||||
0,
|
|
||||||
REASONING_EFFORTS.indexOf(selectedReasoningEffort ?? "xhigh"),
|
|
||||||
);
|
|
||||||
const reasoningEffortThumbLeft =
|
|
||||||
reasoningEffortIndex === 0
|
|
||||||
? "0.5rem"
|
|
||||||
: reasoningEffortIndex === 1
|
|
||||||
? "50%"
|
|
||||||
: "calc(100% - 0.5rem)";
|
|
||||||
const reasoningEffortLabel =
|
|
||||||
selectedReasoningEffort === "low"
|
|
||||||
? t.inputBox.reasoningEffortLow
|
|
||||||
: selectedReasoningEffort === "medium"
|
|
||||||
? t.inputBox.reasoningEffortMedium
|
|
||||||
: t.inputBox.reasoningEffortXHigh;
|
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
(model_name: string) => {
|
(model_name: string) => {
|
||||||
const model = modelOptions.find((m) => m.name === model_name);
|
const model = modelOptions.find((m) => m.name === model_name);
|
||||||
@@ -373,12 +312,8 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name,
|
model_name,
|
||||||
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
|
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||||
reasoning_effort: resolveModelReasoningEffort(
|
reasoning_effort: context.reasoning_effort,
|
||||||
model.supports_reasoning_effort ?? false,
|
|
||||||
context.reasoning_effort,
|
|
||||||
model_name !== context.model_name,
|
|
||||||
),
|
|
||||||
execution_profile: model.execution_profile,
|
execution_profile: model.execution_profile,
|
||||||
});
|
});
|
||||||
setModelDialogOpen(false);
|
setModelDialogOpen(false);
|
||||||
@@ -393,24 +328,22 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: ordinaryModel?.name ?? context.model_name,
|
model_name: ordinaryModel?.name ?? context.model_name,
|
||||||
mode: resolveInputMode(
|
mode: getResolvedMode(
|
||||||
mode,
|
mode,
|
||||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||||
),
|
),
|
||||||
reasoning_effort: resolveModelReasoningEffort(
|
reasoning_effort:
|
||||||
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
|
mode === "ultra"
|
||||||
context.reasoning_effort,
|
? "high"
|
||||||
),
|
: mode === "pro"
|
||||||
|
? "medium"
|
||||||
|
: mode === "thinking"
|
||||||
|
? "low"
|
||||||
|
: "minimal",
|
||||||
execution_profile: undefined,
|
execution_profile: undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[onContextChange, context, models, supportThinking],
|
||||||
onContextChange,
|
|
||||||
context,
|
|
||||||
models,
|
|
||||||
supportThinking,
|
|
||||||
supportReasoningEffort,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSingleRoundRagSelect = useCallback(() => {
|
const handleSingleRoundRagSelect = useCallback(() => {
|
||||||
@@ -424,7 +357,7 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: ragModel.name,
|
model_name: ragModel.name,
|
||||||
mode: resolveInputMode(
|
mode: getResolvedMode(
|
||||||
context.mode,
|
context.mode,
|
||||||
ragModel.supports_thinking ?? false,
|
ragModel.supports_thinking ?? false,
|
||||||
),
|
),
|
||||||
@@ -440,7 +373,7 @@ export function InputBox({
|
|||||||
}, [context, onContextChange]);
|
}, [context, onContextChange]);
|
||||||
|
|
||||||
const handleReasoningEffortSelect = useCallback(
|
const handleReasoningEffortSelect = useCallback(
|
||||||
(effort: ReasoningEffort) => {
|
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
reasoning_effort: effort,
|
reasoning_effort: effort,
|
||||||
@@ -464,13 +397,16 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||||
|
|
||||||
const smartKnowledgeRetrievalActive =
|
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||||
knowledgeRetrieval?.mode === "smart_reasoning";
|
const handleMemoryInjectionToggle = useCallback(() => {
|
||||||
const ragModeLocked =
|
onContextChange?.({
|
||||||
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
|
...context,
|
||||||
const ragModeEnabled =
|
memory_injection_enabled: !memoryInjectionEnabled,
|
||||||
!smartKnowledgeRetrievalActive &&
|
});
|
||||||
(localSettings.display.ragModeEnabled || ragModeLocked);
|
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||||
|
|
||||||
|
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||||
|
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||||
const handleRagModeToggle = useCallback(() => {
|
const handleRagModeToggle = useCallback(() => {
|
||||||
if (ragModeLocked) {
|
if (ragModeLocked) {
|
||||||
return;
|
return;
|
||||||
@@ -488,15 +424,11 @@ export function InputBox({
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (message: PromptInputMessage) => {
|
async (message: PromptInputMessage) => {
|
||||||
if (composerStatus === "streaming") {
|
if (status === "streaming") {
|
||||||
toast.info("正在生成中,请等待当前回复完成后再发送。", {
|
onStop?.();
|
||||||
description: "已保留您输入的内容。",
|
return;
|
||||||
});
|
|
||||||
// Reject rather than resolve: PromptInput treats a resolved submit as
|
|
||||||
// accepted and clears its draft. The current message has not been sent.
|
|
||||||
throw new Error("Cannot submit while a response is streaming.");
|
|
||||||
}
|
}
|
||||||
if (!hasPromptInputPayload(message)) {
|
if (!message.text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
@@ -508,19 +440,14 @@ export function InputBox({
|
|||||||
|
|
||||||
// Guard against submitting before the initial model auto-selection
|
// Guard against submitting before the initial model auto-selection
|
||||||
// effect has flushed thread settings to storage/state.
|
// effect has flushed thread settings to storage/state.
|
||||||
if (
|
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||||
resolvedModelName &&
|
|
||||||
(context.model_name !== resolvedModelName ||
|
|
||||||
context.reasoning_effort !== selectedReasoningEffort)
|
|
||||||
) {
|
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: resolvedModelName,
|
model_name: resolvedModelName,
|
||||||
mode: resolveInputMode(
|
mode: getResolvedMode(
|
||||||
context.mode,
|
context.mode,
|
||||||
selectedModel?.supports_thinking ?? false,
|
selectedModel?.supports_thinking ?? false,
|
||||||
),
|
),
|
||||||
reasoning_effort: selectedReasoningEffort,
|
|
||||||
execution_profile: selectedModel?.execution_profile,
|
execution_profile: selectedModel?.execution_profile,
|
||||||
});
|
});
|
||||||
setTimeout(() => onSubmit?.(message), 0);
|
setTimeout(() => onSubmit?.(message), 0);
|
||||||
@@ -533,30 +460,16 @@ export function InputBox({
|
|||||||
context,
|
context,
|
||||||
onContextChange,
|
onContextChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
composerStatus,
|
onStop,
|
||||||
resolvedModelName,
|
resolvedModelName,
|
||||||
ragModeEnabled,
|
ragModeEnabled,
|
||||||
selectedModel?.supports_thinking,
|
selectedModel?.supports_thinking,
|
||||||
selectedModel?.execution_profile,
|
selectedModel?.execution_profile,
|
||||||
selectedReasoningEffort,
|
|
||||||
status,
|
status,
|
||||||
threadId,
|
threadId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStopCurrentGeneration = useCallback(() => {
|
|
||||||
if (status === "streaming") {
|
|
||||||
onStop?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!documentRewriteActive || !rewritePresentation) return;
|
|
||||||
void rewritePresentation.cancelActiveForThread(threadId).then((result) => {
|
|
||||||
if (!result.cancelled) {
|
|
||||||
toast.error(result.error ?? "停止全文改写失败,请稍后重试。");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [documentRewriteActive, onStop, rewritePresentation, status, threadId]);
|
|
||||||
|
|
||||||
const requestFormSubmit = useCallback(() => {
|
const requestFormSubmit = useCallback(() => {
|
||||||
const form = promptRootRef.current?.querySelector("form");
|
const form = promptRootRef.current?.querySelector("form");
|
||||||
form?.requestSubmit();
|
form?.requestSubmit();
|
||||||
@@ -564,7 +477,7 @@ export function InputBox({
|
|||||||
|
|
||||||
const handleFollowupClick = useCallback(
|
const handleFollowupClick = useCallback(
|
||||||
(suggestion: string) => {
|
(suggestion: string) => {
|
||||||
if (composerStatus === "streaming") {
|
if (status === "streaming") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const current = (textInput.value ?? "").trim();
|
const current = (textInput.value ?? "").trim();
|
||||||
@@ -577,7 +490,7 @@ export function InputBox({
|
|||||||
setFollowupsHidden(true);
|
setFollowupsHidden(true);
|
||||||
setTimeout(() => requestFormSubmit(), 0);
|
setTimeout(() => requestFormSubmit(), 0);
|
||||||
},
|
},
|
||||||
[composerStatus, requestFormSubmit, textInput],
|
[requestFormSubmit, status, textInput],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmReplaceAndSend = useCallback(() => {
|
const confirmReplaceAndSend = useCallback(() => {
|
||||||
@@ -608,43 +521,32 @@ export function InputBox({
|
|||||||
setTimeout(() => requestFormSubmit(), 0);
|
setTimeout(() => requestFormSubmit(), 0);
|
||||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||||
|
|
||||||
const followupsPresent =
|
const showFollowups =
|
||||||
followupsEnabled &&
|
|
||||||
!disabled &&
|
!disabled &&
|
||||||
!isNewThread &&
|
!isNewThread &&
|
||||||
!selectedSingleRoundRag &&
|
!selectedSingleRoundRag &&
|
||||||
!followupsHidden &&
|
!followupsHidden &&
|
||||||
(followupsLoading || followups.length > 0);
|
(followupsLoading || followups.length > 0);
|
||||||
const showFollowups = followupsDisplayAllowed && followupsPresent;
|
|
||||||
|
|
||||||
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
|
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
|
followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
|
||||||
}, [onFollowupsPresenceChange]);
|
}, [onFollowupsVisibilityChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
followupsPresenceChangeRef.current?.(followupsPresent);
|
followupsVisibilityChangeRef.current?.(showFollowups);
|
||||||
}, [followupsPresent]);
|
}, [showFollowups]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => followupsPresenceChangeRef.current?.(false);
|
return () => followupsVisibilityChangeRef.current?.(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (followupsEnabled) return;
|
|
||||||
setFollowups([]);
|
|
||||||
setFollowupsLoading(false);
|
|
||||||
setFollowupsHidden(true);
|
|
||||||
setConfirmOpen(false);
|
|
||||||
setPendingSuggestion(null);
|
|
||||||
}, [followupsEnabled]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const streaming = status === "streaming";
|
const streaming = status === "streaming";
|
||||||
const wasStreaming = wasStreamingRef.current;
|
const wasStreaming = wasStreamingRef.current;
|
||||||
wasStreamingRef.current = streaming;
|
wasStreamingRef.current = streaming;
|
||||||
if (!followupsEnabled || !wasStreaming || streaming) {
|
if (!wasStreaming || streaming) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,7 +598,6 @@ export function InputBox({
|
|||||||
return (await res.json()) as { suggestions?: string[] };
|
return (await res.json()) as { suggestions?: string[] };
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
const suggestions = (data.suggestions ?? [])
|
const suggestions = (data.suggestions ?? [])
|
||||||
.map((s) => (typeof s === "string" ? s.trim() : ""))
|
.map((s) => (typeof s === "string" ? s.trim() : ""))
|
||||||
.filter((s) => s.length > 0)
|
.filter((s) => s.length > 0)
|
||||||
@@ -704,19 +605,14 @@ export function InputBox({
|
|||||||
setFollowups(suggestions);
|
setFollowups(suggestions);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setFollowupsLoading(false);
|
setFollowupsLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => controller.abort();
|
||||||
controller.abort();
|
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||||
setFollowupsLoading(false);
|
|
||||||
};
|
|
||||||
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||||
@@ -726,28 +622,6 @@ export function InputBox({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
|
|
||||||
<div className="px-4 mb-[10px]">
|
|
||||||
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
|
|
||||||
<div className="flex flex-wrap justify-center gap-2.5">
|
|
||||||
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
|
|
||||||
<button
|
|
||||||
key={question}
|
|
||||||
type="button"
|
|
||||||
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
|
|
||||||
onClick={() => {
|
|
||||||
textInput.setInput(question);
|
|
||||||
setFollowupsHidden(true);
|
|
||||||
setTimeout(() => requestFormSubmit(), 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{question}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||||
{recommendedQuestions.map((q) => (
|
{recommendedQuestions.map((q) => (
|
||||||
@@ -859,12 +733,12 @@ export function InputBox({
|
|||||||
</PromptInputBody>
|
</PromptInputBody>
|
||||||
<PromptInputFooter
|
<PromptInputFooter
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
|
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
||||||
compact && "gap-2 px-3 pt-1.5 pb-3",
|
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PromptInputTools
|
<PromptInputTools
|
||||||
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
|
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
|
||||||
>
|
>
|
||||||
{/* TODO: Add more connectors here
|
{/* TODO: Add more connectors here
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
@@ -939,42 +813,62 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">文件</span>
|
<span className="text-xs font-normal">文件</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{referenceModeEntryEnabled &&
|
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||||
!frontendRagActive &&
|
content={
|
||||||
!smartKnowledgeRetrievalActive &&
|
memoryInjectionEnabled
|
||||||
!hideContextTools && (
|
? "记忆注入已开启:对话前会注入历史记忆"
|
||||||
<Tooltip
|
: "记忆注入已关闭:本次对话不注入记忆"
|
||||||
content={
|
}
|
||||||
ragModeEnabled
|
>
|
||||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
<PromptInputButton
|
||||||
: "参考文献模式已关闭:点击开启"
|
className={cn(
|
||||||
}
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
>
|
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
<PromptInputButton
|
memoryInjectionEnabled
|
||||||
type="button"
|
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
|
||||||
aria-pressed={ragModeEnabled}
|
: compact
|
||||||
disabled={ragModeLocked || disabled}
|
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
className={cn(
|
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||||
"gap-1.5! rounded-full px-3! shadow-none",
|
)}
|
||||||
compact &&
|
onClick={handleMemoryInjectionToggle}
|
||||||
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
>
|
||||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
{memoryInjectionEnabled && (
|
||||||
ragModeEnabled
|
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
||||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
)}
|
||||||
: compact
|
<BrainCircuitIcon className="size-3" />
|
||||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
<span className="text-xs font-normal">记忆</span>
|
||||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
</PromptInputButton>
|
||||||
)}
|
</Tooltip>}
|
||||||
onClick={handleRagModeToggle}
|
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||||
>
|
content={
|
||||||
{ragModeEnabled && (
|
ragModeEnabled
|
||||||
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
|
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||||
)}
|
: "参考文献模式已关闭:点击开启"
|
||||||
<BookMarkedIcon className="size-3" />
|
}
|
||||||
<span className="text-xs font-normal">参考文献</span>
|
>
|
||||||
</PromptInputButton>
|
<PromptInputButton
|
||||||
</Tooltip>
|
type="button"
|
||||||
)}
|
aria-pressed={ragModeEnabled}
|
||||||
|
disabled={ragModeLocked || disabled}
|
||||||
|
className={cn(
|
||||||
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
|
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
|
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||||
|
ragModeEnabled
|
||||||
|
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||||
|
: compact
|
||||||
|
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||||
|
)}
|
||||||
|
onClick={handleRagModeToggle}
|
||||||
|
>
|
||||||
|
{ragModeEnabled && (
|
||||||
|
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
|
||||||
|
)}
|
||||||
|
<BookMarkedIcon className="size-3" />
|
||||||
|
<span className="text-xs font-normal">参考文献</span>
|
||||||
|
</PromptInputButton>
|
||||||
|
</Tooltip>}
|
||||||
{!frontendRagActive && <Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
contextCompressionEnabled
|
contextCompressionEnabled
|
||||||
@@ -1267,63 +1161,124 @@ export function InputBox({
|
|||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu>
|
</PromptInputActionMenu>
|
||||||
{!frontendRagActive &&
|
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||||
supportReasoningEffort &&
|
<PromptInputActionMenu>
|
||||||
context.mode !== "flash" && (
|
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
|
||||||
<PromptInputActionMenu>
|
<div className="text-xs font-normal">
|
||||||
<PromptInputActionMenuTrigger
|
{t.inputBox.reasoningEffort}:
|
||||||
aria-label={`${t.inputBox.reasoningEffortSelect}: ${reasoningEffortLabel}`}
|
{context.reasoning_effort === "minimal" &&
|
||||||
className={cn(
|
" " + t.inputBox.reasoningEffortMinimal}
|
||||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
{context.reasoning_effort === "low" &&
|
||||||
compact &&
|
" " + t.inputBox.reasoningEffortLow}
|
||||||
"rounded-lg border border-border px-2.5! py-1! text-[13px]",
|
{context.reasoning_effort === "medium" &&
|
||||||
)}
|
" " + t.inputBox.reasoningEffortMedium}
|
||||||
>
|
{context.reasoning_effort === "high" &&
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium">
|
" " + t.inputBox.reasoningEffortHigh}
|
||||||
<SparklesIcon className="size-3" />
|
</div>
|
||||||
<span className="text-foreground">
|
</PromptInputActionMenuTrigger>
|
||||||
{reasoningEffortLabel}
|
<PromptInputActionMenuContent className="w-70">
|
||||||
</span>
|
<DropdownMenuGroup>
|
||||||
</div>
|
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||||
</PromptInputActionMenuTrigger>
|
{t.inputBox.reasoningEffort}
|
||||||
<PromptInputActionMenuContent className="w-56 p-3">
|
</DropdownMenuLabel>
|
||||||
<div className="flex items-center justify-between gap-3 px-1">
|
<PromptInputActionMenu>
|
||||||
<span className="text-xs text-muted-foreground">
|
<PromptInputActionMenuItem
|
||||||
{t.inputBox.reasoningEffortSelect}
|
className={cn(
|
||||||
</span>
|
context.reasoning_effort === "minimal"
|
||||||
<span className="text-sm font-semibold text-violet-600 dark:text-violet-300">
|
? "text-accent-foreground"
|
||||||
{reasoningEffortLabel}
|
: "text-muted-foreground/65",
|
||||||
</span>
|
)}
|
||||||
</div>
|
onSelect={() => handleReasoningEffortSelect("minimal")}
|
||||||
<div className="mt-2 px-1">
|
>
|
||||||
<div className="relative h-9 w-full">
|
<div className="flex flex-col gap-2">
|
||||||
<input
|
<div className="flex items-center gap-1 font-bold">
|
||||||
type="range"
|
{t.inputBox.reasoningEffortMinimal}
|
||||||
aria-label={`${t.inputBox.reasoningEffortSelect}: ${selectedModel?.display_name || selectedModel?.name || ""}`}
|
</div>
|
||||||
aria-valuetext={reasoningEffortLabel}
|
<div className="pl-2 text-xs">
|
||||||
className="peer absolute inset-x-0 -inset-y-1 z-10 h-auto w-full cursor-grab touch-none opacity-0 active:cursor-grabbing"
|
{t.inputBox.reasoningEffortMinimalDescription}
|
||||||
value={reasoningEffortIndex}
|
</div>
|
||||||
min={0}
|
</div>
|
||||||
max={2}
|
{context.reasoning_effort === "minimal" ? (
|
||||||
step={1}
|
<CheckIcon className="ml-auto size-4" />
|
||||||
onChange={(event) => {
|
) : (
|
||||||
const effort =
|
<div className="ml-auto size-4" />
|
||||||
REASONING_EFFORTS[Number(event.currentTarget.value)];
|
)}
|
||||||
if (effort) handleReasoningEffortSelect(effort);
|
</PromptInputActionMenuItem>
|
||||||
}}
|
<PromptInputActionMenuItem
|
||||||
/>
|
className={cn(
|
||||||
<div className="pointer-events-none absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-fuchsia-500 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/70 peer-focus-visible:ring-offset-2" />
|
context.reasoning_effort === "low"
|
||||||
<div
|
? "text-accent-foreground"
|
||||||
className="pointer-events-none absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-violet-600 shadow-md peer-active:scale-110 dark:border-slate-800"
|
: "text-muted-foreground/65",
|
||||||
style={{ left: reasoningEffortThumbLeft }}
|
)}
|
||||||
/>
|
onSelect={() => handleReasoningEffortSelect("low")}
|
||||||
</div>
|
>
|
||||||
</div>
|
<div className="flex flex-col gap-2">
|
||||||
</PromptInputActionMenuContent>
|
<div className="flex items-center gap-1 font-bold">
|
||||||
</PromptInputActionMenu>
|
{t.inputBox.reasoningEffortLow}
|
||||||
)}
|
</div>
|
||||||
|
<div className="pl-2 text-xs">
|
||||||
|
{t.inputBox.reasoningEffortLowDescription}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{context.reasoning_effort === "low" ? (
|
||||||
|
<CheckIcon className="ml-auto size-4" />
|
||||||
|
) : (
|
||||||
|
<div className="ml-auto size-4" />
|
||||||
|
)}
|
||||||
|
</PromptInputActionMenuItem>
|
||||||
|
<PromptInputActionMenuItem
|
||||||
|
className={cn(
|
||||||
|
context.reasoning_effort === "medium" ||
|
||||||
|
!context.reasoning_effort
|
||||||
|
? "text-accent-foreground"
|
||||||
|
: "text-muted-foreground/65",
|
||||||
|
)}
|
||||||
|
onSelect={() => handleReasoningEffortSelect("medium")}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-1 font-bold">
|
||||||
|
{t.inputBox.reasoningEffortMedium}
|
||||||
|
</div>
|
||||||
|
<div className="pl-2 text-xs">
|
||||||
|
{t.inputBox.reasoningEffortMediumDescription}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{context.reasoning_effort === "medium" ||
|
||||||
|
!context.reasoning_effort ? (
|
||||||
|
<CheckIcon className="ml-auto size-4" />
|
||||||
|
) : (
|
||||||
|
<div className="ml-auto size-4" />
|
||||||
|
)}
|
||||||
|
</PromptInputActionMenuItem>
|
||||||
|
<PromptInputActionMenuItem
|
||||||
|
className={cn(
|
||||||
|
context.reasoning_effort === "high"
|
||||||
|
? "text-accent-foreground"
|
||||||
|
: "text-muted-foreground/65",
|
||||||
|
)}
|
||||||
|
onSelect={() => handleReasoningEffortSelect("high")}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-1 font-bold">
|
||||||
|
{t.inputBox.reasoningEffortHigh}
|
||||||
|
</div>
|
||||||
|
<div className="pl-2 text-xs">
|
||||||
|
{t.inputBox.reasoningEffortHighDescription}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{context.reasoning_effort === "high" ? (
|
||||||
|
<CheckIcon className="ml-auto size-4" />
|
||||||
|
) : (
|
||||||
|
<div className="ml-auto size-4" />
|
||||||
|
)}
|
||||||
|
</PromptInputActionMenuItem>
|
||||||
|
</PromptInputActionMenu>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</PromptInputActionMenuContent>
|
||||||
|
</PromptInputActionMenu>
|
||||||
|
)}
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
|
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||||
{!frontendRagActive && agentSelector && (
|
{!frontendRagActive && agentSelector && (
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
selectedAgentId={agentSelector.selectedAgentId}
|
selectedAgentId={agentSelector.selectedAgentId}
|
||||||
@@ -1333,100 +1288,6 @@ export function InputBox({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!frontendRagActive && knowledgeSelector}
|
{!frontendRagActive && knowledgeSelector}
|
||||||
{!frontendRagActive && knowledgeRetrieval && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<Tooltip
|
|
||||||
content={
|
|
||||||
knowledgeRetrieval.mode === "smart_reasoning"
|
|
||||||
? "智能检索已开启:直接提问即可,系统会检索原文分片并沿 Wiki 线索扩展背景资料"
|
|
||||||
: knowledgeRetrieval.smartAvailabilityReason
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<PromptInputButton
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={`普通知识库检索模式:${
|
|
||||||
knowledgeRetrieval.mode === "smart_reasoning"
|
|
||||||
? "智能检索"
|
|
||||||
: "经典检索"
|
|
||||||
}`}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-3 shadow-none transition-colors",
|
|
||||||
knowledgeRetrieval.mode === "smart_reasoning"
|
|
||||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
|
||||||
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BrainCircuitIcon className="size-3.5" />
|
|
||||||
<span className="max-w-[160px] truncate text-xs font-normal">
|
|
||||||
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
|
|
||||||
</span>
|
|
||||||
</PromptInputButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
className="w-80 rounded-xl p-1.5"
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
|
|
||||||
普通知识库检索模式
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="min-h-16 items-start rounded-lg py-2"
|
|
||||||
disabled={!knowledgeRetrieval.smartAvailable}
|
|
||||||
onSelect={() =>
|
|
||||||
knowledgeRetrieval.onModeChange("smart_reasoning")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BrainCircuitIcon className="mt-0.5 size-4" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
智能检索
|
|
||||||
{knowledgeRetrieval.smartIsDefault ? "(新对话默认)" : ""}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
先检索原文分片,再按命中线索查阅并扩展 Wiki;资料仅作为回答背景
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{knowledgeRetrieval.mode === "smart_reasoning" && (
|
|
||||||
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="min-h-14 items-start rounded-lg py-2"
|
|
||||||
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
|
|
||||||
>
|
|
||||||
<BookMarkedIcon className="mt-0.5 size-4" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
经典检索
|
|
||||||
{knowledgeRetrieval.smartIsDefault ? "(候选)" : "(新对话默认)"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
沿用原有知识库检索与回答方式
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{knowledgeRetrieval.mode === "classic" && (
|
|
||||||
<CheckIcon className="mt-0.5 size-4 text-primary" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1 text-xs leading-5",
|
|
||||||
knowledgeRetrieval.smartAvailable
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-amber-600 dark:text-amber-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{knowledgeRetrieval.smartAvailabilityReason}
|
|
||||||
</p>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
{!frontendRagActive && promptPrefix && (
|
{!frontendRagActive && promptPrefix && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
@@ -1513,18 +1374,8 @@ export function InputBox({
|
|||||||
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
|
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={(event) => {
|
|
||||||
if (composerStatus !== "streaming") return;
|
|
||||||
// A deliberate press of the square button stops the stream.
|
|
||||||
// Prevent the enclosing form from treating it as a new submit.
|
|
||||||
event.preventDefault();
|
|
||||||
handleStopCurrentGeneration();
|
|
||||||
}}
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
status={composerStatus}
|
status={status}
|
||||||
{...(documentRewriteActive
|
|
||||||
? { "aria-label": "停止 AI 全文改写" }
|
|
||||||
: {})}
|
|
||||||
/>
|
/>
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
</PromptInputFooter>
|
</PromptInputFooter>
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
|
||||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
|
||||||
import { resolveInputMode } from "./input-mode.ts";
|
|
||||||
|
|
||||||
test("keeps plan and subagent modes for models without native thinking", () => {
|
|
||||||
assert.equal(resolveInputMode("pro", false), "pro");
|
|
||||||
assert.equal(resolveInputMode("ultra", false), "ultra");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back only when native thinking mode is unsupported", () => {
|
|
||||||
assert.equal(resolveInputMode("thinking", false), "flash");
|
|
||||||
assert.equal(resolveInputMode("thinking", true), "thinking");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps the existing defaults when no mode has been chosen", () => {
|
|
||||||
assert.equal(resolveInputMode(undefined, false), "flash");
|
|
||||||
assert.equal(resolveInputMode(undefined, true), "pro");
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep plan/subagent modes available even when the selected model has no
|
|
||||||
* native thinking switch. The backend already falls back to non-thinking for
|
|
||||||
* that model, while `pro` and `ultra` still control planning and subagents.
|
|
||||||
* Only the model-specific `thinking` mode requires `supports_thinking`.
|
|
||||||
*/
|
|
||||||
export function resolveInputMode(
|
|
||||||
mode: InputMode | undefined,
|
|
||||||
supportsThinking: boolean,
|
|
||||||
): InputMode {
|
|
||||||
if (mode === "thinking" && !supportsThinking) {
|
|
||||||
return "flash";
|
|
||||||
}
|
|
||||||
if (mode) {
|
|
||||||
return mode;
|
|
||||||
}
|
|
||||||
return supportsThinking ? "pro" : "flash";
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,40 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react";
|
import { BookOpen, Database } from "lucide-react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
// import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
|
|
||||||
// import {
|
|
||||||
// useAssistantKnowledgeVisibility,
|
|
||||||
// useUpdateAssistantKnowledgeVisibility,
|
|
||||||
// } from "@/core/system-settings/hooks";
|
|
||||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
import {
|
import {
|
||||||
|
loadKnowledgeBaseProductVersion,
|
||||||
loadOrdinaryKnowledgeLanding,
|
loadOrdinaryKnowledgeLanding,
|
||||||
saveOrdinaryKnowledgeLanding,
|
saveOrdinaryKnowledgeLanding,
|
||||||
type OrdinaryKnowledgeLanding,
|
type OrdinaryKnowledgeLanding,
|
||||||
|
type KnowledgeBaseProductVersion,
|
||||||
|
updateKnowledgeBaseProductVersion,
|
||||||
} from "@/core/system-settings/api";
|
} from "@/core/system-settings/api";
|
||||||
|
|
||||||
|
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||||
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||||
|
|
||||||
export function useOrdinaryKnowledgeLanding(enabled = true) {
|
export function useOrdinaryKnowledgeLanding() {
|
||||||
return useQuery({
|
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||||
queryKey: LANDING_KEY,
|
|
||||||
queryFn: ({ signal }) => loadOrdinaryKnowledgeLanding(signal),
|
|
||||||
enabled,
|
|
||||||
staleTime: 30_000,
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||||
|
|
||||||
|
export function useKnowledgeBaseProductVersion() {
|
||||||
|
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
export function KnowledgeProductNav() {
|
export function KnowledgeProductNav() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
// const assistantVisibility = useAssistantKnowledgeVisibility();
|
const version = useKnowledgeBaseProductVersion();
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const landing = useOrdinaryKnowledgeLanding();
|
||||||
const landing = useOrdinaryKnowledgeLanding(isAdmin);
|
|
||||||
const saveLanding = useMutation({
|
const saveLanding = useMutation({
|
||||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||||
@@ -46,32 +42,24 @@ export function KnowledgeProductNav() {
|
|||||||
});
|
});
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||||
// const assistantAllowed = canAccessAssistantKnowledge(
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
// isAdmin,
|
const save = useMutation({
|
||||||
// assistantVisibility.isError ? undefined : assistantVisibility.data,
|
mutationFn: updateKnowledgeBaseProductVersion,
|
||||||
// );
|
onSuccess: (value) => {
|
||||||
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
|
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
|
||||||
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
|
toast.success(`默认知识库体验已切换为 ${value}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||||
const ordinary = !assistant && !retrievalDebug;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||||
<BookOpen />普通知识库
|
<BookOpen />普通知识库
|
||||||
</Button>
|
</Button>
|
||||||
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
|
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||||
{assistantAllowed && (
|
<Database />助手知识库
|
||||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
</Button>
|
||||||
<Database />助手知识库
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
|
|
||||||
<SearchCheck />检索调试
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
*/}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
普通知识库默认页面
|
普通知识库默认页面
|
||||||
@@ -79,22 +67,11 @@ export function KnowledgeProductNav() {
|
|||||||
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
|
默认产品版本
|
||||||
助手知识库日常展示
|
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||||
<Select
|
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||||
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
|
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
|
||||||
value={assistantVisibility.data === true ? "visible" : "hidden"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
saveAssistantVisibility.mutate(value === "visible", {
|
|
||||||
onSuccess: (visible) => toast.success(visible ? "已向普通用户开放助手知识库" : "已隐藏普通用户的助手知识库"),
|
|
||||||
onError: (error) => toast.error(error.message),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
|
|
||||||
</Select>
|
</Select>
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,418 +0,0 @@
|
|||||||
import { BookOpenText, ChevronRight, FileText } from "lucide-react";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
|
|
||||||
import {
|
|
||||||
classifyKnowledgeWikiSource,
|
|
||||||
filterKnowledgeWikiSources,
|
|
||||||
} from "@/core/llmwiki/knowledge-reference";
|
|
||||||
import { resolveDisplaySourceChunk } from "@/core/llmwiki/source-excerpt";
|
|
||||||
import {
|
|
||||||
formatRetrievalScore,
|
|
||||||
retrievalModeLabel,
|
|
||||||
retrievalRankScoreLabel,
|
|
||||||
} from "@/core/llmwiki/retrieval-debug";
|
|
||||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
|
||||||
|
|
||||||
import { MarkdownContent } from "./messages/markdown-content";
|
|
||||||
|
|
||||||
type KnowledgeWikiPanelProps = {
|
|
||||||
highlightedReferenceId?: string | null;
|
|
||||||
selection: KnowledgeWikiReferenceSelection;
|
|
||||||
sources: RuntimeDisplaySource[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function sourceKey(source: RuntimeDisplaySource) {
|
|
||||||
return `${source.skillName}:${source.index}:${source.id ?? source.url ?? source.title}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringValue(value: unknown): string {
|
|
||||||
return typeof value === "string" ? value.trim() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceContent(source: RuntimeDisplaySource) {
|
|
||||||
return (source.content || source.snippet || "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceMode(source: RuntimeDisplaySource) {
|
|
||||||
return stringValue(
|
|
||||||
source.raw?.retrieval_mode ??
|
|
||||||
(source as RuntimeDisplaySource & { retrieval_mode?: unknown })
|
|
||||||
.retrieval_mode,
|
|
||||||
).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchedHeading(source: RuntimeDisplaySource) {
|
|
||||||
const sections = Array.isArray(source.raw?.matched_sections)
|
|
||||||
? source.raw.matched_sections
|
|
||||||
: [];
|
|
||||||
const firstSectionWithHeading = sections.find(
|
|
||||||
(section): section is Record<string, unknown> =>
|
|
||||||
!!section &&
|
|
||||||
typeof section === "object" &&
|
|
||||||
!Array.isArray(section) &&
|
|
||||||
Boolean(stringValue((section as Record<string, unknown>).heading)),
|
|
||||||
);
|
|
||||||
const matchedSection =
|
|
||||||
source.raw?.matched_section &&
|
|
||||||
typeof source.raw.matched_section === "object" &&
|
|
||||||
!Array.isArray(source.raw.matched_section)
|
|
||||||
? (source.raw.matched_section as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
return stringValue(
|
|
||||||
source.raw?.matched_heading ??
|
|
||||||
source.raw?.heading ??
|
|
||||||
firstSectionWithHeading?.heading ??
|
|
||||||
matchedSection?.heading,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function knowledgeSpaceName(source: RuntimeDisplaySource) {
|
|
||||||
return (
|
|
||||||
stringValue(source.raw?.knowledge_base_name) ||
|
|
||||||
stringValue(source.raw?.base_name) ||
|
|
||||||
stringValue(source.raw?.source) ||
|
|
||||||
source.source ||
|
|
||||||
"知识空间"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function wikiPageTypeLabel(value: unknown) {
|
|
||||||
const normalized = stringValue(value).toLowerCase();
|
|
||||||
if (normalized === "directory") return "目录页";
|
|
||||||
if (normalized === "overview") return "概览页";
|
|
||||||
if (normalized === "article") return "文章页";
|
|
||||||
return normalized ? stringValue(value) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function plainTextPreview(value: string) {
|
|
||||||
return value
|
|
||||||
.replace(/^---[\s\S]*?---\s*/m, "")
|
|
||||||
.replace(/```[\s\S]*?```/g, " ")
|
|
||||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
||||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
||||||
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
|
||||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
|
||||||
.replace(/^#{1,6}\s+/gm, "")
|
|
||||||
.replace(/^\s*[-*+]\s+/gm, "")
|
|
||||||
.replace(/^\s*\d+[.)]\s+/gm, "")
|
|
||||||
.replace(/[>*_`~|]/g, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cardPreview(source: RuntimeDisplaySource) {
|
|
||||||
const summary = stringValue(source.raw?.summary);
|
|
||||||
return plainTextPreview(sourceContent(source) || summary).slice(0, 420);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatedAtLabel(source: RuntimeDisplaySource) {
|
|
||||||
const value =
|
|
||||||
source.time ||
|
|
||||||
stringValue(source.raw?.updated_at) ||
|
|
||||||
stringValue(source.raw?.remote_updated_at);
|
|
||||||
if (!value) return "";
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime())
|
|
||||||
? value
|
|
||||||
: date.toLocaleString("zh-CN", { hour12: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreLabel(source: RuntimeDisplaySource) {
|
|
||||||
const score = formatRetrievalScore(source.score);
|
|
||||||
if (!score) return "";
|
|
||||||
const label = retrievalRankScoreLabel(
|
|
||||||
source.raw?.rank_score_semantics,
|
|
||||||
Boolean(source.raw?.channel_score || source.raw?.vector_score),
|
|
||||||
);
|
|
||||||
return label ? `${label} ${score}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Knowledge-space references keep both evidence layers visible: formatted Wiki
|
|
||||||
* pages and their original document chunks. They deliberately use dedicated
|
|
||||||
* Wiki cards instead of the generic numbered reference-card presentation.
|
|
||||||
*/
|
|
||||||
export function KnowledgeWikiPanel({
|
|
||||||
highlightedReferenceId,
|
|
||||||
selection,
|
|
||||||
sources,
|
|
||||||
}: KnowledgeWikiPanelProps) {
|
|
||||||
const wikiSources = useMemo(
|
|
||||||
() => filterKnowledgeWikiSources(sources, selection),
|
|
||||||
[selection, sources],
|
|
||||||
);
|
|
||||||
const orderedSources = useMemo(() => {
|
|
||||||
const formattedWiki = wikiSources.filter(
|
|
||||||
(source) => classifyKnowledgeWikiSource(source) === "wiki",
|
|
||||||
);
|
|
||||||
const rawDocuments = wikiSources.filter(
|
|
||||||
(source) => classifyKnowledgeWikiSource(source) === "raw",
|
|
||||||
);
|
|
||||||
return [...formattedWiki, ...rawDocuments];
|
|
||||||
}, [wikiSources]);
|
|
||||||
const [openSourceKey, setOpenSourceKey] = useState<string | null>(null);
|
|
||||||
const openSource =
|
|
||||||
wikiSources.find((source) => sourceKey(source) === openSourceKey) ?? null;
|
|
||||||
const previewTarget = openSource
|
|
||||||
? resolveWeKnoraSourceTarget({
|
|
||||||
...(openSource.raw ?? {}),
|
|
||||||
url: openSource.url,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const openContent = openSource ? sourceContent(openSource) : "";
|
|
||||||
const openChunk = openSource ? resolveDisplaySourceChunk(openSource) : "";
|
|
||||||
const selectedSpaceCount =
|
|
||||||
selection.knowledgeBaseIds.length +
|
|
||||||
selection.assistantKnowledgeBaseIds.length;
|
|
||||||
const formattedWikiCount = wikiSources.filter(
|
|
||||||
(source) => classifyKnowledgeWikiSource(source) === "wiki",
|
|
||||||
).length;
|
|
||||||
const rawDocumentCount = wikiSources.length - formattedWikiCount;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex size-full min-h-0 flex-col overflow-hidden">
|
|
||||||
<header className="shrink-0 border-b border-emerald-200/60 px-1 pb-3 dark:border-emerald-900/50">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
|
|
||||||
<BookOpenText className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-baseline gap-2">
|
|
||||||
<h2 className="text-foreground text-base font-semibold tracking-tight">
|
|
||||||
Wiki 资料
|
|
||||||
</h2>
|
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
|
||||||
{wikiSources.length} 条
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">
|
|
||||||
来自已选 {selectedSpaceCount} 个知识空间,点击卡片查看完整内容。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{wikiSources.length > 0 && (
|
|
||||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
|
||||||
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
|
|
||||||
Wiki 页面 {formattedWikiCount}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="bg-background/80">
|
|
||||||
原始资料 {rawDocumentCount}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-3 pb-6">
|
|
||||||
{orderedSources.length > 0 ? (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{orderedSources.map((source) => (
|
|
||||||
<KnowledgeWikiCard
|
|
||||||
key={sourceKey(source)}
|
|
||||||
highlighted={highlightedReferenceId === String(source.index)}
|
|
||||||
source={source}
|
|
||||||
onOpen={() => setOpenSourceKey(sourceKey(source))}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/20 px-6 text-center text-sm">
|
|
||||||
<BookOpenText className="mb-3 size-8 opacity-50" />
|
|
||||||
<span>本轮未检索到所选知识空间内的 Wiki 资料。</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{openSource && previewTarget && (
|
|
||||||
<WeKnoraSourceDrawer
|
|
||||||
open
|
|
||||||
target={previewTarget}
|
|
||||||
title={openSource.title}
|
|
||||||
quote={openChunk}
|
|
||||||
collapseRetrievedExcerptByDefault={
|
|
||||||
classifyKnowledgeWikiSource(openSource) === "wiki"
|
|
||||||
}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setOpenSourceKey(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Sheet
|
|
||||||
open={Boolean(openSource && !previewTarget)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setOpenSourceKey(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
|
||||||
>
|
|
||||||
<SheetHeader className="shrink-0 border-b border-emerald-200/60 bg-emerald-50/60 px-5 py-4 pr-12 text-left dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
|
||||||
<div className="flex items-start gap-3 pr-8">
|
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-600 text-white">
|
|
||||||
<BookOpenText className="size-4.5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<SheetTitle className="text-base leading-relaxed">
|
|
||||||
{openSource?.title || "Wiki 内容"}
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription className="mt-1">
|
|
||||||
{openSource ? knowledgeSpaceName(openSource) : "知识空间"}
|
|
||||||
</SheetDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
|
||||||
{openContent ? (
|
|
||||||
<article className="mx-auto max-w-3xl rounded-xl bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
|
||||||
<MarkdownContent
|
|
||||||
className="max-w-none"
|
|
||||||
content={openContent}
|
|
||||||
isLoading={false}
|
|
||||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
|
||||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm">
|
|
||||||
当前 Wiki 暂无可展示内容。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KnowledgeWikiCard({
|
|
||||||
highlighted,
|
|
||||||
source,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
highlighted: boolean;
|
|
||||||
source: RuntimeDisplaySource;
|
|
||||||
onOpen: () => void;
|
|
||||||
}) {
|
|
||||||
const formattedWiki = classifyKnowledgeWikiSource(source) === "wiki";
|
|
||||||
const heading = matchedHeading(source);
|
|
||||||
const pageType = wikiPageTypeLabel(source.raw?.page_type);
|
|
||||||
const mode = sourceMode(source);
|
|
||||||
const modeLabel = mode ? retrievalModeLabel(mode) : "";
|
|
||||||
const preview = cardPreview(source);
|
|
||||||
const score = scoreLabel(source);
|
|
||||||
const updatedAt = updatedAtLabel(source);
|
|
||||||
const target = resolveWeKnoraSourceTarget({
|
|
||||||
...(source.raw ?? {}),
|
|
||||||
url: source.url,
|
|
||||||
});
|
|
||||||
const canOpen = Boolean(target || sourceContent(source));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
id={`reference-${source.index}`}
|
|
||||||
type="button"
|
|
||||||
disabled={!canOpen}
|
|
||||||
className={cn(
|
|
||||||
"group w-full scroll-mt-12 rounded-xl border bg-background/90 p-4 text-left shadow-sm transition",
|
|
||||||
"focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:opacity-70",
|
|
||||||
formattedWiki
|
|
||||||
? "border-emerald-200/80 hover:-translate-y-0.5 hover:border-emerald-400 hover:shadow-md focus-visible:ring-emerald-500/40 dark:border-emerald-900/70"
|
|
||||||
: "border-sky-200/80 hover:-translate-y-0.5 hover:border-sky-400 hover:shadow-md focus-visible:ring-sky-500/40 dark:border-sky-900/70",
|
|
||||||
highlighted &&
|
|
||||||
(formattedWiki
|
|
||||||
? "border-emerald-500 bg-emerald-50/80 ring-2 ring-emerald-500/20 dark:bg-emerald-950/25"
|
|
||||||
: "border-sky-500 bg-sky-50/80 ring-2 ring-sky-500/20 dark:bg-sky-950/25"),
|
|
||||||
)}
|
|
||||||
onClick={onOpen}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg",
|
|
||||||
formattedWiki
|
|
||||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
|
||||||
: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formattedWiki ? (
|
|
||||||
<BookOpenText className="size-4.5" />
|
|
||||||
) : (
|
|
||||||
<FileText className="size-4.5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
|
||||||
formattedWiki
|
|
||||||
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
|
||||||
: "bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formattedWiki ? "Wiki 页面" : "原始资料"}
|
|
||||||
</Badge>
|
|
||||||
{formattedWiki && pageType && (
|
|
||||||
<Badge variant="outline">{pageType}</Badge>
|
|
||||||
)}
|
|
||||||
{modeLabel && <Badge variant="outline">{modeLabel}</Badge>}
|
|
||||||
<span className="text-muted-foreground min-w-0 truncate text-xs">
|
|
||||||
{knowledgeSpaceName(source)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-foreground mt-2 line-clamp-2 text-[15px] leading-6 font-semibold">
|
|
||||||
{source.title ||
|
|
||||||
(formattedWiki ? "未命名 Wiki 页面" : "未命名原始资料")}
|
|
||||||
</h3>
|
|
||||||
{heading && (
|
|
||||||
<div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
|
||||||
命中章节:{heading}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{preview && (
|
|
||||||
<p className="text-muted-foreground mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6">
|
|
||||||
{preview}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
||||||
{score && <span>{score}</span>}
|
|
||||||
{updatedAt && <span>更新于 {updatedAt}</span>}
|
|
||||||
</div>
|
|
||||||
{canOpen && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center font-medium transition group-hover:translate-x-0.5",
|
|
||||||
formattedWiki
|
|
||||||
? "text-emerald-700 dark:text-emerald-300"
|
|
||||||
: "text-sky-700 dark:text-sky-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{target
|
|
||||||
? formattedWiki
|
|
||||||
? "查看完整 Wiki"
|
|
||||||
: "查看原始资料"
|
|
||||||
: "查看命中内容"}
|
|
||||||
<ChevronRight className="ml-1 size-3.5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,9 +21,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useAssistantKnowledgeAccess } from "@/core/llmwiki/assistant-knowledge-access";
|
|
||||||
import { ASSISTANT_KNOWLEDGE_UI_VISIBLE } from "@/core/llmwiki/assistant-knowledge-ui";
|
|
||||||
import { userFacingKnowledgeSuggestedQuestions } from "@/core/llmwiki/knowledge-suggested-questions";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
type AssistantKnowledgeBase,
|
type AssistantKnowledgeBase,
|
||||||
@@ -38,28 +35,6 @@ import {
|
|||||||
|
|
||||||
type SelectorSource = "all" | "agent";
|
type SelectorSource = "all" | "agent";
|
||||||
type SelectorVariant = "popover" | "panel";
|
type SelectorVariant = "popover" | "panel";
|
||||||
const EMPTY_IDS: string[] = [];
|
|
||||||
const IGNORE_ASSISTANT_CHANGE = () => undefined;
|
|
||||||
|
|
||||||
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
|
|
||||||
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
|
|
||||||
if (cleanNames.length === 0) return [];
|
|
||||||
if (cleanNames.length === 1) {
|
|
||||||
const name = cleanNames[0]!;
|
|
||||||
return [
|
|
||||||
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
|
|
||||||
`「${name}」包含哪些核心主题?请按重要性说明。`,
|
|
||||||
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
|
|
||||||
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
|
|
||||||
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
|
|
||||||
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
|
|
||||||
"所选知识空间中有哪些相互冲突或证据不足的结论?",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
||||||
return [
|
return [
|
||||||
@@ -348,14 +323,9 @@ function SelectionContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
||||||
<div>
|
<span className="text-[11px] text-muted-foreground">
|
||||||
<div className="text-[11px] text-muted-foreground">
|
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
</span>
|
||||||
</div>
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -393,10 +363,9 @@ function SelectionContent({
|
|||||||
|
|
||||||
export function LlmWikiKnowledgeSelector({
|
export function LlmWikiKnowledgeSelector({
|
||||||
value,
|
value,
|
||||||
assistantValue = EMPTY_IDS,
|
assistantValue = [],
|
||||||
onChange,
|
onChange,
|
||||||
onAssistantChange,
|
onAssistantChange = () => undefined,
|
||||||
onSuggestedQuestionsChange,
|
|
||||||
source = "all",
|
source = "all",
|
||||||
agentId,
|
agentId,
|
||||||
autoSelectAll = false,
|
autoSelectAll = false,
|
||||||
@@ -404,15 +373,12 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
className,
|
className,
|
||||||
label = "知识空间",
|
label = "知识空间",
|
||||||
panelTitle = "绑定知识空间",
|
|
||||||
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
|
|
||||||
variant = "popover",
|
variant = "popover",
|
||||||
}: {
|
}: {
|
||||||
value: string[];
|
value: string[];
|
||||||
assistantValue?: string[];
|
assistantValue?: string[];
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
onAssistantChange?: (value: string[]) => void;
|
onAssistantChange?: (value: string[]) => void;
|
||||||
onSuggestedQuestionsChange?: (questions: string[]) => void;
|
|
||||||
source?: SelectorSource;
|
source?: SelectorSource;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
autoSelectAll?: boolean;
|
autoSelectAll?: boolean;
|
||||||
@@ -421,59 +387,31 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
panelTitle?: string;
|
|
||||||
panelDescription?: string;
|
|
||||||
variant?: SelectorVariant;
|
variant?: SelectorVariant;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const assistantAccess = useAssistantKnowledgeAccess({
|
|
||||||
enabled:
|
|
||||||
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
|
|
||||||
});
|
|
||||||
const assistantAllowed =
|
|
||||||
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
|
|
||||||
onAssistantChange !== undefined &&
|
|
||||||
assistantAccess.canAccess;
|
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||||
const shouldLoadOptions =
|
|
||||||
variant === "panel" ||
|
|
||||||
open ||
|
|
||||||
autoSelectAll ||
|
|
||||||
value.length > 0 ||
|
|
||||||
assistantValue.length > 0;
|
|
||||||
const all = useLlmWikiKnowledgeBases(
|
const all = useLlmWikiKnowledgeBases(
|
||||||
"all",
|
"all",
|
||||||
weknoraEnabled && shouldLoadOptions,
|
weknoraEnabled,
|
||||||
);
|
);
|
||||||
const agent = useAgentLlmWikiKnowledgeBases(
|
const agent = useAgentLlmWikiKnowledgeBases(
|
||||||
agentId,
|
agentId,
|
||||||
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
|
weknoraEnabled && source === "agent" && !!agentId,
|
||||||
);
|
);
|
||||||
const assistantBases = useAssistantKnowledgeBases(
|
const assistantBases = useAssistantKnowledgeBases();
|
||||||
assistantAllowed &&
|
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
||||||
(shouldLoadOptions ||
|
const assistantRows = assistantBases.data ?? [];
|
||||||
runtime.isError ||
|
|
||||||
(!runtime.isLoading && !weknoraEnabled)),
|
|
||||||
);
|
|
||||||
// Stable empty results are essential: suggested questions are reported to
|
|
||||||
// the parent, so a fresh [] on every render creates an effect/update loop
|
|
||||||
// before lazy knowledge queries have loaded (or when they are disabled).
|
|
||||||
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
|
|
||||||
const assistantRows = useMemo(
|
|
||||||
() => assistantAllowed ? (assistantBases.data ?? []) : [],
|
|
||||||
[assistantAllowed, assistantBases.data],
|
|
||||||
);
|
|
||||||
const effectiveAssistantValue = assistantAllowed ? assistantValue : EMPTY_IDS;
|
|
||||||
const defaultIds = useMemo(() => {
|
const defaultIds = useMemo(() => {
|
||||||
if (source !== "agent") {
|
if (source !== "agent") {
|
||||||
return rows.map((item) => item.id);
|
return rows.map((item) => item.id);
|
||||||
}
|
}
|
||||||
return (agent.data ?? []).map((item) => item.id);
|
return (agent.data ?? []).map((item) => item.id);
|
||||||
}, [agent.data, rows, source]);
|
}, [agent.data, rows, source]);
|
||||||
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || (assistantAllowed && assistantBases.isLoading) || (source === "agent" && !!agentId && agent.isLoading);
|
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
||||||
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? (assistantAllowed ? assistantBases.error : null);
|
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
|
||||||
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
||||||
const assistantAvailableIds = useMemo(
|
const assistantAvailableIds = useMemo(
|
||||||
() => new Set(assistantRows.map((item) => item.id)),
|
() => new Set(assistantRows.map((item) => item.id)),
|
||||||
@@ -481,41 +419,6 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
);
|
);
|
||||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||||
const initialized = useRef<string | null>(null);
|
const initialized = useRef<string | null>(null);
|
||||||
const selectedNames = useMemo(
|
|
||||||
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
|
||||||
[rows, value],
|
|
||||||
);
|
|
||||||
const selectedAssistantNames = useMemo(
|
|
||||||
() => effectiveAssistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
|
||||||
[assistantRows, effectiveAssistantValue],
|
|
||||||
);
|
|
||||||
const allSelectedNames = useMemo(
|
|
||||||
() => [...selectedNames, ...selectedAssistantNames],
|
|
||||||
[selectedAssistantNames, selectedNames],
|
|
||||||
);
|
|
||||||
const selectedConfiguredQuestions = useMemo(
|
|
||||||
() =>
|
|
||||||
userFacingKnowledgeSuggestedQuestions(
|
|
||||||
value.flatMap(
|
|
||||||
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[rows, value],
|
|
||||||
);
|
|
||||||
const suggestedQuestions = useMemo(
|
|
||||||
() => {
|
|
||||||
if (value.length === 1 && effectiveAssistantValue.length === 0 && selectedConfiguredQuestions.length > 0) {
|
|
||||||
return selectedConfiguredQuestions.slice(0, 12);
|
|
||||||
}
|
|
||||||
const general = buildKnowledgeSpaceQuestions(allSelectedNames);
|
|
||||||
return Array.from(new Set([...selectedConfiguredQuestions, ...general])).slice(0, 12);
|
|
||||||
},
|
|
||||||
[allSelectedNames, effectiveAssistantValue.length, selectedConfiguredQuestions, value.length],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onSuggestedQuestionsChange?.(suggestedQuestions);
|
|
||||||
}, [onSuggestedQuestionsChange, suggestedQuestions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runtime.isLoading && !weknoraEnabled) {
|
if (!runtime.isLoading && !weknoraEnabled) {
|
||||||
@@ -534,36 +437,27 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
|
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!assistantAllowed || !assistantBases.data || !onAssistantChange) return;
|
if (!assistantBases.data) return;
|
||||||
const valid = effectiveAssistantValue.filter((id) => assistantAvailableIds.has(id));
|
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
|
||||||
if (valid.length !== effectiveAssistantValue.length || valid.some((id, index) => id !== effectiveAssistantValue[index])) {
|
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
|
||||||
onAssistantChange(valid);
|
onAssistantChange(valid);
|
||||||
}
|
}
|
||||||
}, [assistantAllowed, assistantAvailableIds, assistantBases.data, effectiveAssistantValue, onAssistantChange]);
|
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) setSearch("");
|
if (!open) setSearch("");
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
if (
|
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
|
||||||
!runtime.isLoading &&
|
|
||||||
(runtime.isError || !weknoraEnabled) &&
|
|
||||||
(!assistantAllowed || !assistantBases.isLoading) &&
|
|
||||||
assistantRows.length === 0
|
|
||||||
) return null;
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<SelectionContent
|
<SelectionContent
|
||||||
rows={rows}
|
rows={rows}
|
||||||
assistantRows={assistantRows}
|
assistantRows={assistantRows}
|
||||||
value={value}
|
value={value}
|
||||||
assistantValue={effectiveAssistantValue}
|
assistantValue={assistantValue}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onAssistantChange={
|
onAssistantChange={onAssistantChange}
|
||||||
assistantAllowed
|
|
||||||
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
|
|
||||||
: IGNORE_ASSISTANT_CHANGE
|
|
||||||
}
|
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -580,20 +474,27 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<Database className="size-4 text-primary" />{panelTitle}
|
<Database className="size-4 text-primary" />绑定知识空间
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{panelDescription}
|
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="shrink-0">{value.length + effectiveAssistantValue.length} 个</Badge>
|
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
||||||
</div>
|
</div>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedCount = value.length + effectiveAssistantValue.length;
|
const selectedNames = value
|
||||||
|
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||||
|
.filter((name): name is string => !!name);
|
||||||
|
const selectedAssistantNames = assistantValue
|
||||||
|
.map((id) => assistantRows.find((item) => item.id === id)?.name)
|
||||||
|
.filter((name): name is string => !!name);
|
||||||
|
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
|
||||||
|
const selectedCount = value.length + assistantValue.length;
|
||||||
const selectedLabel = allSelectedNames.length > 0
|
const selectedLabel = allSelectedNames.length > 0
|
||||||
? allSelectedNames.join("、")
|
? allSelectedNames.join("、")
|
||||||
: selectedCount > 0
|
: selectedCount > 0
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CheckIcon,
|
|
||||||
Loader2Icon,
|
|
||||||
ThumbsDownIcon,
|
|
||||||
ThumbsUpIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
useUpsertAnswerFeedback,
|
|
||||||
type AnswerFeedbackContext,
|
|
||||||
type AnswerFeedbackReason,
|
|
||||||
} from "@/strategy-components/api/answer-feedback";
|
|
||||||
|
|
||||||
import { Tooltip } from "../tooltip";
|
|
||||||
|
|
||||||
const REASON_OPTIONS: Array<{
|
|
||||||
value: AnswerFeedbackReason;
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{ value: "incorrect_or_incomplete", label: "不正确或不完整" },
|
|
||||||
{ value: "unmet_expectations", label: "与期望不符" },
|
|
||||||
{ value: "slow_or_problematic", label: "速度慢或存在问题" },
|
|
||||||
{ value: "style_or_tone", label: "风格或语气" },
|
|
||||||
{ value: "safety_or_legal", label: "安全或法律疑虑" },
|
|
||||||
{ value: "other", label: "其他" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AnswerFeedbackControl({
|
|
||||||
threadId,
|
|
||||||
messageId,
|
|
||||||
context,
|
|
||||||
}: {
|
|
||||||
threadId: string;
|
|
||||||
messageId: string;
|
|
||||||
context: AnswerFeedbackContext;
|
|
||||||
}) {
|
|
||||||
const saveFeedback = useUpsertAnswerFeedback();
|
|
||||||
const feedback = context?.feedback ?? null;
|
|
||||||
const [poorDialogOpen, setPoorDialogOpen] = useState(false);
|
|
||||||
const [reasons, setReasons] = useState<AnswerFeedbackReason[]>([]);
|
|
||||||
const [detail, setDetail] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!poorDialogOpen) return;
|
|
||||||
setReasons(feedback?.rating === -1 ? feedback.reasons : []);
|
|
||||||
setDetail(
|
|
||||||
feedback?.rating === -1
|
|
||||||
? (feedback.detail ?? feedback.comment ?? "")
|
|
||||||
: "",
|
|
||||||
);
|
|
||||||
}, [feedback, poorDialogOpen]);
|
|
||||||
|
|
||||||
const submitPositive = async () => {
|
|
||||||
try {
|
|
||||||
await saveFeedback.mutateAsync({
|
|
||||||
threadId,
|
|
||||||
messageId,
|
|
||||||
input: { rating: 1, reasons: [], detail: null },
|
|
||||||
});
|
|
||||||
toast.success("感谢你的评价");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "评价提交失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitNegative = async () => {
|
|
||||||
if (reasons.length === 0) return;
|
|
||||||
try {
|
|
||||||
await saveFeedback.mutateAsync({
|
|
||||||
threadId,
|
|
||||||
messageId,
|
|
||||||
input: {
|
|
||||||
rating: -1,
|
|
||||||
reasons,
|
|
||||||
detail: detail.trim() || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setPoorDialogOpen(false);
|
|
||||||
toast.success("反馈已提交,感谢你的帮助");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "反馈提交失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleReason = (reason: AnswerFeedbackReason) => {
|
|
||||||
setReasons((current) =>
|
|
||||||
current.includes(reason)
|
|
||||||
? current.filter((item) => item !== reason)
|
|
||||||
: [...current, reason],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerIcon =
|
|
||||||
feedback?.rating === 1 ? (
|
|
||||||
<ThumbsUpIcon
|
|
||||||
size={12}
|
|
||||||
className="fill-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
|
||||||
/>
|
|
||||||
) : feedback?.rating === -1 ? (
|
|
||||||
<ThumbsDownIcon
|
|
||||||
size={12}
|
|
||||||
className="fill-amber-500/15 text-amber-600 dark:text-amber-400"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-px" aria-hidden="true">
|
|
||||||
<ThumbsUpIcon className="size-3" />
|
|
||||||
<ThumbsDownIcon className="size-3" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<Tooltip content="评价回复">
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"size-6",
|
|
||||||
feedback?.rating === 1 &&
|
|
||||||
"bg-emerald-500/10 hover:bg-emerald-500/15",
|
|
||||||
feedback?.rating === -1 &&
|
|
||||||
"bg-amber-500/10 hover:bg-amber-500/15",
|
|
||||||
)}
|
|
||||||
disabled={saveFeedback.isPending}
|
|
||||||
aria-label={
|
|
||||||
feedback?.rating === 1
|
|
||||||
? "已评价为回答优秀"
|
|
||||||
: feedback?.rating === -1
|
|
||||||
? "已评价为回答不佳"
|
|
||||||
: "评价回复"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{saveFeedback.isPending ? (
|
|
||||||
<Loader2Icon className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
triggerIcon
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent align="start" sideOffset={6} className="min-w-44 p-1.5">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="gap-2.5 py-2"
|
|
||||||
onSelect={() => void submitPositive()}
|
|
||||||
>
|
|
||||||
<ThumbsUpIcon className="size-4" />
|
|
||||||
<span>回答优秀</span>
|
|
||||||
{feedback?.rating === 1 && (
|
|
||||||
<CheckIcon className="text-primary ml-auto size-4" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="gap-2.5 py-2"
|
|
||||||
onSelect={() => setPoorDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<ThumbsDownIcon className="size-4" />
|
|
||||||
<span>回答不佳</span>
|
|
||||||
{feedback?.rating === -1 && (
|
|
||||||
<CheckIcon className="text-primary ml-auto size-4" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Dialog open={poorDialogOpen} onOpenChange={setPoorDialogOpen}>
|
|
||||||
<DialogContent className="gap-5 sm:max-w-[min(48rem,calc(100vw-2rem))]">
|
|
||||||
<DialogHeader className="pr-8">
|
|
||||||
<DialogTitle className="text-xl font-medium">分享反馈</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
可多选这条回答需要改进的方面。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2.5">
|
|
||||||
{REASON_OPTIONS.map((option) => {
|
|
||||||
const selected = reasons.includes(option.value);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={selected}
|
|
||||||
onClick={() => toggleReason(option.value)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full border px-4 py-2 text-sm transition-colors",
|
|
||||||
selected
|
|
||||||
? "border-primary bg-primary/10 text-primary"
|
|
||||||
: "border-border bg-background hover:bg-accent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
value={detail}
|
|
||||||
onChange={(event) => setDetail(event.currentTarget.value)}
|
|
||||||
maxLength={2000}
|
|
||||||
placeholder="分享详细信息(可选)"
|
|
||||||
className="min-h-28 resize-y"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-4 py-3 text-sm">
|
|
||||||
你的反馈将用于改进智能体的回答质量和稳定性。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void submitNegative()}
|
|
||||||
disabled={reasons.length === 0 || saveFeedback.isPending}
|
|
||||||
>
|
|
||||||
{saveFeedback.isPending && (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
提交
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import type { Message } from "@langchain/langgraph-sdk";
|
|
||||||
|
|
||||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
|
||||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
|
||||||
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility.ts";
|
|
||||||
|
|
||||||
function message(overrides: Partial<Message> = {}): Message {
|
|
||||||
return {
|
|
||||||
id: "answer-1",
|
|
||||||
type: "ai",
|
|
||||||
content: "A persisted answer",
|
|
||||||
additional_kwargs: {},
|
|
||||||
...overrides,
|
|
||||||
} as Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("accepts a completed persisted assistant answer", () => {
|
|
||||||
assert.equal(isEligibleAnswerFeedbackMessage(message()), true);
|
|
||||||
});
|
|
||||||
test("rejects loading, user, optimistic and empty messages", () => {
|
|
||||||
assert.equal(isEligibleAnswerFeedbackMessage(message(), true), false);
|
|
||||||
assert.equal(
|
|
||||||
isEligibleAnswerFeedbackMessage(message({ type: "human" })),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
isEligibleAnswerFeedbackMessage(message({ id: "opt-ai-1" })),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
isEligibleAnswerFeedbackMessage(message({ content: " " })),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects tool-call shells and virtual business cards", () => {
|
|
||||||
assert.equal(
|
|
||||||
isEligibleAnswerFeedbackMessage(
|
|
||||||
message({
|
|
||||||
tool_calls: [{ id: "tool-1", name: "search", args: {} }],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
isEligibleAnswerFeedbackMessage(
|
|
||||||
message({
|
|
||||||
additional_kwargs: { setup_research_approval: { stage: "plan" } },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects ids outside the message-scoped API contract", () => {
|
|
||||||
assert.equal(
|
|
||||||
isEligibleAnswerFeedbackMessage(message({ id: "x".repeat(65) })),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
|
||||||
|
|
||||||
function hasTextContent(content: Message["content"]): boolean {
|
|
||||||
if (typeof content === "string") return content.trim().length > 0;
|
|
||||||
if (!Array.isArray(content)) return false;
|
|
||||||
return content.some((part) => {
|
|
||||||
if (!part || typeof part !== "object") return false;
|
|
||||||
const text = "text" in part ? part.text : undefined;
|
|
||||||
return typeof text === "string" && text.trim().length > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only completed, persisted, user-visible assistant answers can be rated.
|
|
||||||
* Tool-call shells, optimistic/transient rows and special virtual cards are
|
|
||||||
* deliberately excluded because they are not standalone answers.
|
|
||||||
*/
|
|
||||||
export function isEligibleAnswerFeedbackMessage(
|
|
||||||
message: Message,
|
|
||||||
isLoading = false,
|
|
||||||
): boolean {
|
|
||||||
if (
|
|
||||||
isLoading ||
|
|
||||||
message.type !== "ai" ||
|
|
||||||
!message.id ||
|
|
||||||
message.id.length > 64
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (message.id.startsWith("opt-")) return false;
|
|
||||||
if (!hasTextContent(message.content)) return false;
|
|
||||||
|
|
||||||
const directToolCalls = "tool_calls" in message ? message.tool_calls : null;
|
|
||||||
const additional = message.additional_kwargs ?? {};
|
|
||||||
const additionalToolCalls = additional.tool_calls;
|
|
||||||
if (
|
|
||||||
(Array.isArray(directToolCalls) && directToolCalls.length > 0) ||
|
|
||||||
(Array.isArray(additionalToolCalls) && additionalToolCalls.length > 0)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const virtualCardKeys = [
|
|
||||||
"setup_agent_approval",
|
|
||||||
"setup_writing_approval",
|
|
||||||
"setup_report_structure_approval",
|
|
||||||
"setup_research_approval",
|
|
||||||
"task_report_import_approval",
|
|
||||||
"report_collaboration_card",
|
|
||||||
];
|
|
||||||
return !virtualCardKeys.some((key) => additional[key] !== undefined);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
|
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
|
|
||||||
@@ -14,11 +13,6 @@ export interface ThreadContextType {
|
|||||||
* from the admin-controlled manual reference-mode button.
|
* from the admin-controlled manual reference-mode button.
|
||||||
*/
|
*/
|
||||||
knowledgeReferencesEnabled?: boolean;
|
knowledgeReferencesEnabled?: boolean;
|
||||||
/**
|
|
||||||
* Server-scoped knowledge selection for the dedicated Wiki reference view.
|
|
||||||
* Sources outside these ids are never displayed as knowledge evidence.
|
|
||||||
*/
|
|
||||||
knowledgeReferences?: KnowledgeWikiReferenceSelection;
|
|
||||||
/**
|
/**
|
||||||
* When set, the conversation is rendered from a public read-only share.
|
* When set, the conversation is rendered from a public read-only share.
|
||||||
* Artifact/image URLs then resolve to the unauthenticated
|
* Artifact/image URLs then resolve to the unauthenticated
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronsDownIcon,
|
ChevronsDownIcon,
|
||||||
CopyIcon,
|
|
||||||
FilePenLineIcon,
|
FilePenLineIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
@@ -15,7 +14,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -24,7 +22,6 @@ import {
|
|||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { updateArtifactContent } from "@/core/artifacts/api";
|
import { updateArtifactContent } from "@/core/artifacts/api";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import { copyTextToClipboard } from "@/core/clipboard";
|
|
||||||
import {
|
import {
|
||||||
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
||||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||||
@@ -45,7 +42,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
|||||||
const REWRITE_STEPS = [
|
const REWRITE_STEPS = [
|
||||||
{ key: "snapshot", label: "固定原文版本" },
|
{ key: "snapshot", label: "固定原文版本" },
|
||||||
{ key: "requirements", label: "理解改写要求" },
|
{ key: "requirements", label: "理解改写要求" },
|
||||||
{ key: "writing", label: "生成改写版 Markdown" },
|
{ key: "writing", label: "流式重写 Markdown" },
|
||||||
{ key: "illustrations", label: "生成文档配图" },
|
{ key: "illustrations", label: "生成文档配图" },
|
||||||
{ key: "validation", label: "校验文档" },
|
{ key: "validation", label: "校验文档" },
|
||||||
{ key: "comparison", label: "对比原文并生成说明" },
|
{ key: "comparison", label: "对比原文并生成说明" },
|
||||||
@@ -224,9 +221,6 @@ export function DocumentRewriteTimeline({
|
|||||||
const presentation = useDocumentRewritePresentation();
|
const presentation = useDocumentRewritePresentation();
|
||||||
const [undoing, setUndoing] = useState(false);
|
const [undoing, setUndoing] = useState(false);
|
||||||
const [diffOpen, setDiffOpen] = useState(false);
|
const [diffOpen, setDiffOpen] = useState(false);
|
||||||
const diagnosticText = item.diagnostic
|
|
||||||
? JSON.stringify(item.diagnostic, null, 2)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const reapplying = Boolean(item.revertedAt);
|
const reapplying = Boolean(item.revertedAt);
|
||||||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||||||
@@ -376,7 +370,7 @@ export function DocumentRewriteTimeline({
|
|||||||
: "bg-primary/10 text-primary",
|
: "bg-primary/10 text-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"}
|
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
@@ -446,13 +440,13 @@ export function DocumentRewriteTimeline({
|
|||||||
{step.key === "writing" && (
|
{step.key === "writing" && (
|
||||||
item.writtenCharCount > 0 ? (
|
item.writtenCharCount > 0 ? (
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。
|
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
|
||||||
</p>
|
</p>
|
||||||
) : active ? (
|
) : active ? (
|
||||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||||
{isNewReportGeneration
|
{isNewReportGeneration
|
||||||
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
||||||
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"}
|
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
|
||||||
</p>
|
</p>
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
||||||
@@ -589,42 +583,9 @@ export function DocumentRewriteTimeline({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{item.error && (
|
{item.error && (
|
||||||
<div className="border-t px-5 py-3 text-sm text-destructive">
|
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
|
||||||
<div className="flex items-start gap-2">
|
<XCircleIcon className="size-4 shrink-0" />
|
||||||
<XCircleIcon className="mt-0.5 size-4 shrink-0" />
|
{item.error}
|
||||||
<span>{item.error}</span>
|
|
||||||
</div>
|
|
||||||
{item.diagnostic && (
|
|
||||||
<Collapsible className="mt-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button className="h-8 gap-1.5 px-2.5 text-xs" size="sm" type="button" variant="outline">
|
|
||||||
<ChevronDownIcon className="size-3.5" />
|
|
||||||
查看后台诊断
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<Button
|
|
||||||
className="h-8 gap-1.5 px-2.5 text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
void copyTextToClipboard(diagnosticText)
|
|
||||||
.then(() => toast.success("诊断信息已复制"))
|
|
||||||
.catch(() => toast.error("复制诊断信息失败"));
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<CopyIcon className="size-3.5" />
|
|
||||||
复制诊断
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<pre className="bg-muted/55 text-foreground mt-2 max-h-72 overflow-auto rounded-lg border p-3 font-mono text-xs leading-5 whitespace-pre-wrap break-words">
|
|
||||||
{diagnosticText}
|
|
||||||
</pre>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DocumentRewriteDiffDialog
|
<DocumentRewriteDiffDialog
|
||||||
@@ -637,36 +598,3 @@ export function DocumentRewriteTimeline({
|
|||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A standalone chat row for a sandbox document-rewrite task. */
|
|
||||||
export function DocumentRewriteMessage({
|
|
||||||
item,
|
|
||||||
threadId,
|
|
||||||
onOpenArtifact,
|
|
||||||
onDownloadArtifact,
|
|
||||||
}: {
|
|
||||||
item: DocumentRewritePresentation;
|
|
||||||
threadId: string;
|
|
||||||
onOpenArtifact?: (filepath: string) => void;
|
|
||||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
|
||||||
}) {
|
|
||||||
const artifactPath = item.source.kind === "artifact" ? item.outputPath : undefined;
|
|
||||||
const completed = item.stage === "completed";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label={`AI 全文改写:${item.fileName}`} className="w-full space-y-3">
|
|
||||||
<DocumentRewriteTimeline item={item} />
|
|
||||||
{completed && artifactPath && (
|
|
||||||
<ArtifactFileList
|
|
||||||
fileDescriptions={{
|
|
||||||
[artifactPath]: "AI 改写版 Markdown 文件",
|
|
||||||
}}
|
|
||||||
files={[artifactPath]}
|
|
||||||
onDownloadArtifact={onDownloadArtifact}
|
|
||||||
onOpenArtifact={onOpenArtifact}
|
|
||||||
threadId={threadId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,13 +12,7 @@ import {
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
type MessageResponseProps,
|
type MessageResponseProps,
|
||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import {
|
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||||
looksLikeHtmlDocument,
|
|
||||||
MarkdownFallback,
|
|
||||||
MarkdownRenderBoundary,
|
|
||||||
streamdownPlugins,
|
|
||||||
useStreamdownMermaidPngDownload,
|
|
||||||
} from "@/core/streamdown";
|
|
||||||
import {
|
import {
|
||||||
createMarkdownAnchorResolver,
|
createMarkdownAnchorResolver,
|
||||||
isInPageAnchor,
|
isInPageAnchor,
|
||||||
@@ -26,9 +20,6 @@ import {
|
|||||||
} from "@/core/utils/markdown";
|
} from "@/core/utils/markdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { VisualizationBlock } from "../visualizations/visualization-block";
|
|
||||||
import { splitVisualizationContent } from "../visualizations/visualization-spec";
|
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
|
|
||||||
function isExternalUrl(href: string | undefined): boolean {
|
function isExternalUrl(href: string | undefined): boolean {
|
||||||
@@ -59,7 +50,6 @@ export type MarkdownContentProps = {
|
|||||||
/** Renders markdown content. */
|
/** Renders markdown content. */
|
||||||
export function MarkdownContent({
|
export function MarkdownContent({
|
||||||
content,
|
content,
|
||||||
isLoading,
|
|
||||||
rehypePlugins,
|
rehypePlugins,
|
||||||
className,
|
className,
|
||||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||||
@@ -69,7 +59,6 @@ export function MarkdownContent({
|
|||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||||
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
|
|
||||||
useStreamdownMermaidPngDownload(rootRef);
|
useStreamdownMermaidPngDownload(rootRef);
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||||
@@ -150,72 +139,17 @@ export function MarkdownContent({
|
|||||||
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
// Full HTML pages belong in the artifact iframe. Feeding them to Streamdown
|
|
||||||
// mid-stream can recurse forever in marked and white-screen the chat.
|
|
||||||
if (looksLikeHtmlDocument(content)) {
|
|
||||||
return (
|
|
||||||
<MarkdownFallback message="正在生成 HTML 页面,请查看右侧预览。" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
|
|
||||||
// visualization renderers are lazy-loaded only when a dedicated fence exists.
|
|
||||||
if (
|
|
||||||
visualizationSegments.length === 1 &&
|
|
||||||
visualizationSegments[0]?.type === "markdown"
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
|
||||||
<MarkdownRenderBoundary resetKey={`${content.length}:${content.slice(-32)}`}>
|
|
||||||
<MessageResponse
|
|
||||||
className={className}
|
|
||||||
remarkPlugins={remarkPlugins}
|
|
||||||
rehypePlugins={rehypePlugins}
|
|
||||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
|
||||||
components={components}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</MessageResponse>
|
|
||||||
</MarkdownRenderBoundary>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||||
{visualizationSegments.map((segment, index) =>
|
<MessageResponse
|
||||||
segment.type === "visualization" ? (
|
className={className}
|
||||||
<VisualizationBlock
|
remarkPlugins={remarkPlugins}
|
||||||
key={`visualization-${index}`}
|
rehypePlugins={rehypePlugins}
|
||||||
language={segment.language}
|
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||||
source={segment.source}
|
components={components}
|
||||||
complete={segment.complete}
|
>
|
||||||
isLoading={isLoading}
|
{content}
|
||||||
/>
|
</MessageResponse>
|
||||||
) : segment.content ? (
|
|
||||||
looksLikeHtmlDocument(segment.content) ? (
|
|
||||||
<MarkdownFallback
|
|
||||||
key={`markdown-${index}`}
|
|
||||||
message="正在生成 HTML 页面,请查看右侧预览。"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<MarkdownRenderBoundary
|
|
||||||
key={`markdown-${index}`}
|
|
||||||
resetKey={`${segment.content.length}:${segment.content.slice(-32)}`}
|
|
||||||
>
|
|
||||||
<MessageResponse
|
|
||||||
className={className}
|
|
||||||
remarkPlugins={remarkPlugins}
|
|
||||||
rehypePlugins={rehypePlugins}
|
|
||||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
|
||||||
components={components}
|
|
||||||
>
|
|
||||||
{segment.content}
|
|
||||||
</MessageResponse>
|
|
||||||
</MarkdownRenderBoundary>
|
|
||||||
)
|
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,20 +33,20 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Dialog,
|
||||||
SheetContent,
|
DialogContent,
|
||||||
SheetDescription,
|
DialogDescription,
|
||||||
SheetHeader,
|
DialogHeader,
|
||||||
SheetTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
buildWriteFileVirtualUrl,
|
buildWriteFileVirtualUrl,
|
||||||
|
hasCompleteArtifactExtension,
|
||||||
isAutoPreviewableArtifactPath,
|
isAutoPreviewableArtifactPath,
|
||||||
languageOfStreamingArtifact,
|
languageOfStreamingArtifact,
|
||||||
normalizeStreamingArtifactPath,
|
normalizeStreamingArtifactPath,
|
||||||
namedStreamingWriteFilePath,
|
provisionalWriteFilePath,
|
||||||
} from "@/core/artifacts/streaming-write-preview";
|
} from "@/core/artifacts/streaming-write-preview";
|
||||||
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
normalizeSearchResults,
|
normalizeSearchResults,
|
||||||
@@ -57,12 +57,9 @@ import {
|
|||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
findToolCallResult,
|
findToolCallResult,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
|
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
|
||||||
import { isInternalToolCallName } from "@/core/tools/visibility";
|
|
||||||
import {
|
import {
|
||||||
applyInlineCitations,
|
applyInlineCitations,
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
@@ -85,8 +82,7 @@ import { Tooltip } from "../tooltip";
|
|||||||
import { SourceCard } from "../skill-display-sources";
|
import { SourceCard } from "../skill-display-sources";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { SingleRoundRagCitationDrawer } from "./message-list-item";
|
import { SingleRoundRagCitationDialog } from "./message-list-item";
|
||||||
import { RetrievedFileList } from "./retrieved-file-list";
|
|
||||||
|
|
||||||
export function MessageGroup({
|
export function MessageGroup({
|
||||||
className,
|
className,
|
||||||
@@ -94,17 +90,14 @@ export function MessageGroup({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
displaySources = [],
|
displaySources = [],
|
||||||
ragModeEnabled = false,
|
ragModeEnabled = false,
|
||||||
threadId,
|
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
ragModeEnabled?: boolean;
|
ragModeEnabled?: boolean;
|
||||||
threadId?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
|
||||||
const [showAbove, setShowAbove] = useState(
|
const [showAbove, setShowAbove] = useState(
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
);
|
);
|
||||||
@@ -120,12 +113,6 @@ export function MessageGroup({
|
|||||||
[messages, writingModeEnabled],
|
[messages, writingModeEnabled],
|
||||||
);
|
);
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
const retrievedFiles = useMemo(
|
|
||||||
() => citationDisplay?.retrieved_files_enabled === true
|
|
||||||
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
|
|
||||||
: [],
|
|
||||||
[messages, skills, citationDisplay?.retrieved_files_enabled],
|
|
||||||
);
|
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
[ragModeEnabled, skills],
|
[ragModeEnabled, skills],
|
||||||
@@ -223,7 +210,6 @@ export function MessageGroup({
|
|||||||
{...step}
|
{...step}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skillDisplayMap={skillDisplayMap}
|
skillDisplayMap={skillDisplayMap}
|
||||||
displaySources={displaySources}
|
|
||||||
ragModeEnabled={ragModeEnabled}
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -236,7 +222,6 @@ export function MessageGroup({
|
|||||||
isLast={true}
|
isLast={true}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skillDisplayMap={skillDisplayMap}
|
skillDisplayMap={skillDisplayMap}
|
||||||
displaySources={displaySources}
|
|
||||||
ragModeEnabled={ragModeEnabled}
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
</FlipDisplay>
|
</FlipDisplay>
|
||||||
@@ -286,8 +271,7 @@ export function MessageGroup({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ChainOfThought>
|
</ChainOfThought>
|
||||||
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
|
<SingleRoundRagCitationDialog
|
||||||
<SingleRoundRagCitationDrawer
|
|
||||||
source={selectedCitation}
|
source={selectedCitation}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setSelectedCitation(null);
|
if (!open) setSelectedCitation(null);
|
||||||
@@ -352,7 +336,6 @@ function ToolCall({
|
|||||||
isLast = false,
|
isLast = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
skillDisplayMap = new Map(),
|
skillDisplayMap = new Map(),
|
||||||
displaySources = [],
|
|
||||||
ragModeEnabled = false,
|
ragModeEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -363,7 +346,6 @@ function ToolCall({
|
|||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||||
displaySources?: RuntimeDisplaySource[];
|
|
||||||
ragModeEnabled?: boolean;
|
ragModeEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -374,42 +356,19 @@ function ToolCall({
|
|||||||
null);
|
null);
|
||||||
const displayItems =
|
const displayItems =
|
||||||
displayMatch && result !== undefined
|
displayMatch && result !== undefined
|
||||||
? syncDisplaySourceIndexes(
|
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||||
rankDisplaySources(
|
...item,
|
||||||
normalizeDisplaySources(result, displayMatch.display).map(
|
index: index + 1,
|
||||||
(item, index) => ({
|
skillName: displayMatch.skillName,
|
||||||
...item,
|
mode: item.displayMode ?? displayMatch.display.mode,
|
||||||
index: index + 1,
|
}))
|
||||||
skillName: displayMatch.skillName,
|
|
||||||
mode: item.displayMode ?? displayMatch.display.mode,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
displaySources,
|
|
||||||
)
|
|
||||||
: [];
|
: [];
|
||||||
const isLlmWikiTool =
|
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||||
name === "llmwiki_search" ||
|
|
||||||
name === "llmwiki_read_wiki_page" ||
|
|
||||||
name === "llmwiki_browse_wiki_index";
|
|
||||||
const wikiQuery = isLlmWikiTool
|
|
||||||
? resolveSearchQuery(args, result) ||
|
|
||||||
(name === "llmwiki_read_wiki_page"
|
|
||||||
? normalizeSearchResults(result)[0]?.title ||
|
|
||||||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
|
|
||||||
: "")
|
|
||||||
: "";
|
|
||||||
const wikiSearchLabel = name === "llmwiki_search"
|
const wikiSearchLabel = name === "llmwiki_search"
|
||||||
? wikiQuery
|
? wikiQuery
|
||||||
? `Wiki 文档检索 "${wikiQuery}"`
|
? `Wiki 文档检索 "${wikiQuery}"`
|
||||||
: "Wiki 文档检索"
|
: "Wiki 文档检索"
|
||||||
: name === "llmwiki_read_wiki_page"
|
: null;
|
||||||
? wikiQuery
|
|
||||||
? `读取 Wiki 页面「${wikiQuery}」`
|
|
||||||
: "读取 Wiki 页面"
|
|
||||||
: name === "llmwiki_browse_wiki_index"
|
|
||||||
? "读取 Wiki 索引与知识库摘要"
|
|
||||||
: null;
|
|
||||||
if (displayItems.length > 0) {
|
if (displayItems.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
@@ -435,17 +394,12 @@ function ToolCall({
|
|||||||
|
|
||||||
const searchItems = normalizeSearchResults(result);
|
const searchItems = normalizeSearchResults(result);
|
||||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||||
const defaultItems = syncDisplaySourceIndexes(
|
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||||
rankDisplaySources(
|
...item,
|
||||||
normalizeDefaultDisplaySources(result).map((item, index) => ({
|
index: index + 1,
|
||||||
...item,
|
skillName: name,
|
||||||
index: index + 1,
|
mode: "citation" as const,
|
||||||
skillName: name,
|
}));
|
||||||
mode: "citation" as const,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
displaySources,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||||
<DisplayResultsList results={defaultItems} />
|
<DisplayResultsList results={defaultItems} />
|
||||||
@@ -827,10 +781,17 @@ function ArtifactWriteToolCall({
|
|||||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||||
const content = typeof args.content === "string" ? args.content : "";
|
const content = typeof args.content === "string" ? args.content : "";
|
||||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||||
const sourcePath = namedStreamingWriteFilePath(rawPath);
|
const hasUsablePath =
|
||||||
const showInlineContent = name === "write_file" && !sourcePath;
|
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||||
|
const sourcePath = hasUsablePath
|
||||||
|
? normalizedPath
|
||||||
|
: name === "write_file" && content.trim()
|
||||||
|
? provisionalWriteFilePath(content)
|
||||||
|
: null;
|
||||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||||
// Until a real path arrives, both auto-open and manual clicks stay inline.
|
// Never select a bare provisional name like `未命名文件.md` — the artifacts
|
||||||
|
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
|
||||||
|
// tool call has ids so we can open a write-file: memory preview instead.
|
||||||
const artifactPath =
|
const artifactPath =
|
||||||
sourcePath && name === "write_file" && messageId && id
|
sourcePath && name === "write_file" && messageId && id
|
||||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||||
@@ -871,9 +832,7 @@ function ArtifactWriteToolCall({
|
|||||||
artifacts.setOpen(true);
|
artifacts.setOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showInlineContent ? (
|
{(normalizedPath || sourcePath) && (
|
||||||
content && <InlineWritePreview content={content} />
|
|
||||||
) : (normalizedPath || sourcePath) && (
|
|
||||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||||
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||||||
</ChainOfThoughtSearchResult>
|
</ChainOfThoughtSearchResult>
|
||||||
@@ -882,28 +841,6 @@ function ArtifactWriteToolCall({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineWritePreview({ content }: { content: string }) {
|
|
||||||
const ref = useRef<HTMLPreElement>(null);
|
|
||||||
const follow = useRef(true);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (follow.current && ref.current) {
|
|
||||||
ref.current.scrollTop = ref.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [content]);
|
|
||||||
return (
|
|
||||||
<pre
|
|
||||||
ref={ref}
|
|
||||||
onScroll={(event) => {
|
|
||||||
const node = event.currentTarget;
|
|
||||||
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
|
|
||||||
}}
|
|
||||||
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
|
|
||||||
>
|
|
||||||
<code>{content}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GenericCoTStep<T extends string = string> {
|
interface GenericCoTStep<T extends string = string> {
|
||||||
id?: string;
|
id?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
@@ -924,10 +861,6 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
|||||||
|
|
||||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||||
const steps: CoTStep[] = [];
|
const steps: CoTStep[] = [];
|
||||||
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
|
|
||||||
// per-name cursor so parallel searches consume distinct unbound results in
|
|
||||||
// order instead of every step reusing the first page.
|
|
||||||
const toolCallNameFallbackIndexes = new Map<string, number>();
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.type === "ai") {
|
if (message.type === "ai") {
|
||||||
const reasoning = extractReasoningContentFromMessage(message);
|
const reasoning = extractReasoningContentFromMessage(message);
|
||||||
@@ -940,37 +873,14 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
|||||||
};
|
};
|
||||||
steps.push(step);
|
steps.push(step);
|
||||||
}
|
}
|
||||||
const writes = new Map(
|
for (const tool_call of message.tool_calls ?? []) {
|
||||||
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
|
if (tool_call.name === "task") {
|
||||||
);
|
|
||||||
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
|
|
||||||
.tool_call_chunks;
|
|
||||||
const toolCalls = [...(message.tool_calls ?? [])];
|
|
||||||
for (const write of writes.values()) {
|
|
||||||
if (
|
|
||||||
!toolCalls.some((call) => call.id === write.id) &&
|
|
||||||
chunks?.some(
|
|
||||||
(chunk) => chunk.id === write.id && chunk.name === "write_file",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
toolCalls.push({
|
|
||||||
id: write.id,
|
|
||||||
name: "write_file",
|
|
||||||
args: { path: write.path, content: write.content },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const tool_call of toolCalls) {
|
|
||||||
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const write = tool_call.name === "write_file" && tool_call.id
|
const writePath =
|
||||||
? writes.get(tool_call.id)
|
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
|
||||||
: undefined;
|
? (tool_call.args as { path: string }).path
|
||||||
const args = write
|
: "";
|
||||||
? { ...tool_call.args, path: write.path, content: write.content }
|
|
||||||
: tool_call.args;
|
|
||||||
const writePath = typeof args?.path === "string" ? args.path : "";
|
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
tool_call.name === "write_file" ||
|
tool_call.name === "write_file" ||
|
||||||
@@ -988,27 +898,15 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
|||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
type: "toolCall",
|
type: "toolCall",
|
||||||
name: tool_call.name,
|
name: tool_call.name,
|
||||||
args,
|
args: tool_call.args,
|
||||||
};
|
};
|
||||||
const toolCallId = tool_call.id;
|
const toolCallId = tool_call.id;
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
let toolCallResult = findToolCallResult(
|
const toolCallResult = findToolCallResult(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
messages,
|
messages,
|
||||||
tool_call.name,
|
tool_call.name,
|
||||||
{ allowNameFallback: false },
|
|
||||||
);
|
);
|
||||||
if (toolCallResult === undefined) {
|
|
||||||
const nameKey = tool_call.name.toLowerCase();
|
|
||||||
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
|
|
||||||
toolCallResult = findToolCallResult(
|
|
||||||
toolCallId,
|
|
||||||
messages,
|
|
||||||
tool_call.name,
|
|
||||||
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
|
|
||||||
);
|
|
||||||
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
|
|
||||||
}
|
|
||||||
if (toolCallResult !== undefined) {
|
if (toolCallResult !== undefined) {
|
||||||
if (typeof toolCallResult === "string") {
|
if (typeof toolCallResult === "string") {
|
||||||
try {
|
try {
|
||||||
@@ -1075,48 +973,6 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankDisplaySources(
|
|
||||||
sources: RuntimeDisplaySource[],
|
|
||||||
): RuntimeDisplaySource[] {
|
|
||||||
return sources
|
|
||||||
.map((source, originalOrder) => ({ source, originalOrder }))
|
|
||||||
.sort((left, right) => {
|
|
||||||
const leftScore =
|
|
||||||
typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
|
||||||
? left.source.score
|
|
||||||
: Number.NEGATIVE_INFINITY;
|
|
||||||
const rightScore =
|
|
||||||
typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
|
||||||
? right.source.score
|
|
||||||
: Number.NEGATIVE_INFINITY;
|
|
||||||
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
|
||||||
})
|
|
||||||
.map(({ source }, index) => ({ ...source, index: index + 1 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncDisplaySourceIndexes(
|
|
||||||
sources: RuntimeDisplaySource[],
|
|
||||||
globalSources: RuntimeDisplaySource[],
|
|
||||||
): RuntimeDisplaySource[] {
|
|
||||||
const indexByCitationId = new Map(
|
|
||||||
globalSources.flatMap((source) => {
|
|
||||||
const citationId =
|
|
||||||
typeof source.raw?.citation_id === "string"
|
|
||||||
? source.raw.citation_id.trim()
|
|
||||||
: "";
|
|
||||||
return citationId ? [[citationId, source.index] as const] : [];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return sources.map((source) => {
|
|
||||||
const citationId =
|
|
||||||
typeof source.raw?.citation_id === "string"
|
|
||||||
? source.raw.citation_id.trim()
|
|
||||||
: "";
|
|
||||||
const index = citationId ? indexByCitationId.get(citationId) : undefined;
|
|
||||||
return index ? { ...source, index } : source;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const content = item.snippet ?? item.content;
|
const content = item.snippet ?? item.content;
|
||||||
@@ -1154,46 +1010,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">
|
||||||
>
|
|
||||||
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
|
||||||
<SheetTitle className="text-base leading-relaxed">
|
|
||||||
{item.title ?? "检索结果"}
|
{item.title ?? "检索结果"}
|
||||||
</SheetTitle>
|
</DialogTitle>
|
||||||
{(sourceLabel || date) && (
|
{(sourceLabel || date) && (
|
||||||
<SheetDescription className="flex items-center gap-2 text-xs">
|
<DialogDescription className="flex items-center gap-2 text-xs">
|
||||||
{sourceLabel && <span>{sourceLabel}</span>}
|
{sourceLabel && <span>{sourceLabel}</span>}
|
||||||
{sourceLabel && date && <span>·</span>}
|
{sourceLabel && date && <span>·</span>}
|
||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
</SheetDescription>
|
</DialogDescription>
|
||||||
)}
|
)}
|
||||||
{!sourceLabel && !date && (
|
</DialogHeader>
|
||||||
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
|
{content && (
|
||||||
)}
|
<div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
</SheetHeader>
|
{content}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
|
|
||||||
<div className="mx-auto max-w-3xl space-y-4">
|
|
||||||
{content ? (
|
|
||||||
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
|
|
||||||
{content}
|
|
||||||
</article>
|
|
||||||
) : (
|
|
||||||
<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>
|
||||||
</div>
|
)}
|
||||||
</SheetContent>
|
{item.url && (
|
||||||
</Sheet>
|
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||||
|
{item.url}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Message } from '@langchain/langgraph-sdk';
|
import type { Message } from '@langchain/langgraph-sdk';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
ChevronRightIcon,
|
ChevronDownIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
@@ -26,6 +25,7 @@ import { Loader } from '@/components/ai-elements/loader';
|
|||||||
import {
|
import {
|
||||||
Message as AIElementMessage,
|
Message as AIElementMessage,
|
||||||
MessageContent as AIElementMessageContent,
|
MessageContent as AIElementMessageContent,
|
||||||
|
MessageResponse as AIElementMessageResponse,
|
||||||
MessageToolbar,
|
MessageToolbar,
|
||||||
} from '@/components/ai-elements/message';
|
} from '@/components/ai-elements/message';
|
||||||
import {
|
import {
|
||||||
@@ -37,12 +37,17 @@ import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Dialog,
|
||||||
SheetContent,
|
DialogContent,
|
||||||
SheetDescription,
|
DialogDescription,
|
||||||
SheetHeader,
|
DialogHeader,
|
||||||
SheetTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
UploadedFilePreviewDialog,
|
UploadedFilePreviewDialog,
|
||||||
@@ -53,7 +58,6 @@ import { useChatAskTimes } from '@/core/chat-timestamps';
|
|||||||
import { getBackendBaseURL } from '@/core/config';
|
import { getBackendBaseURL } from '@/core/config';
|
||||||
import { readRuntimeEnv } from '@/env';
|
import { readRuntimeEnv } from '@/env';
|
||||||
import { useI18n } from '@/core/i18n/hooks';
|
import { useI18n } from '@/core/i18n/hooks';
|
||||||
import { stripKnowledgeRetrievalDiagnostics } from '@/core/llmwiki/answer-display';
|
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
@@ -67,20 +71,18 @@ import {
|
|||||||
applyInlineCitations,
|
applyInlineCitations,
|
||||||
type RuntimeDisplaySource,
|
type RuntimeDisplaySource,
|
||||||
} from '@/core/skill-display/runtime';
|
} from '@/core/skill-display/runtime';
|
||||||
|
import { humanMessagePlugins } from '@/core/streamdown';
|
||||||
import {
|
import {
|
||||||
getMultiRoundRagTrace,
|
getMultiRoundRagTrace,
|
||||||
isMultiRoundRagMessage,
|
isMultiRoundRagMessage,
|
||||||
} from '@/core/threads/use-multi-round-rag';
|
} from '@/core/threads/use-multi-round-rag';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { AnswerFeedbackContext } from '@/strategy-components/api/answer-feedback';
|
|
||||||
|
|
||||||
import { CitationLink } from '../citations/citation-link';
|
import { CitationLink } from '../citations/citation-link';
|
||||||
import { CopyButton } from '../copy-button';
|
import { CopyButton } from '../copy-button';
|
||||||
import { Tooltip } from '../tooltip';
|
import { Tooltip } from '../tooltip';
|
||||||
import { SkillDisplayBlock } from '../skill-display-block';
|
import { SkillDisplayBlock } from '../skill-display-block';
|
||||||
import { ThreadContext } from './context';
|
import { ThreadContext } from './context';
|
||||||
import { AnswerFeedbackControl } from './answer-feedback-control';
|
|
||||||
import { isEligibleAnswerFeedbackMessage } from './answer-feedback-eligibility';
|
|
||||||
import { DeleteMessageButton } from './delete-message-button';
|
import { DeleteMessageButton } from './delete-message-button';
|
||||||
import { DownloadWordButton } from './download-word-button';
|
import { DownloadWordButton } from './download-word-button';
|
||||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||||
@@ -166,8 +168,6 @@ export function MessageListItem({
|
|||||||
onEditSave,
|
onEditSave,
|
||||||
onMessageDeleted,
|
onMessageDeleted,
|
||||||
turnTime,
|
turnTime,
|
||||||
answerFeedbackEnabled = false,
|
|
||||||
answerFeedbackContext,
|
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
fitRowToContent?: boolean;
|
fitRowToContent?: boolean;
|
||||||
@@ -194,10 +194,6 @@ export function MessageListItem({
|
|||||||
* wall clock at render time and appear to "drift" to now on every reload.
|
* wall clock at render time and appear to "drift" to now on every reload.
|
||||||
*/
|
*/
|
||||||
turnTime?: string;
|
turnTime?: string;
|
||||||
/** Opt-in feedback affordance for completed, persisted assistant answers. */
|
|
||||||
answerFeedbackEnabled?: boolean;
|
|
||||||
/** Authoritative run/agent mapping hydrated in one conversation batch. */
|
|
||||||
answerFeedbackContext?: AnswerFeedbackContext;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isHuman = message.type === 'human';
|
const isHuman = message.type === 'human';
|
||||||
@@ -222,9 +218,6 @@ export function MessageListItem({
|
|||||||
!!message.id &&
|
!!message.id &&
|
||||||
!message.id.startsWith('opt-') &&
|
!message.id.startsWith('opt-') &&
|
||||||
!!messageMarkdown;
|
!!messageMarkdown;
|
||||||
const canEvaluateAnswer =
|
|
||||||
answerFeedbackEnabled &&
|
|
||||||
isEligibleAnswerFeedbackMessage(message, Boolean(isLoading));
|
|
||||||
const confirmAssistantEdit = useCallback(async () => {
|
const confirmAssistantEdit = useCallback(async () => {
|
||||||
const value = assistantDraft.trim();
|
const value = assistantDraft.trim();
|
||||||
if (!value || !message.id || !onEditSave || assistantSaving) {
|
if (!value || !message.id || !onEditSave || assistantSaving) {
|
||||||
@@ -323,24 +316,17 @@ export function MessageListItem({
|
|||||||
{/*
|
{/*
|
||||||
Human messages carry their own inline copy/edit toolbar inside
|
Human messages carry their own inline copy/edit toolbar inside
|
||||||
MessageContent (above the timestamp). Only assistant answers use the
|
MessageContent (above the timestamp). Only assistant answers use the
|
||||||
always-visible floating bottom toolbar here.
|
floating bottom toolbar here.
|
||||||
*/}
|
*/}
|
||||||
{!isLoading && !isHuman && (
|
{!isLoading && !isHuman && (
|
||||||
<MessageToolbar
|
<MessageToolbar
|
||||||
className={cn(
|
className={cn(
|
||||||
'-bottom-8',
|
'-bottom-8',
|
||||||
'absolute right-0 left-0 z-20',
|
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<CopyButton clipboardData={messageMarkdown} />
|
<CopyButton clipboardData={messageMarkdown} />
|
||||||
{canEvaluateAnswer && message.id && answerFeedbackContext && (
|
|
||||||
<AnswerFeedbackControl
|
|
||||||
threadId={threadId}
|
|
||||||
messageId={message.id}
|
|
||||||
context={answerFeedbackContext}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{messageMarkdown && (
|
{messageMarkdown && (
|
||||||
<DownloadWordButton
|
<DownloadWordButton
|
||||||
markdown={messageMarkdown}
|
markdown={messageMarkdown}
|
||||||
@@ -372,7 +358,7 @@ export function MessageListItem({
|
|||||||
/**
|
/**
|
||||||
* Inline copy/edit affordances + editor for the user's own question bubble.
|
* Inline copy/edit affordances + editor for the user's own question bubble.
|
||||||
*
|
*
|
||||||
* Layout: bubble → toolbar (copy + edit) → timestamp. The toolbar sits
|
* Layout: bubble → hover toolbar (copy + edit) → timestamp. The toolbar sits
|
||||||
* in normal flow above the timestamp (not floating) so it never covers the
|
* in normal flow above the timestamp (not floating) so it never covers the
|
||||||
* following message. Clicking edit turns the bubble into a textarea seeded with
|
* following message. Clicking edit turns the bubble into a textarea seeded with
|
||||||
* the question; confirming sends the edited text as a brand-new turn (the
|
* the question; confirming sends the edited text as a brand-new turn (the
|
||||||
@@ -384,6 +370,7 @@ function HumanMessageBlock({
|
|||||||
pageContext,
|
pageContext,
|
||||||
filesList,
|
filesList,
|
||||||
messageTime,
|
messageTime,
|
||||||
|
components,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onEditSave,
|
onEditSave,
|
||||||
deleteButton,
|
deleteButton,
|
||||||
@@ -393,7 +380,7 @@ function HumanMessageBlock({
|
|||||||
pageContext?: EmbeddedPageContext | null;
|
pageContext?: EmbeddedPageContext | null;
|
||||||
filesList: ReactNode;
|
filesList: ReactNode;
|
||||||
messageTime: { label: string; full: string } | null;
|
messageTime: { label: string; full: string } | null;
|
||||||
components?: MarkdownContentProps["components"];
|
components: MarkdownContentProps["components"];
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||||
deleteButton?: ReactNode;
|
deleteButton?: ReactNode;
|
||||||
@@ -473,9 +460,14 @@ function HumanMessageBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageResponse = text ? (
|
const messageResponse = text ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-[15px] leading-7">
|
<AIElementMessageResponse
|
||||||
|
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||||
|
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||||
|
components={components}
|
||||||
|
parseIncompleteMarkdown={false}
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</AIElementMessageResponse>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -489,7 +481,7 @@ function HumanMessageBlock({
|
|||||||
<MessageTimeStamp time={messageTime} align="right" />
|
<MessageTimeStamp time={messageTime} align="right" />
|
||||||
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
||||||
{(text || deleteButton) && (
|
{(text || deleteButton) && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
||||||
{text && <CopyButton clipboardData={text} className="size-6" />}
|
{text && <CopyButton clipboardData={text} className="size-6" />}
|
||||||
{text && (onEditResubmit || onEditSave) && (
|
{text && (onEditResubmit || onEditSave) && (
|
||||||
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
||||||
@@ -700,16 +692,13 @@ function MessageContent_({
|
|||||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
const sanitizedContent = stripKnowledgeRetrievalDiagnostics(rawContent ?? '');
|
|
||||||
if (isMultiRoundRag) {
|
if (isMultiRoundRag) {
|
||||||
return stripIncompleteCitationMarker(sanitizedContent);
|
return stripIncompleteCitationMarker(rawContent ?? '');
|
||||||
}
|
}
|
||||||
const withCitations = applyInlineCitations(sanitizedContent, displaySources);
|
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||||
return stripIncompleteCitationMarker(withCitations);
|
return stripIncompleteCitationMarker(withCitations);
|
||||||
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
||||||
const markdownRehypePlugins = useMemo<
|
const markdownRehypePlugins = useMemo(
|
||||||
NonNullable<MarkdownContentProps['rehypePlugins']>
|
|
||||||
>(
|
|
||||||
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||||
[rehypePlugins],
|
[rehypePlugins],
|
||||||
);
|
);
|
||||||
@@ -819,7 +808,10 @@ function MessageContent_({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSingleRoundRag && displaySources.length > 0 && (
|
{isSingleRoundRag && displaySources.length > 0 && (
|
||||||
<FrontendRagSourcesPanel sources={displaySources} />
|
<FrontendRagSourcesPanel
|
||||||
|
sources={displaySources}
|
||||||
|
onSelect={setSelectedCitation}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageTokenUsage
|
<MessageTokenUsage
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
@@ -831,7 +823,7 @@ function MessageContent_({
|
|||||||
) : null}
|
) : null}
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
{isSingleRoundRag && (
|
{isSingleRoundRag && (
|
||||||
<SingleRoundRagCitationDrawer
|
<SingleRoundRagCitationDialog
|
||||||
source={selectedCitation}
|
source={selectedCitation}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setSelectedCitation(null);
|
if (!open) setSelectedCitation(null);
|
||||||
@@ -843,173 +835,109 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单轮 RAG 的参考文献入口。先打开右侧抽屉查看列表,
|
* 单轮 RAG 专属的「引用来源」折叠面板。
|
||||||
* 选中条目后在同一抽屉中阅读详情,避免跳出居中弹框打断问答。
|
*
|
||||||
|
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
|
||||||
*/
|
*/
|
||||||
function FrontendRagSourcesPanel({ sources }: { sources: RuntimeDisplaySource[] }) {
|
function FrontendRagSourcesPanel({
|
||||||
|
sources,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
sources: RuntimeDisplaySource[];
|
||||||
|
onSelect: (source: RuntimeDisplaySource) => void;
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [selectedSource, setSelectedSource] = useState<RuntimeDisplaySource | null>(null);
|
|
||||||
if (sources.length === 0) return null;
|
if (sources.length === 0) return null;
|
||||||
|
|
||||||
const handleOpenChange = (nextOpen: boolean) => {
|
|
||||||
setOpen(nextOpen);
|
|
||||||
if (!nextOpen) setSelectedSource(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
|
||||||
<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" />
|
|
||||||
<span className="font-medium">参考文献</span>
|
|
||||||
<span className="text-muted-foreground/70">({sources.length})</span>
|
|
||||||
<span>查看</span>
|
|
||||||
<ChevronRightIcon className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
|
||||||
>
|
>
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<BookMarkedIcon className="size-3.5" />
|
||||||
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
<span className="font-medium">引用来源</span>
|
||||||
<div className="flex items-start gap-3">
|
<span className="text-muted-foreground/70">({sources.length})</span>
|
||||||
{selectedSource && (
|
<ChevronDownIcon
|
||||||
<Button
|
className={cn(
|
||||||
type="button"
|
'size-3.5 transition-transform duration-200',
|
||||||
size="icon-sm"
|
!open && '-rotate-90',
|
||||||
variant="ghost"
|
|
||||||
className="mt-0.5 shrink-0"
|
|
||||||
onClick={() => setSelectedSource(null)}
|
|
||||||
aria-label="返回参考文献列表"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<SheetTitle className="truncate text-base">
|
|
||||||
{selectedSource?.title || "参考文献"}
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription className="mt-1 text-xs">
|
|
||||||
{selectedSource
|
|
||||||
? "引用详情;可返回列表继续查看其他文献。"
|
|
||||||
: `本次回答共引用 ${sources.length} 条资料,点击条目查看具体内容。`}
|
|
||||||
</SheetDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
{selectedSource ? (
|
|
||||||
<CitationSourceDetail source={selectedSource} />
|
|
||||||
) : (
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-5">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{sources.map((source, idx) => (
|
|
||||||
<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>
|
/>
|
||||||
</SheetContent>
|
</button>
|
||||||
</Sheet>
|
</CollapsibleTrigger>
|
||||||
</>
|
<CollapsibleContent className="mt-2 grid gap-2">
|
||||||
|
{sources.map((source, idx) => (
|
||||||
|
<button
|
||||||
|
key={source.id ?? `${source.title}-${idx}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(source)}
|
||||||
|
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
||||||
|
{source.index}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
|
||||||
|
title={source.title}
|
||||||
|
>
|
||||||
|
{source.title}
|
||||||
|
</span>
|
||||||
|
{source.type && (
|
||||||
|
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
|
||||||
|
{source.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{source.snippet && (
|
||||||
|
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||||
|
{source.snippet}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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 (
|
|
||||||
<Sheet open={!!source} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
|
||||||
>
|
|
||||||
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
|
||||||
<SheetTitle className="leading-relaxed">
|
|
||||||
{source?.title || "引用资料"}
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription className="space-y-1 text-xs">
|
|
||||||
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
|
|
||||||
{source?.time && <span className="block">时间:{source.time}</span>}
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
{source && <CitationSourceDetail source={source} />}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CitationSourceDetail({ source }: { source: RuntimeDisplaySource }) {
|
|
||||||
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
|
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
||||||
<div className="mx-auto max-w-3xl space-y-4">
|
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
||||||
|
<DialogHeader className="pr-8">
|
||||||
|
<DialogTitle className="leading-relaxed">
|
||||||
|
{source?.title || "引用资料"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-1 text-xs">
|
||||||
|
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
|
||||||
|
{source?.time && <span className="block">时间:{source.time}</span>}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
{sourceUrl && (
|
{sourceUrl && (
|
||||||
<a
|
<a
|
||||||
href={sourceUrl}
|
href={sourceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="border-input bg-background hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
className="border-input hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-4" />
|
<ExternalLinkIcon className="size-4" />
|
||||||
查看原文链接
|
查看原文链接
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<article className="bg-background rounded-xl border px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
|
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-lg p-3 text-sm leading-7 whitespace-pre-wrap">
|
||||||
{source.content || source.snippet || "暂无引用内容"}
|
{source?.content || source?.snippet || "暂无引用内容"}
|
||||||
</article>
|
</div>
|
||||||
{sourceUrl && (
|
</DialogContent>
|
||||||
<p className="text-muted-foreground border-t pt-3 text-xs break-all">
|
</Dialog>
|
||||||
{sourceUrl}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,8 +1163,6 @@ function RichFileCard({
|
|||||||
<UploadedFilePreviewDialog
|
<UploadedFilePreviewDialog
|
||||||
filename={file.filename}
|
filename={file.filename}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileUrl}
|
||||||
threadId={threadId}
|
|
||||||
virtualPath={file.path}
|
|
||||||
open={previewOpen}
|
open={previewOpen}
|
||||||
onOpenChange={setPreviewOpen}
|
onOpenChange={setPreviewOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
|||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
|
||||||
import {
|
import {
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
collectRuntimeDisplaySources,
|
collectRuntimeDisplaySources,
|
||||||
@@ -41,35 +40,20 @@ import {
|
|||||||
import type { Subtask } from "@/core/tasks";
|
import type { Subtask } from "@/core/tasks";
|
||||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
import {
|
|
||||||
shouldRenderUnattachedKnowledgeRetrievalProgress,
|
|
||||||
useKnowledgeRetrievalProgress,
|
|
||||||
} from "@/core/threads/knowledge-retrieval-progress";
|
|
||||||
import {
|
|
||||||
buildSmartKnowledgeRetrievalDisplaySegments,
|
|
||||||
collectSmartKnowledgeRetrievalTurnAssistantMessageIds,
|
|
||||||
hasSmartKnowledgeRetrievalCalls,
|
|
||||||
latestSmartKnowledgeRetrievalMessageId,
|
|
||||||
type SmartKnowledgeRetrievalResultItem,
|
|
||||||
} from "@/core/threads/smart-knowledge-retrieval";
|
|
||||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||||
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
|
|
||||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
|
||||||
|
|
||||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||||
import { getQuestionAnchorId } from "../question-navigator";
|
import { getQuestionAnchorId } from "../question-navigator";
|
||||||
import { StreamingIndicator } from "../streaming-indicator";
|
import { StreamingIndicator } from "../streaming-indicator";
|
||||||
|
|
||||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||||
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { DocumentRewriteMessage, DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||||
import { MessageGroup } from "./message-group";
|
import { MessageGroup } from "./message-group";
|
||||||
import { MessageListItem } from "./message-list-item";
|
import { MessageListItem } from "./message-list-item";
|
||||||
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
|
|
||||||
import {
|
import {
|
||||||
MessageMetricsProvider,
|
MessageMetricsProvider,
|
||||||
MessageTokenUsageList,
|
MessageTokenUsageList,
|
||||||
@@ -128,50 +112,6 @@ function ScrollToBottomOnUserMessage({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Exposes the conversation's scroll position to hosts that render controls
|
|
||||||
* outside the scroll container (for example the docked follow-up questions).
|
|
||||||
*/
|
|
||||||
function ConversationBottomObserver({
|
|
||||||
onAtBottomChange,
|
|
||||||
}: {
|
|
||||||
onAtBottomChange?: (isAtBottom: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
|
|
||||||
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const scrollElement = scrollRef.current;
|
|
||||||
if (!scrollElement) return;
|
|
||||||
|
|
||||||
const update = () => {
|
|
||||||
const distance =
|
|
||||||
scrollElement.scrollHeight -
|
|
||||||
scrollElement.scrollTop -
|
|
||||||
scrollElement.clientHeight;
|
|
||||||
setIsExactlyAtBottom(distance <= 2);
|
|
||||||
};
|
|
||||||
update();
|
|
||||||
scrollElement.addEventListener("scroll", update, { passive: true });
|
|
||||||
const resizeObserver =
|
|
||||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
|
|
||||||
resizeObserver?.observe(scrollElement);
|
|
||||||
if (contentRef.current) resizeObserver?.observe(contentRef.current);
|
|
||||||
return () => {
|
|
||||||
scrollElement.removeEventListener("scroll", update);
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
};
|
|
||||||
}, [contentRef, scrollRef]);
|
|
||||||
|
|
||||||
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onAtBottomChange?.(displayControlsAtBottom);
|
|
||||||
}, [displayControlsAtBottom, onAtBottomChange]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
className,
|
className,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
@@ -182,7 +122,6 @@ export function MessageList({
|
|||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
scrollButtonClassName,
|
scrollButtonClassName,
|
||||||
onAtBottomChange,
|
|
||||||
onClarificationSubmit,
|
onClarificationSubmit,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onEditSave,
|
onEditSave,
|
||||||
@@ -202,7 +141,6 @@ export function MessageList({
|
|||||||
onOpenArtifact,
|
onOpenArtifact,
|
||||||
onDownloadArtifact,
|
onDownloadArtifact,
|
||||||
afterDocumentRewriteItems,
|
afterDocumentRewriteItems,
|
||||||
answerFeedbackEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
@@ -213,8 +151,6 @@ export function MessageList({
|
|||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||||
scrollButtonClassName?: string;
|
scrollButtonClassName?: string;
|
||||||
/** Reports whether the message viewport is currently at its bottom edge. */
|
|
||||||
onAtBottomChange?: (isAtBottom: boolean) => void;
|
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
@@ -260,12 +196,6 @@ export function MessageList({
|
|||||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||||
/** Optional host content placed immediately after document-rewrite timelines. */
|
/** Optional host content placed immediately after document-rewrite timelines. */
|
||||||
afterDocumentRewriteItems?: React.ReactNode;
|
afterDocumentRewriteItems?: React.ReactNode;
|
||||||
/**
|
|
||||||
* Enables per-answer user feedback for persisted assistant replies. Kept
|
|
||||||
* opt-in because MessageList is also reused by read-only previews, setup
|
|
||||||
* flows and virtual business-card feeds.
|
|
||||||
*/
|
|
||||||
answerFeedbackEnabled?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const rehypePlugins = useMemo(
|
const rehypePlugins = useMemo(
|
||||||
@@ -287,13 +217,8 @@ export function MessageList({
|
|||||||
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
|
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
);
|
);
|
||||||
const [selectedRetrievalResult, setSelectedRetrievalResult] = useState<{
|
|
||||||
item: SmartKnowledgeRetrievalResultItem;
|
|
||||||
target: WeKnoraSourceTarget;
|
|
||||||
} | null>(null);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHiddenMessageIds(new Set());
|
setHiddenMessageIds(new Set());
|
||||||
setSelectedRetrievalResult(null);
|
|
||||||
}, [threadId]);
|
}, [threadId]);
|
||||||
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
|
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
|
||||||
if (deletedIds.length === 0) return;
|
if (deletedIds.length === 0) return;
|
||||||
@@ -304,20 +229,9 @@ export function MessageList({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const documentRewriteItems = useMemo(
|
|
||||||
() => documentRewritePresentation?.forThread(threadId) ?? [],
|
|
||||||
[documentRewritePresentation, threadId],
|
|
||||||
);
|
|
||||||
const artifactRewriteItems = useMemo(
|
|
||||||
() => documentRewriteItems.filter(
|
|
||||||
(item) => item.source.kind === "artifact" && item.operation === "rewrite",
|
|
||||||
),
|
|
||||||
[documentRewriteItems],
|
|
||||||
);
|
|
||||||
|
|
||||||
const allMessages = useMemo(() => {
|
const allMessages = useMemo(() => {
|
||||||
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
||||||
const persistedMessages = thread.messages
|
return thread.messages
|
||||||
.filter((m) => !isHiddenFromUIMessage(m))
|
.filter((m) => !isHiddenFromUIMessage(m))
|
||||||
.map((message) => {
|
.map((message) => {
|
||||||
const override =
|
const override =
|
||||||
@@ -328,23 +242,7 @@ export function MessageList({
|
|||||||
? message
|
? message
|
||||||
: ({ ...message, content: override } as Message);
|
: ({ ...message, content: override } as Message);
|
||||||
});
|
});
|
||||||
const rewriteMessages = artifactRewriteItems.flatMap((item) => [
|
}, [messageContentOverrides, thread.messages]);
|
||||||
{
|
|
||||||
id: `document-rewrite-input-${item.id}`,
|
|
||||||
type: "human",
|
|
||||||
content: "AI改写",
|
|
||||||
} as unknown as Message,
|
|
||||||
{
|
|
||||||
id: `document-rewrite-${item.id}`,
|
|
||||||
type: "ai",
|
|
||||||
content: "",
|
|
||||||
additional_kwargs: {
|
|
||||||
document_rewrite_presentation_id: item.id,
|
|
||||||
},
|
|
||||||
} as unknown as Message,
|
|
||||||
]);
|
|
||||||
return [...persistedMessages, ...rewriteMessages];
|
|
||||||
}, [artifactRewriteItems, messageContentOverrides, thread.messages]);
|
|
||||||
const messages = useMemo(
|
const messages = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||||
@@ -358,56 +256,8 @@ export function MessageList({
|
|||||||
},
|
},
|
||||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||||
);
|
);
|
||||||
const knowledgeRetrievalProgress = useKnowledgeRetrievalProgress(threadId);
|
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||||
const hasHumanMessage = messages.some((message) => message.type === "human");
|
|
||||||
const currentTurnMessages = useMemo(() => {
|
|
||||||
let lastHumanIndex = -1;
|
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
||||||
if (messages[index]?.type === "human") {
|
|
||||||
lastHumanIndex = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return messages.slice(lastHumanIndex + 1);
|
|
||||||
}, [messages]);
|
|
||||||
const latestCurrentSmartMessageId = useMemo(
|
|
||||||
() => latestSmartKnowledgeRetrievalMessageId(currentTurnMessages),
|
|
||||||
[currentTurnMessages],
|
|
||||||
);
|
|
||||||
const lastCurrentTurnMessageId = currentTurnMessages.at(-1)?.id;
|
|
||||||
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
|
|
||||||
const answerFeedbackMessageIds = useMemo(() => {
|
|
||||||
if (!feedbackAvailable) return [];
|
|
||||||
const streamingAssistantId = thread.isLoading
|
|
||||||
? [...messages].reverse().find((message) => message.type === "ai")?.id
|
|
||||||
: undefined;
|
|
||||||
return messages.flatMap((message) => {
|
|
||||||
const kwargs = message.additional_kwargs ?? {};
|
|
||||||
const explicitlyStreaming =
|
|
||||||
kwargs.deep_research_summary_streaming === true ||
|
|
||||||
kwargs.deep_research_follow_up_streaming === true;
|
|
||||||
const loading =
|
|
||||||
explicitlyStreaming ||
|
|
||||||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
|
|
||||||
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
|
|
||||||
? [message.id]
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
}, [feedbackAvailable, messages, thread.isLoading]);
|
|
||||||
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
|
|
||||||
threadId,
|
|
||||||
answerFeedbackMessageIds,
|
|
||||||
feedbackAvailable,
|
|
||||||
);
|
|
||||||
const ratableAnswerMessageIds = useMemo(() => {
|
|
||||||
const lastMessageByRun = new Map<string, string>();
|
|
||||||
for (const message of messages) {
|
|
||||||
if (!message.id) continue;
|
|
||||||
const context = answerFeedbackContexts?.[message.id];
|
|
||||||
if (context) lastMessageByRun.set(context.run_id, message.id);
|
|
||||||
}
|
|
||||||
return new Set(lastMessageByRun.values());
|
|
||||||
}, [answerFeedbackContexts, messages]);
|
|
||||||
const askTimes = useChatAskTimes();
|
const askTimes = useChatAskTimes();
|
||||||
|
|
||||||
// Stamp a wall-clock time onto every *question* the first time it is seen, in
|
// Stamp a wall-clock time onto every *question* the first time it is seen, in
|
||||||
@@ -471,8 +321,7 @@ export function MessageList({
|
|||||||
const referenceModeEntryEnabled =
|
const referenceModeEntryEnabled =
|
||||||
citationDisplay?.reference_mode_enabled ?? false;
|
citationDisplay?.reference_mode_enabled ?? false;
|
||||||
const knowledgeReferencesEnabled =
|
const knowledgeReferencesEnabled =
|
||||||
threadContext?.knowledgeReferencesEnabled === true ||
|
threadContext?.knowledgeReferencesEnabled === true;
|
||||||
!!threadContext?.knowledgeReferences;
|
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||||
isThreadRagModeLocked(threadId) ||
|
isThreadRagModeLocked(threadId) ||
|
||||||
@@ -534,29 +383,10 @@ export function MessageList({
|
|||||||
return map;
|
return map;
|
||||||
}, [thread.messages]);
|
}, [thread.messages]);
|
||||||
|
|
||||||
const smartRetrievalAssistantMessageIds = useMemo(() => {
|
|
||||||
return collectSmartKnowledgeRetrievalTurnAssistantMessageIds(messages);
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
const resolveDisplaySources = useCallback(
|
const resolveDisplaySources = useCallback(
|
||||||
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
||||||
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
||||||
if (isMultiRoundRagMessage(msg)) return [];
|
if (isMultiRoundRagMessage(msg)) return [];
|
||||||
const previousHumanId = findPreviousHumanMessageId(messages, msg.id);
|
|
||||||
// Smart retrieval has its own clickable evidence trace and explicitly
|
|
||||||
// does not require numbered references. Do not feed the same transport
|
|
||||||
// results into the legacy citation layer, which would append [1][2] to
|
|
||||||
// an otherwise complete answer.
|
|
||||||
const isSmartRetrievalTurn = msg.id
|
|
||||||
? smartRetrievalAssistantMessageIds.has(msg.id)
|
|
||||||
: hasSmartKnowledgeRetrievalCalls(
|
|
||||||
messages.slice(
|
|
||||||
previousHumanId
|
|
||||||
? messages.findIndex((message) => message.id === previousHumanId) + 1
|
|
||||||
: 0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (isSmartRetrievalTurn) return [];
|
|
||||||
const batchSources =
|
const batchSources =
|
||||||
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
||||||
?.sources ?? [];
|
?.sources ?? [];
|
||||||
@@ -566,7 +396,7 @@ export function MessageList({
|
|||||||
if (batchSources.length > 0) return batchSources;
|
if (batchSources.length > 0) return batchSources;
|
||||||
if (frontendRagSources.length > 0) return frontendRagSources;
|
if (frontendRagSources.length > 0) return frontendRagSources;
|
||||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||||
afterMessageId: previousHumanId,
|
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||||
beforeMessageId: msg.id,
|
beforeMessageId: msg.id,
|
||||||
includeBeforeMessage: true,
|
includeBeforeMessage: true,
|
||||||
});
|
});
|
||||||
@@ -577,7 +407,6 @@ export function MessageList({
|
|||||||
referenceBatches,
|
referenceBatches,
|
||||||
referencesEnabled,
|
referencesEnabled,
|
||||||
skillDisplayMap,
|
skillDisplayMap,
|
||||||
smartRetrievalAssistantMessageIds,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -695,7 +524,6 @@ export function MessageList({
|
|||||||
className={cn("flex size-full flex-col justify-center", className)}
|
className={cn("flex size-full flex-col justify-center", className)}
|
||||||
>
|
>
|
||||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||||
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
|
|
||||||
<ConversationContent
|
<ConversationContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto w-full max-w-(--container-width-lg)",
|
"mx-auto w-full max-w-(--container-width-lg)",
|
||||||
@@ -705,7 +533,13 @@ export function MessageList({
|
|||||||
>
|
>
|
||||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||||
{(() => {
|
{(() => {
|
||||||
let didRenderLiveKnowledgeRetrieval = false;
|
const rewriteItemsAboveFile = documentRewriteItems.filter(
|
||||||
|
(item) => item.operation !== "generate",
|
||||||
|
);
|
||||||
|
const rewriteItemsBelowFile = documentRewriteItems.filter(
|
||||||
|
(item) => item.operation === "generate",
|
||||||
|
);
|
||||||
|
let didRenderRewriteAboveFile = false;
|
||||||
const grouped = groupMessages(messages, (group) => {
|
const grouped = groupMessages(messages, (group) => {
|
||||||
if (group.type === "human" || group.type === "assistant") {
|
if (group.type === "human" || group.type === "assistant") {
|
||||||
const items = group.messages.map((msg) => {
|
const items = group.messages.map((msg) => {
|
||||||
@@ -751,12 +585,6 @@ export function MessageList({
|
|||||||
allowDelete ? handleMessageDeleted : undefined
|
allowDelete ? handleMessageDeleted : undefined
|
||||||
}
|
}
|
||||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||||
answerFeedbackEnabled={answerFeedbackEnabled}
|
|
||||||
answerFeedbackContext={
|
|
||||||
msg.id && ratableAnswerMessageIds.has(msg.id)
|
|
||||||
? answerFeedbackContexts?.[msg.id]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -891,21 +719,6 @@ export function MessageList({
|
|||||||
{businessCardSlot(message)}
|
{businessCardSlot(message)}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
} else if (group.type === "assistant:document-rewrite") {
|
|
||||||
const presentationId = group.messages[0]?.additional_kwargs?.document_rewrite_presentation_id;
|
|
||||||
const item = typeof presentationId === "string"
|
|
||||||
? artifactRewriteItems.find((candidate) => candidate.id === presentationId)
|
|
||||||
: undefined;
|
|
||||||
return item ? (
|
|
||||||
<div className="w-full" key={group.id}>
|
|
||||||
<DocumentRewriteMessage
|
|
||||||
item={item}
|
|
||||||
onDownloadArtifact={onDownloadArtifact}
|
|
||||||
onOpenArtifact={onOpenArtifact}
|
|
||||||
threadId={threadId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
} else if (group.type === "assistant:present-files") {
|
} else if (group.type === "assistant:present-files") {
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
for (const message of group.messages) {
|
for (const message of group.messages) {
|
||||||
@@ -914,8 +727,15 @@ export function MessageList({
|
|||||||
files.push(...presentFiles);
|
files.push(...presentFiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const rewriteAbove = !didRenderRewriteAboveFile
|
||||||
|
? rewriteItemsAboveFile
|
||||||
|
: [];
|
||||||
|
didRenderRewriteAboveFile = true;
|
||||||
return (
|
return (
|
||||||
<div className="w-full" key={group.id}>
|
<div className="w-full" key={group.id}>
|
||||||
|
{rewriteAbove.map((item) => (
|
||||||
|
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
|
||||||
|
))}
|
||||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
content={extractContentFromMessage(group.messages[0])}
|
content={extractContentFromMessage(group.messages[0])}
|
||||||
@@ -995,7 +815,6 @@ export function MessageList({
|
|||||||
results.push(
|
results.push(
|
||||||
<MessageGroup
|
<MessageGroup
|
||||||
key={"thinking-group-" + message.id}
|
key={"thinking-group-" + message.id}
|
||||||
threadId={threadId}
|
|
||||||
messages={[message]}
|
messages={[message]}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
displaySources={resolveDisplaySources(message)}
|
displaySources={resolveDisplaySources(message)}
|
||||||
@@ -1046,95 +865,14 @@ export function MessageList({
|
|||||||
const processingAi = [...group.messages]
|
const processingAi = [...group.messages]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.type === "ai");
|
.find((message) => message.type === "ai");
|
||||||
const hasSmartRetrievalCalls = hasSmartKnowledgeRetrievalCalls(
|
|
||||||
group.messages,
|
|
||||||
);
|
|
||||||
const isCurrentTurnRetrievalGroup = group.messages.some(
|
|
||||||
(message) =>
|
|
||||||
message.id ===
|
|
||||||
(latestCurrentSmartMessageId ?? lastCurrentTurnMessageId),
|
|
||||||
);
|
|
||||||
const receivesLiveRetrievalProgress =
|
|
||||||
knowledgeRetrievalProgress.length > 0 &&
|
|
||||||
isCurrentTurnRetrievalGroup;
|
|
||||||
const retrievalDisplaySegments =
|
|
||||||
hasSmartRetrievalCalls || receivesLiveRetrievalProgress
|
|
||||||
? buildSmartKnowledgeRetrievalDisplaySegments(
|
|
||||||
group.messages,
|
|
||||||
receivesLiveRetrievalProgress
|
|
||||||
? knowledgeRetrievalProgress
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
kind: "processing" as const,
|
|
||||||
id: `processing-${group.id ?? "group"}`,
|
|
||||||
messages: group.messages,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (
|
|
||||||
retrievalDisplaySegments.some(
|
|
||||||
(segment) =>
|
|
||||||
segment.kind === "retrieval" &&
|
|
||||||
segment.progressFrames.length > 0,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
didRenderLiveKnowledgeRetrieval = true;
|
|
||||||
}
|
|
||||||
const processingBody = (
|
const processingBody = (
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full">
|
||||||
{retrievalDisplaySegments.map((segment) => {
|
<MessageGroup
|
||||||
if (segment.kind === "retrieval") {
|
messages={group.messages}
|
||||||
return (
|
isLoading={thread.isLoading}
|
||||||
<SmartKnowledgeRetrievalTrace
|
displaySources={resolveDisplaySources(processingAi)}
|
||||||
key={segment.id}
|
ragModeEnabled={ragModeEnabled}
|
||||||
messages={segment.messages}
|
/>
|
||||||
progressFrames={segment.progressFrames}
|
|
||||||
dedupeScopeMessages={group.messages}
|
|
||||||
dedupeScopeProgressFrames={
|
|
||||||
receivesLiveRetrievalProgress
|
|
||||||
? knowledgeRetrievalProgress
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
roundOffset={segment.roundOffset}
|
|
||||||
// The AI tool-call message can arrive before its custom
|
|
||||||
// `started` frame. Keep the current turn active through
|
|
||||||
// that short gap instead of misclassifying the pending
|
|
||||||
// call as an interrupted historical action.
|
|
||||||
isLoading={
|
|
||||||
isCurrentTurnRetrievalGroup && thread.isLoading
|
|
||||||
}
|
|
||||||
onOpenResult={(item, target) =>
|
|
||||||
setSelectedRetrievalResult({ item, target })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const segmentHasVisibleProcessing = segment.messages.some(
|
|
||||||
(message) =>
|
|
||||||
message.type === "tool" ||
|
|
||||||
(message.type === "ai" &&
|
|
||||||
(hasReasoning(message) ||
|
|
||||||
hasContent(message) ||
|
|
||||||
hasToolCalls(message))),
|
|
||||||
);
|
|
||||||
if (!segmentHasVisibleProcessing) return null;
|
|
||||||
const segmentAi = [...segment.messages]
|
|
||||||
.reverse()
|
|
||||||
.find((message) => message.type === "ai");
|
|
||||||
return (
|
|
||||||
<MessageGroup
|
|
||||||
key={segment.id}
|
|
||||||
threadId={threadId}
|
|
||||||
messages={segment.messages}
|
|
||||||
isLoading={thread.isLoading}
|
|
||||||
displaySources={resolveDisplaySources(
|
|
||||||
segmentAi ?? processingAi,
|
|
||||||
)}
|
|
||||||
ragModeEnabled={ragModeEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<MessageTokenUsageList
|
<MessageTokenUsageList
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
@@ -1148,27 +886,12 @@ export function MessageList({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const leftoverRewriteItems = documentRewriteItems.filter(
|
const leftoverRewriteItems = didRenderRewriteAboveFile
|
||||||
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
|
? rewriteItemsBelowFile
|
||||||
);
|
: documentRewriteItems;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{grouped}
|
{grouped}
|
||||||
{shouldRenderUnattachedKnowledgeRetrievalProgress({
|
|
||||||
frameCount: knowledgeRetrievalProgress.length,
|
|
||||||
isLoading: thread.isLoading,
|
|
||||||
hasHumanMessage,
|
|
||||||
}) &&
|
|
||||||
!didRenderLiveKnowledgeRetrieval && (
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={knowledgeRetrievalProgress}
|
|
||||||
isLoading={thread.isLoading}
|
|
||||||
onOpenResult={(item, target) =>
|
|
||||||
setSelectedRetrievalResult({ item, target })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{leftoverRewriteItems.map((item) => (
|
{leftoverRewriteItems.map((item) => (
|
||||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
@@ -1190,16 +913,6 @@ export function MessageList({
|
|||||||
aria-label="回到最新"
|
aria-label="回到最新"
|
||||||
title="回到最新"
|
title="回到最新"
|
||||||
/>
|
/>
|
||||||
<WeKnoraSourceDrawer
|
|
||||||
open={selectedRetrievalResult !== null}
|
|
||||||
target={selectedRetrievalResult?.target ?? null}
|
|
||||||
title={selectedRetrievalResult?.item.title}
|
|
||||||
quote={selectedRetrievalResult?.item.preview ?? ""}
|
|
||||||
collapseRetrievedExcerptByDefault
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setSelectedRetrievalResult(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Conversation>
|
</Conversation>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1244,7 +957,6 @@ export type SetupWritingApprovalPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SetupReportStructureApprovalPayload = {
|
export type SetupReportStructureApprovalPayload = {
|
||||||
kind?: "report_structure" | "gongwen_template";
|
|
||||||
title?: string;
|
title?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -1664,7 +1376,6 @@ function SetupReportStructureApprovalCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const approvalPayload = payload;
|
const approvalPayload = payload;
|
||||||
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
|
|
||||||
const busy = disabled || submitting !== null;
|
const busy = disabled || submitting !== null;
|
||||||
const directions = approvalPayload.retrieval_directions ?? [];
|
const directions = approvalPayload.retrieval_directions ?? [];
|
||||||
const skills = approvalPayload.retrieval_skills ?? [];
|
const skills = approvalPayload.retrieval_skills ?? [];
|
||||||
@@ -1689,8 +1400,8 @@ function SetupReportStructureApprovalCard({
|
|||||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
|
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
|
||||||
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
|
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{approvalPayload.title ? (
|
{approvalPayload.title ? (
|
||||||
@@ -1732,7 +1443,7 @@ function SetupReportStructureApprovalCard({
|
|||||||
|
|
||||||
{approvalPayload.content ? (
|
{approvalPayload.content ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div>
|
<div className="text-muted-foreground text-xs">报告大纲</div>
|
||||||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||||
{approvalPayload.content}
|
{approvalPayload.content}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -74,18 +74,6 @@ export function SpeedLegend({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
||||||
if (metric.isPaused) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
title="本段生成停顿,窗口内无新 token"
|
|
||||||
>
|
|
||||||
<GaugeIcon className="text-muted-foreground size-3" />
|
|
||||||
<span className="text-muted-foreground">暂停</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||||
const display =
|
const display =
|
||||||
@@ -97,7 +85,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1.5"
|
className="inline-flex items-center gap-1.5"
|
||||||
title={`${label} · ${metric.fromUsage ? "按本段 output_tokens / 本段生成时长" : "字符估算 ≈ 2 字符 / token"}`}
|
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
|
||||||
>
|
>
|
||||||
<GaugeIcon className="size-3" style={{ color }} />
|
<GaugeIcon className="size-3" style={{ color }} />
|
||||||
<span className="font-mono tabular-nums" style={{ color }}>
|
<span className="font-mono tabular-nums" style={{ color }}>
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
import { ChevronDownIcon, DownloadIcon, EyeIcon, Loader2Icon } from "lucide-react";
|
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import { downloadArtifactFile } from "@/core/artifacts/download";
|
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
|
||||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
|
||||||
import type { RetrievedFile } from "@/core/messages/retrieved-files";
|
|
||||||
import { getFileIcon } from "@/core/utils/files";
|
|
||||||
import {
|
|
||||||
getLlmWikiDocument,
|
|
||||||
getLlmWikiDocumentPreview,
|
|
||||||
getLlmWikiSourceContext,
|
|
||||||
resolveLlmWikiSourceKnowledgeBase,
|
|
||||||
} from "@/strategy-components/api/llmwiki";
|
|
||||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
|
||||||
|
|
||||||
import { ThreadContext } from "./context";
|
|
||||||
import { UploadedFilePreviewDialog } from "./uploaded-file-preview-dialog";
|
|
||||||
|
|
||||||
// File links belong to the upstream service. Never attach DeerFlow auth headers.
|
|
||||||
const fetchExternalFile: typeof fetch = (input, init) =>
|
|
||||||
fetch(input, { ...init, credentials: "omit" });
|
|
||||||
|
|
||||||
export function RetrievedFileList({ files, threadId }: { files: RetrievedFile[]; threadId: string }) {
|
|
||||||
const { isMock, publicShareCode } = useContext(ThreadContext) ?? {};
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const visible = files.filter((file) => !(publicShareCode && file.kind === "knowledge"));
|
|
||||||
// Results arrive in retrieval order; keep the newest file outside the fold.
|
|
||||||
const latest = visible.at(-1);
|
|
||||||
if (!latest) return null;
|
|
||||||
const older = visible.slice(0, -1).reverse();
|
|
||||||
return (
|
|
||||||
<Collapsible asChild open={expanded} onOpenChange={setExpanded}>
|
|
||||||
<section aria-label="检索到的文件" className="not-prose mt-3 space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="text-muted-foreground text-xs">检索到的文件 · {visible.length} 个</div>
|
|
||||||
{older.length > 0 && (
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
|
|
||||||
{expanded ? "收起" : "展开"}
|
|
||||||
<ChevronDownIcon className={`size-3.5 transition-transform ${expanded ? "rotate-180" : ""}`} />
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ul className="grid gap-2">
|
|
||||||
<RetrievedFileCard
|
|
||||||
key={latest.key}
|
|
||||||
file={latest}
|
|
||||||
threadId={threadId}
|
|
||||||
isMock={isMock}
|
|
||||||
publicShareCode={publicShareCode}
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<ul className="grid gap-2">
|
|
||||||
{older.map((file) => (
|
|
||||||
<RetrievedFileCard
|
|
||||||
key={file.key}
|
|
||||||
file={file}
|
|
||||||
threadId={threadId}
|
|
||||||
isMock={isMock}
|
|
||||||
publicShareCode={publicShareCode}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</section>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RetrievedFileCard({ file, threadId, isMock, publicShareCode }: {
|
|
||||||
file: RetrievedFile;
|
|
||||||
threadId: string;
|
|
||||||
isMock?: boolean;
|
|
||||||
publicShareCode?: string;
|
|
||||||
}) {
|
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
|
||||||
const [preparingPreview, setPreparingPreview] = useState(false);
|
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
const [knowledgeTarget, setKnowledgeTarget] = useState<WeKnoraSourceTarget | null>(null);
|
|
||||||
const fileUrl = file.kind === "artifact"
|
|
||||||
? urlOfArtifact({ filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode })
|
|
||||||
: file.kind === "external" ? file.url : "";
|
|
||||||
|
|
||||||
const resolveKnowledgeTarget = async () => {
|
|
||||||
if (file.kind !== "knowledge") throw new Error("当前文件不是知识库文档");
|
|
||||||
if (knowledgeTarget) return knowledgeTarget;
|
|
||||||
const knowledgeBaseId = await resolveLlmWikiSourceKnowledgeBase(file.target.knowledgeBaseId);
|
|
||||||
const target = { ...file.target, knowledgeBaseId };
|
|
||||||
setKnowledgeTarget(target);
|
|
||||||
return target;
|
|
||||||
};
|
|
||||||
|
|
||||||
const preview = async () => {
|
|
||||||
if (preparingPreview) return;
|
|
||||||
setPreparingPreview(true);
|
|
||||||
try {
|
|
||||||
if (file.kind === "knowledge") await resolveKnowledgeTarget();
|
|
||||||
setPreviewOpen(true);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "文件预览失败");
|
|
||||||
} finally {
|
|
||||||
setPreparingPreview(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const download = async () => {
|
|
||||||
if (downloading || file.kind === "external") return;
|
|
||||||
setDownloading(true);
|
|
||||||
try {
|
|
||||||
if (file.kind === "artifact") {
|
|
||||||
await downloadArtifactFile({
|
|
||||||
filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const target = await resolveKnowledgeTarget();
|
|
||||||
let documentId = target.documentId;
|
|
||||||
if (!documentId && target.chunkId) {
|
|
||||||
const context = await getLlmWikiSourceContext(target.knowledgeBaseId, target.chunkId);
|
|
||||||
documentId = context.document_id || undefined;
|
|
||||||
}
|
|
||||||
if (!documentId) throw new Error("当前检索结果没有可下载的文件原件,请先查看预览");
|
|
||||||
const [blob, document] = await Promise.all([
|
|
||||||
getLlmWikiDocumentPreview(target.knowledgeBaseId, documentId),
|
|
||||||
getLlmWikiDocument(target.knowledgeBaseId, documentId).catch(() => null),
|
|
||||||
]);
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
const anchor = window.document.createElement("a");
|
|
||||||
anchor.href = objectUrl;
|
|
||||||
anchor.download = document?.file_name || file.name;
|
|
||||||
anchor.rel = "noopener";
|
|
||||||
window.document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
anchor.remove();
|
|
||||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "文件下载失败");
|
|
||||||
} finally {
|
|
||||||
setDownloading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
|
|
||||||
{getFileIcon(file.name, "text-muted-foreground size-5 shrink-0")}
|
|
||||||
<div className="min-w-0 flex-1 basis-40">
|
|
||||||
<div className="text-sm font-medium break-words">{file.name}</div>
|
|
||||||
{file.source && <div className="text-muted-foreground mt-0.5 truncate text-xs">{file.source}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
{file.kind === "external" && file.previewUrl ? (
|
|
||||||
<Button asChild size="sm" variant="ghost">
|
|
||||||
<a href={file.previewUrl} target="_blank" rel="noopener noreferrer">
|
|
||||||
<EyeIcon className="size-4" />预览
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button type="button" size="sm" variant="ghost" disabled={preparingPreview} onClick={() => void preview()}>
|
|
||||||
{preparingPreview ? <Loader2Icon className="size-4 animate-spin" /> : <EyeIcon className="size-4" />}预览
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{file.kind === "external" ? (
|
|
||||||
<Button asChild size="sm" variant="ghost">
|
|
||||||
<a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer">
|
|
||||||
<DownloadIcon className="size-4" />下载
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button type="button" size="sm" variant="ghost" disabled={downloading} onClick={() => void download()}>
|
|
||||||
{downloading ? <Loader2Icon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}下载
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{previewOpen && (file.kind === "knowledge" ? (
|
|
||||||
<WeKnoraSourceDrawer
|
|
||||||
open
|
|
||||||
target={knowledgeTarget || file.target}
|
|
||||||
title={file.name}
|
|
||||||
quote={file.quote || ""}
|
|
||||||
onOpenChange={setPreviewOpen}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<UploadedFilePreviewDialog
|
|
||||||
filename={file.name}
|
|
||||||
fileUrl={fileUrl}
|
|
||||||
open
|
|
||||||
onOpenChange={setPreviewOpen}
|
|
||||||
threadId={file.kind === "artifact" && !publicShareCode ? threadId : undefined}
|
|
||||||
virtualPath={file.kind === "artifact" ? file.path : undefined}
|
|
||||||
requestFile={file.kind === "external" ? fetchExternalFile : undefined}
|
|
||||||
onDownload={file.kind === "artifact" ? download : undefined}
|
|
||||||
downloading={downloading}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-406
@@ -1,406 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import type { Message } from "@langchain/langgraph-sdk";
|
|
||||||
|
|
||||||
import type { KnowledgeRetrievalProgressFrame } from "@/core/threads/smart-knowledge-retrieval";
|
|
||||||
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
|
|
||||||
|
|
||||||
const completedFrames: KnowledgeRetrievalProgressFrame[] = [
|
|
||||||
{
|
|
||||||
type: "knowledge_retrieval_progress",
|
|
||||||
version: 1,
|
|
||||||
mode: "smart_reasoning",
|
|
||||||
operation: "knowledge_search",
|
|
||||||
status: "started",
|
|
||||||
queries: ["成都世运会"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "knowledge_retrieval_progress",
|
|
||||||
version: 1,
|
|
||||||
mode: "smart_reasoning",
|
|
||||||
operation: "knowledge_search",
|
|
||||||
status: "completed",
|
|
||||||
result_count: 3,
|
|
||||||
elapsed_ms: 420,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "knowledge_retrieval_progress",
|
|
||||||
version: 1,
|
|
||||||
mode: "smart_reasoning",
|
|
||||||
operation: "wiki_search",
|
|
||||||
status: "started",
|
|
||||||
pattern: "火炬|开幕|闭幕",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "knowledge_retrieval_progress",
|
|
||||||
version: 1,
|
|
||||||
mode: "smart_reasoning",
|
|
||||||
operation: "wiki_search",
|
|
||||||
status: "completed",
|
|
||||||
result_count: 4,
|
|
||||||
elapsed_ms: 1380,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
test("completed and replayed retrieval traces start collapsed with their hit summary visible", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={completedFrames}
|
|
||||||
isLoading={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /aria-label="展开智能检索过程"/);
|
|
||||||
assert.match(html, /aria-expanded="false"/);
|
|
||||||
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
|
|
||||||
assert.match(html, /累计耗时 1\.8 秒/);
|
|
||||||
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
|
|
||||||
const messages = [
|
|
||||||
{
|
|
||||||
type: "ai",
|
|
||||||
id: "ai-pending-wiki",
|
|
||||||
content: "",
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
id: "wiki-pending",
|
|
||||||
name: "ordinary_wiki_search",
|
|
||||||
args: { pattern: "数字中国|数据要素" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
] as unknown as Message[];
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={messages}
|
|
||||||
progressFrames={[]}
|
|
||||||
isLoading
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /检索中/);
|
|
||||||
assert.match(html, /正在查 Wiki/);
|
|
||||||
assert.doesNotMatch(html, /部分步骤失败|部分步骤未完成|已中止/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("labels and normalizes a visible model-authored regex", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[
|
|
||||||
{
|
|
||||||
type: "ai",
|
|
||||||
id: "ai-regex",
|
|
||||||
content: "",
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
id: "wiki-regex",
|
|
||||||
name: "ordinary_wiki_search",
|
|
||||||
args: { pattern: "^# [一-龥]{2,10}篇�" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
] as unknown as Message[]}
|
|
||||||
progressFrames={[]}
|
|
||||||
isLoading
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /正则:\^# \\p\{Han\}\{2,10\}篇/);
|
|
||||||
assert.doesNotMatch(html, /一-龥|�/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("presents an interrupted historical action as neutral instead of failed", () => {
|
|
||||||
const messages = [
|
|
||||||
{
|
|
||||||
type: "ai",
|
|
||||||
id: "ai-interrupted-wiki",
|
|
||||||
content: "",
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
id: "wiki-interrupted",
|
|
||||||
name: "ordinary_wiki_search",
|
|
||||||
args: { pattern: "数字中国" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
] as unknown as Message[];
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={messages}
|
|
||||||
progressFrames={[]}
|
|
||||||
isLoading={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /检索已结束(部分步骤未完成)/);
|
|
||||||
assert.doesNotMatch(html, /部分步骤失败|部分步骤异常/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an in-flight retrieval stays expanded and does not expose a collapse control", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={[completedFrames[0]!]}
|
|
||||||
isLoading
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /data-state="open"/);
|
|
||||||
assert.match(html, /检索中/);
|
|
||||||
assert.match(html, /成都世运会/);
|
|
||||||
assert.doesNotMatch(html, /收起智能检索过程|展开智能检索过程/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("completed retrieval can be collapsed while the final answer is still streaming", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={completedFrames}
|
|
||||||
isLoading
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /data-state="open"/);
|
|
||||||
assert.match(html, /检索完成/);
|
|
||||||
assert.match(html, /aria-label="收起智能检索过程"/);
|
|
||||||
assert.match(html, /检索 Wiki/);
|
|
||||||
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
|
|
||||||
assert.doesNotMatch(html, /检索中/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={[completedFrames[0]!]}
|
|
||||||
isLoading
|
|
||||||
roundOffset={2}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /检索原始文档/);
|
|
||||||
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders stable retrieval results as right-drawer controls", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={[
|
|
||||||
{
|
|
||||||
type: "knowledge_retrieval_progress",
|
|
||||||
version: 1,
|
|
||||||
mode: "smart_reasoning",
|
|
||||||
operation: "wiki_search",
|
|
||||||
status: "completed",
|
|
||||||
result_count: 1,
|
|
||||||
result_items: [
|
|
||||||
{
|
|
||||||
kind: "wiki",
|
|
||||||
knowledge_base_id: "kb-news",
|
|
||||||
slug: "news/opening",
|
|
||||||
title: "开幕新闻 Wiki",
|
|
||||||
preview: "页面摘要",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
isLoading
|
|
||||||
onOpenResult={() => undefined}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /data-retrieval-result-detail="true"/);
|
|
||||||
assert.match(html, /在右侧查看:开幕新闻 Wiki/);
|
|
||||||
assert.doesNotMatch(html, /data-retrieval-result-summary="true"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders a Wiki read as a preview card with elapsed time and right-drawer entry", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={[
|
|
||||||
{
|
|
||||||
type: "knowledge_retrieval_progress",
|
|
||||||
version: 1,
|
|
||||||
mode: "smart_reasoning",
|
|
||||||
operation: "wiki_read_page",
|
|
||||||
status: "completed",
|
|
||||||
result_count: 1,
|
|
||||||
knowledge_base_count: 1,
|
|
||||||
coverage: [
|
|
||||||
{ knowledge_base: "数字中国政策目录智能检索远端验收" },
|
|
||||||
],
|
|
||||||
elapsed_ms: 1260,
|
|
||||||
result_items: [
|
|
||||||
{
|
|
||||||
kind: "wiki",
|
|
||||||
knowledge_base_id: "kb-digital-china",
|
|
||||||
knowledge_base: "数字中国政策目录智能检索远端验收",
|
|
||||||
slug: "digital-china/timeline",
|
|
||||||
page_ref: "wiki-page:v1:digital-china-timeline",
|
|
||||||
title: "数字中国建设时间线",
|
|
||||||
content_markdown:
|
|
||||||
"页面正文概览,包含建设目标、关键年份、量化指标和制度保障。",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
isLoading
|
|
||||||
onOpenResult={() => undefined}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /读取结果/);
|
|
||||||
assert.match(html, /数字中国建设时间线/);
|
|
||||||
assert.match(html, /页面正文概览,包含建设目标/);
|
|
||||||
assert.match(html, /右侧查看/);
|
|
||||||
assert.match(html, /耗时 1\.3 秒/);
|
|
||||||
assert.match(html, /覆盖 1 个知识库:数字中国政策目录智能检索远端验收/);
|
|
||||||
assert.doesNotMatch(html, /知识库知识库/);
|
|
||||||
assert.match(html, /data-retrieval-result-detail="true"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows all usable document-read results without a generic partial warning", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={[
|
|
||||||
{
|
|
||||||
type: "knowledge_retrieval_progress",
|
|
||||||
version: 1,
|
|
||||||
mode: "smart_reasoning",
|
|
||||||
operation: "document_chunk_read",
|
|
||||||
status: "completed",
|
|
||||||
result_count: 20,
|
|
||||||
partial: true,
|
|
||||||
warning_codes: ["PROVIDER_CONTEXT_PARTIAL"],
|
|
||||||
message: "原文上下文读取完成,找到 20 条结果",
|
|
||||||
result_items: Array.from({ length: 20 }, (_, index) => ({
|
|
||||||
kind: "raw",
|
|
||||||
knowledge_base_id: "kb-news",
|
|
||||||
knowledge_base: "国内新闻",
|
|
||||||
knowledge_id: "document-long-news",
|
|
||||||
chunk_id: `chunk-${index + 1}`,
|
|
||||||
title: `长篇新闻第 ${index + 1} 段`,
|
|
||||||
content: `原文上下文第 ${index + 1} 段`,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
isLoading
|
|
||||||
onOpenResult={() => undefined}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
(html.match(/data-retrieval-result-detail="true"/g) ?? []).length,
|
|
||||||
20,
|
|
||||||
);
|
|
||||||
assert.match(html, /长篇新闻第 1 段/);
|
|
||||||
assert.match(html, /长篇新闻第 20 段/);
|
|
||||||
assert.doesNotMatch(html, /部分数据源返回警告/);
|
|
||||||
assert.doesNotMatch(html, /部分完成/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps historical results without stable ids as non-clickable summaries", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={[
|
|
||||||
{
|
|
||||||
type: "knowledge_retrieval_progress",
|
|
||||||
version: 1,
|
|
||||||
mode: "smart_reasoning",
|
|
||||||
operation: "knowledge_search",
|
|
||||||
status: "completed",
|
|
||||||
result_count: 1,
|
|
||||||
result_items: [
|
|
||||||
{
|
|
||||||
kind: "raw",
|
|
||||||
knowledge_base: "旧知识库",
|
|
||||||
title: "旧检索结果",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
isLoading
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(html, /data-retrieval-result-summary="true"/);
|
|
||||||
assert.match(html, /该历史检索结果缺少稳定详情标识/);
|
|
||||||
assert.doesNotMatch(html, /data-retrieval-result-detail="true"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses the whole turn as the dedupe scope for interleaved retrieval cards", () => {
|
|
||||||
const firstFrame: KnowledgeRetrievalProgressFrame = {
|
|
||||||
type: "knowledge_retrieval_progress",
|
|
||||||
version: 1,
|
|
||||||
mode: "smart_reasoning",
|
|
||||||
operation: "knowledge_search",
|
|
||||||
retrieval_id: "raw-first",
|
|
||||||
status: "completed",
|
|
||||||
result_count: 1,
|
|
||||||
result_items: [
|
|
||||||
{
|
|
||||||
kind: "raw",
|
|
||||||
knowledge_base_id: "kb-news",
|
|
||||||
knowledge_id: "document-trump",
|
|
||||||
chunk_id: "chunk-1",
|
|
||||||
title: "特朗普生平.txt",
|
|
||||||
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const repeatedFrame: KnowledgeRetrievalProgressFrame = {
|
|
||||||
...firstFrame,
|
|
||||||
retrieval_id: "raw-second",
|
|
||||||
result_items: [
|
|
||||||
{
|
|
||||||
kind: "raw",
|
|
||||||
knowledge_base_id: "kb-news",
|
|
||||||
knowledge_id: "document-trump",
|
|
||||||
chunk_id: "chunk-provider-remapped",
|
|
||||||
title: "特朗普生平.txt",
|
|
||||||
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstHtml = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={[firstFrame]}
|
|
||||||
dedupeScopeMessages={[]}
|
|
||||||
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
|
|
||||||
isLoading
|
|
||||||
onOpenResult={() => undefined}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const repeatedHtml = renderToStaticMarkup(
|
|
||||||
<SmartKnowledgeRetrievalTrace
|
|
||||||
messages={[]}
|
|
||||||
progressFrames={[repeatedFrame]}
|
|
||||||
dedupeScopeMessages={[]}
|
|
||||||
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
|
|
||||||
isLoading
|
|
||||||
onOpenResult={() => undefined}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
(firstHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
|
|
||||||
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
|
|
||||||
});
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
|
||||||
import {
|
|
||||||
AlertCircleIcon,
|
|
||||||
BookOpenTextIcon,
|
|
||||||
CheckCircle2Icon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
CirclePauseIcon,
|
|
||||||
DatabaseIcon,
|
|
||||||
Loader2Icon,
|
|
||||||
PanelRightOpenIcon,
|
|
||||||
SearchCheckIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildSmartKnowledgeRetrievalRounds,
|
|
||||||
type KnowledgeRetrievalProgressFrame,
|
|
||||||
type SmartKnowledgeRetrievalAction,
|
|
||||||
type SmartKnowledgeRetrievalResultItem,
|
|
||||||
} from "@/core/threads/smart-knowledge-retrieval";
|
|
||||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
|
|
||||||
if (action.status === "failed") return "检索失败";
|
|
||||||
if (action.status === "interrupted") return "已中止";
|
|
||||||
if (action.status === "running") {
|
|
||||||
return action.surface === "wiki" ? "正在查 Wiki" : "正在查知识库";
|
|
||||||
}
|
|
||||||
if (action.allResultsAlreadyShown) return "均为已展示结果";
|
|
||||||
const hasUsableResults =
|
|
||||||
action.resultItems.length > 0 || (action.resultCount ?? 0) > 0;
|
|
||||||
const partialPrefix = action.partial && !hasUsableResults ? "部分完成 · " : "";
|
|
||||||
if (action.kind === "search") {
|
|
||||||
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
|
|
||||||
}
|
|
||||||
return typeof action.resultCount === "number"
|
|
||||||
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
|
|
||||||
: `${partialPrefix}读取完成`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsedTime(elapsedMs: number): string {
|
|
||||||
const normalized = Math.max(0, elapsedMs);
|
|
||||||
if (normalized < 1_000) return `${Math.round(normalized)} 毫秒`;
|
|
||||||
if (normalized < 60_000) {
|
|
||||||
const seconds = (normalized / 1_000).toFixed(1).replace(/\.0$/, "");
|
|
||||||
return `${seconds} 秒`;
|
|
||||||
}
|
|
||||||
const roundedSeconds = Math.round(normalized / 1_000);
|
|
||||||
const minutes = Math.floor(roundedSeconds / 60);
|
|
||||||
const seconds = roundedSeconds % 60;
|
|
||||||
return seconds > 0 ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function channelText(channel: string | undefined): string | null {
|
|
||||||
if (!channel) return null;
|
|
||||||
if (channel === "local_wiki_mirror") return "本地 Wiki 镜像";
|
|
||||||
if (channel === "provider") return "知识库 Wiki";
|
|
||||||
if (channel === "mixed") return "本地镜像 + 知识库 Wiki";
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionStatusIcon({ action }: { action: SmartKnowledgeRetrievalAction }) {
|
|
||||||
if (action.status === "running") {
|
|
||||||
return <Loader2Icon className="size-4 animate-spin text-primary" />;
|
|
||||||
}
|
|
||||||
if (action.status === "completed") {
|
|
||||||
return <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />;
|
|
||||||
}
|
|
||||||
if (action.status === "failed") {
|
|
||||||
return <AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400" />;
|
|
||||||
}
|
|
||||||
return <CirclePauseIcon className="size-4 text-muted-foreground" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultDetailTarget(
|
|
||||||
item: SmartKnowledgeRetrievalResultItem,
|
|
||||||
): WeKnoraSourceTarget | null {
|
|
||||||
if (!item.knowledgeBaseId) return null;
|
|
||||||
if (item.kind === "wiki" && item.wikiSlug) {
|
|
||||||
return {
|
|
||||||
knowledgeBaseId: item.knowledgeBaseId,
|
|
||||||
wikiSlug: item.wikiSlug,
|
|
||||||
tab: "wiki",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (item.kind === "raw" && (item.chunkId || item.documentId)) {
|
|
||||||
return {
|
|
||||||
knowledgeBaseId: item.knowledgeBaseId,
|
|
||||||
chunkId: item.chunkId,
|
|
||||||
documentId: item.documentId,
|
|
||||||
tab: "documents",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RetrievalAction({
|
|
||||||
action,
|
|
||||||
onOpenResult,
|
|
||||||
}: {
|
|
||||||
action: SmartKnowledgeRetrievalAction;
|
|
||||||
onOpenResult?: (
|
|
||||||
item: SmartKnowledgeRetrievalResultItem,
|
|
||||||
target: WeKnoraSourceTarget,
|
|
||||||
) => void;
|
|
||||||
}) {
|
|
||||||
const SurfaceIcon = action.surface === "wiki" ? BookOpenTextIcon : DatabaseIcon;
|
|
||||||
const channel = channelText(action.channel);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border border-border/70 bg-background/75 px-3 py-2.5"
|
|
||||||
data-retrieval-operation={action.operation}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-start gap-2.5">
|
|
||||||
<SurfaceIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
|
||||||
<span className="text-sm font-medium text-foreground">{action.label}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 text-xs",
|
|
||||||
action.status === "running"
|
|
||||||
? "text-primary"
|
|
||||||
: action.status === "completed"
|
|
||||||
? "text-emerald-700 dark:text-emerald-300"
|
|
||||||
: action.status === "failed"
|
|
||||||
? "text-amber-700 dark:text-amber-300"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ActionStatusIcon action={action} />
|
|
||||||
{actionStatusText(action)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{action.queries.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
|
||||||
<span className="text-muted-foreground">查询</span>
|
|
||||||
{action.queries.map((query) => (
|
|
||||||
<span
|
|
||||||
key={query}
|
|
||||||
className="max-w-full rounded-md bg-muted px-2 py-0.5 text-foreground/85 break-all"
|
|
||||||
>
|
|
||||||
{query}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
{typeof action.knowledgeBaseCount === "number" ? (
|
|
||||||
<span>
|
|
||||||
覆盖 {action.knowledgeBaseCount} 个知识库
|
|
||||||
{action.knowledgeBases.length > 0
|
|
||||||
? `:${action.knowledgeBases.join("、")}`
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
) : action.knowledgeBases.length > 0 ? (
|
|
||||||
<span>知识库:{action.knowledgeBases.join("、")}</span>
|
|
||||||
) : null}
|
|
||||||
{typeof action.scannedPages === "number" && (
|
|
||||||
<span>扫描 {action.scannedPages} 个 Wiki 页面</span>
|
|
||||||
)}
|
|
||||||
{typeof action.scannedChunks === "number" && (
|
|
||||||
<span>扫描 {action.scannedChunks} 个原文片段</span>
|
|
||||||
)}
|
|
||||||
{action.kind === "read" && typeof action.total === "number" && (
|
|
||||||
<span>共 {action.total} 个片段</span>
|
|
||||||
)}
|
|
||||||
{channel && <span>通道:{channel}</span>}
|
|
||||||
{typeof action.elapsedMs === "number" && (
|
|
||||||
<span>耗时 {formatElapsedTime(action.elapsedMs)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{action.allResultsAlreadyShown ? (
|
|
||||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
|
||||||
本次查询结果均已在前面展示
|
|
||||||
</p>
|
|
||||||
) : action.resultItems.length > 0 ? (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="mb-1.5 text-xs text-muted-foreground">
|
|
||||||
{action.kind === "read" ? "读取结果" : "查询结果"}
|
|
||||||
</div>
|
|
||||||
{action.kind === "read" ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{action.resultItems.map((item) => {
|
|
||||||
const target = onOpenResult
|
|
||||||
? resultDetailTarget(item)
|
|
||||||
: null;
|
|
||||||
const preview = item.preview?.trim().slice(0, 600);
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
||||||
<span className="truncate text-sm font-medium text-foreground">
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
{target && (
|
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
|
|
||||||
右侧查看
|
|
||||||
<PanelRightOpenIcon className="size-3.5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{preview && (
|
|
||||||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
|
|
||||||
{preview}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return target ? (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
data-retrieval-result-detail
|
|
||||||
className="block w-full rounded-lg border border-primary/20 bg-primary/[0.045] px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.075] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
|
||||||
title={`在右侧查看:${item.title}`}
|
|
||||||
onClick={() => onOpenResult?.(item, target)}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
aria-disabled="true"
|
|
||||||
data-retrieval-result-summary
|
|
||||||
className="rounded-lg border border-border/70 bg-muted/45 px-3 py-2.5"
|
|
||||||
title="该历史检索结果缺少稳定详情标识"
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{action.resultItems.map((item) => {
|
|
||||||
const target = onOpenResult
|
|
||||||
? resultDetailTarget(item)
|
|
||||||
: null;
|
|
||||||
return target ? (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
data-retrieval-result-detail
|
|
||||||
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.055] px-2 py-1 text-left text-xs leading-5 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
|
||||||
title={`在右侧查看:${item.title}`}
|
|
||||||
onClick={() => onOpenResult?.(item, target)}
|
|
||||||
>
|
|
||||||
<span className="truncate">{item.title}</span>
|
|
||||||
<PanelRightOpenIcon className="size-3.5 shrink-0 text-primary" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
key={item.id}
|
|
||||||
aria-disabled="true"
|
|
||||||
data-retrieval-result-summary
|
|
||||||
className="inline-flex max-w-full rounded-md bg-muted px-2 py-1 text-xs leading-5 text-muted-foreground"
|
|
||||||
title="该历史检索结果缺少稳定详情标识"
|
|
||||||
>
|
|
||||||
<span className="truncate">{item.title}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(action.resultCount ?? 0) > action.resultItems.length && (
|
|
||||||
<span className="inline-flex items-center px-1 text-xs text-muted-foreground">
|
|
||||||
另有 {(action.resultCount ?? 0) - action.resultItems.length} 条结果
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : action.resultTitles.length > 0 ? (
|
|
||||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
|
||||||
结果摘要:{action.resultTitles.join(";")}
|
|
||||||
{(action.resultCount ?? 0) > action.resultTitles.length ? ";…" : ""}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{action.message && (
|
|
||||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
|
||||||
{action.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{action.error && (
|
|
||||||
<p className="mt-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300">
|
|
||||||
{action.error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{/* Provider warning codes stay in the structured trace for
|
|
||||||
diagnostics. The old generic warning was misleading both when
|
|
||||||
useful evidence existed and when the result set was empty. */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SmartKnowledgeRetrievalTrace({
|
|
||||||
messages,
|
|
||||||
progressFrames = [],
|
|
||||||
dedupeScopeMessages,
|
|
||||||
dedupeScopeProgressFrames,
|
|
||||||
isLoading,
|
|
||||||
onOpenResult,
|
|
||||||
}: {
|
|
||||||
messages: Message[];
|
|
||||||
progressFrames?: KnowledgeRetrievalProgressFrame[];
|
|
||||||
/**
|
|
||||||
* The whole current user-turn group. Interleaved rendering splits one
|
|
||||||
* retrieval invocation into one component, but result dedupe must still see
|
|
||||||
* every invocation in the turn.
|
|
||||||
*/
|
|
||||||
dedupeScopeMessages?: Message[];
|
|
||||||
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
|
|
||||||
isLoading: boolean;
|
|
||||||
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
|
|
||||||
roundOffset?: number;
|
|
||||||
onOpenResult?: (
|
|
||||||
item: SmartKnowledgeRetrievalResultItem,
|
|
||||||
target: WeKnoraSourceTarget,
|
|
||||||
) => void;
|
|
||||||
}) {
|
|
||||||
const rounds = useMemo(
|
|
||||||
() => {
|
|
||||||
const localRounds = buildSmartKnowledgeRetrievalRounds(
|
|
||||||
messages,
|
|
||||||
progressFrames,
|
|
||||||
isLoading,
|
|
||||||
);
|
|
||||||
if (!dedupeScopeMessages) return localRounds;
|
|
||||||
|
|
||||||
const globallyDedupedActions = new Map(
|
|
||||||
buildSmartKnowledgeRetrievalRounds(
|
|
||||||
dedupeScopeMessages,
|
|
||||||
dedupeScopeProgressFrames ?? [],
|
|
||||||
isLoading,
|
|
||||||
)
|
|
||||||
.flatMap((round) => round.actions)
|
|
||||||
.map((action) => [action.id, action] as const),
|
|
||||||
);
|
|
||||||
return localRounds.map((round) => ({
|
|
||||||
...round,
|
|
||||||
actions: round.actions.map((action) => {
|
|
||||||
const globalAction = globallyDedupedActions.get(action.id);
|
|
||||||
return globalAction ? { ...globalAction, round: action.round } : action;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[
|
|
||||||
dedupeScopeMessages,
|
|
||||||
dedupeScopeProgressFrames,
|
|
||||||
isLoading,
|
|
||||||
messages,
|
|
||||||
progressFrames,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const actions = rounds.flatMap((round) => round.actions);
|
|
||||||
// The chat can keep streaming its final answer after retrieval has already
|
|
||||||
// finished. Only an unfinished retrieval action owns the "检索中" state;
|
|
||||||
// the broader thread loading flag must not keep the spinner alive or block
|
|
||||||
// the collapse control during answer generation.
|
|
||||||
const isActive = actions.some((action) => action.status === "running");
|
|
||||||
const hasFailure = actions.some((action) => action.status === "failed");
|
|
||||||
const hasInterrupted = actions.some(
|
|
||||||
(action) => action.status === "interrupted",
|
|
||||||
);
|
|
||||||
const rawHits = actions
|
|
||||||
.filter((action) => action.surface === "raw" && action.kind === "search")
|
|
||||||
.reduce(
|
|
||||||
(total, action) =>
|
|
||||||
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const wikiHits = actions
|
|
||||||
.filter((action) => action.surface === "wiki" && action.kind === "search")
|
|
||||||
.reduce(
|
|
||||||
(total, action) =>
|
|
||||||
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const elapsedActions = actions.filter(
|
|
||||||
(action) => typeof action.elapsedMs === "number",
|
|
||||||
);
|
|
||||||
const cumulativeElapsedMs =
|
|
||||||
elapsedActions.length > 0
|
|
||||||
? elapsedActions.reduce(
|
|
||||||
(total, action) => total + (action.elapsedMs ?? 0),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
// A live trace opens so newly completed result cards remain visible while
|
|
||||||
// the final answer starts streaming. Replayed history starts collapsed.
|
|
||||||
const [isExpanded, setIsExpanded] = useState(
|
|
||||||
isLoading && actions.length > 0,
|
|
||||||
);
|
|
||||||
const wasActive = useRef(isActive);
|
|
||||||
const wasLoading = useRef(isLoading);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!wasActive.current && isActive) {
|
|
||||||
setIsExpanded(true);
|
|
||||||
}
|
|
||||||
if (wasLoading.current && !isLoading) {
|
|
||||||
setIsExpanded(false);
|
|
||||||
}
|
|
||||||
wasActive.current = isActive;
|
|
||||||
wasLoading.current = isLoading;
|
|
||||||
}, [isActive, isLoading]);
|
|
||||||
|
|
||||||
// An in-flight retrieval always stays visible. A just-completed live trace
|
|
||||||
// remains open and user-collapsible while the answer streams; replayed
|
|
||||||
// history starts collapsed and can be reopened for inspection.
|
|
||||||
const isOpen = isActive || isExpanded;
|
|
||||||
|
|
||||||
if (actions.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!isActive) setIsExpanded(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
aria-label="智能检索过程"
|
|
||||||
aria-live={isActive ? "polite" : undefined}
|
|
||||||
className="w-full rounded-xl border border-primary/20 bg-primary/[0.035] p-3.5 shadow-sm"
|
|
||||||
data-smart-knowledge-retrieval-trace
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
||||||
{isActive ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<SearchCheckIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">智能检索过程</h3>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
|
||||||
isActive
|
|
||||||
? "bg-primary/10 text-primary"
|
|
||||||
: hasFailure
|
|
||||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
|
||||||
: hasInterrupted
|
|
||||||
? "bg-muted text-muted-foreground"
|
|
||||||
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isActive
|
|
||||||
? "检索中"
|
|
||||||
: hasFailure
|
|
||||||
? "已完成(部分步骤失败)"
|
|
||||||
: hasInterrupted
|
|
||||||
? "检索已结束(部分步骤未完成)"
|
|
||||||
: "检索完成"}
|
|
||||||
</span>
|
|
||||||
{!isActive && (
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
|
||||||
aria-label={isOpen ? "收起智能检索过程" : "展开智能检索过程"}
|
|
||||||
>
|
|
||||||
<span>{isOpen ? "收起" : "展开"}</span>
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={cn(
|
|
||||||
"size-3.5 transition-transform duration-200",
|
|
||||||
isOpen && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isActive && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
|
|
||||||
{typeof cumulativeElapsedMs === "number"
|
|
||||||
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{isActive && (
|
|
||||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
|
||||||
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
|
|
||||||
{!isActive && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-3">
|
|
||||||
{actions.map((action) => (
|
|
||||||
<RetrievalAction
|
|
||||||
key={action.id}
|
|
||||||
action={action}
|
|
||||||
onOpenResult={onOpenResult}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</section>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
|
||||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
|
||||||
import {
|
|
||||||
GENERATION_IDLE_MS,
|
|
||||||
ROLLING_WINDOW_MS,
|
|
||||||
charsToTokensPerSec,
|
|
||||||
messageHasToolCalls,
|
|
||||||
rollingTokensPerSec,
|
|
||||||
shouldFreezeGeneration,
|
|
||||||
usageTokensPerSec,
|
|
||||||
} from "./speed-metrics.ts";
|
|
||||||
|
|
||||||
test("rolling window reports the recent rate while characters grow", () => {
|
|
||||||
const samples = [
|
|
||||||
{ at: 0, chars: 0 },
|
|
||||||
{ at: 500, chars: 100 },
|
|
||||||
{ at: 1000, chars: 200 },
|
|
||||||
];
|
|
||||||
const rate = rollingTokensPerSec(samples, 200, 1000, 1000);
|
|
||||||
// 200 chars / 1000ms / 2 chars-per-token = 100 tok/s
|
|
||||||
assert.equal(rate, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rolling window drops to 0 when every sample is older than the window", () => {
|
|
||||||
const samples = [
|
|
||||||
{ at: 0, chars: 0 },
|
|
||||||
{ at: 200, chars: 80 },
|
|
||||||
{ at: 400, chars: 160 },
|
|
||||||
];
|
|
||||||
const now = 400 + ROLLING_WINDOW_MS + 50;
|
|
||||||
assert.equal(rollingTokensPerSec(samples, 160, now), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rolling window decays toward 0 after a mid-stream pause", () => {
|
|
||||||
const samples = [
|
|
||||||
{ at: 0, chars: 0 },
|
|
||||||
{ at: 400, chars: 80 },
|
|
||||||
];
|
|
||||||
const live = rollingTokensPerSec(samples, 80, 500, 1000);
|
|
||||||
const paused = rollingTokensPerSec(samples, 80, 900, 1000);
|
|
||||||
assert.ok(live > paused);
|
|
||||||
assert.ok(paused > 0);
|
|
||||||
assert.equal(rollingTokensPerSec(samples, 80, 400 + 1000 + 1, 1000), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("usageTokensPerSec is output tokens over the segment duration", () => {
|
|
||||||
assert.equal(usageTokensPerSec(278, 6200), (278 / 6200) * 1000);
|
|
||||||
assert.equal(usageTokensPerSec(0, 1000), 0);
|
|
||||||
assert.equal(usageTokensPerSec(10, 0), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("charsToTokensPerSec uses the 2-chars-per-token heuristic", () => {
|
|
||||||
assert.equal(charsToTokensPerSec(100, 1000), 50);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shouldFreezeGeneration ends the segment on the next call, tools, idle usage, or run stop", () => {
|
|
||||||
const base = {
|
|
||||||
endedAt: null,
|
|
||||||
isLoading: true,
|
|
||||||
isLatestGenerating: true,
|
|
||||||
hasToolCalls: false,
|
|
||||||
grewThisTick: false,
|
|
||||||
lastGrewAt: 1000,
|
|
||||||
now: 1100,
|
|
||||||
outputTokens: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(shouldFreezeGeneration(base), false);
|
|
||||||
assert.equal(
|
|
||||||
shouldFreezeGeneration({ ...base, isLatestGenerating: false }),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(shouldFreezeGeneration({ ...base, hasToolCalls: true }), true);
|
|
||||||
assert.equal(
|
|
||||||
shouldFreezeGeneration({
|
|
||||||
...base,
|
|
||||||
hasToolCalls: true,
|
|
||||||
grewThisTick: true,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
shouldFreezeGeneration({
|
|
||||||
...base,
|
|
||||||
outputTokens: 40,
|
|
||||||
now: 1000 + GENERATION_IDLE_MS,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
shouldFreezeGeneration({
|
|
||||||
...base,
|
|
||||||
outputTokens: 40,
|
|
||||||
now: 1000 + GENERATION_IDLE_MS - 1,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(shouldFreezeGeneration({ ...base, isLoading: false }), true);
|
|
||||||
assert.equal(shouldFreezeGeneration({ ...base, endedAt: 1000 }), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("messageHasToolCalls reads both top-level and additional_kwargs", () => {
|
|
||||||
assert.equal(messageHasToolCalls({}), false);
|
|
||||||
assert.equal(
|
|
||||||
messageHasToolCalls({
|
|
||||||
tool_calls: [{ id: "1", name: "search" }],
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
messageHasToolCalls({
|
|
||||||
additional_kwargs: { tool_calls: [{ id: "1" }] },
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared tok/s helpers for the chat / roundtable speed pills.
|
|
||||||
*
|
|
||||||
* Live rate uses a rolling window with a virtual tail at "now" so a pause
|
|
||||||
* (no new characters) decays toward 0 instead of freezing on the last two
|
|
||||||
* samples. Final rate prefers provider `output_tokens` over the segment
|
|
||||||
* duration, and only after that model-call segment has ended.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const CHARS_PER_TOKEN = 2;
|
|
||||||
export const SAMPLE_THROTTLE_MS = 60;
|
|
||||||
export const ROLLING_WINDOW_MS = 1000;
|
|
||||||
export const MAX_SPEED_SAMPLES = 256;
|
|
||||||
/** Idle after last visible growth before we treat this model call as finished. */
|
|
||||||
export const GENERATION_IDLE_MS = 400;
|
|
||||||
|
|
||||||
export type SpeedSample = { at: number; chars: number };
|
|
||||||
|
|
||||||
export function estimateTokensFromChars(chars: number): number {
|
|
||||||
return Math.max(0, chars) / CHARS_PER_TOKEN;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function charsToTokensPerSec(chars: number, elapsedMs: number): number {
|
|
||||||
if (elapsedMs <= 0) return 0;
|
|
||||||
return (estimateTokensFromChars(chars) / elapsedMs) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usageTokensPerSec(
|
|
||||||
outputTokens: number,
|
|
||||||
durationMs: number,
|
|
||||||
): number {
|
|
||||||
if (outputTokens <= 0 || durationMs <= 0) return 0;
|
|
||||||
return (outputTokens / durationMs) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instantaneous tok/s over the last `windowMs`.
|
|
||||||
*
|
|
||||||
* The tail is always `(now, lastChars)`. If every sample is older than the
|
|
||||||
* window, the result is 0 — a generation pause must not keep the last
|
|
||||||
* observed rate pinned on screen.
|
|
||||||
*/
|
|
||||||
export function rollingTokensPerSec(
|
|
||||||
samples: SpeedSample[],
|
|
||||||
lastChars: number,
|
|
||||||
now: number,
|
|
||||||
windowMs: number = ROLLING_WINDOW_MS,
|
|
||||||
): number {
|
|
||||||
if (samples.length === 0 || windowMs <= 0) return 0;
|
|
||||||
|
|
||||||
const windowStart = now - windowMs;
|
|
||||||
let headIdx = samples.findIndex((sample) => sample.at >= windowStart);
|
|
||||||
if (headIdx === -1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (headIdx > 0) {
|
|
||||||
headIdx -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const head = samples[headIdx];
|
|
||||||
if (!head) return 0;
|
|
||||||
const elapsedMs = now - head.at;
|
|
||||||
if (elapsedMs <= 0) return 0;
|
|
||||||
return charsToTokensPerSec(Math.max(0, lastChars - head.chars), elapsedMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pushSpeedSample(
|
|
||||||
samples: SpeedSample[],
|
|
||||||
at: number,
|
|
||||||
chars: number,
|
|
||||||
): void {
|
|
||||||
samples.push({ at, chars });
|
|
||||||
if (samples.length > MAX_SPEED_SAMPLES) {
|
|
||||||
samples.splice(0, samples.length - MAX_SPEED_SAMPLES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldFreezeGeneration(args: {
|
|
||||||
endedAt: number | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isLatestGenerating: boolean;
|
|
||||||
hasToolCalls: boolean;
|
|
||||||
grewThisTick: boolean;
|
|
||||||
lastGrewAt: number;
|
|
||||||
now: number;
|
|
||||||
outputTokens: number;
|
|
||||||
}): boolean {
|
|
||||||
if (args.endedAt != null) return false;
|
|
||||||
if (!args.isLatestGenerating) return true;
|
|
||||||
if (args.hasToolCalls && !args.grewThisTick) return true;
|
|
||||||
if (
|
|
||||||
args.outputTokens > 0 &&
|
|
||||||
args.now - args.lastGrewAt >= GENERATION_IDLE_MS
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return !args.isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function messageHasToolCalls(message: {
|
|
||||||
tool_calls?: unknown[] | null;
|
|
||||||
additional_kwargs?: { tool_calls?: unknown[] } | null;
|
|
||||||
}): boolean {
|
|
||||||
const direct = message.tool_calls;
|
|
||||||
if (Array.isArray(direct) && direct.length > 0) return true;
|
|
||||||
const extra = message.additional_kwargs?.tool_calls;
|
|
||||||
return Array.isArray(extra) && extra.length > 0;
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChainOfThought,
|
ChainOfThought,
|
||||||
ChainOfThoughtContent,
|
ChainOfThoughtContent,
|
||||||
@@ -17,10 +19,7 @@ import { ShineBorder } from "@/components/ui/shine-border";
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { hasToolCalls } from "@/core/messages/utils";
|
import { hasToolCalls } from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import {
|
import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
|
||||||
SafeStreamdown,
|
|
||||||
streamdownPluginsWithWordAnimation,
|
|
||||||
} from "@/core/streamdown";
|
|
||||||
import { useSubtask } from "@/core/tasks/context";
|
import { useSubtask } from "@/core/tasks/context";
|
||||||
import { explainLastToolCall } from "@/core/tools/utils";
|
import { explainLastToolCall } from "@/core/tools/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -127,12 +126,12 @@ export function SubtaskCard({
|
|||||||
{task.prompt && (
|
{task.prompt && (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
label={
|
label={
|
||||||
<SafeStreamdown
|
<Streamdown
|
||||||
{...streamdownPluginsWithWordAnimation}
|
{...streamdownPluginsWithWordAnimation}
|
||||||
components={{ a: CitationLink }}
|
components={{ a: CitationLink }}
|
||||||
>
|
>
|
||||||
{task.prompt}
|
{task.prompt}
|
||||||
</SafeStreamdown>
|
</Streamdown>
|
||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DownloadIcon, InfoIcon, Loader2 } from "lucide-react";
|
import { DownloadIcon, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,10 +13,8 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
import { DEFAULT_UPLOAD_POLICY, useUploadPolicy } from "@/core/uploads";
|
|
||||||
|
|
||||||
const markdownClassName =
|
const markdownClassName =
|
||||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
||||||
@@ -58,111 +56,20 @@ function kindOf(filename: string): PreviewKind {
|
|||||||
return "binary";
|
return "binary";
|
||||||
}
|
}
|
||||||
|
|
||||||
function withAppendedMarkdownExtension(url: string): string {
|
function withReplacedExtension(url: string): string {
|
||||||
const [base, query] = url.split("?");
|
// Replace the path extension with .md, keeping any query string intact.
|
||||||
const appended = `${base}.md`;
|
|
||||||
return query ? `${appended}?${query}` : appended;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withLegacyReplacedExtension(url: string): string {
|
|
||||||
// Compatibility with uploads converted before sidecars switched from
|
|
||||||
// `report.md` to the collision-safe `report.docx.md` naming scheme.
|
|
||||||
const [base, query] = url.split("?");
|
const [base, query] = url.split("?");
|
||||||
const i = base.lastIndexOf(".");
|
const i = base.lastIndexOf(".");
|
||||||
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
|
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
|
||||||
return query ? `${swapped}?${query}` : swapped;
|
return query ? `${swapped}?${query}` : swapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtifactPreviewResponse = {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
total_bytes: number;
|
|
||||||
content: string;
|
|
||||||
truncated: boolean;
|
|
||||||
strategy: string;
|
|
||||||
stats?: Record<string, unknown> | null;
|
|
||||||
warnings?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type PreviewMeta = Pick<
|
|
||||||
ArtifactPreviewResponse,
|
|
||||||
"total_bytes" | "truncated" | "strategy" | "warnings"
|
|
||||||
>;
|
|
||||||
|
|
||||||
function parseTotalBytes(response: Response): number | undefined {
|
|
||||||
const contentRange = response.headers.get("Content-Range");
|
|
||||||
const rangeTotal = contentRange?.match(/\/(\d+)$/)?.[1];
|
|
||||||
if (rangeTotal) return Number(rangeTotal);
|
|
||||||
const contentLengthHeader = response.headers.get("Content-Length");
|
|
||||||
if (!contentLengthHeader) return undefined;
|
|
||||||
const contentLength = Number(contentLengthHeader);
|
|
||||||
return Number.isFinite(contentLength) ? contentLength : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read a streamed response up to maxBytes and cancel the rest. */
|
|
||||||
async function readBoundedTextResponse(
|
|
||||||
response: Response,
|
|
||||||
maxBytes: number,
|
|
||||||
): Promise<{ content: string; totalBytes: number; truncated: boolean }> {
|
|
||||||
if (!response.body) throw new Error("浏览器不支持流式预览");
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
const declaredTotal = parseTotalBytes(response);
|
|
||||||
let received = 0;
|
|
||||||
let content = "";
|
|
||||||
let completed = false;
|
|
||||||
|
|
||||||
while (received < maxBytes) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
completed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!value) continue;
|
|
||||||
const remaining = maxBytes - received;
|
|
||||||
const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
|
|
||||||
received += slice.byteLength;
|
|
||||||
content += decoder.decode(slice, { stream: received < maxBytes });
|
|
||||||
if (value.byteLength > remaining || received >= maxBytes) {
|
|
||||||
await reader.cancel();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content += decoder.decode();
|
|
||||||
const totalBytes = declaredTotal ?? received;
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
totalBytes,
|
|
||||||
truncated: !completed || totalBytes > received,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function strategyLabel(strategy: string): string {
|
|
||||||
if (strategy.includes("sampling") || strategy.includes("sample")) {
|
|
||||||
return "流式统计 + 开头/中段/结尾分块采样";
|
|
||||||
}
|
|
||||||
if (strategy === "complete-text") return "完整文本";
|
|
||||||
if (strategy === "bounded-prefix") return "有界前缀";
|
|
||||||
if (strategy === "client-bounded-prefix") return "兼容模式有界前缀";
|
|
||||||
return strategy;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
|
||||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KiB`;
|
|
||||||
return `${bytes} B`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch an auth-gated artifact (the artifact route requires an
|
* Fetch an auth-gated artifact (the artifact route requires an
|
||||||
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
||||||
* hence the 401s) and expose it as an object URL for inline display.
|
* hence the 401s) and expose it as an object URL for inline display.
|
||||||
*/
|
*/
|
||||||
export function useAuthedObjectUrl(
|
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||||
url: string | null,
|
|
||||||
enabled: boolean,
|
|
||||||
requestFile: typeof apiFetch = apiFetch,
|
|
||||||
) {
|
|
||||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -174,10 +81,9 @@ export function useAuthedObjectUrl(
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let created: string | null = null;
|
let created: string | null = null;
|
||||||
const controller = new AbortController();
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
requestFile(url, { signal: controller.signal })
|
apiFetch(url)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return res.blob();
|
return res.blob();
|
||||||
@@ -196,10 +102,9 @@ export function useAuthedObjectUrl(
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
|
||||||
if (created) URL.revokeObjectURL(created);
|
if (created) URL.revokeObjectURL(created);
|
||||||
};
|
};
|
||||||
}, [url, enabled, requestFile]);
|
}, [url, enabled]);
|
||||||
|
|
||||||
return { objectUrl, loading, error };
|
return { objectUrl, loading, error };
|
||||||
}
|
}
|
||||||
@@ -217,152 +122,47 @@ export function useAuthedObjectUrl(
|
|||||||
export function UploadedFilePreviewDialog({
|
export function UploadedFilePreviewDialog({
|
||||||
filename,
|
filename,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
threadId,
|
|
||||||
virtualPath,
|
|
||||||
requestFile = apiFetch,
|
|
||||||
onDownload,
|
|
||||||
downloading = false,
|
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
filename: string;
|
filename: string;
|
||||||
/** Absolute artifact URL for the original uploaded file. */
|
/** Absolute artifact URL for the original uploaded file. */
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
/** Used by the bounded preview endpoint; optional for old/public messages. */
|
|
||||||
threadId?: string;
|
|
||||||
virtualPath?: string;
|
|
||||||
/** External skill links supply an unauthenticated fetch, avoiding token leakage. */
|
|
||||||
requestFile?: typeof apiFetch;
|
|
||||||
/** Authenticated original-file download used by retrieved-file cards. */
|
|
||||||
onDownload?: () => void | Promise<void>;
|
|
||||||
downloading?: boolean;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { data: uploadPolicy } = useUploadPolicy();
|
|
||||||
const effectivePolicy = uploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
|
||||||
const maxPreviewChars = Math.max(
|
|
||||||
256,
|
|
||||||
Math.min(
|
|
||||||
32_000,
|
|
||||||
effectivePolicy.large_file.max_result_chars,
|
|
||||||
effectivePolicy.large_file.hard_max_result_chars,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const kind = useMemo(() => kindOf(filename), [filename]);
|
const kind = useMemo(() => kindOf(filename), [filename]);
|
||||||
const ext = useMemo(() => extOf(filename), [filename]);
|
const ext = useMemo(() => extOf(filename), [filename]);
|
||||||
|
|
||||||
// image / pdf → fetch as authed blob and display via object URL.
|
// image / pdf → fetch as authed blob and display via object URL.
|
||||||
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
||||||
const { objectUrl, loading: blobLoading, error: blobError } =
|
const { objectUrl, loading: blobLoading, error: blobError } =
|
||||||
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
|
useAuthedObjectUrl(fileUrl, blobEnabled);
|
||||||
|
|
||||||
// text / office → fetch text (office reads the converted sibling .md).
|
// text / office → fetch text (office reads the converted sibling .md).
|
||||||
const textUrls = useMemo(
|
const textUrl = useMemo(
|
||||||
() =>
|
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
|
||||||
kind === "office"
|
|
||||||
? Array.from(
|
|
||||||
new Set([
|
|
||||||
withAppendedMarkdownExtension(fileUrl),
|
|
||||||
withLegacyReplacedExtension(fileUrl),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
: [fileUrl],
|
|
||||||
[kind, fileUrl],
|
[kind, fileUrl],
|
||||||
);
|
);
|
||||||
const previewPaths = useMemo(
|
|
||||||
() =>
|
|
||||||
virtualPath
|
|
||||||
? kind === "office"
|
|
||||||
? Array.from(
|
|
||||||
new Set([
|
|
||||||
withAppendedMarkdownExtension(virtualPath),
|
|
||||||
withLegacyReplacedExtension(virtualPath),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
: [virtualPath]
|
|
||||||
: [],
|
|
||||||
[kind, virtualPath],
|
|
||||||
);
|
|
||||||
const [text, setText] = useState<string | null>(null);
|
const [text, setText] = useState<string | null>(null);
|
||||||
const [previewMeta, setPreviewMeta] = useState<PreviewMeta | null>(null);
|
|
||||||
const [textLoading, setTextLoading] = useState(false);
|
const [textLoading, setTextLoading] = useState(false);
|
||||||
const [textError, setTextError] = useState<string | null>(null);
|
const [textError, setTextError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || (kind !== "text" && kind !== "office")) return;
|
if (!open || (kind !== "text" && kind !== "office")) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
|
||||||
setTextLoading(true);
|
setTextLoading(true);
|
||||||
setTextError(null);
|
setTextError(null);
|
||||||
setText(null);
|
setText(null);
|
||||||
setPreviewMeta(null);
|
apiFetch(textUrl)
|
||||||
|
.then(async (res) => {
|
||||||
const load = async () => {
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
if (threadId && previewPaths.length > 0) {
|
return res.text();
|
||||||
for (const previewPath of previewPaths) {
|
})
|
||||||
try {
|
.then((body) => {
|
||||||
const endpoint = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
if (!cancelled) setText(body);
|
||||||
threadId,
|
})
|
||||||
)}/artifact-preview?path=${encodeURIComponent(
|
|
||||||
previewPath,
|
|
||||||
)}&max_chars=${maxPreviewChars}`;
|
|
||||||
const response = await apiFetch(endpoint, { signal: controller.signal });
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
const body = (await response.json()) as ArtifactPreviewResponse;
|
|
||||||
const rawContent = String(body.content ?? "");
|
|
||||||
const content = rawContent.slice(0, maxPreviewChars);
|
|
||||||
if (!cancelled) {
|
|
||||||
setText(content);
|
|
||||||
setPreviewMeta({
|
|
||||||
total_bytes: Number(body.total_bytes) || 0,
|
|
||||||
truncated:
|
|
||||||
Boolean(body.truncated) || rawContent.length > content.length,
|
|
||||||
strategy: String(body.strategy || "bounded-prefix"),
|
|
||||||
warnings: Array.isArray(body.warnings) ? body.warnings : [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// Try a legacy Office sidecar, then fall through to the bounded
|
|
||||||
// Range fallback during a rolling upgrade or public share.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxBytes = Math.max(4_096, maxPreviewChars * 4);
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
for (const textUrl of textUrls) {
|
|
||||||
try {
|
|
||||||
const response = await requestFile(textUrl, {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: { Range: `bytes=0-${maxBytes - 1}` },
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
const bounded = await readBoundedTextResponse(response, maxBytes);
|
|
||||||
if (!cancelled) {
|
|
||||||
const content = bounded.content.slice(0, maxPreviewChars);
|
|
||||||
setText(content);
|
|
||||||
setPreviewMeta({
|
|
||||||
total_bytes: bounded.totalBytes,
|
|
||||||
truncated:
|
|
||||||
bounded.truncated || bounded.content.length > content.length,
|
|
||||||
strategy: "client-bounded-prefix",
|
|
||||||
warnings: [
|
|
||||||
"采样预览接口暂不可用,当前仅显示受限的文件开头,未读取剩余内容。",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error("无法读取文件预览");
|
|
||||||
};
|
|
||||||
|
|
||||||
void load()
|
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
if (!cancelled)
|
if (!cancelled)
|
||||||
setTextError(err instanceof Error ? err.message : String(err));
|
setTextError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -372,9 +172,8 @@ export function UploadedFilePreviewDialog({
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
|
||||||
};
|
};
|
||||||
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
|
}, [open, kind, textUrl]);
|
||||||
|
|
||||||
const renderAsMarkdown =
|
const renderAsMarkdown =
|
||||||
kind === "office" || ext === "md" || ext === "markdown";
|
kind === "office" || ext === "md" || ext === "markdown";
|
||||||
@@ -393,36 +192,21 @@ export function UploadedFilePreviewDialog({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(56rem, calc(100vw - 2rem))",
|
width: "min(56rem, calc(100vw - 2rem))",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
height: "min(720px, calc(100dvh - 2rem))",
|
||||||
height: "min(720px, calc(100vh - 2rem))",
|
|
||||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
|
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
|
||||||
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
||||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||||
{onDownload ? (
|
<a
|
||||||
<Button
|
href={fileUrl}
|
||||||
type="button"
|
target="_blank"
|
||||||
size="sm"
|
rel="noopener noreferrer"
|
||||||
variant="ghost"
|
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||||
disabled={downloading}
|
>
|
||||||
onClick={() => void onDownload()}
|
<DownloadIcon className="size-3" />
|
||||||
>
|
{t.common.download}
|
||||||
<DownloadIcon className="size-3" />
|
</a>
|
||||||
{t.common.download}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={fileUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
<DownloadIcon className="size-3" />
|
|
||||||
{t.common.download}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -431,7 +215,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
blobLoading ? (
|
blobLoading ? (
|
||||||
loadingView
|
loadingView
|
||||||
) : blobError || !objectUrl ? (
|
) : blobError || !objectUrl ? (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
@@ -445,7 +229,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
blobLoading ? (
|
blobLoading ? (
|
||||||
loadingView
|
loadingView
|
||||||
) : blobError || !objectUrl ? (
|
) : blobError || !objectUrl ? (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||||
) : (
|
) : (
|
||||||
<iframe
|
<iframe
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
@@ -457,45 +241,22 @@ export function UploadedFilePreviewDialog({
|
|||||||
textLoading ? (
|
textLoading ? (
|
||||||
loadingView
|
loadingView
|
||||||
) : textError ? (
|
) : textError ? (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||||
|
) : renderAsMarkdown ? (
|
||||||
|
<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} onDownload={onDownload} downloading={downloading} />
|
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -503,34 +264,15 @@ export function UploadedFilePreviewDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
|
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
|
||||||
fileUrl: string;
|
|
||||||
label: string;
|
|
||||||
onDownload?: () => void | Promise<void>;
|
|
||||||
downloading?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
|
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
|
||||||
<p>暂不支持在线预览,请下载原文件查看。</p>
|
<Button asChild size="sm" variant="outline">
|
||||||
{onDownload ? (
|
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={downloading}
|
|
||||||
onClick={() => void onDownload()}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="size-3.5" />
|
<DownloadIcon className="size-3.5" />
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</a>
|
||||||
) : (
|
</Button>
|
||||||
<Button asChild size="sm" variant="outline">
|
|
||||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
|
||||||
<DownloadIcon className="size-3.5" />
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,37 +7,29 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
|
|||||||
import { getUsageMetadata } from "@/core/messages/usage";
|
import { getUsageMetadata } from "@/core/messages/usage";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
|
|
||||||
import {
|
/**
|
||||||
GENERATION_IDLE_MS,
|
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
|
||||||
SAMPLE_THROTTLE_MS,
|
* speed indicator for mixed Chinese/English replies without dragging in a
|
||||||
charsToTokensPerSec,
|
* real tokeniser.
|
||||||
messageHasToolCalls,
|
*/
|
||||||
pushSpeedSample,
|
const CHARS_PER_TOKEN = 2;
|
||||||
rollingTokensPerSec,
|
|
||||||
shouldFreezeGeneration,
|
/** Sample throttle while a message is streaming. */
|
||||||
usageTokensPerSec,
|
const SAMPLE_THROTTLE_MS = 60;
|
||||||
type SpeedSample,
|
|
||||||
} from "./speed-metrics";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-AI-message metric: live rolling rate during that model-call segment,
|
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
|
||||||
* frozen once the segment ends (not when the whole agent turn ends).
|
|
||||||
*/
|
*/
|
||||||
export interface MessageMetric {
|
export interface MessageMetric {
|
||||||
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
||||||
tokensPerSec: number;
|
tokensPerSec: number;
|
||||||
/**
|
/**
|
||||||
* Wall-clock duration of this model-call segment: first visible character
|
* Wall-clock duration from when the message first appeared with text to
|
||||||
* → last visible character (tool-wait after the call is excluded).
|
* either *now* (active) or to when the stream ended (frozen).
|
||||||
*/
|
*/
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
/** True while this message is still generating visible tokens. */
|
/** True while this is the currently streaming message. */
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
/**
|
|
||||||
* True while this message is the live generator but the rolling window
|
|
||||||
* has seen no new characters — the pill should not keep a stale tok/s.
|
|
||||||
*/
|
|
||||||
isPaused: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Whether the speed value was computed from real usage_metadata output
|
* Whether the speed value was computed from real usage_metadata output
|
||||||
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
||||||
@@ -53,12 +45,15 @@ export interface MessageMetric {
|
|||||||
isTurnFinal: boolean;
|
isTurnFinal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Sample = { at: number; chars: number };
|
||||||
|
|
||||||
type TimingEntry = {
|
type TimingEntry = {
|
||||||
firstSeenAt: number;
|
firstSeenAt: number;
|
||||||
endedAt: number | null;
|
endedAt: number | null;
|
||||||
lastGrewAt: number;
|
samples: Sample[];
|
||||||
samples: SpeedSample[];
|
|
||||||
lastChars: number;
|
lastChars: number;
|
||||||
|
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
|
||||||
|
hadUsage: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isAiMessage(message: Message): boolean {
|
function isAiMessage(message: Message): boolean {
|
||||||
@@ -87,12 +82,11 @@ function messageText(message: Message): string {
|
|||||||
*
|
*
|
||||||
* Recording strategy:
|
* Recording strategy:
|
||||||
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
||||||
* - On every text-length change we push a sample.
|
* - On every text-length change we push a sample and recompute the rate.
|
||||||
* - The segment freezes when this model call ends (next AI message, tool
|
* - When `isLoading` flips to false we mark *all* still-active messages as
|
||||||
* calls, usage + idle, or the run stops) so tool-wait is not in the
|
* ended so subsequent renders return a frozen duration.
|
||||||
* denominator.
|
* - Token totals come from usage_metadata when present (most accurate);
|
||||||
* - Live rate is a rolling window that decays to 0 on pause.
|
* otherwise we fall back to char-count / 2.
|
||||||
* - `usage_metadata.output_tokens` only corrects the frozen final value.
|
|
||||||
*/
|
*/
|
||||||
export function useMessageMetrics(
|
export function useMessageMetrics(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
@@ -110,8 +104,7 @@ export function useMessageMetrics(
|
|||||||
// synchronously inside an effect so React isn't tracking the map identity.
|
// synchronously inside an effect so React isn't tracking the map identity.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const grewIds = new Set<string>();
|
let activeId: string | null = null;
|
||||||
let latestId: string | null = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const message = messages[i];
|
const message = messages[i];
|
||||||
@@ -125,62 +118,50 @@ export function useMessageMetrics(
|
|||||||
entry = {
|
entry = {
|
||||||
firstSeenAt: now,
|
firstSeenAt: now,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
lastGrewAt: now,
|
|
||||||
samples: [{ at: now, chars: text.length }],
|
samples: [{ at: now, chars: text.length }],
|
||||||
lastChars: text.length,
|
lastChars: text.length,
|
||||||
|
hadUsage: false,
|
||||||
};
|
};
|
||||||
timingRef.current.set(id, entry);
|
timingRef.current.set(id, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
latestId = id;
|
// Track the most recent AI message as the active one (while streaming).
|
||||||
|
activeId = id;
|
||||||
|
|
||||||
|
// Sample only when actively streaming OR when content actually grew —
|
||||||
|
// throttle to keep arrays bounded.
|
||||||
const grew = text.length !== entry.lastChars;
|
const grew = text.length !== entry.lastChars;
|
||||||
if (grew) {
|
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||||
grewIds.add(id);
|
entry.samples.push({ at: now, chars: text.length });
|
||||||
entry.lastGrewAt = now;
|
entry.lastChars = text.length;
|
||||||
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
if (entry.samples.length > 256) {
|
||||||
pushSpeedSample(entry.samples, now, text.length);
|
entry.samples.splice(0, entry.samples.length - 256);
|
||||||
lastSampleAtRef.current = now;
|
|
||||||
}
|
}
|
||||||
|
lastSampleAtRef.current = now;
|
||||||
|
} else if (grew) {
|
||||||
|
// Still update lastChars so the next render captures it eventually.
|
||||||
entry.lastChars = text.length;
|
entry.lastChars = text.length;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
if (getUsageMetadata(message)) {
|
||||||
const message = messages[i];
|
entry.hadUsage = true;
|
||||||
if (!message || !isAiMessage(message)) continue;
|
|
||||||
const id = getMessageId(message, i);
|
|
||||||
const entry = timingRef.current.get(id);
|
|
||||||
if (!entry) continue;
|
|
||||||
|
|
||||||
const grewThisTick = grewIds.has(id);
|
|
||||||
if (grewThisTick && entry.endedAt != null && isLoading) {
|
|
||||||
entry.endedAt = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = getUsageMetadata(message);
|
|
||||||
if (
|
|
||||||
shouldFreezeGeneration({
|
|
||||||
endedAt: entry.endedAt,
|
|
||||||
isLoading,
|
|
||||||
isLatestGenerating: id === latestId,
|
|
||||||
hasToolCalls: messageHasToolCalls(message),
|
|
||||||
grewThisTick,
|
|
||||||
lastGrewAt: entry.lastGrewAt,
|
|
||||||
now,
|
|
||||||
outputTokens: usage?.outputTokens ?? 0,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
entry.endedAt = entry.lastGrewAt;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastActiveIdRef.current = latestId;
|
// When streaming stops, freeze any non-ended entry to the current time.
|
||||||
|
if (!isLoading) {
|
||||||
|
for (const entry of timingRef.current.values()) {
|
||||||
|
if (entry.endedAt === null) {
|
||||||
|
entry.endedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastActiveIdRef.current = activeId;
|
||||||
setTick((v) => v + 1);
|
setTick((v) => v + 1);
|
||||||
}, [messages, isLoading]);
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
// Drive a slow tick while streaming so live durations / pause decay refresh.
|
// Drive a slow tick while streaming so live durations refresh smoothly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) return;
|
if (!isLoading) return;
|
||||||
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
||||||
@@ -217,36 +198,45 @@ export function useMessageMetrics(
|
|||||||
const entry = timingRef.current.get(id);
|
const entry = timingRef.current.get(id);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
|
|
||||||
const isActive =
|
|
||||||
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
|
|
||||||
const endedAt = entry.endedAt ?? now;
|
const endedAt = entry.endedAt ?? now;
|
||||||
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
||||||
|
const isActive = isLoading && id === lastActiveIdRef.current;
|
||||||
|
|
||||||
let tokensPerSec = 0;
|
let tokensPerSec = 0;
|
||||||
let fromUsage = false;
|
let fromUsage = false;
|
||||||
let isPaused = false;
|
|
||||||
const usage = getUsageMetadata(message);
|
const usage = getUsageMetadata(message);
|
||||||
|
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||||
if (entry.endedAt != null) {
|
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
|
||||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
fromUsage = true;
|
||||||
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
|
|
||||||
fromUsage = true;
|
|
||||||
} else if (durationMs > 0) {
|
|
||||||
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
|
// Char-count fallback. Uses the rolling window during streaming so a
|
||||||
isPaused =
|
// mid-stream snapshot reflects current speed rather than average since
|
||||||
isActive &&
|
// the start.
|
||||||
tokensPerSec <= 0 &&
|
if (isActive && entry.samples.length >= 2) {
|
||||||
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
|
const samples = entry.samples;
|
||||||
|
let start = samples.findIndex((s) => s.at >= now - 1500);
|
||||||
|
if (start === -1) start = samples.length - 2;
|
||||||
|
if (start < 0) start = 0;
|
||||||
|
const head = samples[start];
|
||||||
|
const tail = samples[samples.length - 1];
|
||||||
|
if (head && tail) {
|
||||||
|
const elapsedMs = tail.at - head.at;
|
||||||
|
const chars = Math.max(0, tail.chars - head.chars);
|
||||||
|
if (elapsedMs > 0) {
|
||||||
|
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (durationMs > 0) {
|
||||||
|
// Finished message without usage data: average over full duration.
|
||||||
|
const chars = entry.lastChars;
|
||||||
|
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out.set(id, {
|
out.set(id, {
|
||||||
tokensPerSec,
|
tokensPerSec,
|
||||||
durationMs,
|
durationMs,
|
||||||
isActive,
|
isActive,
|
||||||
isPaused,
|
|
||||||
fromUsage,
|
fromUsage,
|
||||||
isTurnFinal: turnFinalIds.has(id),
|
isTurnFinal: turnFinalIds.has(id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export function ModelNameSelect({
|
|||||||
className,
|
className,
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
defaultLabel = "默认模型",
|
defaultLabel = "默认模型",
|
||||||
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
|
||||||
}: {
|
}: {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
onChange: (modelName: string | null) => void;
|
onChange: (modelName: string | null) => void;
|
||||||
@@ -33,7 +32,6 @@ export function ModelNameSelect({
|
|||||||
className?: string;
|
className?: string;
|
||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
defaultLabel?: string;
|
defaultLabel?: string;
|
||||||
title?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
return (
|
return (
|
||||||
@@ -44,7 +42,7 @@ export function ModelNameSelect({
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||||
title={title}
|
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder={defaultLabel} />
|
<SelectValue placeholder={defaultLabel} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import {
|
|
||||||
FileText,
|
|
||||||
MessageSquareText,
|
|
||||||
Sparkles,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
MY_PROJECT_CONVERSATION_LABELS,
|
|
||||||
type MyProjectConversationType,
|
|
||||||
} from "@/core/threads/project-metadata";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
|
|
||||||
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
|
|
||||||
strategy:
|
|
||||||
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
|
|
||||||
solution:
|
|
||||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICONS: Record<MyProjectConversationType, LucideIcon> = {
|
|
||||||
qa: MessageSquareText,
|
|
||||||
strategy: Sparkles,
|
|
||||||
solution: FileText,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICON_STYLES: Record<MyProjectConversationType, string> = {
|
|
||||||
qa: "bg-sky-50 text-sky-600 dark:bg-sky-950/70 dark:text-sky-300",
|
|
||||||
strategy:
|
|
||||||
"bg-violet-50 text-violet-600 dark:bg-violet-950/70 dark:text-violet-300",
|
|
||||||
solution:
|
|
||||||
"bg-emerald-50 text-emerald-600 dark:bg-emerald-950/70 dark:text-emerald-300",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MyProjectThreadIcon({
|
|
||||||
type,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
type: MyProjectConversationType;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const Icon = ICONS[type];
|
|
||||||
const label = `我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
aria-label={label}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex size-5 shrink-0 items-center justify-center rounded-md",
|
|
||||||
ICON_STYLES[type],
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
role="img"
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
<Icon aria-hidden="true" className="size-3.5" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MyProjectThreadBadge({
|
|
||||||
type,
|
|
||||||
projectName,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
type: MyProjectConversationType;
|
|
||||||
projectName?: string | null;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const typeLabel = `${MY_PROJECT_CONVERSATION_LABELS[type]}项目`;
|
|
||||||
const ownerLabel = projectName?.trim() || "我的项目";
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex min-w-0 max-w-full items-center gap-1 self-start rounded border px-1.5 py-0.5 text-[9px] font-medium leading-none",
|
|
||||||
BADGE_STYLES[type],
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
title={`来自我的项目 · ${typeLabel} · ${ownerLabel}`}
|
|
||||||
>
|
|
||||||
<span className="shrink-0">{typeLabel}</span>
|
|
||||||
<span aria-hidden="true" className="opacity-55">·</span>
|
|
||||||
<span className="min-w-0 truncate">{ownerLabel}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
|
||||||
|
|
||||||
import {
|
|
||||||
extractContentFromMessage,
|
|
||||||
isHiddenFromUIMessage,
|
|
||||||
stripArtifactTag,
|
|
||||||
stripUploadedFilesTag,
|
|
||||||
} from "@/core/messages/utils";
|
|
||||||
|
|
||||||
const SUMMARY_MAX_LENGTH = 80;
|
|
||||||
|
|
||||||
export type QuestionItem = {
|
|
||||||
anchorId: string;
|
|
||||||
summary: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Preserve the raw message index for historical messages without an id. */
|
|
||||||
export function getQuestionAnchorId(message: Pick<Message, "id">, index: number): string {
|
|
||||||
return `message-${message.id ?? index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeQuestion(raw: string): string {
|
|
||||||
let text = stripArtifactTag(stripUploadedFilesTag(raw));
|
|
||||||
text = text
|
|
||||||
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
|
|
||||||
.replace(/```[\s\S]*?(```|$)/g, " ")
|
|
||||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
|
||||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
||||||
.replace(/`([^`]*)`/g, "$1")
|
|
||||||
.replace(/^#{1,6}\s+/gm, "")
|
|
||||||
.replace(/^\s*>\s?/gm, "")
|
|
||||||
.replace(/^\s*[-*+]\s+/gm, "")
|
|
||||||
.replace(/^\s*\d+\.\s+/gm, "")
|
|
||||||
.replace(/(\*\*|__|~~)/g, "")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
return text.length > SUMMARY_MAX_LENGTH ? `${text.slice(0, SUMMARY_MAX_LENGTH)}…` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Index only real, visible questions, using the same visibility policy as chat. */
|
|
||||||
export function buildQuestionItems(messages: Message[]): QuestionItem[] {
|
|
||||||
const items: QuestionItem[] = [];
|
|
||||||
messages.forEach((message, index) => {
|
|
||||||
if (message.type !== "human" || isHiddenFromUIMessage(message)) return;
|
|
||||||
const summary = summarizeQuestion(extractContentFromMessage(message));
|
|
||||||
if (summary) items.push({ anchorId: getQuestionAnchorId(message, index), summary });
|
|
||||||
});
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
@@ -11,21 +11,68 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import {
|
||||||
|
extractContentFromMessage,
|
||||||
|
stripArtifactTag,
|
||||||
|
stripUploadedFilesTag,
|
||||||
|
} from "@/core/messages/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
|
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
export { getQuestionAnchorId } from "./question-navigator-data";
|
|
||||||
|
|
||||||
/** Width of the expanded floating panel, in px. */
|
/** Width of the expanded floating panel, in px. */
|
||||||
const PANEL_WIDTH = 300;
|
const PANEL_WIDTH = 300;
|
||||||
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
|
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
|
||||||
const PANEL_GUTTER_MARGIN = 24;
|
const PANEL_GUTTER_MARGIN = 24;
|
||||||
|
/** Max characters kept in a question summary card. */
|
||||||
|
const SUMMARY_MAX_LENGTH = 80;
|
||||||
/** Questions needed before the navigator shows up at all. */
|
/** Questions needed before the navigator shows up at all. */
|
||||||
const MIN_QUESTIONS = 2;
|
const MIN_QUESTIONS = 2;
|
||||||
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
|
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable DOM anchor id for a message. Prefers the server-side message id and
|
||||||
|
* falls back to the render index so messages without ids still get a target
|
||||||
|
* (requirement: `message-${index}` style fallback).
|
||||||
|
*/
|
||||||
|
export function getQuestionAnchorId(
|
||||||
|
message: Pick<Message, "id">,
|
||||||
|
index: number,
|
||||||
|
): string {
|
||||||
|
return `message-${message.id ?? index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the raw markdown of a user question into a short plain-text summary:
|
||||||
|
* strips upload/artifact/page-context envelopes and common Markdown syntax,
|
||||||
|
* collapses whitespace, and truncates with an ellipsis.
|
||||||
|
*/
|
||||||
|
function summarizeQuestion(raw: string): string {
|
||||||
|
let text = stripArtifactTag(stripUploadedFilesTag(raw));
|
||||||
|
text = text
|
||||||
|
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
|
||||||
|
.replace(/```[\s\S]*?(```|$)/g, " ")
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
||||||
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||||
|
.replace(/`([^`]*)`/g, "$1")
|
||||||
|
.replace(/^#{1,6}\s+/gm, "")
|
||||||
|
.replace(/^\s*>\s?/gm, "")
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, "")
|
||||||
|
.replace(/^\s*\d+\.\s+/gm, "")
|
||||||
|
.replace(/(\*\*|__|~~)/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
if (text.length > SUMMARY_MAX_LENGTH) {
|
||||||
|
return `${text.slice(0, SUMMARY_MAX_LENGTH)}…`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionItem = {
|
||||||
|
anchorId: string;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** Walk up from a message anchor to the element that actually scrolls. */
|
/** Walk up from a message anchor to the element that actually scrolls. */
|
||||||
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||||
let node = el?.parentElement ?? null;
|
let node = el?.parentElement ?? null;
|
||||||
@@ -81,7 +128,16 @@ export function QuestionNavigator({
|
|||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
const questions = useMemo(() => buildQuestionItems(messages), [messages]);
|
const questions = useMemo(() => {
|
||||||
|
const items: QuestionItem[] = [];
|
||||||
|
messages.forEach((message, index) => {
|
||||||
|
if (message.type !== "human") return;
|
||||||
|
const summary = summarizeQuestion(extractContentFromMessage(message));
|
||||||
|
if (!summary) return;
|
||||||
|
items.push({ anchorId: getQuestionAnchorId(message, index), summary });
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const visible = questions.length >= MIN_QUESTIONS;
|
const visible = questions.length >= MIN_QUESTIONS;
|
||||||
|
|
||||||
|
|||||||
@@ -48,13 +48,8 @@ import {
|
|||||||
SidebarMenuAction,
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
import {
|
|
||||||
MyProjectThreadBadge,
|
|
||||||
MyProjectThreadIcon,
|
|
||||||
} from "@/components/workspace/my-project-thread-indicator";
|
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -69,8 +64,6 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
|
||||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
|
||||||
import {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -85,11 +78,6 @@ import { env } from "@/env";
|
|||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
|
|
||||||
import {
|
|
||||||
mergeRecentWorkspaceItems,
|
|
||||||
useRecentStrategyProjects,
|
|
||||||
} from "@/strategy-selection/recent-projects";
|
|
||||||
|
|
||||||
export function RecentChatList({
|
export function RecentChatList({
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -105,7 +93,6 @@ export function RecentChatList({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isMobile, setOpenMobile } = useSidebar();
|
|
||||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||||
);
|
);
|
||||||
@@ -122,9 +109,8 @@ 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();
|
||||||
@@ -148,15 +134,15 @@ export function RecentChatList({
|
|||||||
});
|
});
|
||||||
if (threadIndex > -1) {
|
if (threadIndex > -1) {
|
||||||
if (threads[threadIndex + 1]) {
|
if (threads[threadIndex + 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
|
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||||
} else if (threads[threadIndex - 1]) {
|
} else if (threads[threadIndex - 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
|
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void router.push(nextThreadPath);
|
void router.push(nextThreadPath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
|
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenameClick = useCallback(
|
const handleRenameClick = useCallback(
|
||||||
@@ -212,16 +198,12 @@ export function RecentChatList({
|
|||||||
const visibleThreads = sortThreadsPinnedFirst(
|
const visibleThreads = sortThreadsPinnedFirst(
|
||||||
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
||||||
);
|
);
|
||||||
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
|
||||||
const recentItems = mergeRecentWorkspaceItems(
|
|
||||||
visibleThreads,
|
|
||||||
recentStrategyProjects,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recentItems.length === 0) {
|
if (visibleThreads.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems;
|
const shownThreads =
|
||||||
|
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup
|
<SidebarGroup
|
||||||
@@ -243,7 +225,7 @@ export function RecentChatList({
|
|||||||
</span>
|
</span>
|
||||||
{compact && (
|
{compact && (
|
||||||
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
|
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
|
||||||
{recentItems.length}
|
{visibleThreads.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -260,84 +242,32 @@ export function RecentChatList({
|
|||||||
>
|
>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
{shownItems.map((item) => {
|
{shownThreads.map((thread) => {
|
||||||
if (item.kind === "strategy-project") {
|
const href = listPathOfThread(thread);
|
||||||
const { project } = item;
|
|
||||||
const href = strategyProjectWorkbenchPath(
|
|
||||||
"/page/strategy/qa/projects",
|
|
||||||
project.id,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem
|
|
||||||
key={`strategy-project:${project.id}`}
|
|
||||||
className="group/side-menu-item"
|
|
||||||
>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={href === pathname}
|
|
||||||
asChild
|
|
||||||
className={cn(
|
|
||||||
"h-auto min-h-11 rounded-lg px-3 py-1.5 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
|
||||||
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]",
|
|
||||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
|
|
||||||
compact &&
|
|
||||||
"min-h-11 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
|
||||||
href={href}
|
|
||||||
onClick={() => {
|
|
||||||
if (isMobile) setOpenMobile(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MyProjectThreadIcon type="strategy" />
|
|
||||||
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
|
|
||||||
<span className="w-full truncate">
|
|
||||||
{desensitize(project.title)}
|
|
||||||
</span>
|
|
||||||
<MyProjectThreadBadge
|
|
||||||
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
|
|
||||||
projectName={project.title}
|
|
||||||
type="strategy"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { thread } = item;
|
|
||||||
const href = listPathOfThread(thread, pathname);
|
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
const projectInfo = myProjectConversationInfoOfThread(
|
|
||||||
thread,
|
|
||||||
projectThreadNames,
|
|
||||||
);
|
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
key={`thread:${thread.thread_id}`}
|
key={thread.thread_id}
|
||||||
className="group/side-menu-item"
|
className="group/side-menu-item"
|
||||||
>
|
>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
asChild
|
asChild
|
||||||
className={cn(
|
className={
|
||||||
"h-7 rounded-lg px-3 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
|
||||||
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)]",
|
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
|
||||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none",
|
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none" +
|
||||||
compact &&
|
(compact
|
||||||
"h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
|
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
|
||||||
projectInfo && "h-auto min-h-11 py-1.5",
|
: "")
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||||
href={href}
|
href={href}
|
||||||
onClick={() => { if (isMobile) setOpenMobile(false); }}
|
|
||||||
>
|
>
|
||||||
{isBoot ? (
|
{isBoot ? (
|
||||||
<Bot
|
<Bot
|
||||||
@@ -349,8 +279,6 @@ export function RecentChatList({
|
|||||||
}
|
}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
) : projectInfo ? (
|
|
||||||
<MyProjectThreadIcon type={projectInfo.type} />
|
|
||||||
) : (
|
) : (
|
||||||
compact && (
|
compact && (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
@@ -366,22 +294,8 @@ export function RecentChatList({
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
)}
|
)}
|
||||||
<span
|
<span className="truncate">
|
||||||
className={cn(
|
{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>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
|
||||||
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
|
||||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||||
import { clearYLogToken } from "@/core/log/y-log-token";
|
import { clearYLogToken } from "@/core/log/y-log-token";
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
import { PasswordSettings } from "./password-settings";
|
|
||||||
|
|
||||||
function roleLabel(role?: string) {
|
function roleLabel(role?: string) {
|
||||||
if (role === "admin") return "管理员";
|
if (role === "admin") return "管理员";
|
||||||
@@ -60,15 +57,14 @@ export function AccountSettingsPage() {
|
|||||||
// Match the rest of the app's logout flow: blow away the cookie session
|
// Match the rest of the app's logout flow: blow away the cookie session
|
||||||
// by hitting the gateway logout endpoint and then forcing a full reload
|
// by hitting the gateway logout endpoint and then forcing a full reload
|
||||||
// so any cached query state goes too.
|
// so any cached query state goes too.
|
||||||
void apiFetch(`${getBackendBaseURL()}/api/v1/auth/logout`, { method: "POST" })
|
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
window.location.assign(`${window.location.pathname}#/login`);
|
window.location.assign("/");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
|
||||||
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
|
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
|
||||||
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
|
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -110,8 +106,6 @@ export function AccountSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<PasswordSettings key={auth.user_id} userId={auth.user_id} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
import { FONT_SCALE_MODE_OPTIONS, FONT_SCALE_OPTIONS } from "@/components/font-scale";
|
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -42,20 +42,13 @@ import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-sessio
|
|||||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import {
|
|
||||||
useUpdateUserPreferences,
|
|
||||||
useUserPreferencesQuery,
|
|
||||||
} from "@/core/user-preferences";
|
|
||||||
import {
|
import {
|
||||||
useAppearanceSettings,
|
useAppearanceSettings,
|
||||||
useCitationDisplaySettings,
|
useCitationDisplaySettings,
|
||||||
useHtmlMapSettings,
|
|
||||||
useLayoutMode,
|
useLayoutMode,
|
||||||
useUpdateAppearanceSettings,
|
useUpdateAppearanceSettings,
|
||||||
useUpdateCitationDisplaySettings,
|
useUpdateCitationDisplaySettings,
|
||||||
useUpdateHtmlMapSettings,
|
|
||||||
} from "@/core/system-settings/hooks";
|
} from "@/core/system-settings/hooks";
|
||||||
import type { HtmlMapEngine } from "@/core/system-settings/api";
|
|
||||||
import type {
|
import type {
|
||||||
CompactShortcutButton,
|
CompactShortcutButton,
|
||||||
CompactShortcutRouteParam,
|
CompactShortcutRouteParam,
|
||||||
@@ -186,16 +179,12 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currentTheme = theme;
|
const currentTheme = theme;
|
||||||
const [settings, setSettings] = useLocalSettings();
|
const [settings, setSettings] = useLocalSettings();
|
||||||
const { data: userPreferences, isPending: preferencesLoading } = useUserPreferencesQuery();
|
|
||||||
const updateUserPreferences = useUpdateUserPreferences();
|
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const { data: appearance } = useAppearanceSettings();
|
const { data: appearance } = useAppearanceSettings();
|
||||||
const updateAppearance = useUpdateAppearanceSettings();
|
const updateAppearance = useUpdateAppearanceSettings();
|
||||||
const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
|
const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||||
const { data: htmlMap } = useHtmlMapSettings();
|
|
||||||
const updateHtmlMap = useUpdateHtmlMapSettings();
|
|
||||||
const globalDefault = appearance?.default_theme ?? "light";
|
const globalDefault = appearance?.default_theme ?? "light";
|
||||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||||
@@ -758,7 +747,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="字号"
|
title="字号"
|
||||||
description="调整界面字号。默认 16px(标准)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。"
|
description="调整界面整体字号,方便放大或缩小阅读。默认 16px(标准)。"
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Select
|
<Select
|
||||||
@@ -781,29 +770,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{FONT_SCALE_MODE_OPTIONS.map((item) => {
|
|
||||||
const selected = (settings.display.fontScaleMode ?? "text") === item.value;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={item.value}
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={selected ? "default" : "outline"}
|
|
||||||
onClick={() => setSettings("display", { fontScaleMode: item.value })}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{
|
|
||||||
FONT_SCALE_MODE_OPTIONS.find(
|
|
||||||
(item) => item.value === (settings.display.fontScaleMode ?? "text"),
|
|
||||||
)?.description
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<div className="text-muted-foreground rounded-lg border p-4 text-sm leading-relaxed">
|
<div className="text-muted-foreground rounded-lg border p-4 text-sm leading-relaxed">
|
||||||
预览:多智能体圆桌研讨 · The quick brown fox · 0123456789
|
预览:多智能体圆桌研讨 · The quick brown fox · 0123456789
|
||||||
</div>
|
</div>
|
||||||
@@ -865,30 +831,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
title="对话体验"
|
title="对话体验"
|
||||||
description="问答过程中的辅助显示。"
|
description="问答过程中的辅助显示。"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-start justify-between gap-4 rounded-lg border p-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="followup-suggestions" className="text-sm font-medium">
|
|
||||||
问答后推荐问题
|
|
||||||
</label>
|
|
||||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
|
||||||
默认关闭。开启后,在后续回答结束时生成并显示可继续提问的问题。仅对当前账号生效,设置随账号保存。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="followup-suggestions"
|
|
||||||
checked={userPreferences?.followup_suggestions_enabled ?? false}
|
|
||||||
disabled={preferencesLoading || updateUserPreferences.isPending}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
updateUserPreferences.mutate(
|
|
||||||
{ followup_suggestions_enabled: checked },
|
|
||||||
{
|
|
||||||
onError: (error) => toast.error(error.message || "保存失败"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
aria-label="问答后推荐问题"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">回答速度指示器</div>
|
<div className="text-sm font-medium">回答速度指示器</div>
|
||||||
@@ -925,53 +867,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">HTML 地图</div>
|
|
||||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
|
||||||
管理员配置。生成带地图的 HTML 网页时使用哪种地图。ECharts 使用本地行政区划;地图网页使用 Leaflet 瓦片,地址在后台 config.yaml 的 html_runtime.map_tile_url。下一次生成的页面生效。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={htmlMap?.engine ?? "echarts"}
|
|
||||||
disabled={!htmlMap || updateHtmlMap.isPending}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value !== "echarts" && value !== "leaflet") return;
|
|
||||||
updateHtmlMap.mutate(value as HtmlMapEngine, {
|
|
||||||
onError: (error) => toast.error(error.message || "保存失败"),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[160px]" aria-label="HTML 地图">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="echarts">ECharts</SelectItem>
|
|
||||||
<SelectItem value="leaflet">地图网页</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">技能检索文件展示</div>
|
|
||||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
|
||||||
全局设置。开启后,问答步骤下展示技能检索到的文件,支持预览和下载;不展示生成文件。默认开启。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={citationDisplay?.retrieved_files_enabled ?? false}
|
|
||||||
disabled={!citationDisplay || updateCitationDisplay.isPending}
|
|
||||||
onCheckedChange={(checked) => updateCitationDisplay.mutate(
|
|
||||||
{ retrieved_files_enabled: checked },
|
|
||||||
{ onError: (error) => toast.error(error.message || "保存失败") },
|
|
||||||
)}
|
|
||||||
aria-label="技能检索文件展示"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{referenceModeEntryEnabled && (
|
{referenceModeEntryEnabled && (
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { AUTH_STATE_CHANGED_EVENT, getStoredAuth } from "@/core/auth";
|
|
||||||
import {
|
|
||||||
getAuthenticationSettings,
|
|
||||||
saveAuthenticationSettings,
|
|
||||||
type AuthenticationSettings,
|
|
||||||
} from "@/core/auth/account-settings-api";
|
|
||||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
|
||||||
|
|
||||||
export function AuthenticationSettingsPage({ onClose }: { onClose?: () => void }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const live = useLiveAccount();
|
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const queryKey = ["authentication-settings", live.userId];
|
|
||||||
const query = useQuery({ queryKey, queryFn: getAuthenticationSettings, enabled: isAdmin });
|
|
||||||
const [draft, setDraft] = useState<AuthenticationSettings | null>(null);
|
|
||||||
const mutation = useMutation({ mutationFn: saveAuthenticationSettings });
|
|
||||||
const settings = draft ?? query.data;
|
|
||||||
|
|
||||||
if (!isAdmin) return null;
|
|
||||||
|
|
||||||
const update = (key: keyof AuthenticationSettings, value: boolean) => {
|
|
||||||
if (!settings) return;
|
|
||||||
const next = { ...settings, [key]: value };
|
|
||||||
if (key === "password_login_enabled" && !value) next.registration_enabled = false;
|
|
||||||
setDraft(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!settings || mutation.isPending) return;
|
|
||||||
try {
|
|
||||||
const saved = await mutation.mutateAsync(settings);
|
|
||||||
queryClient.setQueryData(queryKey, saved);
|
|
||||||
setDraft(null);
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["auth-options"] });
|
|
||||||
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
|
|
||||||
toast.success("登录与注册设置已保存,即时生效");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const invalid = settings && !settings.password_login_enabled && !settings.sso_enabled;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection title="登录与注册" description="管理登录页面、自主注册与账号在线限制,保存后即时生效。">
|
|
||||||
{query.isPending ? (
|
|
||||||
<p className="text-muted-foreground text-sm">正在加载设置…</p>
|
|
||||||
) : query.isError ? (
|
|
||||||
<div role="alert" className="space-y-3 text-sm">
|
|
||||||
<p className="text-destructive">{query.error.message}</p>
|
|
||||||
<Button variant="outline" onClick={() => void query.refetch()}>重新加载</Button>
|
|
||||||
</div>
|
|
||||||
) : settings && (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="divide-y rounded-xl border px-5">
|
|
||||||
<SettingSwitch id="password-login-enabled" title="账号密码登录" description="默认关闭。开启后须登录才能进入系统。未设置个人密码的账号使用现有系统口令。" checked={settings.password_login_enabled} disabled={mutation.isPending} onChange={(value) => update("password_login_enabled", value)} />
|
|
||||||
<SettingSwitch id="registration-enabled" title="用户自主注册" description="允许用户在注册页创建自己的普通账号。需先开启账号密码登录。" checked={settings.registration_enabled} disabled={mutation.isPending || !settings.password_login_enabled} onChange={(value) => update("registration_enabled", value)} />
|
|
||||||
<SettingSwitch id="single-session-enabled" title="单点登录" description="默认关闭。开启后同一账号只允许一处在线,新登录会使其他登录会话自动退出。" checked={settings.single_session_enabled ?? false} disabled={mutation.isPending} onChange={(value) => update("single_session_enabled", value)} />
|
|
||||||
<SettingSwitch id="sso-enabled" title="外部系统免登" description="允许外部系统通过票据、令牌或口令链接登录,与单点登录的在线限制独立。" checked={settings.sso_enabled} disabled={mutation.isPending} onChange={(value) => update("sso_enabled", value)} />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-sm">密码与登录口令共用。至少保留账号密码登录或外部系统免登中的一种;关闭自主注册不影响已有账号。</p>
|
|
||||||
<Button variant="outline" onClick={() => { onClose?.(); navigate("/page/strategy/admin/login-whitelist"); }}>管理用户密码与登录口令</Button>
|
|
||||||
{invalid && <p role="alert" className="text-destructive text-sm">请至少开启账号密码登录或外部系统免登。</p>}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button disabled={!draft || Boolean(invalid) || mutation.isPending} onClick={() => void save()}>{mutation.isPending ? "保存中…" : "保存设置"}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingSwitch({ id, title, description, checked, disabled, onChange }: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
checked: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-5 py-5">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label htmlFor={id} className="cursor-pointer text-sm font-medium">{title}</label>
|
|
||||||
<p id={`${id}-description`} className="text-muted-foreground text-sm">{description}</p>
|
|
||||||
</div>
|
|
||||||
<Switch id={id} aria-describedby={`${id}-description`} checked={checked} disabled={disabled} onCheckedChange={onChange} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Loader2Icon, ShrinkIcon } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import {
|
|
||||||
useConversationContextSettings,
|
|
||||||
useUpdateConversationContextSettings,
|
|
||||||
} from "@/core/system-settings/hooks";
|
|
||||||
|
|
||||||
export function ContextWindowSettingsPage() {
|
|
||||||
const { data, isLoading, isError, isFetching, refetch } =
|
|
||||||
useConversationContextSettings();
|
|
||||||
const update = useUpdateConversationContextSettings();
|
|
||||||
const enabled = data?.recent_turn_window_enabled ?? false;
|
|
||||||
const recentTurns = data?.recent_turns ?? 3;
|
|
||||||
|
|
||||||
const handleChange = async (checked: boolean) => {
|
|
||||||
try {
|
|
||||||
await update.mutateAsync({ recent_turn_window_enabled: checked });
|
|
||||||
toast.success(
|
|
||||||
checked
|
|
||||||
? `近 ${recentTurns} 轮上下文模式已开启`
|
|
||||||
: `近 ${recentTurns} 轮上下文模式已关闭`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<ShrinkIcon className="size-5" />
|
|
||||||
上下文管理
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
控制长对话发给模型的历史范围。该模式默认关闭,只有管理员显式开启后才会生效。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-medium">仅保留最近 {recentTurns} 轮对话</div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
开启后,主问答引擎的模型请求只携带最近 {recentTurns}
|
|
||||||
个真实用户回合(含当前轮)及其回复和工具结果,不再调用摘要模型。完整聊天记录仍会保存并显示;若这三轮本身仍超过模型上限,最终安全限制会继续裁剪超长内容。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Switch
|
|
||||||
checked={enabled}
|
|
||||||
disabled={isError || update.isPending}
|
|
||||||
onCheckedChange={handleChange}
|
|
||||||
aria-label={`仅保留最近 ${recentTurns} 轮对话`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<p className="text-destructive text-sm">
|
|
||||||
无法读取当前上下文模式,本页暂不能修改;服务端会继续保持原设置。
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={isFetching}
|
|
||||||
onClick={() => void refetch()}
|
|
||||||
>
|
|
||||||
{isFetching ? "重新加载中…" : "重新加载"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
开启后优先于聊天框中的“上下文压缩”选项,并从下一次新问答或运行开始生效,无需重启服务。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Loader2Icon, SearchIcon } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import {
|
|
||||||
useOrdinaryKnowledgeRetrievalSettings,
|
|
||||||
useUpdateOrdinaryKnowledgeRetrievalSettings,
|
|
||||||
} from "@/core/system-settings/hooks";
|
|
||||||
|
|
||||||
export function KnowledgeRetrievalSettingsPage() {
|
|
||||||
const { data, isLoading, isError, isFetching, refetch } =
|
|
||||||
useOrdinaryKnowledgeRetrievalSettings();
|
|
||||||
const update = useUpdateOrdinaryKnowledgeRetrievalSettings();
|
|
||||||
const enabled = data?.smart_reasoning_enabled ?? false;
|
|
||||||
const advancedManagementEnabled =
|
|
||||||
data?.advanced_management_enabled ?? false;
|
|
||||||
|
|
||||||
const handleChange = async (checked: boolean) => {
|
|
||||||
try {
|
|
||||||
await update.mutateAsync({ smart_reasoning_enabled: checked });
|
|
||||||
toast.success(
|
|
||||||
checked ? "普通知识库智能检索已开启" : "普通知识库智能检索已关闭",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdvancedManagementChange = async (checked: boolean) => {
|
|
||||||
try {
|
|
||||||
await update.mutateAsync({ advanced_management_enabled: checked });
|
|
||||||
toast.success(
|
|
||||||
checked ? "知识库高级管理入口已开放" : "知识库高级管理入口已隐藏",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<SearchIcon className="size-5" />
|
|
||||||
知识检索
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
控制普通知识库问答是否默认使用智能检索。该能力默认关闭,经典检索模式始终保留为候选。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-medium">普通知识库智能推理检索</div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
开启后,新对话默认使用智能检索:先召回原文分片,再根据命中线索查阅并扩展
|
|
||||||
Wiki,把知识库资料作为回答背景。已有对话继续使用各自保存的模式,用户仍可在
|
|
||||||
输入框的检索模式菜单中切换到经典检索。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Switch
|
|
||||||
checked={enabled}
|
|
||||||
disabled={isError || update.isPending}
|
|
||||||
onCheckedChange={handleChange}
|
|
||||||
aria-label="普通知识库智能推理检索"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-medium">知识库高级管理入口</div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
默认隐藏检索测试、增量同步和知识库数据包导入导出。开启后按原有账号权限显示;
|
|
||||||
基础知识库管理和查看 Wiki 索引始终可用。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Switch
|
|
||||||
checked={advancedManagementEnabled}
|
|
||||||
disabled={isError || update.isPending}
|
|
||||||
onCheckedChange={handleAdvancedManagementChange}
|
|
||||||
aria-label="知识库高级管理入口"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<p className="text-destructive text-sm">
|
|
||||||
无法读取当前知识检索设置,客户端按关闭处理,本页暂不能修改。
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={isFetching}
|
|
||||||
onClick={() => void refetch()}
|
|
||||||
>
|
|
||||||
{isFetching ? "重新加载中…" : "重新加载"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
用户只需直接提问,无需填写“先检索什么、再检索什么”的固定模板;系统不会向输入框
|
|
||||||
注入检索提示词。智能检索将检索资料与模型原有能力结合后回答,不强制生成参考文献编号。
|
|
||||||
关闭后不会删除历史对话;已保存为智能检索的线程需要切回经典模式后才能继续发送。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { BrainIcon, Loader2Icon } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import {
|
|
||||||
useMemoryRuntimeSettings,
|
|
||||||
useUpdateMemoryRuntimeSettings,
|
|
||||||
} from "@/core/system-settings/hooks";
|
|
||||||
|
|
||||||
export function MemoryRuntimeSettingsPage() {
|
|
||||||
const { data, isLoading, isError } = useMemoryRuntimeSettings();
|
|
||||||
const update = useUpdateMemoryRuntimeSettings();
|
|
||||||
const enabled = data?.enabled ?? false;
|
|
||||||
|
|
||||||
const handleChange = async (checked: boolean) => {
|
|
||||||
try {
|
|
||||||
await update.mutateAsync({ enabled: checked });
|
|
||||||
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<BrainIcon className="size-5" />
|
|
||||||
问答记忆
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-medium">启用问答记忆</div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Switch
|
|
||||||
checked={enabled}
|
|
||||||
disabled={isError || update.isPending}
|
|
||||||
onCheckedChange={handleChange}
|
|
||||||
aria-label="启用问答记忆"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<p className="text-destructive text-sm">
|
|
||||||
无法读取记忆开关,系统将按关闭处理。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useState, type FormEvent } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { getStoredAuth, setStoredAuth } from "@/core/auth";
|
|
||||||
import { changeAccountPassword, getAccountPasswordStatus } from "@/core/auth/account-settings-api";
|
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
|
||||||
|
|
||||||
export function PasswordSettings({ userId }: { userId: string }) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const status = useQuery({
|
|
||||||
queryKey: ["account-password", userId],
|
|
||||||
queryFn: getAccountPasswordStatus,
|
|
||||||
});
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const hasPassword = status.data?.has_password ?? true;
|
|
||||||
const requiresCurrentPassword = status.data?.requires_current_password ?? true;
|
|
||||||
|
|
||||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (pending || !status.data) return;
|
|
||||||
setError("");
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
setError("两次输入的新密码不一致");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newPassword.length < 8) {
|
|
||||||
setError("新密码至少需要 8 位,请避免常见弱密码");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const auth = getStoredAuth();
|
|
||||||
if (!auth || auth.user_id !== userId) {
|
|
||||||
setError("登录账号已变化,请重新打开个人信息");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPending(true);
|
|
||||||
try {
|
|
||||||
const session = await changeAccountPassword(requiresCurrentPassword ? currentPassword : "", newPassword);
|
|
||||||
// Another tab may have changed the active account while the request was pending.
|
|
||||||
if (getStoredAuth()?.access_token !== auth.access_token) return;
|
|
||||||
if (session.access_token) {
|
|
||||||
setStoredAuth({
|
|
||||||
...auth,
|
|
||||||
access_token: session.access_token,
|
|
||||||
token_type: session.token_type ?? auth.token_type,
|
|
||||||
expires_in: session.expires_in ?? auth.expires_in,
|
|
||||||
needs_setup: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setCurrentPassword("");
|
|
||||||
setNewPassword("");
|
|
||||||
setConfirmPassword("");
|
|
||||||
queryClient.setQueryData(["account-password", userId], { ...status.data, has_password: true, requires_current_password: true, uses_default_password: false });
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
|
||||||
toast.success("密码与登录口令已同步更新,其他登录会话已失效");
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "密码修改失败");
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection title={hasPassword ? "修改密码" : "设置密码"} description="密码与登录口令共用,修改后原密码和原口令链接失效。">
|
|
||||||
{status.isPending ? <p className="text-muted-foreground text-sm">正在读取密码状态…</p> : status.isError ? (
|
|
||||||
<div role="alert" className="space-y-3 text-sm">
|
|
||||||
<p className="text-destructive">{status.error.message}</p>
|
|
||||||
<Button variant="outline" onClick={() => void status.refetch()}>重新加载</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={(event) => void submit(event)} className="space-y-4 rounded-xl border p-5">
|
|
||||||
{!hasPassword && <p className="text-muted-foreground text-sm">此账号尚未设置个人密码,当前使用系统口令。请先输入现有口令,再设置个人密码。</p>}
|
|
||||||
{requiresCurrentPassword && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="account-current-password">当前密码 / 口令</label>
|
|
||||||
<Input id="account-current-password" type="password" autoComplete="current-password" required value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={pending} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="account-new-password">新密码</label>
|
|
||||||
<Input id="account-new-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} placeholder="至少 8 位,避免常见弱密码" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={pending} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="account-confirm-password">确认新密码</label>
|
|
||||||
<Input id="account-confirm-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={pending} />
|
|
||||||
</div>
|
|
||||||
{error && <p role="alert" className="text-destructive text-sm">{error}</p>}
|
|
||||||
<div className="flex justify-end"><Button type="submit" disabled={pending}>{pending ? "保存中…" : "保存密码"}</Button></div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||||
BellIcon,
|
|
||||||
BrainIcon,
|
|
||||||
CpuIcon,
|
|
||||||
FileOutputIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
SearchIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
ShrinkIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
UploadCloudIcon,
|
|
||||||
UserIcon,
|
|
||||||
WrenchIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -25,29 +12,18 @@ import {
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||||
import { AuthenticationSettingsPage } from "@/components/workspace/settings/authentication-settings-page";
|
|
||||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||||
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
|
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||||
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
|
|
||||||
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
|
|
||||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||||
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
|
|
||||||
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
|
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
||||||
|
|
||||||
type SettingsSection =
|
type SettingsSection =
|
||||||
| "account"
|
| "account"
|
||||||
| "authentication"
|
|
||||||
| "appearance"
|
| "appearance"
|
||||||
| "conversation-context"
|
|
||||||
| "knowledge-retrieval"
|
|
||||||
| "memory"
|
| "memory"
|
||||||
| "fileWriting"
|
|
||||||
| "uploads"
|
|
||||||
| "tools"
|
| "tools"
|
||||||
| "encoder"
|
| "encoder"
|
||||||
| "about";
|
| "about";
|
||||||
@@ -59,7 +35,6 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
|||||||
export function SettingsDialog(props: SettingsDialogProps) {
|
export function SettingsDialog(props: SettingsDialogProps) {
|
||||||
const { defaultSection = "appearance", ...dialogProps } = props;
|
const { defaultSection = "appearance", ...dialogProps } = props;
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const live = useLiveAccount();
|
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [activeSection, setActiveSection] =
|
const [activeSection, setActiveSection] =
|
||||||
useState<SettingsSection>(defaultSection);
|
useState<SettingsSection>(defaultSection);
|
||||||
@@ -86,19 +61,15 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
// },
|
// },
|
||||||
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||||
...(isAdmin
|
// 记忆已移至侧边栏入口 (/page/workspace/memory)
|
||||||
? [
|
// {
|
||||||
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
|
// id: "memory",
|
||||||
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
|
// label: t.settings.sections.memory,
|
||||||
{ id: "memory", label: "问答记忆", icon: BrainIcon },
|
// icon: BrainIcon,
|
||||||
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
|
// },
|
||||||
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
|
|
||||||
{ id: "authentication", label: "登录与注册", icon: ShieldCheckIcon },
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
...(isAdmin
|
...(isAdmin
|
||||||
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
@@ -158,36 +129,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
||||||
<div className="min-w-0 space-y-8 p-6">
|
<div className="min-w-0 space-y-8 p-6">
|
||||||
{activeSection === "account" && <AccountSettingsPage />}
|
{activeSection === "account" && <AccountSettingsPage />}
|
||||||
{activeSection === "authentication" && isAdmin && (
|
|
||||||
<AuthenticationSettingsPage
|
|
||||||
key={live.userId}
|
|
||||||
onClose={() => props.onOpenChange?.(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeSection === "appearance" && (
|
{activeSection === "appearance" && (
|
||||||
<AppearanceSettingsPage
|
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||||
onClose={() => props.onOpenChange?.(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeSection === "conversation-context" && isAdmin && (
|
|
||||||
<ContextWindowSettingsPage />
|
|
||||||
)}
|
|
||||||
{activeSection === "knowledge-retrieval" && isAdmin && (
|
|
||||||
<KnowledgeRetrievalSettingsPage />
|
|
||||||
)}
|
|
||||||
{activeSection === "memory" && isAdmin && (
|
|
||||||
<MemoryRuntimeSettingsPage />
|
|
||||||
)}
|
|
||||||
{activeSection === "fileWriting" && isAdmin && (
|
|
||||||
<WriteFileCompatibilitySettingsPage />
|
|
||||||
)}
|
|
||||||
{activeSection === "uploads" && isAdmin && (
|
|
||||||
<UploadSettingsPage />
|
|
||||||
)}
|
)}
|
||||||
|
{activeSection === "memory" && <MemorySettingsPage />}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "encoder" && isAdmin && (
|
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||||
<LocalEncoderSettingsCard />
|
|
||||||
)}
|
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import {
|
|||||||
LockIcon,
|
LockIcon,
|
||||||
PinIcon,
|
PinIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
Power,
|
|
||||||
PowerOff,
|
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
@@ -72,13 +70,11 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
|||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
|
|
||||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||||
import {
|
import {
|
||||||
useDeleteBuiltinSkill,
|
useDeleteBuiltinSkill,
|
||||||
useDeleteCustomSkill,
|
useDeleteCustomSkill,
|
||||||
useDuplicateSkill,
|
useDuplicateSkill,
|
||||||
useEnableSkill,
|
|
||||||
useGenerateMissingSkillNameZh,
|
useGenerateMissingSkillNameZh,
|
||||||
useGenerateSkillNameZh,
|
useGenerateSkillNameZh,
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
@@ -91,23 +87,11 @@ import {
|
|||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
import {
|
|
||||||
canToggleSkillAvailability,
|
|
||||||
isSkillOffline,
|
|
||||||
type SkillAvailabilityControl,
|
|
||||||
} from "@/core/skill-availability";
|
|
||||||
import {
|
import {
|
||||||
usePublishSquares,
|
usePublishSquares,
|
||||||
useSkillCompressionSettings,
|
useSkillCompressionSettings,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "@/core/system-settings/hooks";
|
} from "@/core/system-settings/hooks";
|
||||||
import {
|
|
||||||
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
|
|
||||||
WX_RESTRICTED_TAB_NANJING_DB,
|
|
||||||
isWxRestrictedPlaceholderTab,
|
|
||||||
wxRestrictedDefaultSquareTabValue,
|
|
||||||
wxRestrictedNanjingTabValue,
|
|
||||||
} from "@/core/system-settings/wx-type-filter";
|
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
||||||
import { formatDateTime } from "@/core/utils/datetime";
|
import { formatDateTime } from "@/core/utils/datetime";
|
||||||
@@ -121,25 +105,18 @@ import { SkillStateSheet } from "./skill-state-sheet";
|
|||||||
export function SkillSettingsPage({
|
export function SkillSettingsPage({
|
||||||
onClose,
|
onClose,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
restrictWxTypes = false,
|
|
||||||
}: {
|
}: {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
|
|
||||||
restrictWxTypes?: boolean;
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
if (embedded) {
|
if (embedded) return <SkillSettingsList onClose={onClose} />;
|
||||||
return (
|
|
||||||
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t.settings.skills.title}
|
title={t.settings.skills.title}
|
||||||
description={t.settings.skills.description}
|
description={t.settings.skills.description}
|
||||||
>
|
>
|
||||||
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
|
<SkillSettingsList onClose={onClose} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,13 +165,7 @@ const rescanErrorToastDarkOptions = {
|
|||||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function SkillSettingsList({
|
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||||
onClose,
|
|
||||||
restrictWxTypes = false,
|
|
||||||
}: {
|
|
||||||
onClose?: () => void;
|
|
||||||
restrictWxTypes?: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -216,10 +187,6 @@ function SkillSettingsList({
|
|||||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||||
// backend always returns at least one (the built-in default).
|
// backend always returns at least one (the built-in default).
|
||||||
const squareTabs = publishSquares?.squares ?? [];
|
const squareTabs = publishSquares?.squares ?? [];
|
||||||
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
|
|
||||||
publishSquares?.default_square_id,
|
|
||||||
);
|
|
||||||
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
|
|
||||||
const reconcileMutation = useReconcileCustomSkills();
|
const reconcileMutation = useReconcileCustomSkills();
|
||||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||||
@@ -286,9 +253,6 @@ function SkillSettingsList({
|
|||||||
const isSquareTab = filter !== "mine";
|
const isSquareTab = filter !== "mine";
|
||||||
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
||||||
const filteredSkills = useMemo(() => {
|
const filteredSkills = useMemo(() => {
|
||||||
if (restrictWxTypes && isWxRestrictedPlaceholderTab(filter)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
let list = publisher
|
let list = publisher
|
||||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||||
: scopedSkills;
|
: scopedSkills;
|
||||||
@@ -302,14 +266,7 @@ function SkillSettingsList({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [
|
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
|
||||||
scopedSkills,
|
|
||||||
publisher,
|
|
||||||
filter,
|
|
||||||
isSquareTab,
|
|
||||||
publishSquares?.default_square_id,
|
|
||||||
restrictWxTypes,
|
|
||||||
]);
|
|
||||||
const orderedSkills = useMemo(
|
const orderedSkills = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filteredSkills
|
filteredSkills
|
||||||
@@ -339,30 +296,6 @@ function SkillSettingsList({
|
|||||||
// If the active square tab disappears (admin removed that square), fall back
|
// If the active square tab disappears (admin removed that square), fall back
|
||||||
// to "我的" so the page never points at a dead tab.
|
// to "我的" so the page never points at a dead tab.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (restrictWxTypes) {
|
|
||||||
if (
|
|
||||||
filter === WX_RESTRICTED_TAB_NANJING_DB &&
|
|
||||||
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
|
|
||||||
) {
|
|
||||||
setFilter(nanjingTabValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
filter === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
|
|
||||||
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
|
|
||||||
) {
|
|
||||||
setFilter(defaultSquareTabValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
filter !== "mine" &&
|
|
||||||
filter !== defaultSquareTabValue &&
|
|
||||||
filter !== nanjingTabValue
|
|
||||||
) {
|
|
||||||
setFilter("mine");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
filter !== "mine" &&
|
filter !== "mine" &&
|
||||||
squareTabs.length > 0 &&
|
squareTabs.length > 0 &&
|
||||||
@@ -370,7 +303,7 @@ function SkillSettingsList({
|
|||||||
) {
|
) {
|
||||||
setFilter("mine");
|
setFilter("mine");
|
||||||
}
|
}
|
||||||
}, [filter, squareTabs, restrictWxTypes, defaultSquareTabValue, nanjingTabValue]);
|
}, [filter, squareTabs]);
|
||||||
const pagedSkills = useMemo(
|
const pagedSkills = useMemo(
|
||||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||||
[orderedSkills, page],
|
[orderedSkills, page],
|
||||||
@@ -421,6 +354,7 @@ function SkillSettingsList({
|
|||||||
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
|
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
@@ -428,22 +362,12 @@ function SkillSettingsList({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Tabs value={filter} onValueChange={setFilter}>
|
<Tabs value={filter} onValueChange={setFilter}>
|
||||||
<TabsList variant="line" className="flex-wrap">
|
<TabsList variant="line" className="flex-wrap">
|
||||||
{restrictWxTypes ? (
|
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||||
<>
|
{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
|
||||||
@@ -625,7 +549,6 @@ function SkillSettingsList({
|
|||||||
skill={skill}
|
skill={skill}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
availabilityControl="owner"
|
|
||||||
layout={viewMode === "card" ? "card" : "row"}
|
layout={viewMode === "card" ? "card" : "row"}
|
||||||
onPublisherClick={setPublisher}
|
onPublisherClick={setPublisher}
|
||||||
/>
|
/>
|
||||||
@@ -684,7 +607,6 @@ export function SkillRow({
|
|||||||
skill,
|
skill,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
availabilityControl,
|
|
||||||
layout = "row",
|
layout = "row",
|
||||||
onPublisherClick,
|
onPublisherClick,
|
||||||
knowledgeBindings = [],
|
knowledgeBindings = [],
|
||||||
@@ -692,8 +614,6 @@ export function SkillRow({
|
|||||||
skill: Skill;
|
skill: Skill;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
/** Which permission boundary exposes the global online/offline control. */
|
|
||||||
availabilityControl?: SkillAvailabilityControl;
|
|
||||||
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||||||
layout?: "row" | "card";
|
layout?: "row" | "card";
|
||||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||||
@@ -709,7 +629,6 @@ export function SkillRow({
|
|||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
const setFeatured = useSetSkillFeatured();
|
const setFeatured = useSetSkillFeatured();
|
||||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||||
const setEnabled = useEnableSkill();
|
|
||||||
const { data: compression } = useSkillCompressionSettings();
|
const { data: compression } = useSkillCompressionSettings();
|
||||||
const deleteCustom = useDeleteCustomSkill();
|
const deleteCustom = useDeleteCustomSkill();
|
||||||
const deleteBuiltin = useDeleteBuiltinSkill();
|
const deleteBuiltin = useDeleteBuiltinSkill();
|
||||||
@@ -720,7 +639,6 @@ export function SkillRow({
|
|||||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||||
const [duplicateName, setDuplicateName] = useState("");
|
const [duplicateName, setDuplicateName] = useState("");
|
||||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||||
const [confirmOfflineOpen, setConfirmOfflineOpen] = useState(false);
|
|
||||||
|
|
||||||
const isCustom = skill.category === "custom";
|
const isCustom = skill.category === "custom";
|
||||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||||
@@ -735,27 +653,6 @@ export function SkillRow({
|
|||||||
const canTogglePublish = canManage && !isBuiltin;
|
const canTogglePublish = canManage && !isBuiltin;
|
||||||
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||||||
const canSetAlwaysOn = isAdmin || isOwned;
|
const canSetAlwaysOn = isAdmin || isOwned;
|
||||||
const canToggleAvailability = canToggleSkillAvailability({
|
|
||||||
skill,
|
|
||||||
currentUserId,
|
|
||||||
isAdmin,
|
|
||||||
control: availabilityControl,
|
|
||||||
});
|
|
||||||
const isOffline = isSkillOffline(skill);
|
|
||||||
|
|
||||||
async function handleSetEnabled(enabled: boolean) {
|
|
||||||
try {
|
|
||||||
await setEnabled.mutateAsync({ skillName: skill.name, enabled });
|
|
||||||
toast.success(
|
|
||||||
enabled
|
|
||||||
? t.settings.skills.onlineSuccess
|
|
||||||
: t.settings.skills.offlineSuccess,
|
|
||||||
);
|
|
||||||
setConfirmOfflineOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleAlwaysOn(next: boolean) {
|
async function handleToggleAlwaysOn(next: boolean) {
|
||||||
try {
|
try {
|
||||||
@@ -986,16 +883,6 @@ export function SkillRow({
|
|||||||
{knowledgeBindings.length > 3 && (
|
{knowledgeBindings.length > 3 && (
|
||||||
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
||||||
)}
|
)}
|
||||||
{isOffline && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-red-500/35 bg-red-500/5 text-red-700 dark:text-red-300 text-xs"
|
|
||||||
title={t.settings.skills.badgeOfflineTitle}
|
|
||||||
>
|
|
||||||
<PowerOff className="size-3" />
|
|
||||||
{t.settings.skills.badgeOffline}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1054,43 +941,6 @@ export function SkillRow({
|
|||||||
|
|
||||||
const actionButtons = (
|
const actionButtons = (
|
||||||
<>
|
<>
|
||||||
{canToggleAvailability && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={isOffline ? "default" : "outline"}
|
|
||||||
className={cn(
|
|
||||||
"h-8 shrink-0 gap-1.5 px-2.5",
|
|
||||||
!isOffline && "text-destructive hover:text-destructive",
|
|
||||||
)}
|
|
||||||
disabled={
|
|
||||||
setEnabled.isPending ||
|
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
if (isOffline) {
|
|
||||||
void handleSetEnabled(true);
|
|
||||||
} else {
|
|
||||||
setConfirmOfflineOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
isOffline
|
|
||||||
? t.settings.skills.onlineAction
|
|
||||||
: t.settings.skills.offlineAction
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{setEnabled.isPending ? (
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
) : isOffline ? (
|
|
||||||
<Power className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<PowerOff className="size-3.5" />
|
|
||||||
)}
|
|
||||||
{isOffline
|
|
||||||
? t.settings.skills.onlineAction
|
|
||||||
: t.settings.skills.offlineAction}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1210,12 +1060,7 @@ export function SkillRow({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isCard ? (
|
{isCard ? (
|
||||||
<div
|
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||||
className={cn(
|
|
||||||
"flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
|
|
||||||
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="min-w-0 flex-1 cursor-pointer"
|
className="min-w-0 flex-1 cursor-pointer"
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
@@ -1231,13 +1076,7 @@ export function SkillRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Item
|
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||||
className={cn(
|
|
||||||
"w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
|
|
||||||
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
|
|
||||||
)}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<ItemContent
|
<ItemContent
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
@@ -1318,37 +1157,6 @@ export function SkillRow({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Dialog open={confirmOfflineOpen} onOpenChange={setConfirmOfflineOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t.settings.skills.offlineConfirmDescription.replace(
|
|
||||||
"{name}",
|
|
||||||
desensitize(skill.name),
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setConfirmOfflineOpen(false)}
|
|
||||||
disabled={setEnabled.isPending}
|
|
||||||
>
|
|
||||||
{t.common.cancel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => void handleSetEnabled(false)}
|
|
||||||
disabled={setEnabled.isPending}
|
|
||||||
>
|
|
||||||
{setEnabled.isPending
|
|
||||||
? t.common.loading
|
|
||||||
: t.settings.skills.offlineAction}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
<TagPickerDialog
|
<TagPickerDialog
|
||||||
open={tagPickerOpen}
|
open={tagPickerOpen}
|
||||||
onOpenChange={setTagPickerOpen}
|
onOpenChange={setTagPickerOpen}
|
||||||
@@ -1564,9 +1372,6 @@ function InstallSkillDialog({
|
|||||||
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
||||||
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
||||||
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
||||||
// On-disk leftover with no ownership record: replaceable by whoever uploads it.
|
|
||||||
const unclaimedCustomConflict =
|
|
||||||
!!isCustomConflict && !validation?.owned_by_me && !validation?.owned_by_other && canOverwrite;
|
|
||||||
const customConflictMessage = !validation
|
const customConflictMessage = !validation
|
||||||
? ""
|
? ""
|
||||||
: otherCustomConflict
|
: otherCustomConflict
|
||||||
@@ -1581,8 +1386,6 @@ function InstallSkillDialog({
|
|||||||
? dialog.conflictOwnedByMe
|
? dialog.conflictOwnedByMe
|
||||||
: dialog.conflictOwnedByMeWrongTarget
|
: dialog.conflictOwnedByMeWrongTarget
|
||||||
).replace("{name}", validation.skill_name)
|
).replace("{name}", validation.skill_name)
|
||||||
: unclaimedCustomConflict
|
|
||||||
? dialog.conflictUnclaimed.replace("{name}", validation.skill_name)
|
|
||||||
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1693,16 +1496,6 @@ function InstallSkillDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border bg-muted/30 p-3">
|
|
||||||
<div className="text-sm font-medium">{dialog.exampleTitle}</div>
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
|
||||||
{dialog.exampleHint}
|
|
||||||
</p>
|
|
||||||
<pre className="mt-2 overflow-x-auto rounded bg-background px-3 py-2 text-xs leading-5">
|
|
||||||
<code>{MINIMAL_SKILL_MD_EXAMPLE}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
||||||
@@ -1779,7 +1572,7 @@ function InstallSkillDialog({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 flex items-start gap-2 text-xs",
|
"mt-1 flex items-start gap-2 text-xs",
|
||||||
ownCustomCanOverwrite || unclaimedCustomConflict ? "text-amber-600" : "text-destructive",
|
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
|||||||
@@ -1,346 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Loader2Icon,
|
|
||||||
RotateCcwIcon,
|
|
||||||
SaveIcon,
|
|
||||||
UploadCloudIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
DEFAULT_UPLOAD_POLICY,
|
|
||||||
MEBIBYTE,
|
|
||||||
normalizeUploadExtension,
|
|
||||||
type UploadPolicy,
|
|
||||||
type UploadPolicyGroupKey,
|
|
||||||
useResetUploadPolicy,
|
|
||||||
useUpdateUploadPolicy,
|
|
||||||
useUploadPolicy,
|
|
||||||
} from "@/core/uploads";
|
|
||||||
|
|
||||||
type UploadPolicyDraft = {
|
|
||||||
maxFiles: string;
|
|
||||||
maxTotalMiB: string;
|
|
||||||
groups: Record<
|
|
||||||
UploadPolicyGroupKey,
|
|
||||||
{ maxMiB: string; extensions: string }
|
|
||||||
>;
|
|
||||||
thresholdMiB: string;
|
|
||||||
maxResultChars: string;
|
|
||||||
hardMaxResultChars: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GROUP_META: Array<{
|
|
||||||
key: UploadPolicyGroupKey;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}> = [
|
|
||||||
{ key: "document", label: "文档", description: "Word、PPT、TXT、PDF 等" },
|
|
||||||
{ key: "image", label: "图片", description: "JPG、PNG 等" },
|
|
||||||
{ key: "spreadsheet", label: "表格", description: "Excel 等" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function toDraft(policy: UploadPolicy): UploadPolicyDraft {
|
|
||||||
return {
|
|
||||||
maxFiles: String(policy.max_files_per_message),
|
|
||||||
maxTotalMiB: String(policy.max_total_bytes / MEBIBYTE),
|
|
||||||
groups: {
|
|
||||||
document: {
|
|
||||||
maxMiB: String(policy.groups.document.max_bytes / MEBIBYTE),
|
|
||||||
extensions: policy.groups.document.extensions.join(", "),
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
maxMiB: String(policy.groups.image.max_bytes / MEBIBYTE),
|
|
||||||
extensions: policy.groups.image.extensions.join(", "),
|
|
||||||
},
|
|
||||||
spreadsheet: {
|
|
||||||
maxMiB: String(policy.groups.spreadsheet.max_bytes / MEBIBYTE),
|
|
||||||
extensions: policy.groups.spreadsheet.extensions.join(", "),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
thresholdMiB: String(policy.large_file.threshold_bytes / MEBIBYTE),
|
|
||||||
maxResultChars: String(policy.large_file.max_result_chars),
|
|
||||||
hardMaxResultChars: String(policy.large_file.hard_max_result_chars),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function positiveNumber(value: string, label: string): number {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
||||||
throw new Error(`${label}必须大于 0`);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extensionsOf(value: string, label: string): string[] {
|
|
||||||
const extensions = Array.from(
|
|
||||||
new Set(
|
|
||||||
value
|
|
||||||
.split(/[,,\s]+/)
|
|
||||||
.map(normalizeUploadExtension)
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (extensions.length === 0) throw new Error(`${label}至少填写一个扩展名`);
|
|
||||||
return extensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromDraft(draft: UploadPolicyDraft): UploadPolicy {
|
|
||||||
const maxResultChars = Math.floor(
|
|
||||||
positiveNumber(draft.maxResultChars, "常规输出上限"),
|
|
||||||
);
|
|
||||||
const hardMaxResultChars = Math.floor(
|
|
||||||
positiveNumber(draft.hardMaxResultChars, "硬输出上限"),
|
|
||||||
);
|
|
||||||
if (maxResultChars > hardMaxResultChars) {
|
|
||||||
throw new Error("常规输出上限不能大于硬输出上限");
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = {} as UploadPolicy["groups"];
|
|
||||||
for (const { key, label } of GROUP_META) {
|
|
||||||
groups[key] = {
|
|
||||||
max_bytes: Math.floor(
|
|
||||||
positiveNumber(draft.groups[key].maxMiB, `${label}单文件上限`) *
|
|
||||||
MEBIBYTE,
|
|
||||||
),
|
|
||||||
extensions: extensionsOf(draft.groups[key].extensions, label),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
max_files_per_message: Math.floor(
|
|
||||||
positiveNumber(draft.maxFiles, "单条消息文件数"),
|
|
||||||
),
|
|
||||||
max_total_bytes: Math.floor(
|
|
||||||
positiveNumber(draft.maxTotalMiB, "附件总大小") * MEBIBYTE,
|
|
||||||
),
|
|
||||||
groups,
|
|
||||||
large_file: {
|
|
||||||
threshold_bytes: Math.floor(
|
|
||||||
positiveNumber(draft.thresholdMiB, "大文件阈值") * MEBIBYTE,
|
|
||||||
),
|
|
||||||
max_result_chars: maxResultChars,
|
|
||||||
hard_max_result_chars: hardMaxResultChars,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UploadSettingsPage() {
|
|
||||||
const { data, isLoading } = useUploadPolicy();
|
|
||||||
const update = useUpdateUploadPolicy();
|
|
||||||
const reset = useResetUploadPolicy();
|
|
||||||
const [draft, setDraft] = useState<UploadPolicyDraft>(() =>
|
|
||||||
toDraft(DEFAULT_UPLOAD_POLICY),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) setDraft(toDraft(data));
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const setGroup = (
|
|
||||||
key: UploadPolicyGroupKey,
|
|
||||||
patch: Partial<UploadPolicyDraft["groups"][UploadPolicyGroupKey]>,
|
|
||||||
) => {
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
groups: {
|
|
||||||
...current.groups,
|
|
||||||
[key]: { ...current.groups[key], ...patch },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
try {
|
|
||||||
const saved = await update.mutateAsync(fromDraft(draft));
|
|
||||||
setDraft(toDraft(saved));
|
|
||||||
toast.success("上传设置已保存");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存上传设置失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreDefaults = async () => {
|
|
||||||
if (!window.confirm("确定恢复系统默认上传策略吗?当前自定义设置会被覆盖。")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const saved = await reset.mutateAsync();
|
|
||||||
setDraft(toDraft(saved));
|
|
||||||
toast.success("已恢复默认上传设置");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "恢复默认上传设置失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<UploadCloudIcon className="size-5" />
|
|
||||||
上传设置
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
控制聊天附件的数量、格式和大小,并限制大文件读取结果,防止文件内容一次性占满模型上下文。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 rounded-lg border p-4 sm:grid-cols-2">
|
|
||||||
<NumberField
|
|
||||||
id="upload-max-files"
|
|
||||||
label="单条消息最多文件数"
|
|
||||||
value={draft.maxFiles}
|
|
||||||
onChange={(maxFiles) => setDraft((value) => ({ ...value, maxFiles }))}
|
|
||||||
step="1"
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
id="upload-max-total"
|
|
||||||
label="单条消息附件总上限(MiB)"
|
|
||||||
value={draft.maxTotalMiB}
|
|
||||||
onChange={(maxTotalMiB) =>
|
|
||||||
setDraft((value) => ({ ...value, maxTotalMiB }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-semibold">文件分组</h4>
|
|
||||||
{GROUP_META.map(({ key, label, description }) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="grid gap-4 rounded-lg border p-4 sm:grid-cols-[180px_minmax(0,1fr)]"
|
|
||||||
>
|
|
||||||
<NumberField
|
|
||||||
id={`upload-${key}-size`}
|
|
||||||
label={`${label}单文件上限(MiB)`}
|
|
||||||
value={draft.groups[key].maxMiB}
|
|
||||||
onChange={(maxMiB) => setGroup(key, { maxMiB })}
|
|
||||||
/>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
className="text-sm font-medium"
|
|
||||||
htmlFor={`upload-${key}-extensions`}
|
|
||||||
>
|
|
||||||
{label}扩展名
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id={`upload-${key}-extensions`}
|
|
||||||
value={draft.groups[key].extensions}
|
|
||||||
onChange={(event) =>
|
|
||||||
setGroup(key, { extensions: event.currentTarget.value })
|
|
||||||
}
|
|
||||||
placeholder=".doc, .docx"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{description};用逗号或空格分隔,保存时自动补全小数点。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 rounded-lg border p-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold">大文件读取保护</h4>
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
|
||||||
达到阈值后使用头部、中部、尾部采样;输出始终受字符预算约束,并在界面中标明截断或采样。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<NumberField
|
|
||||||
id="upload-large-threshold"
|
|
||||||
label="大文件阈值(MiB)"
|
|
||||||
value={draft.thresholdMiB}
|
|
||||||
onChange={(thresholdMiB) =>
|
|
||||||
setDraft((value) => ({ ...value, thresholdMiB }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
id="upload-result-chars"
|
|
||||||
label="常规输出上限(字符)"
|
|
||||||
value={draft.maxResultChars}
|
|
||||||
onChange={(maxResultChars) =>
|
|
||||||
setDraft((value) => ({ ...value, maxResultChars }))
|
|
||||||
}
|
|
||||||
step="100"
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
id="upload-hard-result-chars"
|
|
||||||
label="硬输出上限(字符)"
|
|
||||||
value={draft.hardMaxResultChars}
|
|
||||||
onChange={(hardMaxResultChars) =>
|
|
||||||
setDraft((value) => ({ ...value, hardMaxResultChars }))
|
|
||||||
}
|
|
||||||
step="100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{isLoading && (
|
|
||||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
|
||||||
<Loader2Icon className="size-3.5 animate-spin" /> 正在读取配置
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void restoreDefaults()}
|
|
||||||
disabled={update.isPending || reset.isPending}
|
|
||||||
>
|
|
||||||
{reset.isPending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RotateCcwIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
恢复默认
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => void save()}
|
|
||||||
disabled={update.isPending || reset.isPending}
|
|
||||||
>
|
|
||||||
{update.isPending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<SaveIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberField({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
step = "0.1",
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
step?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor={id}>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id={id}
|
|
||||||
type="number"
|
|
||||||
min="0.01"
|
|
||||||
step={step}
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-70
@@ -1,70 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { FileOutputIcon, Loader2Icon } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import {
|
|
||||||
useUpdateWriteFileCompatibilitySettings,
|
|
||||||
useWriteFileCompatibilitySettings,
|
|
||||||
} from "@/core/system-settings/hooks";
|
|
||||||
|
|
||||||
export function WriteFileCompatibilitySettingsPage() {
|
|
||||||
const { data, isLoading, isError } = useWriteFileCompatibilitySettings();
|
|
||||||
const update = useUpdateWriteFileCompatibilitySettings();
|
|
||||||
const enabled = data?.enabled ?? false;
|
|
||||||
|
|
||||||
const handleChange = async (checked: boolean) => {
|
|
||||||
try {
|
|
||||||
await update.mutateAsync({ enabled: checked });
|
|
||||||
toast.success(checked ? "文件流式兼容已开启" : "文件流式兼容已关闭");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<FileOutputIcon className="size-5" />
|
|
||||||
文件流式兼容
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
临时兼容会缓存长工具参数的内网模型网关。开启后,所有兼容模型仍调用原有
|
|
||||||
write_file 工具,但文件正文改由普通文本流生成,再投影为标准 write_file
|
|
||||||
流式事件。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-medium">所有模型启用兼容写入</div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
适用于当前配置的 OpenAI-compatible Chat Completions
|
|
||||||
模型。关闭后恢复各模型原有写入方式。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Switch
|
|
||||||
checked={enabled}
|
|
||||||
disabled={isError || update.isPending}
|
|
||||||
onCheckedChange={handleChange}
|
|
||||||
aria-label="所有模型启用文件流式兼容"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<p className="text-destructive text-sm">
|
|
||||||
无法读取文件流式兼容开关,系统将按关闭处理。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
保存后无需重启后台,从下一次发起的问答开始生效;正在生成中的回答不会中途切换。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,14 @@ import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } fr
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Dialog,
|
||||||
SheetContent,
|
DialogContent,
|
||||||
SheetDescription,
|
DialogDescription,
|
||||||
SheetHeader,
|
DialogHeader,
|
||||||
SheetTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/dialog";
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||||
import {
|
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||||
resolveWeKnoraSourceTarget,
|
|
||||||
type WeKnoraSourceTarget,
|
|
||||||
} from "@/core/llmwiki/source-links";
|
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
@@ -59,9 +56,6 @@ export function SourceCard({
|
|||||||
}) {
|
}) {
|
||||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const previewTarget = resolveWeKnoraSourceTarget({
|
const previewTarget = resolveWeKnoraSourceTarget({
|
||||||
@@ -71,13 +65,7 @@ export function SourceCard({
|
|||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||||
const isWikiIndexSource =
|
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||||
source.type === "llmwiki_wiki_index_intro" ||
|
|
||||||
source.raw?.type === "llmwiki_wiki_index_intro";
|
|
||||||
const isWikiSource =
|
|
||||||
Boolean(previewTarget?.wikiSlug) ||
|
|
||||||
source.type === "llmwiki_wiki" ||
|
|
||||||
source.type === "llmwiki_wiki_page";
|
|
||||||
const matchedHeading = isWikiSource
|
const matchedHeading = isWikiSource
|
||||||
? String(
|
? String(
|
||||||
(source.raw?.matched_heading as string | undefined) ||
|
(source.raw?.matched_heading as string | undefined) ||
|
||||||
@@ -90,17 +78,13 @@ export function SourceCard({
|
|||||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||||
? source.raw.summary
|
? source.raw.summary
|
||||||
: "";
|
: "";
|
||||||
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
|
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||||
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string"
|
const score =
|
||||||
? source.raw.rank_score_semantics
|
typeof source.score === "number"
|
||||||
: "";
|
? source.score <= 1
|
||||||
const scoreMeta = typeof source.score !== "number"
|
? `${Math.round(source.score * 100)}%`
|
||||||
? null
|
: String(source.score)
|
||||||
: scoreSemantics === "rrf"
|
: null;
|
||||||
? `融合排序分 ${source.score.toFixed(4)}`
|
|
||||||
: scoreSemantics === "provider_relevance"
|
|
||||||
? `上游通道分 ${source.score.toFixed(4)}`
|
|
||||||
: `相关度 ${source.score <= 1 ? `${Math.round(source.score * 100)}%` : String(source.score)}`;
|
|
||||||
const meta = [
|
const meta = [
|
||||||
source.source,
|
source.source,
|
||||||
isWikiSource ? "检索对象:Wiki 文档" : null,
|
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||||
@@ -108,7 +92,7 @@ export function SourceCard({
|
|||||||
source.author ? `作者 ${source.author}` : null,
|
source.author ? `作者 ${source.author}` : null,
|
||||||
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
||||||
source.time,
|
source.time,
|
||||||
scoreMeta,
|
score ? `相关度 ${score}` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const isCitation = source.mode === "citation";
|
const isCitation = source.mode === "citation";
|
||||||
const showReferenceNumber = isCitation;
|
const showReferenceNumber = isCitation;
|
||||||
@@ -167,7 +151,7 @@ export function SourceCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(source.url || isWikiSource || isWikiIndexSource) && (
|
{(source.url || isWikiSource) && (
|
||||||
previewTarget ? (
|
previewTarget ? (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
|
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
|
||||||
@@ -178,47 +162,30 @@ export function SourceCard({
|
|||||||
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
||||||
{isWikiSource ? "Wiki 文档" : "知识库文档"}
|
{isWikiSource ? "Wiki 文档" : "知识库文档"}
|
||||||
</span>
|
</span>
|
||||||
) : isWikiIndexSource ? (
|
|
||||||
<span className="mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md bg-emerald-100 px-1.5 py-1 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
|
||||||
<BookOpenTextIcon className="size-3.5" />
|
|
||||||
Wiki 索引
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(previewTarget || isWikiIndexSource) && (
|
{previewTarget && (
|
||||||
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||||
<span>
|
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
|
||||||
{isWikiIndexSource
|
|
||||||
? "点击卡片查看完整 Wiki 索引"
|
|
||||||
: isWikiSource
|
|
||||||
? "点击卡片查看完整 Wiki"
|
|
||||||
: "点击卡片查看详情"}
|
|
||||||
</span>
|
|
||||||
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(previewTarget || linkedTarget) && (
|
{previewTarget && (
|
||||||
<WeKnoraSourceDrawer
|
<WeKnoraSourceDrawer
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
target={linkedTarget ?? previewTarget}
|
target={previewTarget}
|
||||||
title={source.title}
|
title={source.title}
|
||||||
quote={content}
|
quote={content}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={setDetailOpen}
|
||||||
setDetailOpen(open);
|
|
||||||
if (!open) setLinkedTarget(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}>
|
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||||
<SheetContent
|
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||||
side="right"
|
<DialogHeader>
|
||||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
|
||||||
>
|
|
||||||
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
|
||||||
<div className="flex items-start justify-between gap-3 pr-8">
|
<div className="flex items-start justify-between gap-3 pr-8">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -227,19 +194,16 @@ export function SourceCard({
|
|||||||
{source.index}
|
{source.index}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<SheetTitle className="text-base leading-relaxed">
|
<DialogTitle className="text-base leading-relaxed">
|
||||||
{source.title}
|
{source.title}
|
||||||
</SheetTitle>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
{meta.length > 0 && (
|
{meta.length > 0 && (
|
||||||
<SheetDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||||
{meta.map((item, index) => (
|
{meta.map((item, index) => (
|
||||||
<span key={`${item}-${index}`}>{item}</span>
|
<span key={`${item}-${index}`}>{item}</span>
|
||||||
))}
|
))}
|
||||||
</SheetDescription>
|
</DialogDescription>
|
||||||
)}
|
|
||||||
{meta.length === 0 && (
|
|
||||||
<SheetDescription className="sr-only">参考文献详情</SheetDescription>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{source.url && !previewTarget && (
|
{source.url && !previewTarget && (
|
||||||
@@ -255,51 +219,33 @@ 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) =>
|
</article>
|
||||||
href.startsWith("/weknora-source-preview?")
|
</div>
|
||||||
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
|
) : null}
|
||||||
: undefined
|
{source.url && !previewTarget && (
|
||||||
}
|
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||||
onLinkClick={(event, href) => {
|
{source.url}
|
||||||
const target = resolveWeKnoraSourceTarget({ url: href });
|
</div>
|
||||||
if (!target) return;
|
)}
|
||||||
event.preventDefault();
|
</DialogContent>
|
||||||
setLinkedTarget(target);
|
</Dialog>}
|
||||||
setDetailOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
|
|
||||||
暂无可展示的引用内容
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{source.url && !previewTarget && (
|
|
||||||
<div className="text-muted-foreground mt-4 border-t pt-3 text-xs break-all">
|
|
||||||
{source.url}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const PHASE_LABELS: Record<string, string> = {
|
|||||||
writing_sources: "上传知识资料",
|
writing_sources: "上传知识资料",
|
||||||
generating_wiki: "分析并生成分类 Wiki",
|
generating_wiki: "分析并生成分类 Wiki",
|
||||||
vectorizing_wiki: "Wiki 向量化",
|
vectorizing_wiki: "Wiki 向量化",
|
||||||
syncing_assistant: "同步检索索引",
|
syncing_assistant: "同步知识梳理总库",
|
||||||
activating: "激活新版本",
|
activating: "激活新版本",
|
||||||
completed: "完成",
|
completed: "完成",
|
||||||
};
|
};
|
||||||
@@ -219,14 +219,14 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
|
|||||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||||
<p className="p-3 text-xs text-muted-foreground">
|
<p className="p-3 text-xs text-muted-foreground">
|
||||||
{runtime.data?.provider !== "weknora"
|
{runtime.data?.provider !== "weknora"
|
||||||
? "当前未配置普通知识库地址;请先配置知识服务地址,再归纳技能内容。"
|
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
||||||
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及所需索引同步均在后台完成。
|
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
||||||
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||||||
|
|||||||
@@ -226,9 +226,7 @@ export function SkillDetailDialog({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(48rem, calc(100vw - 2rem))",
|
width: "min(48rem, calc(100vw - 2rem))",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
height: "min(760px, calc(100dvh - 2rem))",
|
||||||
height: "min(760px, calc(100vh - 2rem))",
|
|
||||||
maxHeight: "min(760px, calc(100vh - 2rem))",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||||
|
|||||||
@@ -149,9 +149,7 @@ export function SkillFilePreviewDialog({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(56rem, calc(100vw - 2rem))",
|
width: "min(56rem, calc(100vw - 2rem))",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
height: "min(720px, calc(100dvh - 2rem))",
|
||||||
height: "min(720px, calc(100vh - 2rem))",
|
|
||||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
||||||
|
|||||||
@@ -8,31 +8,21 @@ export interface TagColorDef {
|
|||||||
chip: string;
|
chip: string;
|
||||||
/** A solid swatch used in the colour picker. */
|
/** A solid swatch used in the colour picker. */
|
||||||
swatch: string;
|
swatch: string;
|
||||||
/** Real border hex — Tailwind `border-*-200` is often not generated, so `* { border-color: var(--border) }` greys every chip. */
|
|
||||||
border: string;
|
|
||||||
borderDark: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TAG_COLORS: TagColorDef[] = [
|
export const TAG_COLORS: TagColorDef[] = [
|
||||||
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400", border: "#d1d5db", borderDark: "#4b5563" },
|
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
|
||||||
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500", border: "#fca5a5", borderDark: "#7f1d1d" },
|
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
|
||||||
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500", border: "#fdba74", borderDark: "#7c2d12" },
|
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
|
||||||
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500", border: "#fcd34d", borderDark: "#78350f" },
|
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
|
||||||
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500", border: "#86efac", borderDark: "#14532d" },
|
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
|
||||||
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500", border: "#5eead4", borderDark: "#134e4a" },
|
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
|
||||||
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500", border: "#93c5fd", borderDark: "#1e3a8a" },
|
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
|
||||||
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500", border: "#a5b4fc", borderDark: "#312e81" },
|
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
|
||||||
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500", border: "#d8b4fe", borderDark: "#581c87" },
|
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
|
||||||
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500", border: "#f9a8d4", borderDark: "#831843" },
|
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function tagBorderStyle(color: TagColorDef) {
|
|
||||||
return {
|
|
||||||
"--lam-tag-border": color.border,
|
|
||||||
"--lam-tag-border-dark": color.borderDark,
|
|
||||||
} as Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
|
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
|
||||||
|
|
||||||
export function resolveTagColor(name: string | undefined | null): TagColorDef {
|
export function resolveTagColor(name: string | undefined | null): TagColorDef {
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import * as maplibregl from "maplibre-gl";
|
|
||||||
import type { StyleSpecification } from "maplibre-gl";
|
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
|
||||||
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
|
|
||||||
|
|
||||||
import type { MapSpec } from "./visualization-spec";
|
|
||||||
import { VisualizationCard } from "./visualization-card";
|
|
||||||
|
|
||||||
function createStyle(): StyleSpecification {
|
|
||||||
return {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
"google-raster": {
|
|
||||||
type: "raster",
|
|
||||||
tiles: [getMapTileUrl()],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: getMapTileAttribution(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function allCoordinates(spec: MapSpec): Array<[number, number]> {
|
|
||||||
return [
|
|
||||||
...spec.markers.map((marker) => marker.coordinate),
|
|
||||||
...spec.lines.flatMap((line) => line.coordinates),
|
|
||||||
...spec.polygons.flatMap((polygon) => polygon.coordinates),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultCenter(spec: MapSpec): [number, number] {
|
|
||||||
if (spec.center) return spec.center;
|
|
||||||
const coordinates = allCoordinates(spec);
|
|
||||||
if (coordinates.length === 0) return [104.1954, 35.8617];
|
|
||||||
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
|
|
||||||
return [total[0] / coordinates.length, total[1] / coordinates.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MapRenderer({ spec }: { spec: MapSpec }) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
|
||||||
const [tileFailed, setTileFailed] = useState(false);
|
|
||||||
const [reloadToken, setReloadToken] = useState(0);
|
|
||||||
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
setTileFailed(false);
|
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
|
||||||
container,
|
|
||||||
style: createStyle(),
|
|
||||||
center: defaultCenter(spec),
|
|
||||||
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
|
|
||||||
minZoom: 1,
|
|
||||||
maxZoom: 20,
|
|
||||||
attributionControl: { compact: true },
|
|
||||||
});
|
|
||||||
mapRef.current = map;
|
|
||||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
|
||||||
|
|
||||||
const markers: maplibregl.Marker[] = [];
|
|
||||||
for (const marker of spec.markers) {
|
|
||||||
const element = document.createElement("button");
|
|
||||||
element.type = "button";
|
|
||||||
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
|
|
||||||
element.title = marker.name;
|
|
||||||
element.setAttribute("aria-label", marker.name);
|
|
||||||
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
|
|
||||||
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
|
|
||||||
);
|
|
||||||
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
|
|
||||||
}
|
|
||||||
|
|
||||||
let baseTileLoaded = false;
|
|
||||||
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
|
|
||||||
if (event.sourceId !== "google-raster" || event.sourceDataType !== "content") return;
|
|
||||||
baseTileLoaded = true;
|
|
||||||
setTileFailed(false);
|
|
||||||
};
|
|
||||||
const onError = (event: maplibregl.ErrorEvent) => {
|
|
||||||
const message = event.error?.message ?? "";
|
|
||||||
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
|
|
||||||
};
|
|
||||||
map.on("sourcedata", onSourceData);
|
|
||||||
map.on("error", onError);
|
|
||||||
|
|
||||||
map.on("load", () => {
|
|
||||||
if (spec.lines.length > 0) {
|
|
||||||
map.addSource("visual-lines", {
|
|
||||||
type: "geojson",
|
|
||||||
data: {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: spec.lines.map((line) => ({
|
|
||||||
type: "Feature",
|
|
||||||
properties: { name: line.name ?? "" },
|
|
||||||
geometry: { type: "LineString", coordinates: line.coordinates },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
map.addLayer({
|
|
||||||
id: "visual-lines",
|
|
||||||
type: "line",
|
|
||||||
source: "visual-lines",
|
|
||||||
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spec.polygons.length > 0) {
|
|
||||||
map.addSource("visual-polygons", {
|
|
||||||
type: "geojson",
|
|
||||||
data: {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: spec.polygons.map((polygon) => ({
|
|
||||||
type: "Feature",
|
|
||||||
properties: { name: polygon.name ?? "" },
|
|
||||||
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
map.addLayer({
|
|
||||||
id: "visual-polygons-fill",
|
|
||||||
type: "fill",
|
|
||||||
source: "visual-polygons",
|
|
||||||
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
|
|
||||||
});
|
|
||||||
map.addLayer({
|
|
||||||
id: "visual-polygons-border",
|
|
||||||
type: "line",
|
|
||||||
source: "visual-polygons",
|
|
||||||
paint: { "line-color": "#2563eb", "line-width": 2 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!spec.center && coordinates.length > 1) {
|
|
||||||
const bounds = coordinates.reduce(
|
|
||||||
(current, coordinate) => current.extend(coordinate),
|
|
||||||
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
|
|
||||||
);
|
|
||||||
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.off("sourcedata", onSourceData);
|
|
||||||
map.off("error", onError);
|
|
||||||
markers.forEach((marker) => marker.remove());
|
|
||||||
mapRef.current = null;
|
|
||||||
map.remove();
|
|
||||||
};
|
|
||||||
}, [coordinates, reloadToken, spec]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VisualizationCard>
|
|
||||||
<div className="flex min-h-12 items-center gap-2 border-b px-3 py-2">
|
|
||||||
<MapPinned aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
||||||
<span className="truncate text-sm font-medium">{spec.title}</span>
|
|
||||||
<span className="ml-auto text-xs text-muted-foreground">{spec.markers.length} 个地点</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
|
|
||||||
{tileFailed ? (
|
|
||||||
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
|
|
||||||
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
||||||
<span className="flex-1">地图底图当前不可访问;请检查网络。地点和线路数据不会丢失。</span>
|
|
||||||
<Button
|
|
||||||
aria-label="重新加载地图"
|
|
||||||
className="shrink-0 text-amber-950 hover:bg-amber-100"
|
|
||||||
onClick={() => {
|
|
||||||
setTileFailed(false);
|
|
||||||
setReloadToken((value) => value + 1);
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<RefreshCw aria-hidden="true" />
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{spec.markers.length > 0 ? (
|
|
||||||
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
<summary className="cursor-pointer select-none">查看地点列表</summary>
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{spec.markers.map((marker, index) => (
|
|
||||||
<li key={`${marker.name}-${index}`}>
|
|
||||||
<span className="font-medium text-foreground">{marker.name}</span>
|
|
||||||
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
|
|
||||||
{marker.description ? <span>:{marker.description}</span> : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</VisualizationCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
|
||||||
return value.replace(/[&<>'"]/g, (character) => ({
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
"'": "'",
|
|
||||||
'"': """,
|
|
||||||
})[character] ?? character);
|
|
||||||
}
|
|
||||||
@@ -1,682 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Background,
|
|
||||||
BackgroundVariant,
|
|
||||||
Handle,
|
|
||||||
Panel,
|
|
||||||
Position,
|
|
||||||
ReactFlow,
|
|
||||||
ReactFlowProvider,
|
|
||||||
useReactFlow,
|
|
||||||
type Edge,
|
|
||||||
type Node,
|
|
||||||
type NodeProps,
|
|
||||||
} from "@xyflow/react";
|
|
||||||
import "@xyflow/react/dist/style.css";
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
FileJson,
|
|
||||||
ImageDown,
|
|
||||||
LocateFixed,
|
|
||||||
Maximize2,
|
|
||||||
Minus,
|
|
||||||
Plus,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { renderSvgElementToPngBlob, triggerBlobDownload } from "@/core/utils/mermaid";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import { VisualizationCard } from "./visualization-card";
|
|
||||||
import type { MindmapNodeKind, MindmapSpec } from "./visualization-spec";
|
|
||||||
|
|
||||||
const NODE_WIDTH = 184;
|
|
||||||
const NODE_HEIGHT = 68;
|
|
||||||
const LAYER_GAP = 244;
|
|
||||||
const ROW_GAP = 88;
|
|
||||||
const PREVIEW_DEPTH = 2;
|
|
||||||
|
|
||||||
const KIND_META: Record<MindmapNodeKind, { label: string; className: string }> = {
|
|
||||||
topic: { label: "主题", className: "border-primary/50 bg-primary/10" },
|
|
||||||
dimension: { label: "维度", className: "border-sky-500/45 bg-sky-500/10" },
|
|
||||||
finding: { label: "发现", className: "border-violet-500/45 bg-violet-500/10" },
|
|
||||||
evidence: { label: "依据", className: "border-emerald-500/45 bg-emerald-500/10" },
|
|
||||||
risk: { label: "风险", className: "border-amber-500/50 bg-amber-500/10" },
|
|
||||||
action: { label: "建议", className: "border-rose-500/45 bg-rose-500/10" },
|
|
||||||
};
|
|
||||||
|
|
||||||
type MindmapNode = MindmapSpec["nodes"][number];
|
|
||||||
|
|
||||||
type LayoutNode = MindmapNode & {
|
|
||||||
depth: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
hiddenCount: number;
|
|
||||||
hasChildren: boolean;
|
|
||||||
collapsed: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MindmapLayout = {
|
|
||||||
nodes: LayoutNode[];
|
|
||||||
edges: Array<{ id: string; from: string; to: string }>;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function childrenByParent(spec: MindmapSpec) {
|
|
||||||
const children = new Map<string, MindmapNode[]>();
|
|
||||||
for (const node of spec.nodes) {
|
|
||||||
if (node.parentId === null) continue;
|
|
||||||
const list = children.get(node.parentId) ?? [];
|
|
||||||
list.push(node);
|
|
||||||
children.set(node.parentId, list);
|
|
||||||
}
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRoot(spec: MindmapSpec): MindmapNode {
|
|
||||||
return spec.nodes.find((node) => node.parentId === null)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function descendantsCount(id: string, children: Map<string, MindmapNode[]>): number {
|
|
||||||
let count = 0;
|
|
||||||
for (const child of children.get(id) ?? []) {
|
|
||||||
count += 1 + descendantsCount(child.id, children);
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A rooted analysis tree uses a left-to-right layout in messages. It reads more
|
|
||||||
* naturally than a radial graph inside a narrow chat column and stays stable
|
|
||||||
* when the same persisted message is replayed.
|
|
||||||
*/
|
|
||||||
function layoutMindmap(
|
|
||||||
spec: MindmapSpec,
|
|
||||||
maxDepth?: number,
|
|
||||||
collapsedIds: ReadonlySet<string> = new Set(),
|
|
||||||
): MindmapLayout {
|
|
||||||
const children = childrenByParent(spec);
|
|
||||||
const root = getRoot(spec);
|
|
||||||
const visible = new Map<string, { node: MindmapNode; depth: number }>();
|
|
||||||
|
|
||||||
const collect = (node: MindmapNode, depth: number) => {
|
|
||||||
visible.set(node.id, { node, depth });
|
|
||||||
if ((maxDepth !== undefined && depth >= maxDepth) || collapsedIds.has(node.id)) return;
|
|
||||||
for (const child of children.get(node.id) ?? []) collect(child, depth + 1);
|
|
||||||
};
|
|
||||||
collect(root, 0);
|
|
||||||
|
|
||||||
const unitHeight = (id: string): number => {
|
|
||||||
const childIds = (children.get(id) ?? []).filter((child) => visible.has(child.id));
|
|
||||||
if (childIds.length === 0) return 1;
|
|
||||||
return childIds.reduce((sum, child) => sum + unitHeight(child.id), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const positions = new Map<string, { x: number; y: number }>();
|
|
||||||
const assign = (id: string, depth: number, top: number): number => {
|
|
||||||
const ownUnits = unitHeight(id);
|
|
||||||
positions.set(id, {
|
|
||||||
x: depth * LAYER_GAP,
|
|
||||||
y: top + ((ownUnits - 1) * ROW_GAP) / 2,
|
|
||||||
});
|
|
||||||
let nextTop = top;
|
|
||||||
for (const child of children.get(id) ?? []) {
|
|
||||||
if (!visible.has(child.id)) continue;
|
|
||||||
nextTop = assign(child.id, depth + 1, nextTop) + ROW_GAP;
|
|
||||||
}
|
|
||||||
return top + (ownUnits - 1) * ROW_GAP;
|
|
||||||
};
|
|
||||||
assign(root.id, 0, 0);
|
|
||||||
|
|
||||||
const nodes = [...visible.values()].map(({ node, depth }) => ({
|
|
||||||
...node,
|
|
||||||
depth,
|
|
||||||
x: positions.get(node.id)?.x ?? 0,
|
|
||||||
y: positions.get(node.id)?.y ?? 0,
|
|
||||||
hiddenCount:
|
|
||||||
(maxDepth !== undefined && depth === maxDepth) || collapsedIds.has(node.id)
|
|
||||||
? descendantsCount(node.id, children)
|
|
||||||
: 0,
|
|
||||||
hasChildren: (children.get(node.id) ?? []).length > 0,
|
|
||||||
collapsed: collapsedIds.has(node.id),
|
|
||||||
}));
|
|
||||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
||||||
const edges = nodes
|
|
||||||
.filter((node) => node.parentId && nodeIds.has(node.parentId))
|
|
||||||
.map((node) => ({ id: `${node.parentId}->${node.id}`, from: node.parentId!, to: node.id }));
|
|
||||||
const maxX = Math.max(...nodes.map((node) => node.x), 0);
|
|
||||||
const maxY = Math.max(...nodes.map((node) => node.y), 0);
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
width: maxX + NODE_WIDTH + 64,
|
|
||||||
height: maxY + NODE_HEIGHT + 64,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type FlowNodeData = {
|
|
||||||
node: LayoutNode;
|
|
||||||
selected: boolean;
|
|
||||||
onToggle?: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function MindmapFlowNode({ data }: NodeProps) {
|
|
||||||
const { node, selected, onToggle } = data as FlowNodeData;
|
|
||||||
const meta = KIND_META[node.kind];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative w-[184px] rounded-xl border bg-background px-3 py-2 shadow-sm transition-shadow",
|
|
||||||
"hover:shadow-md focus-within:ring-2 focus-within:ring-primary/50",
|
|
||||||
meta.className,
|
|
||||||
selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
|
|
||||||
)}
|
|
||||||
title={node.label}
|
|
||||||
>
|
|
||||||
{node.parentId ? <Handle type="target" position={Position.Left} className="!size-1.5 !border-0 !bg-border" /> : null}
|
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
|
|
||||||
<span className="font-medium">{meta.label}</span>
|
|
||||||
{node.hiddenCount > 0 ? (
|
|
||||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
|
||||||
+{node.hiddenCount}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{node.label}</div>
|
|
||||||
{node.hasChildren ? (
|
|
||||||
<>
|
|
||||||
<Handle type="source" position={Position.Right} className="!size-6 !border-0 !bg-transparent" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute -right-3 top-1/2 z-20 flex size-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-400 bg-background text-slate-600 shadow-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
||||||
aria-label={`${node.collapsed ? "展开" : "收起"}${node.label}`}
|
|
||||||
aria-expanded={!node.collapsed}
|
|
||||||
title={node.collapsed ? "展开子节点" : "收起子节点"}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onToggle?.(node.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{node.collapsed ? <Plus aria-hidden="true" className="size-3.5" /> : <Minus aria-hidden="true" className="size-3.5" />}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const NODE_TYPES = { mindmap: MindmapFlowNode };
|
|
||||||
|
|
||||||
function MindmapCanvasControls({
|
|
||||||
compact,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
compact: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
}) {
|
|
||||||
const rf = useReactFlow();
|
|
||||||
const buttonClassName = compact ? "size-7" : "size-8";
|
|
||||||
const padding = compact ? 0.16 : 0.12;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel position="top-right" className="flex items-center gap-1 rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={buttonClassName}
|
|
||||||
title="放大"
|
|
||||||
aria-label="放大思维导图"
|
|
||||||
onClick={() => void rf.zoomIn({ duration: 150 })}
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={buttonClassName}
|
|
||||||
title="缩小"
|
|
||||||
aria-label="缩小思维导图"
|
|
||||||
onClick={() => void rf.zoomOut({ duration: 150 })}
|
|
||||||
>
|
|
||||||
<Minus aria-hidden="true" className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={buttonClassName}
|
|
||||||
title="重置位置与缩放"
|
|
||||||
aria-label="重置思维导图位置与缩放"
|
|
||||||
onClick={() => void rf.fitView({ duration: 220, padding })}
|
|
||||||
>
|
|
||||||
<LocateFixed aria-hidden="true" className="size-4" />
|
|
||||||
</Button>
|
|
||||||
{onClose ? (
|
|
||||||
<>
|
|
||||||
<span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border" />
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={buttonClassName}
|
|
||||||
title="关闭"
|
|
||||||
aria-label="关闭思维导图"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X aria-hidden="true" className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function overviewNodeColor(kind: MindmapNodeKind): string {
|
|
||||||
return svgColors(kind).fill;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MindmapOverview({ layout }: { layout: MindmapLayout }) {
|
|
||||||
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel position="bottom-right" className="overflow-hidden rounded-lg border bg-background/95 p-1 shadow-md backdrop-blur">
|
|
||||||
<div className="mb-1 px-1.5 pt-0.5 text-[10px] font-medium text-muted-foreground">全图预览</div>
|
|
||||||
<svg
|
|
||||||
className="block h-28 w-44 rounded bg-muted/30"
|
|
||||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
role="img"
|
|
||||||
aria-label="思维导图全图预览"
|
|
||||||
>
|
|
||||||
{layout.edges.map((edge) => {
|
|
||||||
const from = nodesById.get(edge.from);
|
|
||||||
const to = nodesById.get(edge.to);
|
|
||||||
if (!from || !to) return null;
|
|
||||||
return (
|
|
||||||
<path
|
|
||||||
key={edge.id}
|
|
||||||
d={`M ${from.x + NODE_WIDTH} ${from.y + NODE_HEIGHT / 2} L ${to.x} ${to.y + NODE_HEIGHT / 2}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="#64748b"
|
|
||||||
strokeWidth="4"
|
|
||||||
strokeOpacity="0.8"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{layout.nodes.map((node) => (
|
|
||||||
<rect
|
|
||||||
key={node.id}
|
|
||||||
x={node.x}
|
|
||||||
y={node.y}
|
|
||||||
width={NODE_WIDTH}
|
|
||||||
height={NODE_HEIGHT}
|
|
||||||
rx="10"
|
|
||||||
fill={overviewNodeColor(node.kind)}
|
|
||||||
stroke="#94a3b8"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MindmapCanvas({
|
|
||||||
spec,
|
|
||||||
preview,
|
|
||||||
selectedId,
|
|
||||||
onSelect,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
spec: MindmapSpec;
|
|
||||||
preview: boolean;
|
|
||||||
selectedId?: string | null;
|
|
||||||
onSelect?: (id: string) => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}) {
|
|
||||||
const rf = useReactFlow();
|
|
||||||
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => new Set());
|
|
||||||
const toggleNode = useCallback((id: string) => {
|
|
||||||
setCollapsedIds((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
const layout = useMemo(
|
|
||||||
() => layoutMindmap(spec, preview ? PREVIEW_DEPTH : undefined, collapsedIds),
|
|
||||||
[collapsedIds, preview, spec],
|
|
||||||
);
|
|
||||||
const nodes = useMemo<Node[]>(
|
|
||||||
() =>
|
|
||||||
layout.nodes.map((node) => ({
|
|
||||||
id: node.id,
|
|
||||||
type: "mindmap",
|
|
||||||
position: { x: node.x, y: node.y },
|
|
||||||
width: NODE_WIDTH,
|
|
||||||
height: NODE_HEIGHT,
|
|
||||||
data: { node, selected: node.id === selectedId, onToggle: toggleNode } satisfies FlowNodeData,
|
|
||||||
})),
|
|
||||||
[layout.nodes, selectedId, toggleNode],
|
|
||||||
);
|
|
||||||
const edges = useMemo<Edge[]>(
|
|
||||||
() =>
|
|
||||||
layout.edges.map((edge) => ({
|
|
||||||
id: edge.id,
|
|
||||||
source: edge.from,
|
|
||||||
target: edge.to,
|
|
||||||
type: "smoothstep",
|
|
||||||
style: { stroke: "#64748b", strokeWidth: 2, strokeOpacity: 0.8 },
|
|
||||||
})),
|
|
||||||
[layout.edges],
|
|
||||||
);
|
|
||||||
const handleNodeClick = useCallback((id: string) => {
|
|
||||||
const node = layout.nodes.find((item) => item.id === id);
|
|
||||||
if (node?.hasChildren) {
|
|
||||||
toggleNode(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelect?.(id);
|
|
||||||
}, [layout.nodes, onSelect, toggleNode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
void rf.fitView({ padding: preview ? 0.16 : 0.12, duration: 180 });
|
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [collapsedIds, preview, rf, spec]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReactFlow
|
|
||||||
nodes={nodes}
|
|
||||||
edges={edges}
|
|
||||||
nodeTypes={NODE_TYPES}
|
|
||||||
fitView
|
|
||||||
fitViewOptions={{ padding: preview ? 0.16 : 0.12 }}
|
|
||||||
minZoom={0.12}
|
|
||||||
maxZoom={2}
|
|
||||||
nodesDraggable={false}
|
|
||||||
nodesConnectable={false}
|
|
||||||
elementsSelectable={Boolean(onSelect)}
|
|
||||||
panOnDrag
|
|
||||||
panOnScroll={false}
|
|
||||||
zoomOnScroll
|
|
||||||
zoomOnDoubleClick
|
|
||||||
zoomOnPinch
|
|
||||||
preventScrolling
|
|
||||||
onNodeClick={(_, node) => handleNodeClick(node.id)}
|
|
||||||
aria-label={`${spec.title}思维导图`}
|
|
||||||
proOptions={{ hideAttribution: true }}
|
|
||||||
>
|
|
||||||
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--border)" />
|
|
||||||
<MindmapCanvasControls compact={preview} onClose={onClose} />
|
|
||||||
{!preview ? <MindmapOverview layout={layout} /> : null}
|
|
||||||
</ReactFlow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function svgEscape(value: string): string {
|
|
||||||
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!);
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitSvgLabel(label: string): string[] {
|
|
||||||
const chunks = label.match(/.{1,16}/gu) ?? [label];
|
|
||||||
return chunks.slice(0, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMindmapSvg(spec: MindmapSpec): string {
|
|
||||||
const layout = layoutMindmap(spec);
|
|
||||||
const offset = 32;
|
|
||||||
const lines = layout.edges
|
|
||||||
.map((edge) => {
|
|
||||||
const from = layout.nodes.find((node) => node.id === edge.from)!;
|
|
||||||
const to = layout.nodes.find((node) => node.id === edge.to)!;
|
|
||||||
const x1 = from.x + NODE_WIDTH + offset;
|
|
||||||
const y1 = from.y + NODE_HEIGHT / 2 + offset;
|
|
||||||
const x2 = to.x + offset;
|
|
||||||
const y2 = to.y + NODE_HEIGHT / 2 + offset;
|
|
||||||
const control = Math.max(42, (x2 - x1) / 2);
|
|
||||||
return `<path d="M ${x1} ${y1} C ${x1 + control} ${y1}, ${x2 - control} ${y2}, ${x2} ${y2}" fill="none" stroke="#94a3b8" stroke-width="1.5"/>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
const nodes = layout.nodes
|
|
||||||
.map((node) => {
|
|
||||||
const meta = KIND_META[node.kind];
|
|
||||||
const { fill, stroke } = svgColors(node.kind);
|
|
||||||
const lines = splitSvgLabel(node.label)
|
|
||||||
.map((line, index) => `<tspan x="${node.x + offset + 14}" dy="${index === 0 ? 0 : 18}">${svgEscape(line)}</tspan>`)
|
|
||||||
.join("");
|
|
||||||
return [
|
|
||||||
`<g>`,
|
|
||||||
`<rect x="${node.x + offset}" y="${node.y + offset}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="12" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`,
|
|
||||||
`<text x="${node.x + offset + 14}" y="${node.y + offset + 18}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="10" fill="#64748b">${meta.label}</text>`,
|
|
||||||
`<text x="${node.x + offset + 14}" y="${node.y + offset + 42}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="13" font-weight="600" fill="#0f172a">${lines}</text>`,
|
|
||||||
`</g>`,
|
|
||||||
].join("");
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width + offset}" height="${layout.height + offset}" viewBox="0 0 ${layout.width + offset} ${layout.height + offset}" role="img" aria-label="${svgEscape(spec.title)}"><rect width="100%" height="100%" fill="#ffffff"/><text x="32" y="26" font-family="Microsoft YaHei, Arial, sans-serif" font-size="16" font-weight="700" fill="#0f172a">${svgEscape(spec.title)}</text>${lines}${nodes}</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function svgColors(kind: MindmapNodeKind): { fill: string; stroke: string } {
|
|
||||||
switch (kind) {
|
|
||||||
case "topic": return { fill: "#eff6ff", stroke: "#60a5fa" };
|
|
||||||
case "dimension": return { fill: "#f0f9ff", stroke: "#38bdf8" };
|
|
||||||
case "evidence": return { fill: "#ecfdf5", stroke: "#34d399" };
|
|
||||||
case "risk": return { fill: "#fffbeb", stroke: "#fbbf24" };
|
|
||||||
case "action": return { fill: "#fff1f2", stroke: "#fb7185" };
|
|
||||||
default: return { fill: "#f5f3ff", stroke: "#a78bfa" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeDownloadName(title: string, extension: string): string {
|
|
||||||
const clean = title.replace(/[\\/:*?"<>|]/g, "-").trim().slice(0, 80) || "分析思维导图";
|
|
||||||
return `${clean}.${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MindmapDownloads({ spec }: { spec: MindmapSpec }) {
|
|
||||||
const [downloading, setDownloading] = useState<"png" | "svg" | "json" | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const download = useCallback(async (format: "png" | "svg" | "json") => {
|
|
||||||
setDownloading(format);
|
|
||||||
try {
|
|
||||||
if (format === "json") {
|
|
||||||
triggerBlobDownload(
|
|
||||||
new Blob([JSON.stringify(spec, null, 2)], { type: "application/json;charset=utf-8" }),
|
|
||||||
safeDownloadName(spec.title, "mindmap.json"),
|
|
||||||
);
|
|
||||||
setOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const svgMarkup = buildMindmapSvg(spec);
|
|
||||||
if (format === "svg") {
|
|
||||||
triggerBlobDownload(
|
|
||||||
new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" }),
|
|
||||||
safeDownloadName(spec.title, "svg"),
|
|
||||||
);
|
|
||||||
setOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const host = document.createElement("div");
|
|
||||||
host.style.cssText = "position:fixed;left:-10000px;top:-10000px;pointer-events:none;opacity:0";
|
|
||||||
host.innerHTML = svgMarkup;
|
|
||||||
document.body.appendChild(host);
|
|
||||||
try {
|
|
||||||
const svg = host.querySelector("svg");
|
|
||||||
if (!(svg instanceof SVGSVGElement)) throw new Error("无法生成思维导图图片");
|
|
||||||
const blob = await renderSvgElementToPngBlob(svg);
|
|
||||||
triggerBlobDownload(blob, safeDownloadName(spec.title, "png"));
|
|
||||||
setOpen(false);
|
|
||||||
} finally {
|
|
||||||
host.remove();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to download mindmap", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "下载思维导图失败");
|
|
||||||
} finally {
|
|
||||||
setDownloading(null);
|
|
||||||
}
|
|
||||||
}, [spec]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="size-9"
|
|
||||||
title="下载思维导图"
|
|
||||||
aria-label="下载思维导图"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<Download aria-hidden="true" className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="max-w-sm p-5">
|
|
||||||
<DialogHeader className="text-left">
|
|
||||||
<DialogTitle>下载思维导图</DialogTitle>
|
|
||||||
<DialogDescription>选择需要导出的文件格式。</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
|
||||||
onClick={() => void download("png")}
|
|
||||||
disabled={downloading !== null}
|
|
||||||
>
|
|
||||||
<ImageDown aria-hidden="true" className="size-4 shrink-0" />
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm font-medium">PNG 图片</span>
|
|
||||||
<span className="block text-xs font-normal text-muted-foreground">
|
|
||||||
{downloading === "png" ? "正在生成…" : "便于在文档或图片中使用"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
|
||||||
onClick={() => void download("svg")}
|
|
||||||
disabled={downloading !== null}
|
|
||||||
>
|
|
||||||
<Download aria-hidden="true" className="size-4 shrink-0" />
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm font-medium">SVG 矢量图</span>
|
|
||||||
<span className="block text-xs font-normal text-muted-foreground">适合无损缩放和二次编辑</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
|
||||||
onClick={() => void download("json")}
|
|
||||||
disabled={downloading !== null}
|
|
||||||
>
|
|
||||||
<FileJson aria-hidden="true" className="size-4 shrink-0" />
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm font-medium">JSON 数据</span>
|
|
||||||
<span className="block text-xs font-normal text-muted-foreground">保留完整节点结构</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MindmapDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
spec,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
spec: MindmapSpec;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent
|
|
||||||
showCloseButton={false}
|
|
||||||
className="flex h-[calc(100dvh-2rem)] !w-[calc(100vw-2rem)] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden rounded-xl p-0 sm:!max-w-[calc(100vw-2rem)]"
|
|
||||||
>
|
|
||||||
<DialogTitle className="sr-only">{spec.title}</DialogTitle>
|
|
||||||
<DialogDescription className="sr-only">
|
|
||||||
思维导图画布。支持鼠标滚轮缩放、拖动画布,并可使用右上角按钮调整视图。
|
|
||||||
</DialogDescription>
|
|
||||||
<div className="relative min-h-0 flex-1 bg-muted/20">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<ReactFlowProvider>
|
|
||||||
<MindmapCanvas spec={spec} preview={false} onClose={() => onOpenChange(false)} />
|
|
||||||
</ReactFlowProvider>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MindmapRenderer({ spec }: { spec: MindmapSpec }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const root = getRoot(spec);
|
|
||||||
const ariaSummary = spec.nodes.map((node) => `${KIND_META[node.kind].label}:${node.label}`).join(";");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<VisualizationCard className="my-4" >
|
|
||||||
<section aria-label={`${spec.title}思维导图`}>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h2 className="truncate text-sm font-semibold">{spec.title}</h2>
|
|
||||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">思维导图</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
|
||||||
{spec.summary || `围绕“${root.label}”整理的 ${spec.nodes.length} 个分析节点。`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<MindmapDownloads spec={spec} />
|
|
||||||
<Button type="button" size="sm" className="h-8 gap-1.5" onClick={() => setOpen(true)}>
|
|
||||||
<Maximize2 aria-hidden="true" className="size-3.5" />
|
|
||||||
展开查看
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="sr-only">{ariaSummary}</p>
|
|
||||||
<div className="h-[20rem] bg-muted/15">
|
|
||||||
<ReactFlowProvider>
|
|
||||||
<MindmapCanvas
|
|
||||||
spec={spec}
|
|
||||||
preview
|
|
||||||
onSelect={() => {
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ReactFlowProvider>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3 border-t px-4 py-2 text-xs text-muted-foreground">
|
|
||||||
<span>预览显示前 {PREVIEW_DEPTH + 1} 层;完整导图可缩放查看。</span>
|
|
||||||
<span>{spec.nodes.length} 个节点</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</VisualizationCard>
|
|
||||||
<MindmapDialog open={open} onOpenChange={setOpen} spec={spec} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loaded 100 of 1900 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user