Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1ad5a3e5 |
No files matched your search
-19
@@ -90,21 +90,14 @@ hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_large_file_safety.py
|
||||
!offline-backend-20260512/backend/tests/test_account_authentication.py
|
||||
# Seeder-owned Agent Skills are product source; keep them out of the broad
|
||||
# runtime `skills/` ignore above.
|
||||
!offline-backend-20260512/backend/app/gateway/routers/_*_seed_assets/skills/
|
||||
!offline-backend-20260512/backend/app/gateway/routers/_*_seed_assets/skills/**
|
||||
# Keep startup seed completeness and task-report conversion regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_seed_assets.py
|
||||
!offline-backend-20260512/backend/tests/test_latency_diagnosis_seed.py
|
||||
!offline-backend-20260512/backend/tests/test_query_slowdown_diagnosis_seed.py
|
||||
!offline-backend-20260512/backend/tests/test_elasticsearch_offline_ops_seed.py
|
||||
!offline-backend-20260512/backend/tests/test_task_report_convert.py
|
||||
!offline-backend-20260512/backend/tests/test_migration_compatibility.py
|
||||
# Keep the dedicated taskId + goPath history Gateway contract versioned.
|
||||
!offline-backend-20260512/backend/tests/test_task_deeplink_threads_api.py
|
||||
!offline-backend-20260512/backend/tests/test_thread_clear.py
|
||||
# Keep project-scoped cross-conversation memory regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_analysis_project_memory.py
|
||||
!offline-backend-20260512/backend/tests/test_analysis_project_memory_integration.py
|
||||
@@ -121,15 +114,6 @@ offline-backend-20260512/backend/tests/*
|
||||
!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_llmwiki_smart_reasoning.py
|
||||
!offline-backend-20260512/backend/tests/test_smart_reasoning_retrieval_middleware.py
|
||||
!offline-backend-20260512/backend/tests/test_knowledge_retrieval_settings.py
|
||||
!offline-backend-20260512/backend/tests/test_wiki_generation_config.py
|
||||
!offline-backend-20260512/backend/tests/test_wiki_directory_constraints.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_parent_directory_routing.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_loading.py
|
||||
!offline-backend-20260512/backend/tests/test_weknora_embed_script.py
|
||||
!offline-backend-20260512/backend/tests/test_weknora_static.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_advanced_retrieval.py
|
||||
!offline-backend-20260512/backend/tests/test_knowledge_live_embedding.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
@@ -189,8 +173,6 @@ offline-backend-20260512/backend/tests/*
|
||||
!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
|
||||
# Per-account question recommendation preferences.
|
||||
!offline-backend-20260512/backend/tests/test_user_preferences.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
|
||||
@@ -218,7 +200,6 @@ git-clone/
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
/deerflow-frontend-dist-*.zip
|
||||
|
||||
# Local dependency caches and backend scratch archives
|
||||
/.pnpm-store/
|
||||
|
||||
@@ -98,11 +98,10 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **技能上下线 (Skill Availability, owner/admin)**: the global `Skill.enabled` flag is the online/offline switch. Taking a skill offline persists `skills.<name>.enabled=false` in `extensions_config.json` but does **not** delete its directory or remove it from existing agent/workflow configurations; the row remains visible and bringing it online restores those references. Administrators may toggle every skill from 「技能管理」; ordinary users may toggle only their own non-built-in `custom` skills from 「技能 → 技能列表 → 我的」. Offline skills must be absent from every model-facing skill catalogue (`lead` prompt, `search_skills`, `skill_list`, `skill_view`, subagents, AI writing/deep research/roundtable and Workflow run-start validation), from agent skill pickers, and from direct sandbox access: `read_file`/`ls`/`glob`/`grep`/`bash` reject offline, unknown, root-wide and category-wide `/mnt/skills` paths. The backend mutation is `PUT /api/skills/{name}`, authorizes either an administrator or the persisted owner of the target custom skill, patches the raw document so MCP placeholders and unrelated extension fields survive, uses atomic replacement when supported plus a Docker single-file-bind fallback, and refreshes the skill prompt cache. Launchers keep the runtime extensions file in a writable persisted directory. Existing agent/strategy-agent references stay visible as 「已下线」 and may be removed but not newly selected.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (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`.
|
||||
- **单智能体任务深链聊天记录(2026-10-19)**: `3qfx` / `rwfx` / `xdfx` 的会话历史使用独立 `task_deeplink_threads` 索引表及 `GET /api/task-deeplink-threads?task_id=&go_path=`,前端由 `useTaskDeeplinkThreads` 读取,不再筛选通用聊天列表。LangGraph thread/checkpoint 仍是消息权威数据;专用表仅按 `(task_id, go_path)` 定位 thread。`3qfx/rwfx` 按任务 id,`xdfx` 按 URL 原始行动 id;新 thread 写 `metadata.{taskRecordId,taskGoPath,thread_type:"task_deeplink"}` 并自动登记,通用最近对话默认隐藏。迁移 `20261019_01` 只建表、**不迁移/回填旧数据**。
|
||||
- **上下文近 3 轮滑窗(管理员全局开关)**: 「设置 → 上下文管理」可开启 `system_settings.json -> conversation_context.recent_turn_window_enabled`(默认关闭;`GET/PUT /api/system-settings/conversation-context`,PUT 仅管理员)。开启后 `RecentTurnsMiddleware` 只改变单次模型请求,保留含当前轮在内的最近 3 个真实用户回合及其 assistant/tool 链;checkpoint 与前端完整聊天记录不删。该模式优先于聊天框「上下文压缩」,且不会调用摘要模型;关闭后沿用原压缩行为。实现 `deerflow/agents/middlewares/recent_turns_middleware.py`,回归 `tests/test_summarization_multiturn.py` / `tests/test_prompt_prefix.py`。
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
- **UI**: Radix UI primitives + TailwindCSS v3 + shadcn-style components in `src/components/ui/`
|
||||
- **麒麟 Chromium ~80 兼容 (Kylin legacy Chrome)**: Tailwind 自 v4 降级到 v3(v4 的 `@layer` 需要 Chrome 99+,麒麟系统自带 Chromium 80 多整站裸奔)。要点:① `tailwind.config.ts` —— 语义色走六套主题(`:root/.dark/.lightblue/.lightgreen/.iceblue/.lightyellow`)维护的 `--legacy-*-rgb` 通道变量,透明度修饰符(`bg-primary/10`)用 `rgb(var()/<alpha-value>)` 原生支持;border/input/sidebar-border/ring 用 `--*-alpha` 变量还原 v4 color-mix 叠乘语义;`xs/sm` 刻度(shadow/blur/rounded)与裸 `ring`(1px+currentColor)按 v4 值覆盖,类名零改动;streamdown 运行时拼类名的白名单在 `safelist`;v4 独有工具类(outline-hidden/text-shadow-sm/field-sizing-*)以插件手写兜底。② `darkMode: "class"` 输出 `:is(.dark *)`,由 `scripts/postcss-dark-descendant.mjs` 在 postcss 阶段改写为 `.dark X` 后代形式(v3.4 的 `["selector", ...]` 堆叠变体会生成非法选择器、无 darkMode 配置会退化成媒体查询,均不可用)。③ 手写 CSS 禁用 `:is()`(复杂参数 lightningcss 无法安全降级,globals/roundtable/intent 已全部展开为逗号选择器);`:has()`(105+)按渐进增强放行,唯一功能性依赖(input-group 焦点/校验环)在 globals.css 有 `@supports not selector(:has(*))` 回退。④ vite `build.target` + lightningcss targets = chrome80(转译 `:is()`/逻辑属性/媒体 range 语法、厂商前缀);`src/polyfills.ts`(core-js 精选 API)在 main.tsx 首位引入。⑤ 构建后跑 `pnpm check:legacy-css` 防回归(产物不得含 @layer/@property/:is()/未守护 color-mix)。
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
|
||||
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
|
||||
|
||||
@@ -1,40 +1,5 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Retrieved files in chat (2026-10-07)
|
||||
|
||||
The shared `MessageGroup` renders `RetrievedFileList` below its reasoning/tool
|
||||
timeline, so retrieved originals remain visible when old tool steps collapse and
|
||||
do not depend on a model calling `present_files` or enabling reference mode.
|
||||
The file list starts collapsed, keeps the latest retrieved file visible with the
|
||||
total count, and expands the remaining files in reverse retrieval order.
|
||||
`core/messages/retrieved-files.ts` derives these cards only from completed skill
|
||||
invocations (a known skill tool name or a skill script path in an execution tool).
|
||||
Generic reads, listings, report generation tools, uploads and outputs are excluded;
|
||||
workspace paths require a structured retrieval/file container and exclude paths
|
||||
written in the same message group. It accepts structured file links and WeKnora identifiers such as `knowledge_id` and
|
||||
`document_file_name`. It handles JSON wrappers, MCP text blocks and bounded JSON
|
||||
following console logs, deduplicates by file identity and caps one group at 100
|
||||
files / 2,000 visited payload nodes. Do not extract file cards from assistant prose,
|
||||
failed tool calls, arbitrary document text or `/mnt/skills` / host filesystem paths.
|
||||
|
||||
The admin-only Settings → Appearance switch「技能检索文件展示」persists globally as
|
||||
`citation_display.retrieved_files_enabled` (default true). The existing citation-display
|
||||
PUT endpoint enforces administrator authorization. The UI waits for the setting
|
||||
before collecting files, shares its query cache, and refreshes active clients every
|
||||
60 seconds/on stale window focus. Disabling it hides retrieved cards, not normal artifacts.
|
||||
|
||||
Thread files reuse the authorized artifact endpoints and uploaded-file reader;
|
||||
knowledge files reuse the authorized LLMWiki original-file API and
|
||||
`WeKnoraSourceDrawer`. `/api/llmwiki/sources/resolve` maps provider knowledge-base
|
||||
ids to local mapping ids only after checking the viewer's existing read access.
|
||||
External links use HTTP(S) only, credential-free fetches for
|
||||
inline preview, and a native original-file link for download/fallback; never send
|
||||
the DeerFlow bearer token to them. External download behavior depends on the
|
||||
source's response headers and browser cross-origin rules. Unknown formats or
|
||||
unavailable Office sidecars fall back to downloading the original. Public shares
|
||||
use their existing file route and do not query private knowledge document APIs.
|
||||
Closing the shared preview dialog aborts in-flight requests and releases blob URLs.
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
@@ -112,20 +77,18 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
|
||||
|
||||
### Frontend
|
||||
|
||||
- **账号登录、自主注册与单点登录**:`/login` 提供用户名/邮箱与密码登录,`/register` 提供自主注册。管理员在「设置 → 登录与注册」管理 `system_settings.json.authentication` 的四个开关:账号密码登录、自主注册、单点登录(同一账号仅一处在线)默认关闭;既有外部系统免登 `sso_enabled` 默认开启。已保存配置保持不变。开启密码登录后,私有页面先经 `/api/v1/auth/me` 验证,后端 Gateway/LangGraph 同步拒绝旧免密 JWT;合法登录在 JWT 写入 `auth_method`。未设置个人密码的账号使用配置中的系统口令,未自定义时为 `123ewq`;修改密码也须验证当前个人密码或默认口令。开启 `single_session_enabled` 后,每次成功登录原子递增 `token_version`,旧会话下一次请求即失效;前端每 15 秒及窗口激活时检查并退出,失效页禁止自动重登录,避免互踢。关闭单处限制时允许多个登录会话。外部免登参数兼容 hash 前后两种位置(hash 参数优先)。公开配置 `GET /api/v1/auth/options`,管理员读写 `GET/PUT /api/system-settings/authentication`。个人信息下提供修改密码;`LoginWhitelistPage.tsx` 提供管理员重置。密码与口令共用,更新后旧密码/链接失效并撤销旧会话;当前用户改密或管理员重置自身须保存返回的新 Bearer token。新密码不在用户列表回传明文,管理员仅在本次重置后临时复制链接。注册与新建外部账号均为普通用户,禁止依据名称自动授予管理员权限。
|
||||
|
||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
|
||||
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod, lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
|
||||
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是地图网页模式读取的瓦片模板。设置 → 外观的管理员选项「HTML 地图」可选 `echarts`(本地 GeoJSON,`data-df-geo`,默认)或 `leaflet`(地图网页,`data-df-map`);运行时同时保留两种初始化,已生成页面不因切换失效。用户上传的栅格图若要作为页面背景,使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
|
||||
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是初始化脚本读取的瓦片模板。用户上传的栅格图若要作为页面背景,使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`,地图使用 Leaflet `data-df-map`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
|
||||
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
|
||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
|
||||
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级登录口令与密码**已统一,个人口令优先于系统共享口令;新密码保存哈希,旧明文口令兼容校验。管理员重置使用 `PUT /api/v1/auth/users/{id}/login-password`(别名 `/reset-password`),同时更新两种历史凭据字段并撤销旧会话,不再支持清空密码或读取新密码明文。未设置个人密码时使用配置共享口令(默认 `123ewq`);登录开关关闭时保留旧入口兼容行为,开启后禁止免密进入。详见上方「账号登录、自主注册与单点登录」。
|
||||
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
|
||||
- **敏感词管理 (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/`。
|
||||
- **按钮管理 (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, openMode, loginParams, appendAuth, authTokenParam, authNameParam, 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 (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`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. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
|
||||
@@ -133,7 +96,6 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **会话冲突与模型工具流恢复**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` returns the Chinese detail「当前对话还在生成回答…」). `core/threads/hooks.ts` detects the conflict and routes it through `confirmOrRejoin`. Run-creation SSE responses expose `Content-Location` plus the SDK `Location` reconnect endpoint; `core/api/api-client.ts` records the last consumed event id, so an in-page rejoin resumes after that cursor instead of replaying tool arguments from `-1`. `write_file` argument updates carried by `messages-tuple` count as business activity. A known active run is probed only after 90 seconds without a business frame; the watchdog hides `lg:stream`, stops only the old browser reader, and rejoins without cancelling the backend run. Clean EOF, transport errors, 409 and watchdog recovery share one single-flight Promise to prevent duplicate joins/finalization. The backend `PatchedChatOpenAI` separately normalizes confirmed cumulative argument snapshots and bounded compatible response shapes while preserving standard deltas; its content-free diagnostics never log prompts, arguments or file bodies.
|
||||
- **React #185 与流式刷新保护**: every direct LangGraph `useStream` surface (main/agent chat, AI writing, Canvas and Studio notebook) uses shared `STREAM_RENDER_BATCHING = true`. The installed SDK implements numeric `throttle: 16` as a trailing debounce, which can indefinitely postpone UI updates during a continuous fast stream; `true` retains asynchronous next-task batching without requiring 16ms silence. `stream-error-classification.ts` detects `Maximum update depth exceeded` / production `#185` through bounded, cycle-safe wrappers. Main chat filters the SDK's stored error and duplicate submit rejection; `react-ui-error.ts` gives each send at most one automatic UI recovery, then pauses rejoin without cancelling the server run. Token activity must not reset that budget. The top-level `ReactUiErrorBoundary` catches only this render/commit error, shows a static friendly reload panel, and never automatically remounts the failing tree. Other errors are not swallowed. Shared toasts are deduplicated for 30 seconds; diagnostics retain error type, recovery phase, thread/run identifiers and component stack but never message/tool content. Real SDK SSE replay covers partial reasoning and write_file Markdown before generation ends; the browser boundary check triggers a real production React layout-effect loop and requires manual reload to recover. These safeguards do not prove the unknown production component loop is fixed.
|
||||
- **流式游标作用域**: resumable cursors belong to the current SDK message accumulator, not a module-global map. `createStreamAPIClient` isolates same-reader recovery; newly mounted chats and real thread switches must replay the retained prefix instead of borrowing another reader's consumed position. Clearing replaces the cursor map so late old-reader events cannot pollute the new scope. The shared `getAPIClient` does not rewrite replay-from-start cursors. `stream-cursor-replay.test.ts` uses the real SDK to protect against both duplicate tool arguments on reconnect and missing prefixes on reopening.
|
||||
- **单智能体任务深链聊天记录(2026-10-19)**: `3qfx` / `rwfx` / `xdfx` 已不再通过 `useTaskThreads` 扫描、过滤通用 `threads_meta` 列表;`TaskFxWorkspace` / `TaskFxIndexResolver` 统一调用 `useTaskDeeplinkThreads` → `GET /api/task-deeplink-threads?task_id=&go_path=`,读取独立表 `task_deeplink_threads` 的 `(task_id, go_path)` 索引,再按 `thread_id` 关联 canonical `threads_meta` 标题/状态(消息与 checkpoint 仍由 LangGraph 管理)。新会话 metadata 写 `taskRecordId`、`taskGoPath`、`thread_type=task_deeplink` 并由 thread-meta 仓储同事务登记;`3qfx/rwfx` 的 `taskRecordId` 是任务 id,`xdfx` 是 URL 原始行动 id(真实任务 id 仍保留在 `metadata.taskId` 维持共享访问语义)。普通聊天记录默认隐藏该 thread type;显式 taskId 查询仍可读取。迁移 `20261019_01` **只建表、不回填旧数据**,因此上线前旧深链会话不会出现在新列表。`/api/open/3qfx/ask` 同样写专用索引。Tests: `packages/harness/tests/test_task_deeplink_threads.py`, `src/core/auth/task-purpose-metadata.test.ts`, `src/core/threads/hidden-chats.test.ts`。
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
@@ -144,10 +106,6 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
|
||||
### Configuration
|
||||
|
||||
普通问答与智能体问答的 `write_file` 流式展示:`namedStreamingWriteFilePath` 统一判断路径是否可用;缺少路径、路径尚未完整或只有占位名时,不生成虚拟文件来自动打开沙箱,而由 `message-group.tsx` 在步骤内用绿色小字展示正文。步骤参数合并 `tool_calls` 与 `tool_call_chunks`,后续收到真实路径时复用原工具调用 ID 恢复正常文件预览;`outputs` 下真实文件的自动打开和沙箱流式预览保持原流程。
|
||||
|
||||
沙箱文件正在生成时,ArtifactsProvider 的 `select` / `openStreamedArtifact` 阻止切到其他文件;手动操作提示「当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。」,自动选择静默跳过。ChatBox 按选中文件的未完成 write_file 和运行状态注册锁,预览组件同时覆盖流式写入及全文改写;完成、停止或卸载时移除所属锁。同一个文件的虚拟预览和真实路径视为同一目标。
|
||||
|
||||
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
|
||||
|
||||
## Detailed Backend Reference
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# 本地问答、资料库与技能并发验收
|
||||
|
||||
测试日期:2026-09-28 至 2026-09-29 凌晨。环境:Windows、约 8GB 物理内存、Python 3.12、Node 22、Gateway 单进程、SQLite、默认 MiniMax-M2.5。前端 Vite 5174,Gateway 8001。模型并发限流与 Redis 分布式门控未启用。
|
||||
|
||||
本次保留并验收了工作区原有的资料库索引、问答收尾、沙箱超时和流式恢复修改,另外修复了压测复现的技能刷新重复校验,以及真实地图问答暴露的内部待办提醒显示问题。测试结果不能等同于 Linux 多进程生产环境容量。
|
||||
|
||||
## 版本范围
|
||||
|
||||
开始测试时本地为 `3a07c476`,没有 `cognitive-situation-map`。随后拉取远程,发现 `origin/master` 已前进至 `3107f2cc`,包含该技能的初始化及 v2.6.0 精简运行时。保留本地修改后合入远程,合并提交为 `c9effe83`;最终绘图与多用户测试以合入后的代码为准。
|
||||
|
||||
旧本地版本里 `page-report-generation` 的地图仅支持中、美、日、台,因此其“不支持菲律宾”的反馈不能用于评价最新的 `cognitive-situation-map`。
|
||||
|
||||
## 真实问答
|
||||
|
||||
使用独立普通测试账户,阶梯间顺序运行,逐条检查预期测试关键词与结果、SSE `end`、持久化 `success`,结束后删除本轮生成的测试线程。1/3/5/10 路检查正文包含“测试”和数字 4;20 路增强为检查“测试正常”及明确的“2加2等于4”。这属于简单问答验收,不是复杂内容质量评测。
|
||||
|
||||
| 同时问答用户数 | 成功 | 首段答案中位耗时 | 流结束中位耗时 | 流结束最慢 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 1 | 1/1 | 5.792s | 6.439s | 6.439s |
|
||||
| 3 | 3/3 | 2.214s | 3.245s | 3.287s |
|
||||
| 5 | 5/5 | 2.885s | 3.702s | 4.373s |
|
||||
| 10 | 10/10 | 2.920s | 4.796s | 5.416s |
|
||||
| 20 | 20/20 | 4.364s | 10.622s | 11.135s |
|
||||
|
||||
累计 39/39 通过。20 用户整批耗时 11.829s,同期 health 12/12 成功、中位 13ms、最大 371ms。这里是一次阶梯短时突发,并未证明百用户持续长任务容量。
|
||||
|
||||
取消接口返回 204,58ms 后状态为 `interrupted`,测试线程删除返回 200。同期健康探测 20 次均成功,中位 9ms,最大 3.044s;说明仍存在数秒调度尖峰,不能仅凭平均值宣布无阻塞。
|
||||
|
||||
早期多个磁盘基准同时运行时,单路首段答案为 33.853s,日志显示同步 agent 构建 28.344s,health 也有 5s 超时。这批明确有本机资源争用,不纳入稳定容量结论。开发脚本启动的服务进程还曾消失,没有拿到退出或 reload 日志,原因未确认;期间的连接失败剔除。后续使用无热重载的固定进程复测。
|
||||
|
||||
## 资料库和知识库
|
||||
|
||||
- 资料库/知识库相关回归 186 项通过;资料库新存储 Memory/SQLite 专项另有 8 项通过,覆盖用户隔离、分页、原子换代、并发租约和旧任务围栏。
|
||||
- 真实测试账户看到 2 个共享知识库;选中的库包含 668 份文档、168 个 Wiki 页面。首次 hybrid 查询 3.58s,返回 5 条。
|
||||
- 排除合成磁盘负载后,空资料库、知识库目录和 hybrid 查询分别测试 1/4/8 并发,每档 8 次,总计 72/72 HTTP 200,零异常。
|
||||
|
||||
| 接口 | 8 并发中位耗时 | 8 并发最大耗时 |
|
||||
|---|---:|---:|
|
||||
| 资料库列表(空测试账户) | 165ms | 224ms |
|
||||
| 知识库目录 | 517ms | 557ms |
|
||||
| hybrid 检索(相同问题、5条结果) | 426ms | 441ms |
|
||||
|
||||
这组重复使用同账号和查询,包含热缓存效果;空账户列表不代表有大量文件的资料库。独立测试 SQLite 另生成了 101 个合成用户、10 万条资料元数据,480 次查询全部成功;其中单用户 5 万条资料关键词查询 P95 为 1.08s、首页 P95 为 130ms,但此轮同时有其他 IO 测试,只作为规模观察值。
|
||||
|
||||
资料库索引按 5 分钟过期刷新;进程异常退出时正在同步的租约最多等待 30 分钟恢复。大库关键词查询仍使用多字段包含匹配,不能据当前测试证明生产规模检索始终快速。
|
||||
|
||||
## 大技能与并发刷新
|
||||
|
||||
文件体积大和小文件数量多分别测试,使用隔离临时目录,不改真实技能内容。
|
||||
|
||||
| 场景 | 结果 |
|
||||
|---|---|
|
||||
| 真实技能副本,269 文件 / 2.75MiB,稳定缓存 64 并发 | 全成功,约 1.05s;早期混合负载观察值 |
|
||||
| 同副本改 1 个资产,32 并发,修复前混合负载 | 27 成功、5 个 30s 锁等待超时,复现问题 |
|
||||
| 独占配对:同副本改 1 个资产,32 并发,旧逻辑 | 32/32 成功,P95 13.700s,最大 14.842s |
|
||||
| 独占配对:同场景,新逻辑 | 32/32 成功,P95 / 最大 3.963s,P95 降低约 71% |
|
||||
| 128MiB 资产、34 文件,新逻辑 | 冷初始化 5.54s;变更后 32 并发 P95 0.584s,全成功 |
|
||||
| 约 160 技能、5120 个小文件(修复前规模观察) | 冷加载执行者约 107s,31/32 等待者超时;热校验约 18s |
|
||||
|
||||
修复让同一进程内、相同权限配置的一批重叠请求共享一次刷新及最终校验。等待者仍检查当前缓存身份、权限哈希和能力标记;失败或技能下线时不返回旧视图。新增 8 项回归连同原有 62 项,共 70 项通过。
|
||||
|
||||
**剩余风险:** 数千个小文件的单次 Windows 元数据扫描仍昂贵,本次修复没有消除这项成本,也未证明跨进程刷新能合并。不能把 32/64 个技能调用线程等同于 32/64 个真实模型用户。
|
||||
|
||||
实际调用 `gongwen-assistant` 读取技能并输出短通知,10.672s 内正常结束,无工具卡住。但答案把要求的 10月8日改成10月15日:通信/技能读取通过,内容日期遵循失败,不能记为整体语义验收通过。
|
||||
|
||||
## 绘图与界面
|
||||
|
||||
普通问答已实际生成菲律宾地图底图及马尼拉、吕宋北部、巴拉望、棉兰老四个点位,点位弹窗可打开。使用明确标注的虚构数据,未进行时事真实性评估。初次模型额外要求确认消息内地图,回复确认后完成,属于可见的额外交互成本。
|
||||
|
||||
这次真实对话发现 `todo_completion_reminder` 被当作用户问题展示。修复后,后端待办提醒带 `hide_from_ui`,前端也按两个历史内部名字过滤聊天和问题导航;真实用户引用 `<system_reminder>` 的文本仍保留。后端 3 项、前端新及关联分组 12 项通过。
|
||||
|
||||
最新 `cognitive-situation-map v2.6.0` 已经通过真实问答完整调用:读取技能和菲律宾参考 JSON、写入场景、调用技能自带 `node render.mjs`、检查文件、`present_files` 交付,42.481s 完成,SSE 正常结束并保存 `success`。产物标题为“菲律宾冲突认知态势图(功能验收示意)”,包含 4 个事件、4 个标注、3 个行为体和 2 条连线,明确标注所有事件与等级均为测试数据。输出 `status=ok`、无恢复和警告。模型曾发起两次过宽的目录 glob,被技能访问隔离拒绝后自行改用指定技能目录完成,没有循环卡住。
|
||||
|
||||
同一最新技能的直接渲染为 624ms;8 个并发独立渲染进程全部成功,范围 650–949ms。围栏/截断/缺失字段/约 600KiB 场景输入的恢复、非空目录拒绝覆盖、脚本结束标签转义均完成专项检查。真实 Windows LocalSandbox 虚拟路径经 PowerShell 调用 Node 成功(含冷 shell 7.85s)。CSS、JS、菲律宾底图和旗帜资源均返回 200;2.93MB 底图一次请求耗时 208ms。8 个渲染进程不等于 8 个同时生成长内容的模型用户。
|
||||
|
||||
**最新 HTML 的视觉验收未通过:** 测试账户能在会话中看到生成文件卡片,文件接口返回 200,源码模式能显示 HTML、正确资源地址和页面结构,但 Codex 内嵌浏览器的预览面板空白;DOM 中 iframe 的 `src` 为 `blob:http://127.0.0.1:5174/...`,可访问树仍为 `about:blank`。普通地图组件的可见性不能替代这个 HTML 的验收,渲染器返回成功也不能证明最终视觉正确。已保留空白预览截图,未将此项计为通过。
|
||||
|
||||
进一步在内存中执行真实 `prepareGeneratedHtml`:11,269 字节输入变为 14,597 字节,原有 3 个脚本逐字保留、DOM id 与外链 URL 均不变,新增兼容脚本语法有效。暂未发现生成器或预处理破坏 HTML 的证据;故障停在预览加载/Blob 导航阶段,具体原因未确认,不将其归为已知浏览器限制,也不宣称已修复。
|
||||
|
||||
界面还复现了 TDesign 新手引导闲置时的 `reading 'element'` 异常:`current=-1` 时其 resize 监听仍读取不存在的步骤。简洁模式与智能体两处包装组件已改为只有有效步骤才挂载 Guide。浏览器复测了文件面板开关、侧栏切换、手动启动、下一步、跳过,未再出现新的同类异常;完成/跳过的持久化语义保持不变。
|
||||
|
||||
## 合并后回归
|
||||
|
||||
- 完整 harness:274/274 通过,53.07s;包含前述技能及 Todo 子集,不重复累加。
|
||||
- 模型并发配置专项:10 项通过;种子技能资源:4 项通过。
|
||||
- 前端流式恢复专项:9 组共 44 项通过;待办可见性及相关消息分组:12 项通过。
|
||||
- 全量前端构建和类型检查最初并行运行时,系统空闲内存降到约 17MB,因此主动停止该轮类型检查,改为串行执行;该轮中断不作为代码失败。
|
||||
- `pnpm build` 通过(10m25s,包含前期资源争用,有大 chunk 警告)。其后新增的两个 Guide 挂载条件已通过单独语法检查和真实浏览器回归;另补齐问题导航抽取后的类型导入,不改变生成代码行为。
|
||||
- 最终 `pnpm typecheck` 仍未通过,共 73 项原有诊断;与测试前本地 `hang-typecheck.log` 的 75 项逐条比较,减少了消息分组测试的 2 项,没有新增诊断。本轮发现的 `QuestionItem` 类型导入遗漏已经修正,复跑后消失。剩余问题包括 `nextra` 依赖、旧 JSX 类型和既有组件/测试类型,未扩大范围处理,不能标记全量类型检查通过。
|
||||
|
||||
## 本地证据和复测入口
|
||||
|
||||
详细证据仅保留在 `.runtime/test-results/20260928-local-qa/`,不提交模型凭证、问答日志、测试数据库、构建 ZIP 或技能临时大目录:
|
||||
|
||||
- `qa-concurrency-results.json`:逐轮时间、结束事件、答案检查和清理结果。
|
||||
- `qa-concurrency-20-results.json`、`harness-full.xml`:20 个独立用户问答和完整 harness 回归。
|
||||
- `knowledge-stable-recheck.json`、`knowledge-library-report.md`:知识库与资料库稳定复测及限制。
|
||||
- `skills-benchmark-paired.json`、`skills-findings.md`:同机旧/新刷新配对;“旧逻辑”在隔离进程禁用新合并函数回放,不改服务源码。
|
||||
- `philippines-html.json`、`large-skill.json`:旧页面技能能力缺口与公文技能真实调用。
|
||||
- `philippines-map-ui.png`:普通地图真实浏览器渲染。
|
||||
- `cognitive-philippines.json`、`cognitive-skill-probe.json`:最新技能真实完整问答及渲染器专项。
|
||||
- `cognitive-preview-ui.png`:最新 HTML 在本轮内嵌浏览器出现空白的实际截图。
|
||||
- `frontend-build.log`、`frontend-typecheck-final.log`:全量构建与最终类型检查。
|
||||
|
||||
提交的回归入口包括 `packages/harness/tests/test_skill_view_refresh_coalescing.py`、`test_skill_view_waits.py`、`test_sandbox_command_liveness.py`、`test_run_completion_timeout.py`、`test_todo_reminder_visibility.py`,以及前端 `control-timeout`、`stream-terminal-recovery` 和 `todo-reminder-visibility` 测试。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 技能并发与流式修复:配置、前端包与验收说明
|
||||
|
||||
日期:2026-09-27(2026-09-28 补充高并发运行配置)。
|
||||
日期:2026-09-27。
|
||||
|
||||
本说明对应本次提交的修改,包含模型流式兼容与恢复、React #185 错误处理和已完成的最小技能示例,
|
||||
以及可选的技能视图复用、版本缓存、模型计时日志队列和 Gateway 线程池扩容。
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- **后台代码也要更新**:四个新开关需要新版后台读取。只换前端 dist 或只把字段写进旧版 config.yaml 不会实现后台优化。
|
||||
- **前端**:使用本说明末尾的 ZIP,里面是完整 `dist/`,不是源码或 node_modules。
|
||||
- **仓库运行配置已开启优化**:两份 config.yaml 的四个开关均已显式设为 `true`。但已有服务器的持久化/外挂配置不会被仓库自动覆盖,仍需手工同步。
|
||||
- **默认不启用优化**:仓库两份 config.yaml 的四个新开关均为 `false`。只有主动改为 `true` 才启用对应项。
|
||||
- 开启线程池开关后可将上限调整为 100,但不提高模型服务商配额、不启用多 worker。
|
||||
|
||||
## 2. .env 需要修改哪些项
|
||||
@@ -54,9 +54,8 @@ DEER_FLOW_ASYNCIO_THREAD_POOL_SIZE=100
|
||||
|
||||
## 3. config.yaml 新增配置
|
||||
|
||||
在实际加载的 `config.yaml` **顶层**配置这一段,不能缩进到 `models`、`sandbox` 或 `skills` 内,
|
||||
也不要重复定义两个 `runtime_performance`。下面是程序在字段缺失时的兼容性默认值;
|
||||
当前仓库的两份可运行配置已使用后文的高并发开启值:
|
||||
在实际加载的 `config.yaml` **顶层**新增一段,不能缩进到 `models`、`sandbox` 或 `skills` 内,
|
||||
也不要重复定义两个 `runtime_performance`:
|
||||
|
||||
```yaml
|
||||
runtime_performance:
|
||||
@@ -93,7 +92,7 @@ runtime_performance:
|
||||
|
||||
- 已设置 `DEER_FLOW_CONFIG_PATH` 时,以它指向的文件为准;Docker 内该值必须是容器内可访问路径。
|
||||
- 本仓库包含 `offline-backend-20260512/config.yaml` 和 `offline-backend-20260512/backend/config.yaml`;
|
||||
两份已同步为高并发开启值,但服务器要修改的仍是启动时实际加载/挂载的那一份。
|
||||
两份都已补齐默认关闭的示例,但服务器要修改的是启动时实际加载/挂载的那一份。
|
||||
- 启动日志 `Loaded AppConfig from ...` 可确认路径,`RUNTIME_PERFORMANCE` 可确认实际开关值。
|
||||
|
||||
## 4. 开启后做了什么,没做什么
|
||||
@@ -113,9 +112,6 @@ runtime_performance:
|
||||
没有采用会延迟下线的 TTL 缓存;下一次工具调用能发现下线或无效配置并拒绝访问。
|
||||
已经开始执行的工具不因此被强行中断。
|
||||
缓存仅用于标准 `LocalSkillStorage`,自定义存储实现/子类保守回到原刷新路径。
|
||||
同一 Gateway 进程内,仅在多个热命中调用真正重叠时合并为一次元数据校验;
|
||||
该批调用结束后结果立即丢弃,新调用仍重新校验。启停文件哈希不同的调用不会加入旧校验,
|
||||
校验异常会对该批调用统一 fail-closed,且不会阻止后续调用重试。
|
||||
|
||||
热路径仍有 O(文件数) 元数据校验,慢磁盘/网络挂载仍可能耗时;这不是“所有文件操作零开销”,
|
||||
也没有把受锁保护的重建变成无锁写入。
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# 问答停在执行命令或读取技能:排查与修复
|
||||
|
||||
日期:2026-09-28。
|
||||
|
||||
本次检查针对普通问答和智能体问答:页面停在工具步骤,上游模型没有收到下一轮请求。
|
||||
以下是代码审查和受控回归确认的触发路径;尚未收到具体生产对话 ID 和现场日志,不能据此认定某次现场故障只有一个根因。
|
||||
|
||||
## 已修复的等待路径
|
||||
|
||||
| 位置 | 原来的触发条件 | 当前行为 |
|
||||
| --- | --- | --- |
|
||||
| LocalSandbox 命令执行 | `subprocess.run` 超时仅终止外层 shell,后代仍持有输出管道;Windows 超时收尾继续无期限 `communicate` | 独立进程组、关闭交互输入、临时文件采集输出;超时清理进程树并限制回收等待 |
|
||||
| 技能视图刷新 | 进程锁、跨进程文件锁和等待并发校验结果均没有统一等待上限 | 等待最多 30 秒后抛出明确工具错误;不删除正在执行的刷新,不使用未经校验的旧视图 |
|
||||
| AIO 沙箱 | 上次命令未返回时,后续命令或文件操作无限等待同一把锁 | 锁等待 30 秒后失败;不抢占旧请求的锁,不自动重复执行命令 |
|
||||
| 文件分段读取 | 超长单行先读完整行才裁剪;FIFO/设备文件可能一直等数据 | 达到当前行输出预算即返回;本地读取用非阻塞打开并检查普通文件,远端分段读取同步限制 |
|
||||
| 前端结束恢复 | 后端已 success/error,但旧 SSE 仍开着,SDK `isLoading` 一直为真;整段 SSE 期间的 `sendInFlight` 又把同一轮结束快照丢掉 | 核对轮次及运行 ID 后清理重连标记、关闭本地 reader、应用结束快照;不因此取消后端或覆盖新一轮 |
|
||||
| 前端恢复查询 | `/runs`、检查点或历史请求悬挂,占着 single-flight 恢复槽 | 普通 SDK 请求设置 30 秒超时;恢复失败仍保留后续检查机会,SSE 不受此总时长限制 |
|
||||
| 后端结束收尾 | 图已返回,但检查点检查、状态写入、日志刷新或标题同步永远不返回,导致 `end` 发不出去 | 终态检查、终态状态写入分别有 10 秒预算;后续元数据收尾共享 10 秒预算,独立 finally 发出 `end` |
|
||||
|
||||
技能锁超时走原有工具错误处理链,模型可收到失败结果并决定后续操作;不会把“超时”伪装成成功。
|
||||
元数据收尾超时只发生在图已经结束后,不给整个研究/子智能体执行增加统一短时限。
|
||||
终态检查点超时会报告运行失败,不能直接当成模型成功。
|
||||
|
||||
## 配置和上线
|
||||
|
||||
本次需同时更新后端代码和前端构建产物,重启后端后生效。只刷新页面或调线程数不能替代修复。
|
||||
本次未操作生产服务、未发布前端包;正式离线发版仍使用独立的 `deerflow-offline-deployment` 仓库。
|
||||
|
||||
本地命令保留原来的 600 秒默认执行时限,新版允许在实际加载的配置文件中调整:
|
||||
|
||||
```yaml
|
||||
sandbox:
|
||||
# 合并到已有 sandbox 段中,保留 use、mounts 等既有字段。
|
||||
command_timeout_seconds: 600
|
||||
```
|
||||
|
||||
该字段只控制 LocalSandbox 命令执行,必须为有限正数。未配置仍为 600 秒;不要把它误认为模型请求超时。
|
||||
命令输出每个流最多读入 1 MiB 头尾内容,再经过既有工具输出裁剪。
|
||||
运行中的命令约每 100ms 检查累计捕获输出,超过 16 MiB 请求终止;这是采样护栏,不是磁盘硬配额。
|
||||
正常退出后的显式后台进程需要自行重定向输出和管理生命周期。
|
||||
|
||||
前端已有的无业务事件检查窗口仍为 90 秒;这是启动后台状态核对的时机,不是宣判任务失败的时限。
|
||||
30 秒控制请求超时不影响合法的长时间 SSE 和工具执行。
|
||||
|
||||
## 仍需现场证据区分的情况
|
||||
|
||||
- 磁盘/NFS 本身阻塞的刷新执行者仍可能卡在操作系统 I/O;本次限制的是其他调用的等待,不宣称能终止任意内核 I/O。
|
||||
- 自定义技能脚本自身请求外部服务不返回,仍会等到本地命令时限;建议技能内各 HTTP 请求也设置连接/读取超时。
|
||||
- 整个图中的第三方异步工具、模型前中间件和检查点操作没有统一的“无事件即取消”规则,避免误杀合法研究任务。
|
||||
- 检查点回滚是实际数据修改,保持原有完成/取消语义,不把仍在执行的回滚丢到后台后开放下一轮。
|
||||
- 已提交给数据库的单条写入未必能被驱动撤销;取消和新轮次检查可阻止旧清理继续发起后续写入,不能保证撤回已经执行的 SQL。
|
||||
|
||||
再次出现时,记录对话 ID、运行 ID、最后一步和时间,并对照上游请求日志、运行状态及
|
||||
`RUN_COMPLETION_TIMEOUT run_id=... phase=terminal_checkpoint|terminal_status|metadata_cleanup`。
|
||||
技能锁超时会包含 `Timed out waiting for skill view ...`;沙箱命令超时会包含 `Command timed out`。
|
||||
不要仅凭页面最后显示“读取技能”就断定仍在读文件,它也可能是结束消息没有传到浏览器。
|
||||
|
||||
## 回归入口
|
||||
|
||||
- 后端 `packages/harness/tests/test_skill_view_waits.py`:锁竞争和并发校验超时、失败后恢复。
|
||||
- 后端 `packages/harness/tests/test_skill_view_cache.py`、`test_skill_view_tool_reuse.py`:既有启停与缓存安全性。
|
||||
- 后端 `packages/harness/tests/test_sandbox_command_liveness.py`:真实父子进程、输出、非交互输入及特殊文件/长行。
|
||||
- 后端 `packages/harness/tests/test_run_completion_timeout.py`:挂起的结束收尾、取消、新一轮隔离及正常长图执行。
|
||||
- 前端 `src/core/api/control-timeout.test.ts`、`src/core/threads/stream-terminal-recovery.test.ts`:控制请求超时及真实 SDK reader 恢复。
|
||||
- 前端既有 `stream-recovery`、`stream-display`、`stream-reasoning-replay`、`stream-cursor` 测试继续验证断流重连和消息显示。
|
||||
|
||||
## 本机验证结果
|
||||
|
||||
- 后端 `python -m pytest packages/harness/tests -q`:**263 passed**。旧同步日志断言补了显式测试配置,避免当前启用异步日志时与后台队列竞速;异步日志仍由独立测试覆盖,未改实际运行配置。
|
||||
- 前端八个相关测试文件打包后用 Node test runner 执行:**31 passed**;新增测试类型修正后其中三个新增用例再次通过。
|
||||
- 修改的后端代码和测试通过 Ruff;`git diff --check` 通过。
|
||||
- 完整前端类型检查未通过:**75 个 TypeScript 错误**,均在本次未修改的文件,涉及 `nextra` 缺失、旧 JSX 类型、既有测试类型等。本次修改文件及新增测试无诊断。详细输出保留在 `frontend-web/.runtime/hang-typecheck.log`(本机文件,不作为发版资产)。
|
||||
- 命令进程树测试在 Windows 使用真实子进程;Linux 进程组分支已审查,但本机未做 Linux 实跑,AIO 未连接实际容器。未用生产模型调用或真实业务技能执行验证。
|
||||
@@ -1,7 +1,5 @@
|
||||
# 技能知识归纳与“知识梳理”助手知识库
|
||||
|
||||
助手知识库的目录、正文、来源和任务记录读取已提交快照,不等待后台导入的全局写锁,也不重复统计整库内容。导入仍在提交前完成实体及 Wiki 对齐,旧数据在检索、导出时保留兼容对齐。页面和展示权限的读取支持取消及 30 秒超时,失败显示可重试错误,避免一直加载;大目录分页失败后停止自动续页并保留已加载正文。来源列表仅在打开「来源与同步」或「导入 / 导出记录」时请求。
|
||||
|
||||
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
|
||||
|
||||
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 麒麟系统 Chromium ~80 真机回归清单
|
||||
|
||||
> 背景:Tailwind v4 依赖 `@layer`(Chrome 99+)/`@property`(85+)/`color-mix`、`oklch`(111+),麒麟系统自带的 Chromium 80 多浏览器上整站样式失效。本分支(`feat/tailwind-v3-legacy-chrome`)完成 v4→v3 降级迁移,产物结构性指标已全部达标(详见提交 1607c7fe / 40b02520 / e54abcc6)。本文档供真机验收使用。
|
||||
|
||||
## 一、测试准备
|
||||
|
||||
1. 在麒麟机器的浏览器地址栏输入 `chrome://version`,记录完整版本号(重点关注是否 ≥84 —— 低于 84 时 flex `gap` 不支持,各处间距会塌陷,需单独反馈)。
|
||||
2. 部署本分支 `pnpm build` 产物(或开发机 `pnpm preview` 后用麒麟机器访问)。
|
||||
3. 先跑一次 `pnpm check:legacy-css`(开发机),确认绿色再发版。
|
||||
|
||||
## 二、页面巡检(明/暗两套主题各过一遍)
|
||||
|
||||
| 页面 | 路径 | 重点检查点 |
|
||||
|---|---|---|
|
||||
| 登录/落地页 | `/` | 布局完整、文字阴影(hero 标题)、无裸奔样式 |
|
||||
| 问答首页 | `/chats/new` | 整体配色、输入框焦点环(回退为内层 outline)、常用提示词栏 |
|
||||
| 对话流 | 任意会话 | Markdown 渲染(标题/列表/表格/代码块/引用——streamdown 白名单类)、气泡圆角、流式输出 |
|
||||
| 智能体 | `/page/workspace/agents` 及详情 | 卡片、Tabs、新建页表单、技能穿梭框 |
|
||||
| 圆桌会商 | roundtable 页 | 左侧步骤条、消息气泡、流程图面板、暗色兜底配色 |
|
||||
| 任务深链 | `?taskId=&goPath=3qfx/rwfx/xdfx` | 左侧任务卡、目的树、快捷跳转栏(按钮管理配置项) |
|
||||
| 轻应用/菜单管理/敏感词 | 各管理页 | Tabs、表格、弹窗 |
|
||||
| 简洁模式 | 外观设置切换 | 无全局导航栏布局、右侧工具箱、引导高亮框 |
|
||||
| 换肤 | 浅红/暗色/浅蓝/绿/冰蓝/浅黄 | 六套主题各自的 primary/ring 颜色、半透明档(primary/10 等) |
|
||||
| 大屏(dashboard-agent) | 战情大屏页 | 时间线、图表、暗色文字覆盖 |
|
||||
| iframe 嵌入 | `?embed=1&theme=dark-blue` | 去全局 chrome、强制主题生效 |
|
||||
|
||||
## 三、已知降级点(预期行为,非 bug)
|
||||
|
||||
1. **`:has()` 渐进增强**(Chrome <105 不生效):按钮内含图标时内边距略宽、alert 带图标时首列宽度、item 带描述时对齐微差 —— 视觉细节,不影响功能。
|
||||
2. **input-group 焦点环回退**:聊天输入框的焦点指示从「整框 3px 环」回退为「内层输入控件 2px outline」;校验态从整框环回退为内层红色阴影 —— 仍有明确指示。
|
||||
3. **`:where()` 圆桌字号缩放**(Chrome <88 不生效):设置页调字号时圆桌页面小字号文本不跟随缩放,基础字号正常。
|
||||
4. **textarea 自增高**:`field-sizing` 需要 Chrome 123+,旧浏览器走原生 `rows` 行为(已有多行默认值)。
|
||||
5. **prefers-color-scheme 单块**:产物中 1 处 lightningcss 内部标记(`--lightningcss-light/dark`),无影响。
|
||||
|
||||
## 四、重点风险项(出现问题按此排查)
|
||||
|
||||
- **整页无样式** → 确认产物是本分支构建(旧 v4 产物在 80 上必然裸奔);查 `@layer`。
|
||||
- **暗色主题失效** → 查产物 `.dark .dark\:` 前缀选择器是否存在(`pnpm check:legacy-css` 会拦)。
|
||||
- **弹层错位(下拉/气泡/选择器)** → radix 弹层依赖 `origin-[var(--radix-...)]`,确认类已生成。
|
||||
- **控制台语法错误/白屏** → JS 侧:确认构建 `target: chrome80` 且 `polyfills.ts` 在 main chunk 首位;记录报错栈反馈。
|
||||
- **mermaid 流程图 / 地图(maplibre)**:重型库在 80 上的运行时差异,出现异常截图 + 控制台报错反馈,单独处理。
|
||||
|
||||
## 五、发版前自检(开发机)
|
||||
|
||||
```bash
|
||||
pnpm build # 构建绿
|
||||
pnpm check:legacy-css # 产物守卫:@layer/@property/:is() 为 0,color-mix 全部守护
|
||||
pnpm typecheck # 58 个存量错误 = master 基线(迁移零新增)
|
||||
```
|
||||
+5
-67
@@ -12,74 +12,12 @@
|
||||
root.classList.add("dark");
|
||||
} catch (e) {}
|
||||
})();
|
||||
// Chrome 102 has no AbortSignal.timeout (Chrome 103+). Install before
|
||||
// any module so LangGraph SDK control requests can set a deadline.
|
||||
(function () {
|
||||
try {
|
||||
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout === "function") {
|
||||
return;
|
||||
}
|
||||
AbortSignal.timeout = function (milliseconds) {
|
||||
var controller = new AbortController();
|
||||
var timer = setTimeout(function () {
|
||||
var reason;
|
||||
try {
|
||||
reason = new DOMException("signal timed out", "TimeoutError");
|
||||
} catch (err) {
|
||||
reason = new Error("signal timed out");
|
||||
reason.name = "TimeoutError";
|
||||
}
|
||||
try {
|
||||
controller.abort(reason);
|
||||
} catch (err) {
|
||||
controller.abort();
|
||||
}
|
||||
}, milliseconds);
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
function () {
|
||||
clearTimeout(timer);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
return controller.signal;
|
||||
};
|
||||
} catch (err) {}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// React 19's development profiler uses the User Timing Level 3
|
||||
// `performance.measure(name, options)` overload. Firefox 101 only
|
||||
// implements the legacy string-marker form and otherwise throws a
|
||||
// DOMException during every scheduled update. Detect that overload
|
||||
// before React loads and disable its optional profiler when unavailable.
|
||||
(function () {
|
||||
try {
|
||||
var perf = window.performance;
|
||||
if (!perf || typeof perf.measure !== "function" || typeof perf.now !== "function") return;
|
||||
var probe = "__deerflow_user_timing_options_probe__";
|
||||
var now = perf.now();
|
||||
perf.measure(probe, { start: now, end: now });
|
||||
if (typeof perf.clearMeasures === "function") perf.clearMeasures(probe);
|
||||
} catch (e) {
|
||||
try {
|
||||
Object.defineProperty(window.performance, "measure", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
} catch (defineError) {
|
||||
try {
|
||||
window.performance.measure = undefined;
|
||||
} catch (assignmentError) {}
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>CM 助手</title>
|
||||
<script src="/runtime-config.js" data-runtime-config></script>
|
||||
</head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>CM 助手</title>
|
||||
<script src="/runtime-config.js" data-runtime-config></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
@@ -14,13 +14,10 @@
|
||||
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
|
||||
"test:answer-feedback": "node --experimental-strip-types --test src/components/workspace/agents/agent-feedback-visibility.test.ts src/components/workspace/messages/answer-feedback-eligibility.test.ts src/strategy-components/api/answer-feedback-batching.test.ts",
|
||||
"test:retrieval-debug": "node --experimental-strip-types --test src/core/llmwiki/retrieval-debug.test.ts",
|
||||
"test:abort-signal": "node --experimental-strip-types --test src/core/compat/abort-signal.test.ts",
|
||||
"test:auth-entry": "node scripts/test-auth-entry.mjs",
|
||||
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
|
||||
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
|
||||
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||
"check:legacy-css": "node scripts/check-legacy-css.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -74,6 +71,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"core-js": "3.45.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dnd-kit": "^0.0.2",
|
||||
@@ -116,17 +114,17 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"lightningcss": "^1.30.2",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
@@ -134,5 +132,8 @@
|
||||
"overrides": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"browserslist": [
|
||||
"Chrome >= 80"
|
||||
],
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import tailwindcss from "@tailwindcss/postcss";
|
||||
import legacyColorFallback from "./scripts/postcss-legacy-color-fallback.mjs";
|
||||
import lightningCssLegacyChrome from "./scripts/postcss-lightningcss-chrome109.mjs";
|
||||
import tailwindcss from "tailwindcss";
|
||||
import darkDescendant from "./scripts/postcss-dark-descendant.mjs";
|
||||
import autoprefixer from "autoprefixer";
|
||||
|
||||
// Tailwind v3:透明度修饰符(bg-primary/10 等)由 tailwind.config.ts 里的
|
||||
// --legacy-*-rgb 通道变量直接实现,不再需要 v4 时代的 color-mix 回退插件;
|
||||
// dark-descendant 把暗色变体的 :is(.dark *) 改写为 Chrome 80 可读的后代形式;
|
||||
// dev 环境面向开发者的现代 Chrome,产物侧的旧浏览器降级(:is()/逻辑属性/
|
||||
// 媒体查询 range 语法/厂商前缀)由 vite 的 lightningcss targets 负责。
|
||||
export default {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
legacyColorFallback(),
|
||||
lightningCssLegacyChrome(),
|
||||
],
|
||||
plugins: [tailwindcss, darkDescendant, autoprefixer],
|
||||
};
|
||||
@@ -155,11 +155,6 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||
|
||||
// 对话历史页「收藏」右侧的「清空」按钮。
|
||||
// "false" 隐藏;不填、删掉这一项,或填其它值,都显示。
|
||||
// 确认后会删除当前账号的全部对话,以及该账号下所有智能体的聊天记录。
|
||||
VITE_CHAT_HISTORY_CLEAR_VISIBLE: "true",
|
||||
|
||||
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
|
||||
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
|
||||
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import postcss from "postcss";
|
||||
|
||||
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
|
||||
const assetsDir = path.resolve("dist/assets");
|
||||
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
throw new Error("dist/assets 不存在,请先运行 pnpm build");
|
||||
}
|
||||
|
||||
const cssFiles = fs
|
||||
.readdirSync(assetsDir)
|
||||
.filter((file) => file.endsWith(".css"));
|
||||
|
||||
if (cssFiles.length === 0) {
|
||||
throw new Error("dist/assets 中没有 CSS 构建产物");
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
let modernDeclarations = 0;
|
||||
|
||||
const requiredLegacyFallbacks = [
|
||||
{
|
||||
file: "src/components/ui/textarea.tsx",
|
||||
pattern: /rows=\{rows\s*\?\?\s*3\}/,
|
||||
description: "Textarea needs a native rows fallback",
|
||||
},
|
||||
{
|
||||
file: "src/components/ai-elements/prompt-input.tsx",
|
||||
pattern: /<InputGroup className="h-auto min-h-16 flex-col items-stretch">/,
|
||||
description: "Prompt textarea must not rely on :has() for its layout",
|
||||
},
|
||||
{
|
||||
file: "src/core/utils/mermaid.ts",
|
||||
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
|
||||
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
|
||||
},
|
||||
{
|
||||
file: "src/core/utils/legacy-html-preview.ts",
|
||||
pattern: /export function downlevelModernColors[\s\S]*function injectLegacyChartShim/,
|
||||
description: "Generated HTML charts must downlevel modern CSS colors for Chrome 109",
|
||||
},
|
||||
{
|
||||
file: "src/pages/ChatPage.tsx",
|
||||
pattern: /chat-landing-composer-offset[\s\S]*chat-landing-content-lift/,
|
||||
description: "Chat landing layout must not rely on Tailwind's individual translate property",
|
||||
},
|
||||
{
|
||||
file: "src/styles/globals.css",
|
||||
pattern: /\.chat-landing-composer-offset\s*\{\s*position:\s*relative;\s*top:\s*calc\(96px\s*-\s*50vh\);\s*\}/,
|
||||
description: "Chat landing composer must offset with top, not Tailwind translate",
|
||||
},
|
||||
{
|
||||
file: "src/styles/globals.css",
|
||||
pattern:
|
||||
/\.legacy-dialog-viewport\s*\{[\s\S]*?display:\s*flex;[\s\S]*?align-items:\s*center;[\s\S]*?justify-content:\s*center;/,
|
||||
description: "Dialog content must be flex-centered without Tailwind translate",
|
||||
},
|
||||
{
|
||||
file: "src/components/ui/dialog.tsx",
|
||||
pattern: /legacy-dialog-viewport[\s\S]*relative grid/,
|
||||
description: "Dialog panel must not use fixed + translate centering",
|
||||
},
|
||||
{
|
||||
file: "src/styles/globals.css",
|
||||
pattern:
|
||||
/\.legacy-dialog-viewport\s*>\s*\[data-slot="dialog-content"\]\s*\{[\s\S]*?min-height:\s*0;[\s\S]*?max-height:\s*100%;/,
|
||||
description: "Flex-centered dialogs must keep min-height:0 so inner scroll works",
|
||||
},
|
||||
{
|
||||
file: "src/styles/globals.css",
|
||||
pattern: /\.legacy-floating-layer\s*\{\s*z-index:\s*300;/,
|
||||
description: "Portaled select/dropdown layers must sit above dialog overlay z-index 200",
|
||||
},
|
||||
{
|
||||
file: "src/components/workspace/skills/skill-file-preview-dialog.tsx",
|
||||
pattern: /minHeight:[\s\S]*100vh[\s\S]*maxHeight:[\s\S]*100vh/,
|
||||
description: "Skill file preview must size with vh, not dvh, for Chrome 102",
|
||||
},
|
||||
{
|
||||
file: "src/core/compat/abort-signal.ts",
|
||||
pattern: /export function installAbortSignalTimeout[\s\S]*AbortSignal/,
|
||||
description: "LangGraph SDK timeoutMs needs AbortSignal.timeout for Chrome 102",
|
||||
},
|
||||
{
|
||||
file: "src/core/api/api-client.ts",
|
||||
pattern: /function installLegacyControlTimeout[\s\S]*AbortSignal\.timeout/,
|
||||
description: "Chat send must skip AbortSignal.timeout when Chrome 102 lacks it",
|
||||
},
|
||||
{
|
||||
file: "index.html",
|
||||
pattern: /AbortSignal\.timeout = function/,
|
||||
description: "Chrome 102 needs AbortSignal.timeout before any module loads",
|
||||
},
|
||||
{
|
||||
file: "src/styles/globals.css",
|
||||
pattern: /\.workspace-chat-bg,\s*\n\s*\.canvas-writing-bg\s*\{\s*background-color:\s*var\(--background\);/,
|
||||
description: "Agent chat canvas needs an explicit token background for Chrome 102",
|
||||
},
|
||||
{
|
||||
file: "src/styles/globals.css",
|
||||
pattern: /@supports not \(translate: 0\)[\s\S]*\.dialog-content-center\s*\{\s*transform:\s*translate\(-50%,\s*-50%\);/,
|
||||
description: "Dialog centering needs a transform fallback for Chrome 102",
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of cssFiles) {
|
||||
const root = postcss.parse(
|
||||
fs.readFileSync(path.join(assetsDir, file), "utf8"),
|
||||
{ from: file },
|
||||
);
|
||||
|
||||
root.walkDecls((declaration) => {
|
||||
if (!unsupportedColor.test(declaration.value)) return;
|
||||
modernDeclarations += 1;
|
||||
|
||||
let ancestor = declaration.parent;
|
||||
while (ancestor) {
|
||||
if (
|
||||
ancestor.type === "atrule" &&
|
||||
ancestor.name === "supports" &&
|
||||
unsupportedColor.test(ancestor.params)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
|
||||
let previous = declaration.prev();
|
||||
while (previous?.type === "comment") previous = previous.prev();
|
||||
const hasSafeFallback =
|
||||
!declaration.prop.startsWith("--") &&
|
||||
previous?.type === "decl" &&
|
||||
previous.prop === declaration.prop &&
|
||||
!unsupportedColor.test(previous.value);
|
||||
|
||||
if (!hasSafeFallback) {
|
||||
failures.push(
|
||||
`${file}:${declaration.source?.start?.line ?? "?"} ${declaration.prop}: ${declaration.value}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function walk(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? walk(fullPath) : [fullPath];
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of walk(path.resolve("src"))) {
|
||||
if (!/\.[jt]sx?$/.test(file)) continue;
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
if (unsupportedColor.test(source)) {
|
||||
failures.push(
|
||||
`${path.relative(process.cwd(), file)} contains a runtime-only modern color function`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const fallback of requiredLegacyFallbacks) {
|
||||
const source = fs.readFileSync(path.resolve(fallback.file), "utf8");
|
||||
if (!fallback.pattern.test(source)) {
|
||||
failures.push(`${fallback.file}: ${fallback.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Legacy Chrome CSS compatibility check failed:\n");
|
||||
console.error(failures.slice(0, 50).join("\n"));
|
||||
if (failures.length > 50) {
|
||||
console.error(`... and ${failures.length - 50} more`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Legacy Chrome (100+) CSS compatibility check passed (${modernDeclarations} guarded modern declarations).`,
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import postcss from "postcss";
|
||||
|
||||
/**
|
||||
* 旧浏览器(麒麟 Chromium ~80)CSS 产物守卫:构建后运行,防止回归。
|
||||
* 迁移后的结构性保证(见 tailwind.config.ts / postcss.config.js):
|
||||
* - Tailwind v3 输出无 @layer(Chrome 99+)与 @property(Chrome 85+);
|
||||
* - dark: 变体经 postcss-dark-descendant 改写为 .dark 后代形式,全库 :is( 为 0;
|
||||
* - 媒体查询 range 语法(Chrome 104+)由 lightningcss 按 target 转译;
|
||||
* - 手写 CSS 不使用 :is()(复杂参数无法安全降级,源头展开为逗号选择器);
|
||||
* - color-mix()/oklch() 仅允许出现在 @supports 守护内或带前置回退声明。
|
||||
* :has()(Chrome 105+)与 :where()(Chrome 88+)按渐进增强放行,不在检查范围。
|
||||
*/
|
||||
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
|
||||
const assetsDir = path.resolve("dist/assets");
|
||||
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
throw new Error("dist/assets 不存在,请先运行 pnpm build");
|
||||
}
|
||||
|
||||
const cssFiles = fs.readdirSync(assetsDir).filter((f) => f.endsWith(".css"));
|
||||
if (cssFiles.length === 0) throw new Error("dist/assets 中没有 CSS 构建产物");
|
||||
|
||||
const failures = [];
|
||||
const hardTokens = [
|
||||
[/@layer\b/, "@layer(Chrome 99+)"],
|
||||
[/@property\b/, "@property(Chrome 85+)"],
|
||||
[/:is\(/, ":is()(Chrome 88+)"],
|
||||
[/\(width\s*>=|\(width\s*<=|\(height\s*>=/, "媒体查询 range 语法(Chrome 104+)"],
|
||||
];
|
||||
let guardedColors = 0;
|
||||
|
||||
for (const file of cssFiles) {
|
||||
const raw = fs.readFileSync(path.join(assetsDir, file), "utf8");
|
||||
for (const [re, label] of hardTokens) {
|
||||
const hits = raw.match(new RegExp(re.source, "g")) || [];
|
||||
if (hits.length > 0) failures.push(`${file}: ${hits.length} 处 ${label}`);
|
||||
}
|
||||
|
||||
const root = postcss.parse(raw, { from: file });
|
||||
root.walkDecls((decl) => {
|
||||
if (!unsupportedColor.test(decl.value)) return;
|
||||
guardedColors += 1;
|
||||
let anc = decl.parent;
|
||||
while (anc) {
|
||||
if (anc.type === "atrule" && anc.name === "supports" && unsupportedColor.test(anc.params)) return;
|
||||
anc = anc.parent;
|
||||
}
|
||||
let prev = decl.prev();
|
||||
while (prev?.type === "comment") prev = prev.prev();
|
||||
const hasFallback =
|
||||
!decl.prop.startsWith("--") &&
|
||||
prev?.type === "decl" &&
|
||||
prev.prop === decl.prop &&
|
||||
!unsupportedColor.test(prev.value);
|
||||
if (!hasFallback) {
|
||||
failures.push(`${file}: 未守护的现代颜色 ${decl.prop}: ${decl.value.slice(0, 60)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("旧浏览器 CSS 兼容检查失败:\n");
|
||||
console.error(failures.slice(0, 50).join("\n"));
|
||||
if (failures.length > 50) console.error(`... 及另外 ${failures.length - 50} 处`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`旧浏览器(麒麟 Chromium ~80)CSS 兼容检查通过(${guardedColors} 处守护的现代颜色声明)。`,
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 把 Tailwind v3.4 class 模式暗色变体输出的 `X:is(.dark *)` 机械改写为
|
||||
* `.dark X` 后代选择器,供麒麟 Chromium ~80 使用(无 :is())。
|
||||
*
|
||||
* - 特异性不变:`:is(.dark *)` 取最高参数 = (0,1,0),剥离后前置 `.dark `
|
||||
* 同样 +(0,1,0),两种形式均为 (0,2,0),级联行为一致。
|
||||
* - `.dark` 挂在 <html> 上、是全局祖先,前置到组合器最左侧语义等价
|
||||
* (group-hover:dark:* 等组合下同样成立)。
|
||||
* - 堆叠变体(dark:marker:* 等)生成的 `X *:is(.dark *)::marker` 会被
|
||||
* 改写为 `.dark X *::marker`,保持合法。
|
||||
*/
|
||||
const TOKEN = ":is(.dark *)";
|
||||
|
||||
export default function darkDescendant() {
|
||||
return {
|
||||
postcssPlugin: "postcss-dark-descendant",
|
||||
Rule(rule) {
|
||||
if (!rule.selector.includes(TOKEN)) return;
|
||||
rule.selector = rule.selector
|
||||
.split(",")
|
||||
.map((sel) => {
|
||||
const trimmed = sel.trim();
|
||||
if (!trimmed.includes(TOKEN)) return trimmed;
|
||||
return ".dark " + trimmed.split(TOKEN).join("");
|
||||
})
|
||||
.join(", ");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
darkDescendant.postcss = true;
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Tailwind 4 gracefully falls back from color-mix() to the base semantic
|
||||
* color, but that turns utilities such as bg-primary/5 into an opaque fill on
|
||||
* Chrome 109. Replace that fallback with an sRGB-channel based alpha color.
|
||||
* The modern color-mix() declaration remains in its @supports block.
|
||||
*/
|
||||
|
||||
const LEGACY_RGB_TOKENS = new Set([
|
||||
"accent",
|
||||
"background",
|
||||
"border",
|
||||
"brand-200",
|
||||
"card",
|
||||
"destructive",
|
||||
"foreground",
|
||||
"input",
|
||||
"muted",
|
||||
"muted-foreground",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"ring",
|
||||
"secondary",
|
||||
"sidebar-accent",
|
||||
"sidebar-border",
|
||||
"sidebar-foreground",
|
||||
"workspace-brand",
|
||||
]);
|
||||
|
||||
const TRANSPARENT_MIX_RE =
|
||||
/color-mix\(\s*in\s+[\w-]+\s*,\s*var\(--([\w-]+)\)\s+([\d.]+)%\s*,\s*transparent\s*\)/i;
|
||||
|
||||
function legacyAlphaColor(value) {
|
||||
const match = value.match(TRANSPARENT_MIX_RE);
|
||||
if (!match || !LEGACY_RGB_TOKENS.has(match[1])) return null;
|
||||
|
||||
const [, token, percentage] = match;
|
||||
return `rgb(var(--legacy-${token}-rgb) / ${percentage}%)`;
|
||||
}
|
||||
|
||||
export default function legacyColorFallback() {
|
||||
return {
|
||||
postcssPlugin: "postcss-legacy-color-fallback",
|
||||
OnceExit(root) {
|
||||
root.walkAtRules("supports", (atRule) => {
|
||||
if (!atRule.params.includes("color-mix")) return;
|
||||
|
||||
atRule.walkDecls((modernDeclaration) => {
|
||||
const fallbackValue = legacyAlphaColor(modernDeclaration.value);
|
||||
if (!fallbackValue) return;
|
||||
|
||||
// Tailwind emits the old-browser declaration immediately before
|
||||
// the nested @supports block, in the same utility/variant rule.
|
||||
let fallbackDeclaration = atRule.prev();
|
||||
while (fallbackDeclaration?.type === "comment") {
|
||||
fallbackDeclaration = fallbackDeclaration.prev();
|
||||
}
|
||||
|
||||
if (
|
||||
fallbackDeclaration?.type === "decl" &&
|
||||
fallbackDeclaration.prop === modernDeclaration.prop
|
||||
) {
|
||||
fallbackDeclaration.value = fallbackValue;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
legacyColorFallback.postcss = true;
|
||||
@@ -1,33 +0,0 @@
|
||||
import postcss from "postcss";
|
||||
import { transform } from "lightningcss";
|
||||
|
||||
const LEGACY_CHROME = 100 << 16;
|
||||
|
||||
/**
|
||||
* Keep Vite's PostCSS pipeline (required by Tailwind 4), then compile the
|
||||
* resulting CSS to Chrome 100-compatible syntax for both dev and production.
|
||||
*/
|
||||
export default function lightningCssLegacyChrome() {
|
||||
return {
|
||||
postcssPlugin: "postcss-lightningcss-legacy-chrome",
|
||||
OnceExit(root, { result }) {
|
||||
const filename = root.source?.input.file ?? result.opts.from ?? "style.css";
|
||||
const output = transform({
|
||||
filename,
|
||||
code: Buffer.from(root.toString()),
|
||||
targets: { chrome: LEGACY_CHROME },
|
||||
minify: false,
|
||||
});
|
||||
const compiled = postcss.parse(output.code.toString(), { from: filename });
|
||||
|
||||
root.removeAll();
|
||||
root.append(compiled.nodes);
|
||||
|
||||
for (const warning of output.warnings) {
|
||||
result.warn(warning.message, { plugin: "lightningcss" });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
lightningCssLegacyChrome.postcss = true;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
// Resolve Vite's installed dependency directly: pnpm does not expose a
|
||||
// transitive esbuild binary in node_modules/.bin, and tests must run offline.
|
||||
const viteRequire = createRequire(require.resolve("vite/package.json"));
|
||||
const { build, stop } = viteRequire("esbuild");
|
||||
const testNames = ["login-entry", "session-validation"];
|
||||
const outputDirectory = path.join(root, ".runtime/unit-tests");
|
||||
|
||||
try {
|
||||
await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: testNames.map((name) => `src/core/auth/${name}.test.ts`),
|
||||
outdir: outputDirectory,
|
||||
outExtension: { ".js": ".mjs" },
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
});
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, ["--test", ...testNames.map((name) => path.join(outputDirectory, `${name}.test.mjs`))], {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
+79
-114
@@ -1,13 +1,11 @@
|
||||
import { lazy, Suspense, useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader";
|
||||
import { FontScaleApplier } from "@/components/font-scale";
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { RouteLoadingFallback } from "@/components/route-loading";
|
||||
import { AuthSyncWatcher } from "@/core/auth/sync";
|
||||
import { RequireAccount } from "@/core/auth/require-account";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { fetchYLogConfig } from "@/core/log/y-log-config";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
@@ -27,47 +25,28 @@ import {
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import PublicScheduledTaskPage, {
|
||||
PublicScheduledTaskFullscreenPage,
|
||||
} from "./pages/PublicScheduledTaskPage";
|
||||
import PublicSharePage from "./pages/PublicSharePage";
|
||||
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
|
||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
import {
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
} from "@/position-roundtable/demo/position-roundtable-demo-config";
|
||||
|
||||
const LandingPage = lazy(() => import("./pages/LandingPage"));
|
||||
const LoginPage = lazy(() => import("./pages/LoginPage"));
|
||||
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
|
||||
const PageRoutes = lazy(() => import("./pages/PageRoutes"));
|
||||
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
|
||||
const PublicScheduledTaskFullscreenPage = lazy(() =>
|
||||
import("./pages/PublicScheduledTaskPage").then((module) => ({
|
||||
default: module.PublicScheduledTaskFullscreenPage,
|
||||
})),
|
||||
);
|
||||
const PublicSharePage = lazy(() => import("./pages/PublicSharePage"));
|
||||
const PublicLeaderboardPage = lazy(() => import("./pages/PublicLeaderboardPage"));
|
||||
const PublicRoundtableSharePage = lazy(() => import("./pages/PublicRoundtableSharePage"));
|
||||
const EmbedChatPage = lazy(() => import("./pages/EmbedChatPage"));
|
||||
const EmbedRoundtablePage = lazy(() => import("./pages/EmbedRoundtablePage"));
|
||||
const SiteStudioPage = lazy(() => import("./pages/site-studio/SiteStudioPage"));
|
||||
const DashboardStandalonePage = lazy(
|
||||
() => import("@/roundtable-planning/pages/DashboardStandalonePage"),
|
||||
);
|
||||
const PositionRoundtablePage = lazy(
|
||||
() => import("@/position-roundtable/pages/PositionRoundtablePage"),
|
||||
);
|
||||
const WeKnoraWikiPageView = lazy(
|
||||
() => import("@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView"),
|
||||
);
|
||||
const ChatRuntime = lazy(() =>
|
||||
import("@/components/workspace/chat-runtime").then((module) => ({
|
||||
default: module.ChatRuntime,
|
||||
})),
|
||||
);
|
||||
const NotFoundPage = lazy(() =>
|
||||
import("./pages/NotFoundPage").then((module) => ({
|
||||
default: module.NotFoundPage,
|
||||
})),
|
||||
);
|
||||
const OfficialDeerFlowEntry = lazy(() => import("./pages/OfficialDeerFlowEntry"));
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
|
||||
|
||||
const PUBLIC_EMBED_PREFIXES = [
|
||||
"/share/",
|
||||
@@ -166,86 +145,72 @@ export function App() {
|
||||
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
|
||||
);
|
||||
const pageElement = positionStandaloneMode ? (
|
||||
<RequireAccount allowLegacyAnonymous>
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome />
|
||||
</ChatRuntime>
|
||||
</RequireAccount>
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome />
|
||||
</ChatRuntime>
|
||||
) : (
|
||||
<RequireAccount><PageRoutes /></RequireAccount>
|
||||
<PageRoutes />
|
||||
);
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<AppProviders>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login/:username"
|
||||
element={
|
||||
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
positionStandaloneMode ? standaloneLoginRedirect : <RegisterPage />
|
||||
}
|
||||
/>
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode"
|
||||
element={<PublicRoundtableSharePage view="landing" />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode/report"
|
||||
element={<PublicRoundtableSharePage view="report" />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode/dashboard"
|
||||
element={<PublicRoundtableSharePage view="dashboard" />}
|
||||
/>
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
element={<PublicScheduledTaskPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
|
||||
element={<PublicScheduledTaskFullscreenPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/embed/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<EmbedChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||
<Route
|
||||
path="/embed/knowledge/:noteId"
|
||||
element={<WeKnoraWikiPageView publicEmbed />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-dashboard"
|
||||
element={<RequireAccount><DashboardStandalonePage /></RequireAccount>}
|
||||
/>
|
||||
<Route path="/official/*" element={<RequireAccount><OfficialDeerFlowEntry /></RequireAccount>} />
|
||||
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
|
||||
<Route path="/sites/:threadId/:siteName" element={<RequireAccount><SiteStudioPage /></RequireAccount>} />
|
||||
<Route path="/page/*" element={pageElement} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/login/:username"
|
||||
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||
/>
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode"
|
||||
element={<PublicRoundtableSharePage view="landing" />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode/report"
|
||||
element={<PublicRoundtableSharePage view="report" />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode/dashboard"
|
||||
element={<PublicRoundtableSharePage view="dashboard" />}
|
||||
/>
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
element={<PublicScheduledTaskPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
|
||||
element={<PublicScheduledTaskFullscreenPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/embed/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<EmbedChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||
<Route
|
||||
path="/embed/knowledge/:noteId"
|
||||
element={<WeKnoraWikiPageView publicEmbed />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
/>
|
||||
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
|
||||
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
|
||||
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
|
||||
<Route path="/page/*" element={pageElement} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AppProviders>
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
@@ -34,14 +34,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
|
||||
import {
|
||||
DEFAULT_UPLOAD_POLICY,
|
||||
splitUnsupportedUploadFiles,
|
||||
useUploadPolicy,
|
||||
validateChatUploadFiles,
|
||||
} from "@/core/uploads";
|
||||
import { imeEnterAction } from "@/lib/ime";
|
||||
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
|
||||
import {
|
||||
DEFAULT_UPLOAD_POLICY,
|
||||
splitUnsupportedUploadFiles,
|
||||
useUploadPolicy,
|
||||
validateChatUploadFiles,
|
||||
} from "@/core/uploads";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
@@ -61,12 +61,11 @@ import {
|
||||
type ChangeEventHandler,
|
||||
Children,
|
||||
type ClipboardEventHandler,
|
||||
type ComponentProps,
|
||||
type CompositionEventHandler,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
type HTMLAttributes,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
type HTMLAttributes,
|
||||
type KeyboardEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
@@ -129,11 +128,11 @@ export const usePromptInputController = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// Optional variants (do NOT throw). Useful for dual-mode components and for
|
||||
// Fast Refresh transitions where a page can briefly retain the previous
|
||||
// provider context instance.
|
||||
export const useOptionalPromptInputController = () =>
|
||||
useContext(PromptInputController);
|
||||
// Optional variants (do NOT throw). Useful for dual-mode components and for
|
||||
// Fast Refresh transitions where a page can briefly retain the previous
|
||||
// provider context instance.
|
||||
export const useOptionalPromptInputController = () =>
|
||||
useContext(PromptInputController);
|
||||
|
||||
export const useProviderAttachments = () => {
|
||||
const ctx = useContext(ProviderAttachmentsContext);
|
||||
@@ -149,94 +148,94 @@ const useOptionalProviderAttachments = () =>
|
||||
useContext(ProviderAttachmentsContext);
|
||||
const usePromptInputValidation = () => useContext(PromptInputValidationContext);
|
||||
|
||||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
uploadPolicy?: UploadPolicy;
|
||||
}>;
|
||||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
uploadPolicy?: UploadPolicy;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Optional global provider that lifts PromptInput state outside of PromptInput.
|
||||
* If you don't use it, PromptInput stays fully self-managed.
|
||||
*/
|
||||
export function PromptInputProvider({
|
||||
initialInput: initialTextInput = "",
|
||||
uploadPolicy,
|
||||
children,
|
||||
}: PromptInputProviderProps) {
|
||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
||||
const effectiveUploadPolicy =
|
||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||
// ----- textInput state
|
||||
export function PromptInputProvider({
|
||||
initialInput: initialTextInput = "",
|
||||
uploadPolicy,
|
||||
children,
|
||||
}: PromptInputProviderProps) {
|
||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
||||
const effectiveUploadPolicy =
|
||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||
// ----- textInput state
|
||||
const [textInput, setTextInput] = useState(initialTextInput);
|
||||
const clearInput = useCallback(() => setTextInput(""), []);
|
||||
|
||||
// ----- attachments state (global when wrapped)
|
||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||
(PromptInputFilePart & { id: string })[]
|
||||
>([]);
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const openRef = useRef<() => void>(() => {});
|
||||
|
||||
const add = useCallback((files: File[] | FileList) => {
|
||||
const incoming = Array.from(files);
|
||||
>([]);
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const openRef = useRef<() => void>(() => {});
|
||||
|
||||
const add = useCallback((files: File[] | FileList) => {
|
||||
const incoming = Array.from(files);
|
||||
if (incoming.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = attachmentsRef.current;
|
||||
const existing = current.map((item) => ({
|
||||
name: item.filename ?? "attachment",
|
||||
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(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, [effectiveUploadPolicy]);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
const current = attachmentsRef.current;
|
||||
const found = current.find((f) => f.id === id);
|
||||
if (found?.url) URL.revokeObjectURL(found.url);
|
||||
const next = current.filter((f) => f.id !== id);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
for (const f of attachmentsRef.current) {
|
||||
if (f.url) {
|
||||
URL.revokeObjectURL(f.url);
|
||||
}
|
||||
}
|
||||
attachmentsRef.current = [];
|
||||
setAttachmentFiles([]);
|
||||
}, []);
|
||||
|
||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||
attachmentsRef.current = attachmentFiles;
|
||||
return;
|
||||
}
|
||||
|
||||
const current = attachmentsRef.current;
|
||||
const existing = current.map((item) => ({
|
||||
name: item.filename ?? "attachment",
|
||||
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(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, [effectiveUploadPolicy]);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
const current = attachmentsRef.current;
|
||||
const found = current.find((f) => f.id === id);
|
||||
if (found?.url) URL.revokeObjectURL(found.url);
|
||||
const next = current.filter((f) => f.id !== id);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
for (const f of attachmentsRef.current) {
|
||||
if (f.url) {
|
||||
URL.revokeObjectURL(f.url);
|
||||
}
|
||||
}
|
||||
attachmentsRef.current = [];
|
||||
setAttachmentFiles([]);
|
||||
}, []);
|
||||
|
||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||
attachmentsRef.current = attachmentFiles;
|
||||
|
||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
@@ -431,14 +430,14 @@ export function PromptInputAttachments({
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{attachments.files.map((file) => (
|
||||
<div key={file.id} className="max-w-60">
|
||||
{children(file)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{...props}
|
||||
>
|
||||
{attachments.files.map((file) => (
|
||||
<div key={file.id} className="max-w-60">
|
||||
{children(file)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -484,17 +483,17 @@ export type PromptInputProps = Omit<
|
||||
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
|
||||
syncHiddenInput?: boolean;
|
||||
// Minimal constraints
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // bytes
|
||||
uploadPolicy?: UploadPolicy;
|
||||
onError?: (err: {
|
||||
code:
|
||||
| "max_files"
|
||||
| "max_file_size"
|
||||
| "max_total_size"
|
||||
| "unsupported_type"
|
||||
| "accept"
|
||||
| "unsupported_package";
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // bytes
|
||||
uploadPolicy?: UploadPolicy;
|
||||
onError?: (err: {
|
||||
code:
|
||||
| "max_files"
|
||||
| "max_file_size"
|
||||
| "max_total_size"
|
||||
| "unsupported_type"
|
||||
| "accept"
|
||||
| "unsupported_package";
|
||||
message: string;
|
||||
}) => void;
|
||||
onSubmit: (
|
||||
@@ -510,25 +509,25 @@ export const PromptInput = ({
|
||||
multiple,
|
||||
globalDrop,
|
||||
syncHiddenInput,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
uploadPolicy,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
uploadPolicy,
|
||||
onError,
|
||||
onSubmit,
|
||||
children,
|
||||
...props
|
||||
}: 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
|
||||
}: 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
|
||||
const controller = useOptionalPromptInputController();
|
||||
const usingProvider = !!controller;
|
||||
|
||||
@@ -561,11 +560,11 @@ export const PromptInput = ({
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.startsWith(".")) {
|
||||
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
|
||||
}
|
||||
if (pattern.endsWith("/*")) {
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.startsWith(".")) {
|
||||
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
|
||||
}
|
||||
if (pattern.endsWith("/*")) {
|
||||
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
||||
return f.type.startsWith(prefix);
|
||||
}
|
||||
@@ -575,72 +574,72 @@ export const PromptInput = ({
|
||||
[accept],
|
||||
);
|
||||
|
||||
const addLocal = useCallback(
|
||||
(fileList: File[] | FileList) => {
|
||||
const incoming = Array.from(fileList);
|
||||
const accepted = incoming.filter((f) => matchesAccept(f));
|
||||
if (accepted.length < incoming.length) {
|
||||
const error = {
|
||||
code: "accept",
|
||||
message: "部分文件不符合当前输入框允许的格式。",
|
||||
} as const;
|
||||
onError?.(error);
|
||||
if (!onError) toast.error(error.message);
|
||||
}
|
||||
const localPolicy: UploadPolicy = {
|
||||
...effectiveUploadPolicy,
|
||||
max_files_per_message:
|
||||
typeof maxFiles === "number"
|
||||
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
|
||||
: effectiveUploadPolicy.max_files_per_message,
|
||||
groups: Object.fromEntries(
|
||||
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
|
||||
key,
|
||||
{
|
||||
...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) =>
|
||||
prev.concat(
|
||||
validation.accepted.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
),
|
||||
);
|
||||
},
|
||||
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
|
||||
);
|
||||
const addLocal = useCallback(
|
||||
(fileList: File[] | FileList) => {
|
||||
const incoming = Array.from(fileList);
|
||||
const accepted = incoming.filter((f) => matchesAccept(f));
|
||||
if (accepted.length < incoming.length) {
|
||||
const error = {
|
||||
code: "accept",
|
||||
message: "部分文件不符合当前输入框允许的格式。",
|
||||
} as const;
|
||||
onError?.(error);
|
||||
if (!onError) toast.error(error.message);
|
||||
}
|
||||
const localPolicy: UploadPolicy = {
|
||||
...effectiveUploadPolicy,
|
||||
max_files_per_message:
|
||||
typeof maxFiles === "number"
|
||||
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
|
||||
: effectiveUploadPolicy.max_files_per_message,
|
||||
groups: Object.fromEntries(
|
||||
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
|
||||
key,
|
||||
{
|
||||
...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) =>
|
||||
prev.concat(
|
||||
validation.accepted.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
),
|
||||
);
|
||||
},
|
||||
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
|
||||
);
|
||||
|
||||
const removeLocal = useCallback(
|
||||
(id: string) =>
|
||||
@@ -828,17 +827,11 @@ export const PromptInput = ({
|
||||
event.preventDefault();
|
||||
|
||||
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
|
||||
? (liveText ?? controller.textInput.value)
|
||||
? controller.textInput.value
|
||||
: (() => {
|
||||
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
|
||||
@@ -901,7 +894,7 @@ export const PromptInput = ({
|
||||
const inner = (
|
||||
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
||||
<input
|
||||
accept={accept ?? policyAccept}
|
||||
accept={accept ?? policyAccept}
|
||||
aria-label="Upload files"
|
||||
className="hidden"
|
||||
multiple={multiple}
|
||||
@@ -916,10 +909,10 @@ export const PromptInput = ({
|
||||
ref={formRef}
|
||||
{...props}
|
||||
>
|
||||
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions
|
||||
before 105 ignore them, keep the horizontal flex layout, and
|
||||
squeeze the textarea beside the footer controls. */}
|
||||
<InputGroup className="h-auto min-h-16 flex-col items-stretch">
|
||||
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
|
||||
versions before 105 ignore :has(), retain h-9, and clip the whole
|
||||
multi-line composer to a single row. */}
|
||||
<InputGroup className="prompt-input-group h-auto min-h-16 flex-col items-stretch">
|
||||
{children}
|
||||
</InputGroup>
|
||||
</form>
|
||||
@@ -941,107 +934,69 @@ export const PromptInputBody = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputBodyProps) => (
|
||||
<div className={cn("contents", className)} {...props} />
|
||||
<div
|
||||
data-slot="prompt-input-body"
|
||||
className={cn("contents", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputTextareaProps = ComponentProps<
|
||||
typeof InputGroupTextarea
|
||||
>;
|
||||
|
||||
export const PromptInputTextarea = ({
|
||||
onChange,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
className,
|
||||
placeholder = "What would you like to know?",
|
||||
defaultValue,
|
||||
...props
|
||||
}: PromptInputTextareaProps) => {
|
||||
const controller = useOptionalPromptInputController();
|
||||
const attachments = usePromptInputAttachments();
|
||||
const sanitizeIncomingFiles = usePromptInputValidation();
|
||||
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) => {
|
||||
// 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 (
|
||||
typeof CSS !== "undefined" &&
|
||||
typeof CSS.supports === "function" &&
|
||||
CSS.supports("field-sizing", "content")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
|
||||
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
|
||||
// stuck at its minimum height.
|
||||
textarea.style.height = "auto";
|
||||
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
|
||||
textarea.style.height = `${height}px`;
|
||||
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (composingRef.current) return;
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
resizeForLegacyChrome(textarea);
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [controller?.textInput.value, resizeForLegacyChrome]);
|
||||
export const PromptInputTextarea = ({
|
||||
onChange,
|
||||
onInput,
|
||||
className,
|
||||
placeholder = "What would you like to know?",
|
||||
...props
|
||||
}: PromptInputTextareaProps) => {
|
||||
const controller = useOptionalPromptInputController();
|
||||
const attachments = usePromptInputAttachments();
|
||||
const sanitizeIncomingFiles = usePromptInputValidation();
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
|
||||
if (
|
||||
typeof CSS !== "undefined" &&
|
||||
typeof CSS.supports === "function" &&
|
||||
CSS.supports("field-sizing", "content")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
|
||||
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
|
||||
// stuck at its minimum height.
|
||||
textarea.style.height = "auto";
|
||||
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
|
||||
textarea.style.height = `${height}px`;
|
||||
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
resizeForLegacyChrome(textarea);
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [controller?.textInput.value, resizeForLegacyChrome]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const action = imeEnterAction({
|
||||
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") {
|
||||
if (isIMEComposing(e, isComposing)) {
|
||||
return;
|
||||
}
|
||||
onKeyDownRef.current?.(e);
|
||||
if (e.defaultPrevented || e.shiftKey) {
|
||||
if (e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
// The confirming Enter already finished the candidate. Swallow it so
|
||||
// Firefox does not also insert a newline or send the message.
|
||||
e.preventDefault();
|
||||
if (action === "swallow") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the submit button is disabled before submitting
|
||||
const form = e.currentTarget.form;
|
||||
@@ -1053,59 +1008,22 @@ export const PromptInputTextarea = ({
|
||||
}
|
||||
|
||||
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
|
||||
if (
|
||||
e.key === "Backspace" &&
|
||||
e.currentTarget.value === "" &&
|
||||
attachmentsRef.current.files.length > 0
|
||||
attachments.files.length > 0
|
||||
) {
|
||||
e.preventDefault();
|
||||
const lastAttachment = attachmentsRef.current.files.at(-1);
|
||||
const lastAttachment = attachments.files.at(-1);
|
||||
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 items = event.clipboardData?.items;
|
||||
|
||||
@@ -1135,28 +1053,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 (
|
||||
<InputGroupTextarea
|
||||
{...props}
|
||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||
name="message"
|
||||
ref={textareaRef}
|
||||
rows={3}
|
||||
onChange={handleChange}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={(event) => {
|
||||
if (!composingRef.current) {
|
||||
resizeForLegacyChrome(event.currentTarget);
|
||||
}
|
||||
onInputRef.current?.(event);
|
||||
}}
|
||||
<InputGroupTextarea
|
||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||
name="message"
|
||||
ref={textareaRef}
|
||||
rows={3}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={(event) => {
|
||||
resizeForLegacyChrome(event.currentTarget);
|
||||
onInput?.(event);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
{...(controller
|
||||
? { value: controller.textInput.value }
|
||||
: { defaultValue })}
|
||||
{...props}
|
||||
{...controlledProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1274,28 +1199,24 @@ export const PromptInputSubmit = ({
|
||||
status,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputSubmitProps) => {
|
||||
let Icon = <ArrowUpIcon className="size-4" />;
|
||||
let ariaLabel = "发送消息";
|
||||
|
||||
if (status === "submitted") {
|
||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||
ariaLabel = "正在发送";
|
||||
} else if (status === "streaming") {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
ariaLabel = "停止生成";
|
||||
} else if (status === "error") {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
ariaLabel = "重新发送";
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label={ariaLabel}
|
||||
}: PromptInputSubmitProps) => {
|
||||
let Icon = <ArrowUpIcon className="size-4" />;
|
||||
|
||||
if (status === "submitted") {
|
||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||
} else if (status === "streaming") {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
} else if (status === "error") {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label="Submit"
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
status === "streaming" &&
|
||||
"animate-breathe-stop bg-primary/10 text-primary border-primary/25 shadow-[0_0_0_0_rgba(217,33,33,0.25)] hover:bg-primary/14",
|
||||
"transition-all duration-300",
|
||||
status === "streaming" &&
|
||||
"streaming-stop-button bg-primary/10 text-primary border-primary/25 hover:bg-primary/14",
|
||||
className,
|
||||
)}
|
||||
size={size}
|
||||
|
||||
@@ -31,11 +31,6 @@ import { queryClient } from "@/components/query-client-provider";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import Empty from "./empty";
|
||||
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 }
|
||||
) => 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 {
|
||||
@@ -214,7 +224,7 @@ export const DocumentUploadDialogProvider: FC<{
|
||||
) => void | Promise<void>;
|
||||
searchSpace: { id: number | string;[key: string]: unknown };
|
||||
onUploadSuccess?: () => void;
|
||||
onUploadComplete?: (sources: StudioUploadSource[]) => void;
|
||||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||||
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialSearchText, setInitialSearchText] = useState("");
|
||||
@@ -343,23 +353,27 @@ const DocumentUploadPopupContent: FC<{
|
||||
for (const file of files) {
|
||||
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",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as V2UploadResponse;
|
||||
const data = await res.json() as UploadResponse;
|
||||
// 调用上传完成回调,传递来源信息
|
||||
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} 个文件`);
|
||||
// 只关闭上传弹窗
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
const payload = await res.json().catch(() => null) as { detail?: string } | null;
|
||||
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
|
||||
toast.error("上传失败,请重试");
|
||||
}
|
||||
} catch {
|
||||
toast.error("上传失败,请重试");
|
||||
@@ -396,6 +410,7 @@ const DocumentUploadPopupContent: FC<{
|
||||
selectedSearchSources.map((s) => ({
|
||||
id: undefined,
|
||||
title: s.title,
|
||||
task_id: undefined,
|
||||
status: "success" as const,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
applyFontScaleToDocument,
|
||||
normalizeFontScale,
|
||||
normalizeFontScaleMode,
|
||||
} from "@/core/ui/page-zoom";
|
||||
|
||||
/**
|
||||
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
|
||||
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
|
||||
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
|
||||
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
|
||||
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
|
||||
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
|
||||
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
|
||||
* `fontScale === 1` 即默认 16px。
|
||||
*/
|
||||
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 },
|
||||
];
|
||||
|
||||
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() {
|
||||
const [settings] = useLocalSettings();
|
||||
const { fontScale, fontScaleMode } = settings.display;
|
||||
const { fontScale } = settings.display;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
applyFontScaleToDocument(
|
||||
normalizeFontScaleMode(fontScaleMode),
|
||||
normalizeFontScale(fontScale),
|
||||
);
|
||||
}, [fontScale, fontScaleMode]);
|
||||
useEffect(() => {
|
||||
const scale =
|
||||
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
|
||||
? fontScale
|
||||
: 1;
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--ui-font-scale", String(scale));
|
||||
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
|
||||
root.style.removeProperty("font-size");
|
||||
}, [fontScale]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { toCssPx } from "@/core/ui/page-zoom";
|
||||
|
||||
import type { TooltipState } from "./graphTypes";
|
||||
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
|
||||
|
||||
@@ -11,8 +9,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
|
||||
if (!tooltip) return null;
|
||||
const meta = NODE_KIND_META[tooltip.kind];
|
||||
// Keep the tooltip inside the viewport.
|
||||
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
|
||||
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
|
||||
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
|
||||
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
|
||||
|
||||
return (
|
||||
<div className="kg-tooltip" style={{ left, top }}>
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
width: 98px;
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
||||
height: calc(100vh - 4rem);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
|
||||
.sidebar-contnet--header-hidden {
|
||||
top: 0;
|
||||
height: calc(100vh / var(--df-page-zoom, 1));
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar-contnet--header-hidden .item-nav-content {
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
||||
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
||||
height: calc(100vh - 20px);
|
||||
min-height: calc(100vh - 20px);
|
||||
}
|
||||
|
||||
.sidebar-contents {
|
||||
@@ -160,8 +160,8 @@
|
||||
|
||||
.item-nav-content {
|
||||
width: 155px;
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
||||
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
||||
height: calc(100vh - 4rem - 20px);
|
||||
min-height: calc(100vh - 4rem - 20px);
|
||||
background: rgba(3, 38, 80, 0.8);
|
||||
border: 1px solid;
|
||||
border-color: rgba(43, 143, 254, 0.6);
|
||||
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
width: 96px;
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
||||
height: calc(100vh - 4rem);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
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. */
|
||||
.light-sidebar--header-hidden {
|
||||
top: 0;
|
||||
height: calc(100vh / var(--df-page-zoom, 1));
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
||||
|
||||
@@ -194,7 +194,7 @@ export function PageSidebar({
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<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
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
: "border-r border-red-100 bg-white"
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ScrollText,
|
||||
Timer,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
@@ -270,22 +268,6 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
模型配置
|
||||
</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 && (
|
||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||
<Cpu className="size-4" />
|
||||
@@ -500,9 +482,7 @@ function LegacyAccordionSidebar({
|
||||
/>
|
||||
<aside
|
||||
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
headerHidden
|
||||
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
|
||||
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
|
||||
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
|
||||
} ${
|
||||
isDark
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
@@ -607,22 +587,6 @@ function LegacyAccordionSidebar({
|
||||
模型配置
|
||||
</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 && (
|
||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||
<Cpu className="size-4" />
|
||||
|
||||
@@ -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}
|
||||
data-slot="dialog-overlay"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -71,42 +71,37 @@ const DialogOverlay = React.forwardRef<
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<div
|
||||
data-slot="dialog-viewport"
|
||||
className={cn("legacy-dialog-viewport", overlayClassName)}
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,270 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"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-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"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-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
const HoverCardTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
|
||||
@@ -38,7 +38,7 @@ const HoverCardContent = React.forwardRef<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"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 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"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 w-64 origin-[var(--radix-hover-card-content-transform-origin)] rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -48,5 +48,5 @@ const HoverCardContent = React.forwardRef<
|
||||
});
|
||||
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -1,198 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
>(({ className, size = "default", children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEE2] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#022248] dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
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]",
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-xs dark:text-[#566B7F]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"dark:text-[#AACEFF] dark:focus:bg-[#0062D9] dark:focus:text-white dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus:[&_svg:not([class*='text-'])]:text-white dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
>(({ className, size = "default", children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEE2] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#022248] dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"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-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--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" &&
|
||||
"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,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-xs dark:text-[#566B7F]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"dark:text-[#AACEFF] dark:focus:bg-[#0062D9] dark:focus:text-white dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus:[&_svg:not([class*='text-'])]:text-white dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -36,7 +36,7 @@ function ToggleGroup({
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
"group/toggle-group flex w-fit items-center gap-[calc(var(--gap)*0.25rem)] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const TooltipTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
|
||||
@@ -54,7 +54,7 @@ const TooltipContent = React.forwardRef<
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset ?? 4}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 bg-foreground text-background dark:text-foreground z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -66,5 +66,5 @@ const TooltipContent = React.forwardRef<
|
||||
});
|
||||
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -19,11 +17,6 @@ export function UserManagementPage() {
|
||||
title={t.adminUsers.title}
|
||||
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 && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -172,7 +172,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
|
||||
|
||||
return (
|
||||
// 配色与 AgentModuleSidebar 一致:浅色白底,深蓝主题半透明深蓝玻璃面板。
|
||||
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
|
||||
<aside className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
|
||||
{/* 智能体(上半,可收起)。展开时:标题栏 + 来源下拉/名称搜索 + 列表;
|
||||
收起时只留标题栏,下方「最近的对话」占满剩余空间。 */}
|
||||
<div
|
||||
|
||||
@@ -278,7 +278,6 @@ export function AgentGuide({
|
||||
autoStart?: boolean;
|
||||
}) {
|
||||
const [current, setCurrent] = useState(-1);
|
||||
const [appDialogOpen, setAppDialogOpen] = useState(false);
|
||||
const stepsRef = useRef<GuideStep[]>([]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
@@ -318,32 +317,7 @@ export function AgentGuide({
|
||||
setCurrent(-1);
|
||||
}, [stage]);
|
||||
|
||||
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200,
|
||||
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 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;
|
||||
}
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<Guide
|
||||
|
||||
@@ -291,7 +291,7 @@ export function AgentModuleSidebar({
|
||||
// 浅色保持白底。其余颜色已走语义 token 自适配。
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80",
|
||||
"flex h-full w-[var(--sidebar-width)] shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { defaultUrlTransform } from "react-markdown";
|
||||
@@ -66,6 +66,11 @@ import {
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import {
|
||||
clearArtifactRewriteFallback,
|
||||
completeArtifactRewriteFallback,
|
||||
primeArtifactRewriteFallback,
|
||||
} from "@/core/document-rewrite/local-fallback";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
} from "@/core/artifacts/download";
|
||||
@@ -283,7 +288,7 @@ export function ArtifactFileDetail({
|
||||
headerLeadingActionBare?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const navigate = useNavigate();
|
||||
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
||||
@@ -438,23 +443,16 @@ export function ArtifactFileDetail({
|
||||
presentationId: null,
|
||||
thinking: "",
|
||||
});
|
||||
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 maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||
const displayContent = documentRewriteActive
|
||||
? documentRewrite.run.draft
|
||||
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
|
||||
? draftContent
|
||||
: (content ?? ""));
|
||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
|
||||
const documentRewriteActive = documentRewrite.isActive;
|
||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||
const displayContent = documentRewriteActive
|
||||
? documentRewrite.run.draft
|
||||
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
|
||||
? draftContent
|
||||
: (content ?? ""));
|
||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
|
||||
const sandboxDisplayText = useMemo(() => {
|
||||
const raw = displayContent ?? "";
|
||||
if (disableDesensitize) return raw;
|
||||
@@ -477,8 +475,8 @@ export function ArtifactFileDetail({
|
||||
!isArtifactContentLoading;
|
||||
const isAutoPreviewLanguage =
|
||||
language === "html" || language === "markdown" || language === "mermaid";
|
||||
const isPreviewStreaming =
|
||||
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
|
||||
const isPreviewStreaming =
|
||||
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
if (isImageFile(filepath)) return "IMG";
|
||||
@@ -503,16 +501,16 @@ export function ArtifactFileDetail({
|
||||
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
|
||||
null,
|
||||
);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentRewriteActive) return;
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setViewMode("code");
|
||||
}, [documentRewriteActive]);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentRewriteActive) return;
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setViewMode("code");
|
||||
}, [documentRewriteActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode !== "edit" || canUseAiEditor) return;
|
||||
@@ -557,20 +555,13 @@ export function ArtifactFileDetail({
|
||||
presentationId: string,
|
||||
event: DocumentRewriteStreamEvent,
|
||||
) => {
|
||||
if (!rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "job_started":
|
||||
rewritePresentation.update(presentationId, {
|
||||
jobId: event.jobId,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
});
|
||||
break;
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
originalCharCount: event.originalCharCount,
|
||||
if (!rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
originalCharCount: event.originalCharCount,
|
||||
requirementsPlan: "",
|
||||
});
|
||||
break;
|
||||
@@ -631,11 +622,10 @@ export function ArtifactFileDetail({
|
||||
case "commit_started":
|
||||
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||
break;
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
versionId: event.versionId ?? null,
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
versionId: event.versionId ?? null,
|
||||
committedHash: event.committedHash,
|
||||
});
|
||||
break;
|
||||
@@ -651,13 +641,9 @@ export function ArtifactFileDetail({
|
||||
case "cancelled":
|
||||
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
||||
break;
|
||||
case "error":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error: event.message,
|
||||
diagnostic: event.diagnostic ?? null,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
console.error("AI 全文改写历史恢复失败:", event.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -679,18 +665,10 @@ export function ArtifactFileDetail({
|
||||
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
|
||||
? historicalResult.summary as DocumentRewriteSummary
|
||||
: null;
|
||||
const historicalPresentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
jobId: historical.id,
|
||||
outputPath: typeof historicalResult.outputPath === "string"
|
||||
? historicalResult.outputPath
|
||||
: historical.outputPath,
|
||||
fileName: getFileName(
|
||||
typeof historicalResult.outputPath === "string"
|
||||
? historicalResult.outputPath
|
||||
: historical.outputPath ?? filepath,
|
||||
),
|
||||
const historicalPresentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
fileName: getFileName(filepath),
|
||||
instruction: historical.request.instruction,
|
||||
style: historical.request.style,
|
||||
modelName: historical.request.modelName,
|
||||
@@ -711,14 +689,11 @@ export function ArtifactFileDetail({
|
||||
summary: historicalSummary,
|
||||
warnings: historicalSummary?.validationWarnings ?? [],
|
||||
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
|
||||
error: historicalResult.status === "conflict"
|
||||
? "文件在改写期间已变化,未覆盖原文件。"
|
||||
: historical.status === "cancelled"
|
||||
? "已停止全文改写,原文件未被修改。"
|
||||
: historical.status === "failed"
|
||||
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
|
||||
: null,
|
||||
diagnostic: historical.diagnostic ?? null,
|
||||
error: historicalResult.status === "conflict"
|
||||
? "文件在改写期间已变化,未覆盖原文件。"
|
||||
: historical.status === "cancelled"
|
||||
? "已停止全文改写,原文件未被修改。"
|
||||
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
|
||||
});
|
||||
}
|
||||
const result = latest.result;
|
||||
@@ -727,25 +702,36 @@ export function ArtifactFileDetail({
|
||||
: null;
|
||||
const originalContent = latest.request.originalContent || draftContent;
|
||||
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
||||
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
||||
const outputPath = typeof result.outputPath === "string"
|
||||
? result.outputPath
|
||||
: latest.outputPath;
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
jobId: latest.id,
|
||||
outputPath,
|
||||
fileName: getFileName(outputPath ?? filepath),
|
||||
const versionSnapshotFailed = result.versionSnapshotFailed === true;
|
||||
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
const localFallbackAvailable =
|
||||
versionSnapshotFailed &&
|
||||
latest.status === "completed" &&
|
||||
result.status !== "conflict" &&
|
||||
committedHash !== null &&
|
||||
completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent,
|
||||
committedHash,
|
||||
});
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
fileName: getFileName(filepath),
|
||||
instruction: latest.request.instruction,
|
||||
style: latest.request.style,
|
||||
modelName: latest.request.modelName,
|
||||
generateImages: latest.request.generateImages,
|
||||
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||||
originalContent,
|
||||
rewrittenContent,
|
||||
committedHash,
|
||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||
rewrittenContent,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||
});
|
||||
if (!presentationId || !rewritePresentation) return;
|
||||
setDocumentRewritePresentationId(presentationId);
|
||||
@@ -754,22 +740,32 @@ export function ArtifactFileDetail({
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: result.status === "conflict" ? "conflict" : "completed",
|
||||
summary,
|
||||
warnings: summary?.validationWarnings ?? [],
|
||||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||
committedHash,
|
||||
error: result.status === "conflict" ? "改写版文件未生成。" : null,
|
||||
});
|
||||
return;
|
||||
warnings: summary?.validationWarnings ?? [],
|
||||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||
committedHash,
|
||||
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;
|
||||
}
|
||||
if (latest.status === "failed" || latest.status === "cancelled") {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
||||
summary,
|
||||
error: latest.status === "cancelled"
|
||||
? "已停止全文改写,原文件未被修改。"
|
||||
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
|
||||
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
|
||||
});
|
||||
if (latest.status === "failed" || latest.status === "cancelled") {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
||||
summary,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -791,22 +787,35 @@ export function ArtifactFileDetail({
|
||||
signal,
|
||||
),
|
||||
onEvent: (event) => updatePresentation(presentationId, event),
|
||||
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
...(outputPath ? { outputPath } : {}),
|
||||
});
|
||||
},
|
||||
onFailure: ({ error, diagnostic }) => {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error,
|
||||
diagnostic: diagnostic ?? null,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
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, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
});
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||
{ content: rewritten, url: undefined },
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Existing artifacts remain available even if history hydration fails.
|
||||
}
|
||||
@@ -929,8 +938,8 @@ export function ArtifactFileDetail({
|
||||
return;
|
||||
}
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
|
||||
if (language === "html") {
|
||||
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -940,9 +949,9 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
|
||||
setViewMode("code");
|
||||
}, [
|
||||
documentRewriteActive,
|
||||
isPreviewStreaming,
|
||||
}, [
|
||||
documentRewriteActive,
|
||||
isPreviewStreaming,
|
||||
isSupportPreview,
|
||||
language,
|
||||
writeFileInFlight,
|
||||
@@ -953,7 +962,7 @@ export function ArtifactFileDetail({
|
||||
return;
|
||||
}
|
||||
|
||||
if (writeFileInFlight || documentRewriteActive) {
|
||||
if (writeFileInFlight || documentRewriteActive) {
|
||||
setViewMode("code");
|
||||
return;
|
||||
}
|
||||
@@ -965,10 +974,10 @@ export function ArtifactFileDetail({
|
||||
}, 1400);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [
|
||||
allowAutoViewSwitch,
|
||||
documentRewriteActive,
|
||||
isAutoPreviewLanguage,
|
||||
}, [
|
||||
allowAutoViewSwitch,
|
||||
documentRewriteActive,
|
||||
isAutoPreviewLanguage,
|
||||
isSupportPreview,
|
||||
writeFileInFlight,
|
||||
]);
|
||||
@@ -1129,17 +1138,16 @@ export function ArtifactFileDetail({
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}) => {
|
||||
if (!canEditArtifact) {
|
||||
toast.error("当前文件暂不支持全文改写。");
|
||||
return;
|
||||
}
|
||||
setDocumentRewriteDialogOpen(false);
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setViewMode("code");
|
||||
|
||||
// Do not make users manually save before using AI rewrite. Persist their
|
||||
if (!canEditArtifact) {
|
||||
toast.error("当前文件暂不支持全文改写。");
|
||||
return;
|
||||
}
|
||||
setDocumentRewriteDialogOpen(false);
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setViewMode("code");
|
||||
|
||||
// Do not make users manually save before using AI rewrite. Persist their
|
||||
// current draft first so the rewrite starts from exactly what they see,
|
||||
// and the optimistic hash remains valid on the server.
|
||||
if (hasLocalEdits) {
|
||||
@@ -1158,7 +1166,12 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
}
|
||||
|
||||
const expectedHash = await sha256Content(draftContent);
|
||||
primeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent: draftContent,
|
||||
});
|
||||
const expectedHash = await sha256Content(draftContent);
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
@@ -1190,21 +1203,14 @@ export function ArtifactFileDetail({
|
||||
expectedHash,
|
||||
},
|
||||
{
|
||||
onEvent: (event) => {
|
||||
if (!presentationId || !rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "job_started":
|
||||
rewritePresentation.update(presentationId, {
|
||||
jobId: event.jobId,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
});
|
||||
break;
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
originalCharCount: event.originalCharCount,
|
||||
onEvent: (event) => {
|
||||
if (!presentationId || !rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
originalCharCount: event.originalCharCount,
|
||||
requirementsPlan: "",
|
||||
});
|
||||
break;
|
||||
@@ -1268,11 +1274,9 @@ export function ArtifactFileDetail({
|
||||
case "commit_started":
|
||||
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||
break;
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
versionId: event.versionId ?? null,
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
versionId: event.versionId ?? null,
|
||||
committedHash: event.committedHash,
|
||||
});
|
||||
break;
|
||||
@@ -1282,48 +1286,77 @@ export function ArtifactFileDetail({
|
||||
versionId: event.versionId ?? null,
|
||||
});
|
||||
break;
|
||||
case "conflict":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "conflict",
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "cancelled":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "cancelled",
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error: event.message,
|
||||
diagnostic: event.diagnostic ?? null,
|
||||
});
|
||||
break;
|
||||
case "conflict":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "conflict",
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
// The sandbox retains the visible draft and exposes manual
|
||||
// save instead of showing an error state to the user.
|
||||
console.error("AI 全文改写接口失败:", event.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
...(outputPath ? { outputPath } : {}),
|
||||
});
|
||||
}
|
||||
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。");
|
||||
},
|
||||
onFailure: ({ error, diagnostic }) => {
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error,
|
||||
diagnostic: diagnostic ?? null,
|
||||
});
|
||||
}
|
||||
},
|
||||
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) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
});
|
||||
}
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
},
|
||||
{ content: rewritten, url: undefined },
|
||||
);
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
} else {
|
||||
toast.success("Markdown 全文改写完成,已安全替换原文件。");
|
||||
}
|
||||
},
|
||||
onFailure: ({ content, error }) => {
|
||||
// The rewrite transport can fail after the server has already
|
||||
// streamed text (or before the first token on a 404). Keep that
|
||||
// visible content in the sandbox, make Save actionable, and leave
|
||||
// diagnostics to the developer console only.
|
||||
const retainedContent = content || draftContent;
|
||||
setDraftContent(retainedContent);
|
||||
setHasLocalEdits(true);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
},
|
||||
{ content: retainedContent, url: undefined },
|
||||
);
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "completed",
|
||||
rewrittenContent: retainedContent,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -1341,37 +1374,15 @@ export function ArtifactFileDetail({
|
||||
],
|
||||
);
|
||||
|
||||
const handleCancelDocumentRewrite = useCallback(() => {
|
||||
documentRewrite.cancel();
|
||||
const handleCancelDocumentRewrite = useCallback(() => {
|
||||
documentRewrite.cancel();
|
||||
if (documentRewritePresentationId && rewritePresentation) {
|
||||
rewritePresentation.update(documentRewritePresentationId, {
|
||||
stage: "cancelled",
|
||||
error: "已停止全文改写,原文件未被修改。",
|
||||
});
|
||||
}
|
||||
}, [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,
|
||||
]);
|
||||
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
|
||||
|
||||
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
||||
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
||||
@@ -1610,25 +1621,19 @@ export function ArtifactFileDetail({
|
||||
{canEditArtifact && (
|
||||
<ArtifactAction
|
||||
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
|
||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||
tooltip={
|
||||
documentRewriteActive
|
||||
? "正在生成独立改写版文件,原文件保持不变;点击停止"
|
||||
: hasLocalEdits
|
||||
? "将先保存当前 Markdown 修改,再开始改写"
|
||||
: "使用 AI 重写当前完整 Markdown"
|
||||
}
|
||||
// Keep the entry available after a completed rewrite and when
|
||||
// the user has local edits. Starting with local edits saves
|
||||
// them automatically in handleStartDocumentRewrite.
|
||||
onClick={() => {
|
||||
if (documentRewriteActive) {
|
||||
handleCancelDocumentRewrite();
|
||||
return;
|
||||
}
|
||||
setDocumentRewriteDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||
tooltip={
|
||||
documentRewriteActive
|
||||
? "正在生成临时草稿,原文件尚未被覆盖"
|
||||
: hasLocalEdits
|
||||
? "将先保存当前 Markdown 修改,再开始改写"
|
||||
: "使用 AI 重写当前完整 Markdown"
|
||||
}
|
||||
// Keep the entry available after a completed rewrite and when
|
||||
// the user has local edits. Starting with local edits saves
|
||||
// them automatically in handleStartDocumentRewrite.
|
||||
onClick={() => setDocumentRewriteDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
@@ -1754,6 +1759,27 @@ export function ArtifactFileDetail({
|
||||
正在改写 Markdown
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1764,7 +1790,7 @@ export function ArtifactFileDetail({
|
||||
滑动到底部并继续自动下滑
|
||||
</button>
|
||||
)}
|
||||
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
|
||||
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
|
||||
<MarkdownAiEditor
|
||||
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
|
||||
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
|
||||
@@ -1821,16 +1847,16 @@ export function ArtifactFileDetail({
|
||||
? (displayContent ?? "")
|
||||
: sandboxDisplayText
|
||||
}
|
||||
readonly={!canEditArtifact || documentRewriteActive}
|
||||
onChange={
|
||||
canEditArtifact && !documentRewriteActive
|
||||
readonly={!canEditArtifact || documentRewriteActive}
|
||||
onChange={
|
||||
canEditArtifact && !documentRewriteActive
|
||||
? (value) => {
|
||||
setDraftContent(value);
|
||||
setHasLocalEdits(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isStreaming={writeFileInFlight || documentRewriteActive}
|
||||
isStreaming={writeFileInFlight || documentRewriteActive}
|
||||
streamingReadonlyFallback={!canEditArtifact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env";
|
||||
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
|
||||
|
||||
export function artifactPreviewKey(artifact: string): string {
|
||||
if (!artifact.startsWith("write-file:")) {
|
||||
@@ -40,8 +38,6 @@ export interface ArtifactsContextType {
|
||||
select: (artifact: string, autoSelect?: boolean) => void;
|
||||
/** Open/update a streamed write without overriding a manual close of that call. */
|
||||
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;
|
||||
|
||||
open: boolean;
|
||||
@@ -90,26 +86,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [preOpen, setPreOpen] = useState(false);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
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,避免每次
|
||||
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
||||
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
||||
@@ -153,7 +129,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
if (!canSwitchArtifact(artifact, !autoSelect)) return;
|
||||
const previewKey = artifactPreviewKey(artifact);
|
||||
selectedArtifactKeyRef.current = previewKey;
|
||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||
@@ -165,12 +140,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
},
|
||||
[canSwitchArtifact, sidebar],
|
||||
[sidebar],
|
||||
);
|
||||
|
||||
const openStreamedArtifact = useCallback(
|
||||
(artifact: string, previewKey: string) => {
|
||||
if (!canSwitchArtifact(artifact, false)) return false;
|
||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||
return false;
|
||||
}
|
||||
@@ -206,11 +180,10 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
|
||||
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
generatingArtifactsRef.current.clear();
|
||||
selectedArtifactKeyRef.current = null;
|
||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||
@@ -251,7 +224,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
selectedArtifactKey,
|
||||
select,
|
||||
openStreamedArtifact,
|
||||
setGeneratingArtifact,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
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 { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
latestCompletedArtifactMutationKey,
|
||||
latestInFlightWriteFilePreview,
|
||||
latestPreparedHtmlArtifact,
|
||||
isWriteFileInFlight,
|
||||
} from "@/core/artifacts/write-file-in-flight";
|
||||
import {
|
||||
isAutoPreviewableArtifactPath,
|
||||
@@ -31,7 +30,7 @@ import {
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { resolveRagModeRequested, useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
|
||||
@@ -153,11 +152,11 @@ const ChatBox: React.FC<{
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled ||
|
||||
!!knowledgeReferences;
|
||||
const ragModeEnabled = resolveRagModeRequested({
|
||||
referenceModeEntryEnabled,
|
||||
ragModeEnabled: settings.display.ragModeEnabled,
|
||||
knowledgeSelected: knowledgeReferencesEnabled || !!knowledgeReferences,
|
||||
});
|
||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||
// the existing reference panel, but must not request or write reference
|
||||
// batches through DeerFlow's backend APIs.
|
||||
@@ -253,20 +252,8 @@ const ChatBox: React.FC<{
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
openStreamedArtifact,
|
||||
setGeneratingArtifact,
|
||||
} = 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 skillDisplayMap = useMemo(
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
@@ -319,7 +306,7 @@ const ChatBox: React.FC<{
|
||||
return;
|
||||
}
|
||||
openedPreferredArtifactRef.current = preferredKey;
|
||||
selectArtifact(availablePreferredArtifact, true);
|
||||
selectArtifact(availablePreferredArtifact);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
// The same canonical report path may be overwritten by a later run while
|
||||
@@ -587,7 +574,7 @@ const ChatBox: React.FC<{
|
||||
previewKey,
|
||||
);
|
||||
} else {
|
||||
selectArtifact(automaticTarget, true);
|
||||
selectArtifact(automaticTarget);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
@@ -613,7 +600,7 @@ const ChatBox: React.FC<{
|
||||
(artifact) => !isWordDocumentFile(artifact),
|
||||
);
|
||||
if (firstPreviewableArtifact) {
|
||||
selectArtifact(firstPreviewableArtifact, true);
|
||||
selectArtifact(firstPreviewableArtifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,8 +623,8 @@ const ChatBox: React.FC<{
|
||||
writingModeEnabled,
|
||||
]);
|
||||
|
||||
// Live sandbox: open when an in-flight write_file has a usable output path.
|
||||
// Pathless content stays inline in the CoT, including content-first streams.
|
||||
// Live sandbox: open as soon as any in-flight write_file has a path or
|
||||
// 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
|
||||
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
||||
const streamingWritePreview = useMemo(
|
||||
@@ -688,7 +675,7 @@ const ChatBox: React.FC<{
|
||||
return;
|
||||
}
|
||||
if (!selectedArtifact) {
|
||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
|
||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||
}
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
@@ -815,13 +802,13 @@ const ChatBox: React.FC<{
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<div className="flex size-full max-w-[var(--container-width-sm)] flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
className="max-w-[var(--container-width-sm)] p-4 pt-12"
|
||||
files={artifacts}
|
||||
threadId={threadId}
|
||||
/>
|
||||
|
||||
@@ -304,9 +304,7 @@ export function CompactModeGuide({
|
||||
setCurrent(-1);
|
||||
}, [clearPollTimer, releasePromptDialog]);
|
||||
|
||||
// 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 || current < 0 || current >= stepsRef.current.length) return null;
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<Guide
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BookMarkedIcon,
|
||||
BrainCircuitIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
FolderTreeIcon,
|
||||
@@ -63,23 +61,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
isDocumentRewriteActiveStage,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
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 { AgentThreadContext } from "@/core/threads";
|
||||
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -105,11 +92,7 @@ import {
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
@@ -137,7 +120,6 @@ export function InputBox({
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeRetrieval,
|
||||
knowledgeSuggestedQuestions = [],
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
@@ -160,7 +142,7 @@ export function InputBox({
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: StoredReasoningEffort;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
@@ -176,7 +158,7 @@ export function InputBox({
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: StoredReasoningEffort;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
},
|
||||
) => void;
|
||||
@@ -211,15 +193,6 @@ export function InputBox({
|
||||
};
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
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. */
|
||||
@@ -239,25 +212,11 @@ export function InputBox({
|
||||
return singleRoundRagActive ? extraModels : models;
|
||||
}, [extraModels, models, singleRoundRagActive]);
|
||||
const { thread, isMock } = useThread();
|
||||
const rewritePresentation = useDocumentRewritePresentation();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
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 [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
@@ -272,7 +231,6 @@ export function InputBox({
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const followupsEnabled = userPreferences.followup_suggestions_enabled;
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
knowledgeSuggestedQuestions.length === 0 &&
|
||||
@@ -291,20 +249,12 @@ export function InputBox({
|
||||
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||
const supportsReasoningEffort =
|
||||
fallbackModel.supports_reasoning_effort ?? false;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = resolveInputMode(context.mode, supportsThinking);
|
||||
const nextReasoningEffort = resolveModelReasoningEffort(
|
||||
supportsReasoningEffort,
|
||||
context.reasoning_effort,
|
||||
context.model_name !== nextModelName,
|
||||
);
|
||||
|
||||
if (
|
||||
context.model_name === nextModelName &&
|
||||
context.mode === nextMode &&
|
||||
context.reasoning_effort === nextReasoningEffort &&
|
||||
context.execution_profile === fallbackModel.execution_profile
|
||||
) {
|
||||
return;
|
||||
@@ -314,7 +264,6 @@ export function InputBox({
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
reasoning_effort: nextReasoningEffort,
|
||||
execution_profile: fallbackModel.execution_profile,
|
||||
});
|
||||
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
||||
@@ -339,31 +288,6 @@ export function InputBox({
|
||||
[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(
|
||||
(model_name: string) => {
|
||||
const model = modelOptions.find((m) => m.name === model_name);
|
||||
@@ -374,11 +298,7 @@ export function InputBox({
|
||||
...context,
|
||||
model_name,
|
||||
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: resolveModelReasoningEffort(
|
||||
model.supports_reasoning_effort ?? false,
|
||||
context.reasoning_effort,
|
||||
model_name !== context.model_name,
|
||||
),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile: model.execution_profile,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
@@ -397,20 +317,18 @@ export function InputBox({
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
reasoning_effort: resolveModelReasoningEffort(
|
||||
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
|
||||
context.reasoning_effort,
|
||||
),
|
||||
reasoning_effort:
|
||||
mode === "ultra"
|
||||
? "high"
|
||||
: mode === "pro"
|
||||
? "medium"
|
||||
: mode === "thinking"
|
||||
? "low"
|
||||
: "minimal",
|
||||
execution_profile: undefined,
|
||||
});
|
||||
},
|
||||
[
|
||||
onContextChange,
|
||||
context,
|
||||
models,
|
||||
supportThinking,
|
||||
supportReasoningEffort,
|
||||
],
|
||||
[onContextChange, context, models, supportThinking],
|
||||
);
|
||||
|
||||
const handleSingleRoundRagSelect = useCallback(() => {
|
||||
@@ -440,7 +358,7 @@ export function InputBox({
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const handleReasoningEffortSelect = useCallback(
|
||||
(effort: ReasoningEffort) => {
|
||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
reasoning_effort: effort,
|
||||
@@ -464,19 +382,13 @@ export function InputBox({
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const smartKnowledgeRetrievalActive =
|
||||
knowledgeRetrieval?.mode === "smart_reasoning";
|
||||
const ragModeLocked =
|
||||
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled =
|
||||
!smartKnowledgeRetrievalActive &&
|
||||
(localSettings.display.ragModeEnabled || ragModeLocked);
|
||||
const ragModeEnabled =
|
||||
referenceModeEntryEnabled && localSettings.display.ragModeEnabled;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
}
|
||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
||||
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
||||
setLocalSettings("display", {
|
||||
ragModeEnabled: !localSettings.display.ragModeEnabled,
|
||||
});
|
||||
}, [localSettings.display.ragModeEnabled, setLocalSettings]);
|
||||
|
||||
const contextCompressionEnabled =
|
||||
localSettings.display.contextCompressionEnabled ?? false;
|
||||
@@ -486,33 +398,21 @@ export function InputBox({
|
||||
});
|
||||
}, [contextCompressionEnabled, setLocalSettings]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (composerStatus === "streaming") {
|
||||
toast.info("正在生成中,请等待当前回复完成后再发送。", {
|
||||
description: "已保留您输入的内容。",
|
||||
});
|
||||
// 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.");
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
onStop?.();
|
||||
return;
|
||||
}
|
||||
if (!hasPromptInputPayload(message)) {
|
||||
return;
|
||||
}
|
||||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
if (
|
||||
resolvedModelName &&
|
||||
(context.model_name !== resolvedModelName ||
|
||||
context.reasoning_effort !== selectedReasoningEffort)
|
||||
) {
|
||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
@@ -520,7 +420,6 @@ export function InputBox({
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
reasoning_effort: selectedReasoningEffort,
|
||||
execution_profile: selectedModel?.execution_profile,
|
||||
});
|
||||
setTimeout(() => onSubmit?.(message), 0);
|
||||
@@ -531,40 +430,24 @@ export function InputBox({
|
||||
},
|
||||
[
|
||||
context,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
composerStatus,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.execution_profile,
|
||||
selectedReasoningEffort,
|
||||
status,
|
||||
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 form = promptRootRef.current?.querySelector("form");
|
||||
form?.requestSubmit();
|
||||
}, []);
|
||||
|
||||
const handleFollowupClick = useCallback(
|
||||
(suggestion: string) => {
|
||||
if (composerStatus === "streaming") {
|
||||
const handleFollowupClick = useCallback(
|
||||
(suggestion: string) => {
|
||||
if (status === "streaming") {
|
||||
return;
|
||||
}
|
||||
const current = (textInput.value ?? "").trim();
|
||||
@@ -577,7 +460,7 @@ export function InputBox({
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
},
|
||||
[composerStatus, requestFormSubmit, textInput],
|
||||
[requestFormSubmit, status, textInput],
|
||||
);
|
||||
|
||||
const confirmReplaceAndSend = useCallback(() => {
|
||||
@@ -609,7 +492,6 @@ export function InputBox({
|
||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||
|
||||
const followupsPresent =
|
||||
followupsEnabled &&
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!selectedSingleRoundRag &&
|
||||
@@ -631,20 +513,11 @@ export function InputBox({
|
||||
return () => followupsPresenceChangeRef.current?.(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (followupsEnabled) return;
|
||||
setFollowups([]);
|
||||
setFollowupsLoading(false);
|
||||
setFollowupsHidden(true);
|
||||
setConfirmOpen(false);
|
||||
setPendingSuggestion(null);
|
||||
}, [followupsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const streaming = status === "streaming";
|
||||
const wasStreaming = wasStreamingRef.current;
|
||||
wasStreamingRef.current = streaming;
|
||||
if (!followupsEnabled || !wasStreaming || streaming) {
|
||||
if (!wasStreaming || streaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -696,7 +569,6 @@ export function InputBox({
|
||||
return (await res.json()) as { suggestions?: string[] };
|
||||
})
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const suggestions = (data.suggestions ?? [])
|
||||
.map((s) => (typeof s === "string" ? s.trim() : ""))
|
||||
.filter((s) => s.length > 0)
|
||||
@@ -704,19 +576,14 @@ export function InputBox({
|
||||
setFollowups(suggestions);
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFollowups([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFollowupsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
setFollowupsLoading(false);
|
||||
};
|
||||
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
return () => controller.abort();
|
||||
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
@@ -830,7 +697,7 @@ export function InputBox({
|
||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||
isNewThread
|
||||
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
|
||||
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
|
||||
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:shadow-md dark:bg-slate-800 dark:border-slate-700",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
@@ -848,8 +715,8 @@ export function InputBox({
|
||||
<PromptInputTextarea
|
||||
className={cn(
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
|
||||
isNewThread && "min-h-[110px]!",
|
||||
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
|
||||
isNewThread && "!min-h-[110px]",
|
||||
compact && "!min-h-[84px] px-3 pt-3 pb-1 text-[14px]",
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
@@ -878,9 +745,9 @@ export function InputBox({
|
||||
{!frontendRagActive && (
|
||||
<AddAttachmentsButton
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-2.5! shadow-none 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",
|
||||
"rounded-full border-0 bg-transparent !px-2.5 shadow-none 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",
|
||||
compact &&
|
||||
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
"!gap-1.5 rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -939,42 +806,35 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{referenceModeEntryEnabled &&
|
||||
!frontendRagActive &&
|
||||
!smartKnowledgeRetrievalActive &&
|
||||
!hideContextTools && (
|
||||
<Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:点击开启"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
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>
|
||||
)}
|
||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:点击开启"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
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
|
||||
content={
|
||||
contextCompressionEnabled
|
||||
@@ -1267,61 +1127,122 @@ export function InputBox({
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
{!frontendRagActive &&
|
||||
supportReasoningEffort &&
|
||||
context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger
|
||||
aria-label={`${t.inputBox.reasoningEffortSelect}: ${reasoningEffortLabel}`}
|
||||
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]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<SparklesIcon className="size-3" />
|
||||
<span className="text-foreground">
|
||||
{reasoningEffortLabel}
|
||||
</span>
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
<PromptInputActionMenuContent className="w-56 p-3">
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.inputBox.reasoningEffortSelect}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-violet-600 dark:text-violet-300">
|
||||
{reasoningEffortLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 px-1">
|
||||
<div className="relative h-9 w-full">
|
||||
<input
|
||||
type="range"
|
||||
aria-label={`${t.inputBox.reasoningEffortSelect}: ${selectedModel?.display_name || selectedModel?.name || ""}`}
|
||||
aria-valuetext={reasoningEffortLabel}
|
||||
className="peer absolute inset-x-0 -inset-y-1 z-10 h-auto w-full cursor-grab touch-none opacity-0 active:cursor-grabbing"
|
||||
value={reasoningEffortIndex}
|
||||
min={0}
|
||||
max={2}
|
||||
step={1}
|
||||
onChange={(event) => {
|
||||
const effort =
|
||||
REASONING_EFFORTS[Number(event.currentTarget.value)];
|
||||
if (effort) handleReasoningEffortSelect(effort);
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-fuchsia-500 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/70 peer-focus-visible:ring-offset-2" />
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-violet-600 shadow-md peer-active:scale-110 dark:border-slate-800"
|
||||
style={{ left: reasoningEffortThumbLeft }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<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]")}>
|
||||
<div className="text-xs font-normal">
|
||||
{t.inputBox.reasoningEffort}:
|
||||
{context.reasoning_effort === "minimal" &&
|
||||
" " + t.inputBox.reasoningEffortMinimal}
|
||||
{context.reasoning_effort === "low" &&
|
||||
" " + t.inputBox.reasoningEffortLow}
|
||||
{context.reasoning_effort === "medium" &&
|
||||
" " + t.inputBox.reasoningEffortMedium}
|
||||
{context.reasoning_effort === "high" &&
|
||||
" " + t.inputBox.reasoningEffortHigh}
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
<PromptInputActionMenuContent className="w-70">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||
{t.inputBox.reasoningEffort}
|
||||
</DropdownMenuLabel>
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "minimal"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("minimal")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortMinimal}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortMinimalDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "minimal" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "low"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("low")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortLow}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortLowDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "low" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "medium" ||
|
||||
!context.reasoning_effort
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("medium")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortMedium}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortMediumDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "medium" ||
|
||||
!context.reasoning_effort ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "high"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("high")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortHigh}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortHighDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "high" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
</PromptInputActionMenu>
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
|
||||
{!frontendRagActive && agentSelector && (
|
||||
@@ -1333,100 +1254,6 @@ export function InputBox({
|
||||
/>
|
||||
)}
|
||||
{!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 && (
|
||||
<Tooltip
|
||||
content={
|
||||
@@ -1511,21 +1338,11 @@ export function InputBox({
|
||||
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
|
||||
compact &&
|
||||
"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}
|
||||
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"
|
||||
status={composerStatus}
|
||||
{...(documentRewriteActive
|
||||
? { "aria-label": "停止 AI 全文改写" }
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
status={status}
|
||||
/>
|
||||
</PromptInputTools>
|
||||
</PromptInputFooter>
|
||||
{!isNewThread && (
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react";
|
||||
import { BookOpen, Database, SearchCheck } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
// import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
|
||||
// import {
|
||||
// useAssistantKnowledgeVisibility,
|
||||
// useUpdateAssistantKnowledgeVisibility,
|
||||
// } from "@/core/system-settings/hooks";
|
||||
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 {
|
||||
loadOrdinaryKnowledgeLanding,
|
||||
@@ -20,14 +20,8 @@ import {
|
||||
|
||||
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||
|
||||
export function useOrdinaryKnowledgeLanding(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: LANDING_KEY,
|
||||
queryFn: ({ signal }) => loadOrdinaryKnowledgeLanding(signal),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
export function useOrdinaryKnowledgeLanding() {
|
||||
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||
}
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
@@ -36,9 +30,8 @@ export function KnowledgeProductNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
// const assistantVisibility = useAssistantKnowledgeVisibility();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const landing = useOrdinaryKnowledgeLanding(isAdmin);
|
||||
const assistantVisibility = useAssistantKnowledgeVisibility();
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
const saveLanding = useMutation({
|
||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||
@@ -46,11 +39,12 @@ export function KnowledgeProductNav() {
|
||||
});
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||
// const assistantAllowed = canAccessAssistantKnowledge(
|
||||
// isAdmin,
|
||||
// assistantVisibility.isError ? undefined : assistantVisibility.data,
|
||||
// );
|
||||
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const assistantAllowed = canAccessAssistantKnowledge(
|
||||
isAdmin,
|
||||
assistantVisibility.isError ? undefined : assistantVisibility.data,
|
||||
);
|
||||
const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
|
||||
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
const ordinary = !assistant && !retrievalDebug;
|
||||
@@ -60,7 +54,6 @@ export function KnowledgeProductNav() {
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
|
||||
{assistantAllowed && (
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
@@ -71,7 +64,6 @@ export function KnowledgeProductNav() {
|
||||
<SearchCheck />检索调试
|
||||
</Button>
|
||||
)}
|
||||
*/}
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
普通知识库默认页面
|
||||
@@ -79,7 +71,6 @@ export function KnowledgeProductNav() {
|
||||
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||
</Select>
|
||||
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
|
||||
助手知识库日常展示
|
||||
<Select
|
||||
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
|
||||
@@ -94,7 +85,6 @@ export function KnowledgeProductNav() {
|
||||
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
|
||||
</Select>
|
||||
*/}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 {
|
||||
type AssistantKnowledgeBase,
|
||||
@@ -48,14 +46,14 @@ export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
|
||||
const name = cleanNames[0]!;
|
||||
return [
|
||||
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
|
||||
`「${name}」包含哪些核心主题?请按重要性说明。`,
|
||||
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
|
||||
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
|
||||
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
|
||||
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
|
||||
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
|
||||
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
|
||||
"所选知识空间中有哪些相互冲突或证据不足的结论?",
|
||||
];
|
||||
@@ -428,13 +426,10 @@ export function LlmWikiKnowledgeSelector({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const assistantAccess = useAssistantKnowledgeAccess({
|
||||
enabled:
|
||||
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
|
||||
enabled: onAssistantChange !== undefined,
|
||||
});
|
||||
const assistantAllowed =
|
||||
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
|
||||
onAssistantChange !== undefined &&
|
||||
assistantAccess.canAccess;
|
||||
onAssistantChange !== undefined && assistantAccess.canAccess;
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||
const shouldLoadOptions =
|
||||
@@ -494,12 +489,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
[selectedAssistantNames, selectedNames],
|
||||
);
|
||||
const selectedConfiguredQuestions = useMemo(
|
||||
() =>
|
||||
userFacingKnowledgeSuggestedQuestions(
|
||||
value.flatMap(
|
||||
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
|
||||
),
|
||||
),
|
||||
() => value.flatMap((id) => rows.find((item) => item.id === id)?.test_questions ?? []),
|
||||
[rows, value],
|
||||
);
|
||||
const suggestedQuestions = useMemo(
|
||||
@@ -559,11 +549,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
value={value}
|
||||
assistantValue={effectiveAssistantValue}
|
||||
onChange={onChange}
|
||||
onAssistantChange={
|
||||
assistantAllowed
|
||||
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
|
||||
: IGNORE_ASSISTANT_CHANGE
|
||||
}
|
||||
onAssistantChange={onAssistantChange ?? IGNORE_ASSISTANT_CHANGE}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
loading={loading}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronsDownIcon,
|
||||
CopyIcon,
|
||||
FilePenLineIcon,
|
||||
LightbulbIcon,
|
||||
Loader2Icon,
|
||||
@@ -15,7 +14,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -24,7 +22,6 @@ import {
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { updateArtifactContent } from "@/core/artifacts/api";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import {
|
||||
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
@@ -45,7 +42,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
||||
const REWRITE_STEPS = [
|
||||
{ key: "snapshot", label: "固定原文版本" },
|
||||
{ key: "requirements", label: "理解改写要求" },
|
||||
{ key: "writing", label: "生成改写版 Markdown" },
|
||||
{ key: "writing", label: "流式重写 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验文档" },
|
||||
{ key: "comparison", label: "对比原文并生成说明" },
|
||||
@@ -224,9 +221,6 @@ export function DocumentRewriteTimeline({
|
||||
const presentation = useDocumentRewritePresentation();
|
||||
const [undoing, setUndoing] = useState(false);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
const diagnosticText = item.diagnostic
|
||||
? JSON.stringify(item.diagnostic, null, 2)
|
||||
: "";
|
||||
|
||||
const reapplying = Boolean(item.revertedAt);
|
||||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||||
@@ -376,7 +370,7 @@ export function DocumentRewriteTimeline({
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"}
|
||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
@@ -446,13 +440,13 @@ export function DocumentRewriteTimeline({
|
||||
{step.key === "writing" && (
|
||||
item.writtenCharCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。
|
||||
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{isNewReportGeneration
|
||||
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
||||
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"}
|
||||
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
|
||||
</p>
|
||||
) : null
|
||||
)}
|
||||
@@ -589,42 +583,9 @@ export function DocumentRewriteTimeline({
|
||||
)}
|
||||
|
||||
{item.error && (
|
||||
<div className="border-t px-5 py-3 text-sm text-destructive">
|
||||
<div className="flex items-start gap-2">
|
||||
<XCircleIcon className="mt-0.5 size-4 shrink-0" />
|
||||
<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 className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
|
||||
<XCircleIcon className="size-4 shrink-0" />
|
||||
{item.error}
|
||||
</div>
|
||||
)}
|
||||
<DocumentRewriteDiffDialog
|
||||
@@ -637,36 +598,3 @@ export function DocumentRewriteTimeline({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -41,12 +41,12 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
namedStreamingWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
normalizeSearchResults,
|
||||
@@ -57,12 +57,9 @@ import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { isInternalToolCallName } from "@/core/tools/visibility";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
buildSkillDisplayMap,
|
||||
@@ -86,7 +83,6 @@ import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { SingleRoundRagCitationDrawer } from "./message-list-item";
|
||||
import { RetrievedFileList } from "./retrieved-file-list";
|
||||
|
||||
export function MessageGroup({
|
||||
className,
|
||||
@@ -94,17 +90,14 @@ export function MessageGroup({
|
||||
isLoading = false,
|
||||
displaySources = [],
|
||||
ragModeEnabled = false,
|
||||
threadId,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
threadId?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
@@ -119,13 +112,7 @@ export function MessageGroup({
|
||||
() => convertToSteps(messages, writingModeEnabled),
|
||||
[messages, writingModeEnabled],
|
||||
);
|
||||
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 { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
@@ -223,7 +210,6 @@ export function MessageGroup({
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
@@ -236,7 +222,6 @@ export function MessageGroup({
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
@@ -286,7 +271,6 @@ export function MessageGroup({
|
||||
</>
|
||||
)}
|
||||
</ChainOfThought>
|
||||
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
|
||||
<SingleRoundRagCitationDrawer
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
@@ -352,7 +336,6 @@ function ToolCall({
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
skillDisplayMap = new Map(),
|
||||
displaySources = [],
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
@@ -363,7 +346,6 @@ function ToolCall({
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -374,42 +356,19 @@ function ToolCall({
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? syncDisplaySourceIndexes(
|
||||
rankDisplaySources(
|
||||
normalizeDisplaySources(result, displayMatch.display).map(
|
||||
(item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}),
|
||||
),
|
||||
),
|
||||
displaySources,
|
||||
)
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
const isLlmWikiTool =
|
||||
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 wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: name === "llmwiki_read_wiki_page"
|
||||
? wikiQuery
|
||||
? `读取 Wiki 页面「${wikiQuery}」`
|
||||
: "读取 Wiki 页面"
|
||||
: name === "llmwiki_browse_wiki_index"
|
||||
? "读取 Wiki 索引与知识库摘要"
|
||||
: null;
|
||||
: null;
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
@@ -435,17 +394,12 @@ function ToolCall({
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = syncDisplaySourceIndexes(
|
||||
rankDisplaySources(
|
||||
normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
})),
|
||||
),
|
||||
displaySources,
|
||||
);
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
@@ -827,10 +781,17 @@ function ArtifactWriteToolCall({
|
||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const sourcePath = namedStreamingWriteFilePath(rawPath);
|
||||
const showInlineContent = name === "write_file" && !sourcePath;
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: name === "write_file" && content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: 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 =
|
||||
sourcePath && name === "write_file" && messageId && id
|
||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||
@@ -871,9 +832,7 @@ function ArtifactWriteToolCall({
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{showInlineContent ? (
|
||||
content && <InlineWritePreview content={content} />
|
||||
) : (normalizedPath || sourcePath) && (
|
||||
{(normalizedPath || sourcePath) && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||||
</ChainOfThoughtSearchResult>
|
||||
@@ -882,29 +841,7 @@ 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;
|
||||
messageId?: string;
|
||||
type: T;
|
||||
@@ -924,10 +861,6 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
|
||||
function convertToSteps(messages: Message[], writingMode: boolean): 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) {
|
||||
if (message.type === "ai") {
|
||||
const reasoning = extractReasoningContentFromMessage(message);
|
||||
@@ -940,37 +873,14 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
};
|
||||
steps.push(step);
|
||||
}
|
||||
const writes = new Map(
|
||||
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
|
||||
);
|
||||
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)) {
|
||||
for (const tool_call of message.tool_calls ?? []) {
|
||||
if (tool_call.name === "task") {
|
||||
continue;
|
||||
}
|
||||
const write = tool_call.name === "write_file" && tool_call.id
|
||||
? writes.get(tool_call.id)
|
||||
: undefined;
|
||||
const args = write
|
||||
? { ...tool_call.args, path: write.path, content: write.content }
|
||||
: tool_call.args;
|
||||
const writePath = typeof args?.path === "string" ? args.path : "";
|
||||
const writePath =
|
||||
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
|
||||
? (tool_call.args as { path: string }).path
|
||||
: "";
|
||||
if (
|
||||
(
|
||||
tool_call.name === "write_file" ||
|
||||
@@ -988,27 +898,15 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
messageId: message.id,
|
||||
type: "toolCall",
|
||||
name: tool_call.name,
|
||||
args,
|
||||
args: tool_call.args,
|
||||
};
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
let toolCallResult = findToolCallResult(
|
||||
const toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
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 (typeof toolCallResult === "string") {
|
||||
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 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -74,18 +74,6 @@ export function SpeedLegend({
|
||||
}
|
||||
|
||||
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 { color, label } = classifySpeed(metric.tokensPerSec);
|
||||
const display =
|
||||
@@ -97,7 +85,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
||||
return (
|
||||
<span
|
||||
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 }} />
|
||||
<span className="font-mono tabular-nums" style={{ color }}>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function MultiRoundRagMessageList({
|
||||
return (
|
||||
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
|
||||
<ConversationContent
|
||||
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
|
||||
className="mx-auto w-full max-w-[var(--container-width-lg)] gap-8 pt-10"
|
||||
>
|
||||
{messages.map((message, index) => {
|
||||
const isLastAssistant =
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { ChevronDownIcon, DownloadIcon, EyeIcon, Loader2Icon } from "lucide-react";
|
||||
import { useContext, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { downloadArtifactFile } from "@/core/artifacts/download";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import type { RetrievedFile } from "@/core/messages/retrieved-files";
|
||||
import { getFileIcon } from "@/core/utils/files";
|
||||
import {
|
||||
getLlmWikiDocument,
|
||||
getLlmWikiDocumentPreview,
|
||||
getLlmWikiSourceContext,
|
||||
resolveLlmWikiSourceKnowledgeBase,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
|
||||
import { ThreadContext } from "./context";
|
||||
import { UploadedFilePreviewDialog } from "./uploaded-file-preview-dialog";
|
||||
|
||||
// File links belong to the upstream service. Never attach DeerFlow auth headers.
|
||||
const fetchExternalFile: typeof fetch = (input, init) =>
|
||||
fetch(input, { ...init, credentials: "omit" });
|
||||
|
||||
export function RetrievedFileList({ files, threadId }: { files: RetrievedFile[]; threadId: string }) {
|
||||
const { isMock, publicShareCode } = useContext(ThreadContext) ?? {};
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visible = files.filter((file) => !(publicShareCode && file.kind === "knowledge"));
|
||||
// Results arrive in retrieval order; keep the newest file outside the fold.
|
||||
const latest = visible.at(-1);
|
||||
if (!latest) return null;
|
||||
const older = visible.slice(0, -1).reverse();
|
||||
return (
|
||||
<Collapsible asChild open={expanded} onOpenChange={setExpanded}>
|
||||
<section aria-label="检索到的文件" className="not-prose mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-muted-foreground text-xs">检索到的文件 · {visible.length} 个</div>
|
||||
{older.length > 0 && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
|
||||
{expanded ? "收起" : "展开"}
|
||||
<ChevronDownIcon className={`size-3.5 transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</div>
|
||||
<ul className="grid gap-2">
|
||||
<RetrievedFileCard
|
||||
key={latest.key}
|
||||
file={latest}
|
||||
threadId={threadId}
|
||||
isMock={isMock}
|
||||
publicShareCode={publicShareCode}
|
||||
/>
|
||||
</ul>
|
||||
<CollapsibleContent>
|
||||
<ul className="grid gap-2">
|
||||
{older.map((file) => (
|
||||
<RetrievedFileCard
|
||||
key={file.key}
|
||||
file={file}
|
||||
threadId={threadId}
|
||||
isMock={isMock}
|
||||
publicShareCode={publicShareCode}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function RetrievedFileCard({ file, threadId, isMock, publicShareCode }: {
|
||||
file: RetrievedFile;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [preparingPreview, setPreparingPreview] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [knowledgeTarget, setKnowledgeTarget] = useState<WeKnoraSourceTarget | null>(null);
|
||||
const fileUrl = file.kind === "artifact"
|
||||
? urlOfArtifact({ filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode })
|
||||
: file.kind === "external" ? file.url : "";
|
||||
|
||||
const resolveKnowledgeTarget = async () => {
|
||||
if (file.kind !== "knowledge") throw new Error("当前文件不是知识库文档");
|
||||
if (knowledgeTarget) return knowledgeTarget;
|
||||
const knowledgeBaseId = await resolveLlmWikiSourceKnowledgeBase(file.target.knowledgeBaseId);
|
||||
const target = { ...file.target, knowledgeBaseId };
|
||||
setKnowledgeTarget(target);
|
||||
return target;
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
if (preparingPreview) return;
|
||||
setPreparingPreview(true);
|
||||
try {
|
||||
if (file.kind === "knowledge") await resolveKnowledgeTarget();
|
||||
setPreviewOpen(true);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "文件预览失败");
|
||||
} finally {
|
||||
setPreparingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
if (downloading || file.kind === "external") return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
if (file.kind === "artifact") {
|
||||
await downloadArtifactFile({
|
||||
filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode,
|
||||
});
|
||||
} else {
|
||||
const target = await resolveKnowledgeTarget();
|
||||
let documentId = target.documentId;
|
||||
if (!documentId && target.chunkId) {
|
||||
const context = await getLlmWikiSourceContext(target.knowledgeBaseId, target.chunkId);
|
||||
documentId = context.document_id || undefined;
|
||||
}
|
||||
if (!documentId) throw new Error("当前检索结果没有可下载的文件原件,请先查看预览");
|
||||
const [blob, document] = await Promise.all([
|
||||
getLlmWikiDocumentPreview(target.knowledgeBaseId, documentId),
|
||||
getLlmWikiDocument(target.knowledgeBaseId, documentId).catch(() => null),
|
||||
]);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = document?.file_name || file.name;
|
||||
anchor.rel = "noopener";
|
||||
window.document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "文件下载失败");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
|
||||
{getFileIcon(file.name, "text-muted-foreground size-5 shrink-0")}
|
||||
<div className="min-w-0 flex-1 basis-40">
|
||||
<div className="text-sm font-medium break-words">{file.name}</div>
|
||||
{file.source && <div className="text-muted-foreground mt-0.5 truncate text-xs">{file.source}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{file.kind === "external" && file.previewUrl ? (
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<a href={file.previewUrl} target="_blank" rel="noopener noreferrer">
|
||||
<EyeIcon className="size-4" />预览
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" variant="ghost" disabled={preparingPreview} onClick={() => void preview()}>
|
||||
{preparingPreview ? <Loader2Icon className="size-4 animate-spin" /> : <EyeIcon className="size-4" />}预览
|
||||
</Button>
|
||||
)}
|
||||
{file.kind === "external" ? (
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer">
|
||||
<DownloadIcon className="size-4" />下载
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" variant="ghost" disabled={downloading} onClick={() => void download()}>
|
||||
{downloading ? <Loader2Icon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{previewOpen && (file.kind === "knowledge" ? (
|
||||
<WeKnoraSourceDrawer
|
||||
open
|
||||
target={knowledgeTarget || file.target}
|
||||
title={file.name}
|
||||
quote={file.quote || ""}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
) : (
|
||||
<UploadedFilePreviewDialog
|
||||
filename={file.name}
|
||||
fileUrl={fileUrl}
|
||||
open
|
||||
onOpenChange={setPreviewOpen}
|
||||
threadId={file.kind === "artifact" && !publicShareCode ? threadId : undefined}
|
||||
virtualPath={file.kind === "artifact" ? file.path : undefined}
|
||||
requestFile={file.kind === "external" ? fetchExternalFile : undefined}
|
||||
onDownload={file.kind === "artifact" ? download : undefined}
|
||||
downloading={downloading}
|
||||
/>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,79 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const STAGGER_MS = 60;
|
||||
|
||||
function SkeletonBar({
|
||||
className,
|
||||
style,
|
||||
originRight,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
originRight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-skeleton-entrance fill-mode-[forwards] overflow-hidden rounded-md ${originRight ? "origin-[right]" : "origin-[left]"} ${className ?? ""}`}
|
||||
style={{ opacity: 0, ...style }}
|
||||
>
|
||||
<Skeleton className="h-full w-full rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageListSkeleton() {
|
||||
let index = 0;
|
||||
return (
|
||||
<div className="flex w-full max-w-(--container-width-lg) flex-col gap-12 p-8 pt-16">
|
||||
<div
|
||||
role="human-message"
|
||||
className="flex w-[50%] flex-col items-end gap-2 self-end"
|
||||
>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
originRight
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-[80%]"
|
||||
originRight
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
</div>
|
||||
<div role="assistant-message" className="flex flex-col gap-2">
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-[70%]"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-[60%]"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-[40%]"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const STAGGER_MS = 60;
|
||||
|
||||
function SkeletonBar({
|
||||
className,
|
||||
style,
|
||||
originRight,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
originRight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-skeleton-entrance fill-mode-[forwards] overflow-hidden rounded-md ${originRight ? "origin-[right]" : "origin-[left]"} ${className ?? ""}`}
|
||||
style={{ opacity: 0, ...style }}
|
||||
>
|
||||
<Skeleton className="h-full w-full rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageListSkeleton() {
|
||||
let index = 0;
|
||||
return (
|
||||
<div className="flex w-full max-w-[var(--container-width-lg)] flex-col gap-12 p-8 pt-16">
|
||||
<div
|
||||
role="human-message"
|
||||
className="flex w-[50%] flex-col items-end gap-2 self-end"
|
||||
>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
originRight
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-[80%]"
|
||||
originRight
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
</div>
|
||||
<div role="assistant-message" className="flex flex-col gap-2">
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-[70%]"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-full"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-[60%]"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
<SkeletonBar
|
||||
className="h-6 w-[40%]"
|
||||
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-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;
|
||||
}
|
||||
@@ -158,11 +158,7 @@ function formatBytes(bytes: number): string {
|
||||
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
||||
* hence the 401s) and expose it as an object URL for inline display.
|
||||
*/
|
||||
export function useAuthedObjectUrl(
|
||||
url: string | null,
|
||||
enabled: boolean,
|
||||
requestFile: typeof apiFetch = apiFetch,
|
||||
) {
|
||||
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -174,10 +170,9 @@ export function useAuthedObjectUrl(
|
||||
}
|
||||
let cancelled = false;
|
||||
let created: string | null = null;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
requestFile(url, { signal: controller.signal })
|
||||
apiFetch(url)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.blob();
|
||||
@@ -196,10 +191,9 @@ export function useAuthedObjectUrl(
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [url, enabled, requestFile]);
|
||||
}, [url, enabled]);
|
||||
|
||||
return { objectUrl, loading, error };
|
||||
}
|
||||
@@ -219,9 +213,6 @@ export function UploadedFilePreviewDialog({
|
||||
fileUrl,
|
||||
threadId,
|
||||
virtualPath,
|
||||
requestFile = apiFetch,
|
||||
onDownload,
|
||||
downloading = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
@@ -231,11 +222,6 @@ export function UploadedFilePreviewDialog({
|
||||
/** 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;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
@@ -256,7 +242,7 @@ export function UploadedFilePreviewDialog({
|
||||
// image / pdf → fetch as authed blob and display via object URL.
|
||||
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
||||
const { objectUrl, loading: blobLoading, error: blobError } =
|
||||
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
|
||||
useAuthedObjectUrl(fileUrl, blobEnabled);
|
||||
|
||||
// text / office → fetch text (office reads the converted sibling .md).
|
||||
const textUrls = useMemo(
|
||||
@@ -293,7 +279,6 @@ export function UploadedFilePreviewDialog({
|
||||
useEffect(() => {
|
||||
if (!open || (kind !== "text" && kind !== "office")) return;
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setTextLoading(true);
|
||||
setTextError(null);
|
||||
setText(null);
|
||||
@@ -308,7 +293,7 @@ export function UploadedFilePreviewDialog({
|
||||
)}/artifact-preview?path=${encodeURIComponent(
|
||||
previewPath,
|
||||
)}&max_chars=${maxPreviewChars}`;
|
||||
const response = await apiFetch(endpoint, { signal: controller.signal });
|
||||
const response = await apiFetch(endpoint);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const body = (await response.json()) as ArtifactPreviewResponse;
|
||||
const rawContent = String(body.content ?? "");
|
||||
@@ -335,8 +320,7 @@ export function UploadedFilePreviewDialog({
|
||||
let lastError: Error | null = null;
|
||||
for (const textUrl of textUrls) {
|
||||
try {
|
||||
const response = await requestFile(textUrl, {
|
||||
signal: controller.signal,
|
||||
const response = await apiFetch(textUrl, {
|
||||
headers: { Range: `bytes=0-${maxBytes - 1}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
@@ -372,9 +356,8 @@ export function UploadedFilePreviewDialog({
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
|
||||
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId]);
|
||||
|
||||
const renderAsMarkdown =
|
||||
kind === "office" || ext === "md" || ext === "markdown";
|
||||
@@ -393,36 +376,21 @@ export function UploadedFilePreviewDialog({
|
||||
style={{
|
||||
width: "min(56rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100vh - 2rem))",
|
||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100dvh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
|
||||
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||
{onDownload ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={downloading}
|
||||
onClick={() => void onDownload()}
|
||||
>
|
||||
<DownloadIcon className="size-3" />
|
||||
{t.common.download}
|
||||
</Button>
|
||||
) : (
|
||||
<a
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -431,7 +399,7 @@ export function UploadedFilePreviewDialog({
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : 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">
|
||||
<img
|
||||
@@ -445,7 +413,7 @@ export function UploadedFilePreviewDialog({
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : blobError || !objectUrl ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
@@ -457,7 +425,7 @@ export function UploadedFilePreviewDialog({
|
||||
textLoading ? (
|
||||
loadingView
|
||||
) : textError ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{previewMeta &&
|
||||
@@ -495,7 +463,7 @@ export function UploadedFilePreviewDialog({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -503,34 +471,15 @@ export function UploadedFilePreviewDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
|
||||
fileUrl: string;
|
||||
label: string;
|
||||
onDownload?: () => void | Promise<void>;
|
||||
downloading?: boolean;
|
||||
}) {
|
||||
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
|
||||
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">
|
||||
<p>暂不支持在线预览,请下载原文件查看。</p>
|
||||
{onDownload ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloading}
|
||||
onClick={() => void onDownload()}
|
||||
>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,37 +7,29 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
|
||||
import { getUsageMetadata } from "@/core/messages/usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
|
||||
import {
|
||||
GENERATION_IDLE_MS,
|
||||
SAMPLE_THROTTLE_MS,
|
||||
charsToTokensPerSec,
|
||||
messageHasToolCalls,
|
||||
pushSpeedSample,
|
||||
rollingTokensPerSec,
|
||||
shouldFreezeGeneration,
|
||||
usageTokensPerSec,
|
||||
type SpeedSample,
|
||||
} from "./speed-metrics";
|
||||
/**
|
||||
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
|
||||
* speed indicator for mixed Chinese/English replies without dragging in a
|
||||
* real tokeniser.
|
||||
*/
|
||||
const CHARS_PER_TOKEN = 2;
|
||||
|
||||
/** Sample throttle while a message is streaming. */
|
||||
const SAMPLE_THROTTLE_MS = 60;
|
||||
|
||||
/**
|
||||
* Per-AI-message metric: live rolling rate during that model-call segment,
|
||||
* frozen once the segment ends (not when the whole agent turn ends).
|
||||
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
|
||||
*/
|
||||
export interface MessageMetric {
|
||||
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
||||
tokensPerSec: number;
|
||||
/**
|
||||
* Wall-clock duration of this model-call segment: first visible character
|
||||
* → last visible character (tool-wait after the call is excluded).
|
||||
* Wall-clock duration from when the message first appeared with text to
|
||||
* either *now* (active) or to when the stream ended (frozen).
|
||||
*/
|
||||
durationMs: number;
|
||||
/** True while this message is still generating visible tokens. */
|
||||
/** True while this is the currently streaming message. */
|
||||
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
|
||||
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
||||
@@ -53,12 +45,15 @@ export interface MessageMetric {
|
||||
isTurnFinal: boolean;
|
||||
}
|
||||
|
||||
type Sample = { at: number; chars: number };
|
||||
|
||||
type TimingEntry = {
|
||||
firstSeenAt: number;
|
||||
endedAt: number | null;
|
||||
lastGrewAt: number;
|
||||
samples: SpeedSample[];
|
||||
samples: Sample[];
|
||||
lastChars: number;
|
||||
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
|
||||
hadUsage: boolean;
|
||||
};
|
||||
|
||||
function isAiMessage(message: Message): boolean {
|
||||
@@ -87,12 +82,11 @@ function messageText(message: Message): string {
|
||||
*
|
||||
* Recording strategy:
|
||||
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
||||
* - On every text-length change we push a sample.
|
||||
* - The segment freezes when this model call ends (next AI message, tool
|
||||
* calls, usage + idle, or the run stops) so tool-wait is not in the
|
||||
* denominator.
|
||||
* - Live rate is a rolling window that decays to 0 on pause.
|
||||
* - `usage_metadata.output_tokens` only corrects the frozen final value.
|
||||
* - On every text-length change we push a sample and recompute the rate.
|
||||
* - When `isLoading` flips to false we mark *all* still-active messages as
|
||||
* ended so subsequent renders return a frozen duration.
|
||||
* - Token totals come from usage_metadata when present (most accurate);
|
||||
* otherwise we fall back to char-count / 2.
|
||||
*/
|
||||
export function useMessageMetrics(
|
||||
messages: Message[],
|
||||
@@ -110,8 +104,7 @@ export function useMessageMetrics(
|
||||
// synchronously inside an effect so React isn't tracking the map identity.
|
||||
useEffect(() => {
|
||||
const now = performance.now();
|
||||
const grewIds = new Set<string>();
|
||||
let latestId: string | null = null;
|
||||
let activeId: string | null = null;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
@@ -125,62 +118,50 @@ export function useMessageMetrics(
|
||||
entry = {
|
||||
firstSeenAt: now,
|
||||
endedAt: null,
|
||||
lastGrewAt: now,
|
||||
samples: [{ at: now, chars: text.length }],
|
||||
lastChars: text.length,
|
||||
hadUsage: false,
|
||||
};
|
||||
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;
|
||||
if (grew) {
|
||||
grewIds.add(id);
|
||||
entry.lastGrewAt = now;
|
||||
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||
pushSpeedSample(entry.samples, now, text.length);
|
||||
lastSampleAtRef.current = now;
|
||||
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||
entry.samples.push({ at: now, chars: text.length });
|
||||
entry.lastChars = text.length;
|
||||
if (entry.samples.length > 256) {
|
||||
entry.samples.splice(0, entry.samples.length - 256);
|
||||
}
|
||||
lastSampleAtRef.current = now;
|
||||
} else if (grew) {
|
||||
// Still update lastChars so the next render captures it eventually.
|
||||
entry.lastChars = text.length;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
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;
|
||||
if (getUsageMetadata(message)) {
|
||||
entry.hadUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}, [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(() => {
|
||||
if (!isLoading) return;
|
||||
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
||||
@@ -217,36 +198,45 @@ export function useMessageMetrics(
|
||||
const entry = timingRef.current.get(id);
|
||||
if (!entry) continue;
|
||||
|
||||
const isActive =
|
||||
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
|
||||
const endedAt = entry.endedAt ?? now;
|
||||
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
||||
const isActive = isLoading && id === lastActiveIdRef.current;
|
||||
|
||||
let tokensPerSec = 0;
|
||||
let fromUsage = false;
|
||||
let isPaused = false;
|
||||
const usage = getUsageMetadata(message);
|
||||
|
||||
if (entry.endedAt != null) {
|
||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
|
||||
fromUsage = true;
|
||||
} else if (durationMs > 0) {
|
||||
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
|
||||
}
|
||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
|
||||
fromUsage = true;
|
||||
} else {
|
||||
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
|
||||
isPaused =
|
||||
isActive &&
|
||||
tokensPerSec <= 0 &&
|
||||
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
|
||||
// Char-count fallback. Uses the rolling window during streaming so a
|
||||
// mid-stream snapshot reflects current speed rather than average since
|
||||
// the start.
|
||||
if (isActive && entry.samples.length >= 2) {
|
||||
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, {
|
||||
tokensPerSec,
|
||||
durationMs,
|
||||
isActive,
|
||||
isPaused,
|
||||
fromUsage,
|
||||
isTurnFinal: turnFinalIds.has(id),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
} from "@/core/messages/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export { getQuestionAnchorId } from "./question-navigator-data";
|
||||
|
||||
/** Width of the expanded floating panel, in px. */
|
||||
const PANEL_WIDTH = 300;
|
||||
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
|
||||
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. */
|
||||
const MIN_QUESTIONS = 2;
|
||||
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. */
|
||||
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||
let node = el?.parentElement ?? null;
|
||||
@@ -81,7 +128,16 @@ export function QuestionNavigator({
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
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;
|
||||
|
||||
|
||||
@@ -5,15 +5,12 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { clearYLogToken } from "@/core/log/y-log-token";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { PasswordSettings } from "./password-settings";
|
||||
|
||||
function roleLabel(role?: string) {
|
||||
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
|
||||
// by hitting the gateway logout endpoint and then forcing a full reload
|
||||
// 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)
|
||||
.finally(() => {
|
||||
window.location.assign(`${window.location.pathname}#/login`);
|
||||
window.location.assign("/");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -110,8 +106,6 @@ export function AccountSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<PasswordSettings key={auth.user_id} userId={auth.user_id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,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";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BrainIcon,
|
||||
CpuIcon,
|
||||
FileOutputIcon,
|
||||
PaletteIcon,
|
||||
SearchIcon,
|
||||
ShieldCheckIcon,
|
||||
ShrinkIcon,
|
||||
SparklesIcon,
|
||||
UploadCloudIcon,
|
||||
UserIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UploadCloudIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -25,28 +12,19 @@ import {
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-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 { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-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 { 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 { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
||||
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "authentication"
|
||||
| "appearance"
|
||||
| "conversation-context"
|
||||
| "knowledge-retrieval"
|
||||
| "memory"
|
||||
| "fileWriting"
|
||||
| "uploads"
|
||||
| "tools"
|
||||
| "encoder"
|
||||
@@ -59,7 +37,6 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
export function SettingsDialog(props: SettingsDialogProps) {
|
||||
const { defaultSection = "appearance", ...dialogProps } = props;
|
||||
const { t } = useI18n();
|
||||
const live = useLiveAccount();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SettingsSection>(defaultSection);
|
||||
@@ -88,12 +65,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
...(isAdmin
|
||||
? [
|
||||
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
|
||||
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
|
||||
{ id: "memory", label: "问答记忆", 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 },
|
||||
@@ -158,36 +131,17 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
||||
<div className="min-w-0 space-y-8 p-6">
|
||||
{activeSection === "account" && <AccountSettingsPage />}
|
||||
{activeSection === "authentication" && isAdmin && (
|
||||
<AuthenticationSettingsPage
|
||||
key={live.userId}
|
||||
onClose={() => props.onOpenChange?.(false)}
|
||||
/>
|
||||
{activeSection === "appearance" && (
|
||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||
)}
|
||||
{activeSection === "appearance" && (
|
||||
<AppearanceSettingsPage
|
||||
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 === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "encoder" && isAdmin && (
|
||||
<LocalEncoderSettingsCard />
|
||||
)}
|
||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
-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>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import {
|
||||
resolveWeKnoraSourceTarget,
|
||||
type WeKnoraSourceTarget,
|
||||
} from "@/core/llmwiki/source-links";
|
||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
@@ -59,9 +56,6 @@ export function SourceCard({
|
||||
}) {
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const previewTarget = resolveWeKnoraSourceTarget({
|
||||
@@ -71,13 +65,7 @@ export function SourceCard({
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const isWikiIndexSource =
|
||||
source.type === "llmwiki_wiki_index_intro" ||
|
||||
source.raw?.type === "llmwiki_wiki_index_intro";
|
||||
const isWikiSource =
|
||||
Boolean(previewTarget?.wikiSlug) ||
|
||||
source.type === "llmwiki_wiki" ||
|
||||
source.type === "llmwiki_wiki_page";
|
||||
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||
const matchedHeading = isWikiSource
|
||||
? String(
|
||||
(source.raw?.matched_heading as string | undefined) ||
|
||||
@@ -90,7 +78,7 @@ export function SourceCard({
|
||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||
? 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"
|
||||
? source.raw.rank_score_semantics
|
||||
: "";
|
||||
@@ -167,7 +155,7 @@ export function SourceCard({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(source.url || isWikiSource || isWikiIndexSource) && (
|
||||
{(source.url || isWikiSource) && (
|
||||
previewTarget ? (
|
||||
<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",
|
||||
@@ -178,39 +166,25 @@ export function SourceCard({
|
||||
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
||||
{isWikiSource ? "Wiki 文档" : "知识库文档"}
|
||||
</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" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{(previewTarget || isWikiIndexSource) && (
|
||||
{previewTarget && (
|
||||
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{isWikiIndexSource
|
||||
? "点击卡片查看完整 Wiki 索引"
|
||||
: isWikiSource
|
||||
? "点击卡片查看完整 Wiki"
|
||||
: "点击卡片查看详情"}
|
||||
</span>
|
||||
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
|
||||
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(previewTarget || linkedTarget) && (
|
||||
{previewTarget && (
|
||||
<WeKnoraSourceDrawer
|
||||
open={detailOpen}
|
||||
target={linkedTarget ?? previewTarget}
|
||||
target={previewTarget}
|
||||
title={source.title}
|
||||
quote={content}
|
||||
onOpenChange={(open) => {
|
||||
setDetailOpen(open);
|
||||
if (!open) setLinkedTarget(null);
|
||||
}}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
)}
|
||||
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
@@ -272,18 +246,6 @@ export function SourceCard({
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
linkClassName={(href) =>
|
||||
href.startsWith("/weknora-source-preview?")
|
||||
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
|
||||
: undefined
|
||||
}
|
||||
onLinkClick={(event, href) => {
|
||||
const target = resolveWeKnoraSourceTarget({ url: href });
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
setLinkedTarget(target);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
</>
|
||||
|
||||
@@ -27,7 +27,7 @@ const PHASE_LABELS: Record<string, string> = {
|
||||
writing_sources: "上传知识资料",
|
||||
generating_wiki: "分析并生成分类 Wiki",
|
||||
vectorizing_wiki: "Wiki 向量化",
|
||||
syncing_assistant: "同步检索索引",
|
||||
syncing_assistant: "同步知识梳理总库",
|
||||
activating: "激活新版本",
|
||||
completed: "完成",
|
||||
};
|
||||
@@ -219,14 +219,14 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{runtime.data?.provider !== "weknora"
|
||||
? "当前未配置普通知识库地址;请先配置知识服务地址,再归纳技能内容。"
|
||||
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
||||
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及所需索引同步均在后台完成。
|
||||
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
|
||||
</div>
|
||||
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
||||
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||||
|
||||
@@ -226,9 +226,7 @@ export function SkillDetailDialog({
|
||||
style={{
|
||||
width: "min(48rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
||||
height: "min(760px, calc(100vh - 2rem))",
|
||||
maxHeight: "min(760px, calc(100vh - 2rem))",
|
||||
height: "min(760px, calc(100dvh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||
|
||||
@@ -149,9 +149,7 @@ export function SkillFilePreviewDialog({
|
||||
style={{
|
||||
width: "min(56rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100vh - 2rem))",
|
||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100dvh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
||||
|
||||
@@ -49,30 +49,6 @@ test("accepts a dedicated mindmap contract", () => {
|
||||
assert.equal(result.ok && result.spec.type, "mindmap");
|
||||
});
|
||||
|
||||
test("accepts four meaningful children under a non-root analysis node", () => {
|
||||
const result = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
title: "人物分析",
|
||||
rootId: "root",
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "人物分析", kind: "topic" },
|
||||
{ id: "basic", parentId: "root", label: "基本信息", kind: "dimension" },
|
||||
{ id: "identity", parentId: "basic", label: "身份", kind: "finding" },
|
||||
{ id: "experience", parentId: "basic", label: "经历", kind: "finding" },
|
||||
{ id: "relations", parentId: "basic", label: "关系", kind: "finding" },
|
||||
{ id: "events", parentId: "basic", label: "事件", kind: "finding" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(
|
||||
result.ok && result.spec.type === "mindmap" && result.spec.nodes.length,
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
test("renders a completed legacy mindmap fence as a mindmap", () => {
|
||||
const content = [
|
||||
"```mindmap",
|
||||
@@ -129,26 +105,6 @@ test("rejects dense mindmaps before they can overload the canvas", () => {
|
||||
);
|
||||
assert.equal(tooManyChildren.ok, false);
|
||||
|
||||
const tooManyBranchChildren = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "basic", parentId: "root", label: "基本信息" },
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `detail-${index}`,
|
||||
parentId: "basic",
|
||||
label: `要点 ${index + 1}`,
|
||||
})),
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(tooManyBranchChildren.ok, false);
|
||||
assert.equal(
|
||||
tooManyBranchChildren.ok ? "" : tooManyBranchChildren.error,
|
||||
"节点“基本信息”最多保留 4 个子节点,当前有 5 个;请合并同类信息",
|
||||
);
|
||||
|
||||
const tooManyNodes = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
@@ -169,27 +125,6 @@ test("rejects dense mindmaps before they can overload the canvas", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the four-layer depth guard after branch tolerance", () => {
|
||||
const tooDeep = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "level-1", parentId: "root", label: "一级" },
|
||||
{ id: "level-2", parentId: "level-1", label: "二级" },
|
||||
{ id: "level-3", parentId: "level-2", label: "三级" },
|
||||
{ id: "level-4", parentId: "level-3", label: "四级" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(tooDeep.ok, false);
|
||||
assert.equal(
|
||||
tooDeep.ok ? "" : tooDeep.error,
|
||||
"思维导图最多支持 4 层层级",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders a valid map contract from a generic json fence", () => {
|
||||
const content = [
|
||||
"下面是地图:",
|
||||
|
||||
@@ -83,14 +83,6 @@ export type MindmapSpec = z.infer<typeof mindmapSchema>;
|
||||
type MindmapNode = MindmapSpec["nodes"][number];
|
||||
export type VisualizationSpec = MapSpec | MindmapSpec;
|
||||
|
||||
const MINDMAP_ROOT_CHILD_LIMIT = 6;
|
||||
// The generation prompt still asks the model to target three children so the
|
||||
// overview stays compact. Real analysis dimensions can naturally contain four
|
||||
// equally important facts, though, and rejecting the entire graph for that
|
||||
// one-node overflow is too brittle. Keep a one-item rendering tolerance while
|
||||
// the global node, depth and cycle guards continue to bound the canvas.
|
||||
const MINDMAP_BRANCH_CHILD_LIMIT = 4;
|
||||
|
||||
export type VisualizationParseResult =
|
||||
| { ok: true; spec: VisualizationSpec }
|
||||
| { ok: false; error: string };
|
||||
@@ -139,15 +131,12 @@ function validateMindmap(spec: MindmapSpec): string | null {
|
||||
children.set(node.parentId, siblings);
|
||||
}
|
||||
for (const [parentId, siblings] of children) {
|
||||
const limit =
|
||||
parentId === root.id
|
||||
? MINDMAP_ROOT_CHILD_LIMIT
|
||||
: MINDMAP_BRANCH_CHILD_LIMIT;
|
||||
const limit = parentId === root.id ? 6 : 3;
|
||||
if (siblings.length > limit) {
|
||||
const parentLabel = nodesById.get(parentId)?.label ?? parentId;
|
||||
return parentId === root.id
|
||||
? `中心主题最多保留 ${MINDMAP_ROOT_CHILD_LIMIT} 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
|
||||
: `节点“${parentLabel}”最多保留 ${MINDMAP_BRANCH_CHILD_LIMIT} 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
|
||||
? `中心主题最多保留 6 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
|
||||
: `节点“${parentLabel}”最多保留 3 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,15 +147,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/scheduled/tasks",
|
||||
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
|
||||
},
|
||||
// {
|
||||
// id: "workspace-knowledge",
|
||||
// label: "知识库管理",
|
||||
// icon: BookOpen,
|
||||
// path: "/page/workspace/knowledge",
|
||||
// match: (pathname) =>
|
||||
// pathname.startsWith("/page/workspace/knowledge") &&
|
||||
// !pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
// },
|
||||
{
|
||||
id: "workspace-knowledge",
|
||||
label: "知识库管理",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
match: (pathname) =>
|
||||
pathname.startsWith("/page/workspace/knowledge") &&
|
||||
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
{
|
||||
...MARKDOWN_DOCX_MENU_ITEM,
|
||||
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
|
||||
@@ -20,10 +20,9 @@ import {
|
||||
SettingsIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
TimerIcon,
|
||||
TrophyIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
@@ -93,34 +92,11 @@ export function WorkspaceNavMenu() {
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const { settingsItems } = useWorkspaceCompactNavItems();
|
||||
// 整页缩放时菜单会盖住按钮。按下后松手若落在菜单项上,Radix 会当成选中并立刻关闭。
|
||||
// 这次打开手势的 pointerup / click 不传给菜单项。
|
||||
const openingGesture = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const markOpeningGesture = () => {
|
||||
openingGesture.current = true;
|
||||
window.setTimeout(() => {
|
||||
openingGesture.current = false;
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const swallowOpeningPointerUp = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
if (!openingGesture.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const swallowOpeningClick = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
if (!openingGesture.current) return;
|
||||
openingGesture.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
|
||||
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
|
||||
const startCompactGuide = () => {
|
||||
@@ -164,23 +140,19 @@ export function WorkspaceNavMenu() {
|
||||
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
onPointerDown={markOpeningGesture}
|
||||
>
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side="top"
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-56 rounded-lg"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
onPointerUpCapture={swallowOpeningPointerUp}
|
||||
onClickCapture={swallowOpeningClick}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
{settingsItems.map((item) => {
|
||||
@@ -293,22 +265,6 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skill-timing")}
|
||||
>
|
||||
<TimerIcon />
|
||||
技能耗时排查
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
|
||||
>
|
||||
<ScrollTextIcon />
|
||||
模型收发记录
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/models")}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
@@ -30,115 +30,115 @@ export function WorkspaceSidebar({
|
||||
/** Enables wx's shared Agent Gallery category selection. */
|
||||
wxCommonAgents?: boolean;
|
||||
}) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa =
|
||||
wxCommonAgents ||
|
||||
pathname.startsWith("/page/workspace/chats") ||
|
||||
pathname.startsWith("/page/strategy/chats");
|
||||
|
||||
if (forceCollapsed) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||
if (compactChrome) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width) border-r border-border",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
|
||||
if (forceCollapsed) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||
if (compactChrome) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-[var(--sidebar-width)]" : "w-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-[var(--sidebar-width)] border-r border-border",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
||||
// 非紧凑沿用 icon。
|
||||
collapsible={compactChrome ? "none" : "icon"}
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!compactChrome && (
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList
|
||||
showCollapseTrigger={compactChrome}
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
|
||||
(/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
|
||||
无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
|
||||
全量模式本就不展示。 */}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
<WorkspaceNavMenu />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
||||
// 非紧凑沿用 icon。
|
||||
collapsible={compactChrome ? "none" : "icon"}
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!compactChrome && (
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList
|
||||
showCollapseTrigger={compactChrome}
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
|
||||
(/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
|
||||
无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
|
||||
全量模式本就不展示。 */}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
<WorkspaceNavMenu />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface ModelExchangeRecord {
|
||||
id: string;
|
||||
at: string;
|
||||
model: string;
|
||||
user_id: string;
|
||||
enable_thinking: string;
|
||||
request_preview: string;
|
||||
response_preview: string;
|
||||
request_text: string;
|
||||
request_body: string;
|
||||
response_text: string;
|
||||
response_raw: string;
|
||||
response_content: string;
|
||||
response_reasoning: string;
|
||||
reasoning_field: string;
|
||||
}
|
||||
|
||||
export async function loadModelExchanges(): Promise<ModelExchangeRecord[]> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/model-exchanges`);
|
||||
if (!response.ok) {
|
||||
throw new Error("模型收发记录读取失败");
|
||||
}
|
||||
const body = (await response.json()) as { records?: ModelExchangeRecord[] };
|
||||
return body.records ?? [];
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** One completed sandbox call. Written only after that call returns. */
|
||||
export interface SkillViewEvent {
|
||||
at: string;
|
||||
status: string;
|
||||
total_ms: number;
|
||||
lock_wait_ms: number;
|
||||
refresh_ms: number;
|
||||
source_entries: number;
|
||||
target_entries: number;
|
||||
enabled_count: number;
|
||||
path_count: number;
|
||||
tool: string;
|
||||
command: string;
|
||||
skill_names: string[];
|
||||
tool_ms: number;
|
||||
explanation: {
|
||||
level: string;
|
||||
headline: string;
|
||||
look_at: string;
|
||||
cause: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillViewCheck {
|
||||
id: string;
|
||||
title: string;
|
||||
result: string;
|
||||
level: string;
|
||||
look_at: string;
|
||||
cause: string;
|
||||
command: string;
|
||||
skill_names: string[];
|
||||
}
|
||||
|
||||
export interface SkillViewDiagnostics {
|
||||
cache_enabled: boolean;
|
||||
reuse_enabled: boolean;
|
||||
level: string;
|
||||
conclusion: string;
|
||||
checks: SkillViewCheck[];
|
||||
events: SkillViewEvent[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
sort: "time" | "duration" | "check";
|
||||
order: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface SkillViewQuery {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sort: "time" | "duration" | "check";
|
||||
order: "asc" | "desc";
|
||||
}
|
||||
|
||||
/** Polled by the admin timing page. Not an operation log. */
|
||||
export async function loadSkillViewDiagnostics(query: SkillViewQuery): Promise<SkillViewDiagnostics> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(query.page),
|
||||
page_size: String(query.pageSize),
|
||||
sort: query.sort,
|
||||
order: query.order,
|
||||
});
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/skill-view-diagnostics?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("技能耗时记录读取失败");
|
||||
}
|
||||
return (await response.json()) as SkillViewDiagnostics;
|
||||
}
|
||||
@@ -2,218 +2,149 @@
|
||||
|
||||
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import {
|
||||
createDeadlineSignal,
|
||||
installAbortSignalTimeout,
|
||||
} from "../compat/abort-signal";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import {
|
||||
rememberRunStreamCursor,
|
||||
resolveRunStreamResumeCursor,
|
||||
type RunStreamCursorStore,
|
||||
} from "./stream-cursor";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import {
|
||||
rememberRunStreamCursor,
|
||||
resolveRunStreamResumeCursor,
|
||||
type RunStreamCursorStore,
|
||||
} from "./stream-cursor";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
// Chrome 102 has AbortController but not AbortSignal.timeout (Chrome 103+).
|
||||
// The LangGraph client calls it whenever timeoutMs is set, which is exactly
|
||||
// the threads.create request fired by pressing Enter. Install the global
|
||||
// polyfill, and also skip that call if the host object refuses the new method.
|
||||
installAbortSignalTimeout();
|
||||
|
||||
const LEGACY_TIMEOUT_PATCH = Symbol.for("deerflow.legacyControlTimeout");
|
||||
|
||||
type ControlFetchOptions = {
|
||||
timeoutMs?: number | null;
|
||||
signal?: AbortSignal | null;
|
||||
};
|
||||
|
||||
type PrepareFetchTarget = {
|
||||
timeoutMs?: number | null;
|
||||
};
|
||||
|
||||
type PrepareFetchOptions = (
|
||||
this: PrepareFetchTarget,
|
||||
path: string,
|
||||
options?: ControlFetchOptions,
|
||||
) => [URL, RequestInit];
|
||||
|
||||
type PrepareFetchOwner = {
|
||||
prepareFetchOptions: PrepareFetchOptions;
|
||||
[LEGACY_TIMEOUT_PATCH]?: boolean;
|
||||
};
|
||||
|
||||
function installLegacyControlTimeout(instance: object) {
|
||||
let owner: PrepareFetchOwner | null = null;
|
||||
let proto = Object.getPrototypeOf(instance);
|
||||
while (proto && proto !== Object.prototype) {
|
||||
if (Object.prototype.hasOwnProperty.call(proto, "prepareFetchOptions")) {
|
||||
owner = proto as PrepareFetchOwner;
|
||||
break;
|
||||
}
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
}
|
||||
if (!owner || owner[LEGACY_TIMEOUT_PATCH]) return;
|
||||
const original = owner.prepareFetchOptions;
|
||||
owner.prepareFetchOptions = function (path, options) {
|
||||
if (typeof AbortSignal.timeout === "function") {
|
||||
return original.call(this, path, options);
|
||||
}
|
||||
const timeoutMs =
|
||||
options != null &&
|
||||
Object.prototype.hasOwnProperty.call(options, "timeoutMs")
|
||||
? options.timeoutMs
|
||||
: this.timeoutMs;
|
||||
if (timeoutMs == null) return original.call(this, path, options);
|
||||
const signal = createDeadlineSignal(timeoutMs, options?.signal);
|
||||
return original.call(this, path, {
|
||||
...options,
|
||||
timeoutMs: null,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
owner[LEGACY_TIMEOUT_PATCH] = true;
|
||||
}
|
||||
|
||||
function streamEventId(event: unknown): string | undefined {
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"id" in event &&
|
||||
typeof event.id === "string"
|
||||
) {
|
||||
return event.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createCompatibleClient(
|
||||
isMock?: boolean,
|
||||
getRunStreamCursors?: () => RunStreamCursorStore,
|
||||
): LangGraphClient {
|
||||
function streamEventId(event: unknown): string | undefined {
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"id" in event &&
|
||||
typeof event.id === "string"
|
||||
) {
|
||||
return event.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createCompatibleClient(
|
||||
isMock?: boolean,
|
||||
getRunStreamCursors?: () => RunStreamCursorStore,
|
||||
): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
// Bound control/history requests so a stalled lookup cannot hold the
|
||||
// recovery single-flight (or the SDK's post-stream history refresh) open
|
||||
// forever. The SDK explicitly sets timeoutMs: null for SSE requests.
|
||||
timeoutMs: 30_000,
|
||||
onRequest(_url, init) {
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
installLegacyControlTimeout(client.threads);
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(_url, init) {
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||
client.runs.stream = (async function* (threadId, assistantId, payload) {
|
||||
// Capture this reader's map. A thread switch replaces the owner's map,
|
||||
// so late events from an old reader cannot repopulate the new scope.
|
||||
const readerCursors = getRunStreamCursors?.();
|
||||
let runId: string | undefined;
|
||||
let effectiveThreadId = threadId ?? undefined;
|
||||
const sanitizedPayload = sanitizeRunStreamOptions(payload);
|
||||
const originalOnRunCreated = sanitizedPayload?.onRunCreated;
|
||||
const stream = originalRunStream(
|
||||
client.runs.stream = (async function* (threadId, assistantId, payload) {
|
||||
// Capture this reader's map. A thread switch replaces the owner's map,
|
||||
// so late events from an old reader cannot repopulate the new scope.
|
||||
const readerCursors = getRunStreamCursors?.();
|
||||
let runId: string | undefined;
|
||||
let effectiveThreadId = threadId ?? undefined;
|
||||
const sanitizedPayload = sanitizeRunStreamOptions(payload);
|
||||
const originalOnRunCreated = sanitizedPayload?.onRunCreated;
|
||||
const stream = originalRunStream(
|
||||
threadId,
|
||||
assistantId,
|
||||
{
|
||||
...sanitizedPayload,
|
||||
onRunCreated(params) {
|
||||
runId = params.run_id;
|
||||
effectiveThreadId = params.thread_id ?? effectiveThreadId;
|
||||
originalOnRunCreated?.(params);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (readerCursors) {
|
||||
rememberRunStreamCursor(
|
||||
readerCursors,
|
||||
effectiveThreadId,
|
||||
runId,
|
||||
streamEventId(event),
|
||||
);
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
}) as typeof client.runs.stream;
|
||||
{
|
||||
...sanitizedPayload,
|
||||
onRunCreated(params) {
|
||||
runId = params.run_id;
|
||||
effectiveThreadId = params.thread_id ?? effectiveThreadId;
|
||||
originalOnRunCreated?.(params);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (readerCursors) {
|
||||
rememberRunStreamCursor(
|
||||
readerCursors,
|
||||
effectiveThreadId,
|
||||
runId,
|
||||
streamEventId(event),
|
||||
);
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
}) as typeof client.runs.stream;
|
||||
|
||||
const originalJoinStream = client.runs.joinStream.bind(client.runs);
|
||||
client.runs.joinStream = (async function* (threadId, runId, options) {
|
||||
const readerCursors = getRunStreamCursors?.();
|
||||
const sanitizedOptions = sanitizeRunStreamOptions(options);
|
||||
// The shared client preserves -1. Only a client owned by one message
|
||||
// accumulator may resume its SDK-initiated reconnectOnMount retry: that
|
||||
// callback also passes -1 after an error, while retaining previous chunks.
|
||||
const joinOptions =
|
||||
readerCursors && threadId != null && !(sanitizedOptions instanceof AbortSignal)
|
||||
? {
|
||||
...sanitizedOptions,
|
||||
lastEventId: resolveRunStreamResumeCursor(
|
||||
readerCursors,
|
||||
threadId,
|
||||
runId,
|
||||
sanitizedOptions?.lastEventId,
|
||||
),
|
||||
}
|
||||
: sanitizedOptions;
|
||||
if (threadId != null) {
|
||||
const lastEventId =
|
||||
joinOptions instanceof AbortSignal ? undefined : joinOptions?.lastEventId;
|
||||
console.info("[deer-flow] joining resumable run stream", {
|
||||
threadId,
|
||||
runId,
|
||||
hasLastEventId: Boolean(lastEventId && lastEventId !== "-1"),
|
||||
replayFromStart: lastEventId === "-1",
|
||||
});
|
||||
}
|
||||
const stream = originalJoinStream(threadId, runId, joinOptions);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (readerCursors) {
|
||||
rememberRunStreamCursor(
|
||||
readerCursors,
|
||||
threadId,
|
||||
runId,
|
||||
streamEventId(event),
|
||||
);
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
}) as typeof client.runs.joinStream;
|
||||
client.runs.joinStream = (async function* (threadId, runId, options) {
|
||||
const readerCursors = getRunStreamCursors?.();
|
||||
const sanitizedOptions = sanitizeRunStreamOptions(options);
|
||||
// The shared client preserves -1. Only a client owned by one message
|
||||
// accumulator may resume its SDK-initiated reconnectOnMount retry: that
|
||||
// callback also passes -1 after an error, while retaining previous chunks.
|
||||
const joinOptions =
|
||||
readerCursors && threadId != null && !(sanitizedOptions instanceof AbortSignal)
|
||||
? {
|
||||
...sanitizedOptions,
|
||||
lastEventId: resolveRunStreamResumeCursor(
|
||||
readerCursors,
|
||||
threadId,
|
||||
runId,
|
||||
sanitizedOptions?.lastEventId,
|
||||
),
|
||||
}
|
||||
: sanitizedOptions;
|
||||
if (threadId != null) {
|
||||
const lastEventId =
|
||||
joinOptions instanceof AbortSignal ? undefined : joinOptions?.lastEventId;
|
||||
console.info("[deer-flow] joining resumable run stream", {
|
||||
threadId,
|
||||
runId,
|
||||
hasLastEventId: Boolean(lastEventId && lastEventId !== "-1"),
|
||||
replayFromStart: lastEventId === "-1",
|
||||
});
|
||||
}
|
||||
const stream = originalJoinStream(threadId, runId, joinOptions);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (readerCursors) {
|
||||
rememberRunStreamCursor(
|
||||
readerCursors,
|
||||
threadId,
|
||||
runId,
|
||||
streamEventId(event),
|
||||
);
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
}) as typeof client.runs.joinStream;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Own this scope alongside exactly one SDK message accumulator, never globally.
|
||||
* Clear it whenever that accumulator clears (a real thread switch). A newly
|
||||
* mounted hook must create a new scope even when it opens the same live run.
|
||||
*/
|
||||
export function createStreamAPIClient(isMock?: boolean) {
|
||||
let cursors: RunStreamCursorStore = new Map();
|
||||
return {
|
||||
client: createCompatibleClient(isMock, () => cursors),
|
||||
getResumeCursor(threadId: string, runId: string): string | undefined {
|
||||
return resolveRunStreamResumeCursor(cursors, threadId, runId, undefined);
|
||||
},
|
||||
clearResumeCursors() {
|
||||
cursors = new Map();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Own this scope alongside exactly one SDK message accumulator, never globally.
|
||||
* Clear it whenever that accumulator clears (a real thread switch). A newly
|
||||
* mounted hook must create a new scope even when it opens the same live run.
|
||||
*/
|
||||
export function createStreamAPIClient(isMock?: boolean) {
|
||||
let cursors: RunStreamCursorStore = new Map();
|
||||
return {
|
||||
client: createCompatibleClient(isMock, () => cursors),
|
||||
getResumeCursor(threadId: string, runId: string): string | undefined {
|
||||
return resolveRunStreamResumeCursor(cursors, threadId, runId, undefined);
|
||||
},
|
||||
clearResumeCursors() {
|
||||
cursors = new Map();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const _clients = new Map<string, LangGraphClient>();
|
||||
export function getAPIClient(isMock?: boolean): LangGraphClient {
|
||||
const cacheKey = isMock ? "mock" : "default";
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import { runStreamRecoverySingleFlight, type StreamRecoverySingleFlight } from "../threads/stream-recovery";
|
||||
import { createStreamAPIClient } from "./api-client";
|
||||
|
||||
test("a stalled recovery request times out and releases the single-flight slot", async (t) => {
|
||||
const timers: Array<{ milliseconds: number; controller: AbortController }> = [];
|
||||
t.mock.method(AbortSignal, "timeout", (milliseconds: number) => {
|
||||
const controller = new AbortController();
|
||||
timers.push({ milliseconds, controller });
|
||||
return controller.signal;
|
||||
});
|
||||
t.mock.method(globalThis, "fetch", async (_input, init) => new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
assert.ok(signal, "control requests need an abortable deadline");
|
||||
if (signal.aborted) reject(signal.reason);
|
||||
else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||||
}));
|
||||
|
||||
const { client } = createStreamAPIClient();
|
||||
const flight: StreamRecoverySingleFlight = { current: null };
|
||||
const recovery = runStreamRecoverySingleFlight(flight, async () => {
|
||||
await client.runs.get("thread-1", "run-1");
|
||||
return "terminal";
|
||||
});
|
||||
await nextTurn();
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(timers[0]!.milliseconds, 30_000);
|
||||
timers[0]!.controller.abort(new Error("AbortError: control request timed out"));
|
||||
await assert.rejects(recovery, /control request timed out/);
|
||||
assert.equal(flight.current, null);
|
||||
assert.equal(await runStreamRecoverySingleFlight(flight, async () => "rejoined"), "rejoined");
|
||||
});
|
||||
|
||||
test("Chrome 102 can create a thread when AbortSignal.timeout is missing", async (t) => {
|
||||
const original = AbortSignal.timeout;
|
||||
Object.defineProperty(AbortSignal, "timeout", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
t.after(() => {
|
||||
Object.defineProperty(AbortSignal, "timeout", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: original,
|
||||
});
|
||||
});
|
||||
|
||||
const caller = new AbortController();
|
||||
t.mock.method(globalThis, "fetch", async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/runs/stream") || url.includes("/join")) {
|
||||
return new Response('event: values\ndata: {"messages":[]}\n\n', {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
const signal = init?.signal;
|
||||
assert.ok(signal, "control requests still need an abortable deadline");
|
||||
assert.notEqual(signal, caller.signal);
|
||||
assert.equal(signal.aborted, false);
|
||||
caller.abort();
|
||||
assert.equal(signal.aborted, true);
|
||||
return new Response(JSON.stringify({ thread_id: "thread-1" }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
const { client } = createStreamAPIClient();
|
||||
const created = await client.threads.create({ signal: caller.signal });
|
||||
assert.equal(created.thread_id, "thread-1");
|
||||
|
||||
const frames: Array<{ event: string }> = [];
|
||||
for await (const frame of client.runs.stream("thread-1", "lead_agent", {
|
||||
streamMode: ["values"],
|
||||
streamIdleReconnect: 0,
|
||||
})) {
|
||||
frames.push(frame);
|
||||
}
|
||||
assert.equal(frames.length, 1);
|
||||
assert.equal(frames[0]!.event, "values");
|
||||
});
|
||||
|
||||
test("the control timeout does not interrupt long-running SSE create or rejoin streams", async (t) => {
|
||||
t.mock.method(AbortSignal, "timeout", () => assert.fail("SSE must not receive the control deadline"));
|
||||
t.mock.method(globalThis, "fetch", async () => new Response(
|
||||
'event: values\ndata: {"messages":[]}\n\n',
|
||||
{ headers: { "Content-Type": "text/event-stream", "Content-Location": "/threads/thread-1/runs/run-1" } },
|
||||
));
|
||||
const { client } = createStreamAPIClient();
|
||||
for (const stream of [
|
||||
client.runs.stream("thread-1", "lead_agent", { streamMode: ["values"], streamIdleReconnect: 0 }),
|
||||
client.runs.joinStream("thread-1", "run-1", { streamIdleReconnect: 0 }),
|
||||
]) {
|
||||
const frames: Array<{ event: string }> = [];
|
||||
for await (const frame of stream) frames.push(frame);
|
||||
assert.equal(frames.length, 1);
|
||||
assert.equal(frames[0]!.event, "values");
|
||||
}
|
||||
});
|
||||
@@ -21,12 +21,10 @@ function requestUrl(input: RequestInfo | URL): string {
|
||||
}
|
||||
|
||||
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const headers = withAuthHeaders(init?.headers);
|
||||
const requestedAuthorization = headers.get("Authorization");
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
credentials: init?.credentials ?? "include",
|
||||
headers,
|
||||
headers: withAuthHeaders(init?.headers),
|
||||
});
|
||||
|
||||
// Session is considered lost ONLY when the backend actually answers 401.
|
||||
@@ -37,16 +35,13 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
if (response.status === 401) {
|
||||
const url = requestUrl(input);
|
||||
const isAuthEndpoint = url.includes("/api/v1/auth/");
|
||||
// An older in-flight request may finish after a successful login/password
|
||||
// change. Its 401 must not erase the newly issued session.
|
||||
if (!isAuthEndpoint && getStoredAuth() && requestedAuthorization
|
||||
&& requestedAuthorization === getAuthorizationHeaderValue()) {
|
||||
if (!isAuthEndpoint && getStoredAuth()) {
|
||||
clearStoredAuth();
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
!window.location.hash.startsWith("#/login")
|
||||
) {
|
||||
window.location.replace("#/login?reason=session-expired");
|
||||
window.location.replace("#/login");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,6 @@ export interface ArtifactLibraryResponse {
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: ArtifactLibraryItem[];
|
||||
syncing?: boolean;
|
||||
syncStatus?: string | null;
|
||||
indexedAt?: string | null;
|
||||
syncError?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchArtifactLibrary({
|
||||
@@ -91,10 +87,6 @@ export async function fetchArtifactLibrary({
|
||||
total?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
syncing?: boolean;
|
||||
sync_status?: string | null;
|
||||
indexed_at?: string | null;
|
||||
sync_error?: string | null;
|
||||
items?: Array<{
|
||||
thread_id: string;
|
||||
thread_title: string;
|
||||
@@ -119,10 +111,6 @@ export async function fetchArtifactLibrary({
|
||||
total: data.total ?? 0,
|
||||
offset: data.offset ?? offset,
|
||||
limit: data.limit ?? limit,
|
||||
syncing: data.syncing ?? false,
|
||||
syncStatus: data.sync_status ?? null,
|
||||
indexedAt: data.indexed_at ?? null,
|
||||
syncError: data.sync_error ?? null,
|
||||
items: (data.items ?? []).map((item) => ({
|
||||
threadId: item.thread_id,
|
||||
threadTitle: item.thread_title,
|
||||
|
||||
@@ -70,14 +70,8 @@ function matchingToolArgChunks(
|
||||
const byId = toolCall.id
|
||||
? chunks.filter((chunk) => chunk?.id && chunk.id === toolCall.id)
|
||||
: [];
|
||||
const anchoredIndex =
|
||||
toolCall.index ?? byId.find((chunk) => chunk?.index != null)?.index;
|
||||
const selected = byId.length
|
||||
? chunks.filter((chunk) => {
|
||||
if (chunk?.id) return chunk.id === toolCall.id;
|
||||
if (anchoredIndex == null || chunk?.index !== anchoredIndex) return false;
|
||||
return !toolCall.name || !chunk?.name || chunk.name === toolCall.name;
|
||||
})
|
||||
? byId
|
||||
: chunks.filter((chunk) => {
|
||||
if (toolCall.name && chunk?.name && chunk.name !== toolCall.name) {
|
||||
return false;
|
||||
@@ -97,15 +91,12 @@ function matchingToolArgChunks(
|
||||
}
|
||||
|
||||
export function resolveStreamingWriteFileArgs(
|
||||
message: object,
|
||||
message: { tool_call_chunks?: readonly ToolCallChunkLike[] },
|
||||
toolCall: ToolCallLike,
|
||||
): { path: string; content: string } {
|
||||
const chunks = (message as {
|
||||
tool_call_chunks?: readonly ToolCallChunkLike[];
|
||||
}).tool_call_chunks;
|
||||
const direct = asRecord(toolCall.args);
|
||||
const fromChunks = parsePartialJsonObject(
|
||||
accumulateToolArgChunks(matchingToolArgChunks(chunks, toolCall)),
|
||||
accumulateToolArgChunks(matchingToolArgChunks(message.tool_call_chunks, toolCall)),
|
||||
);
|
||||
return {
|
||||
path: pickLongerString(direct?.path, fromChunks?.path),
|
||||
|
||||
@@ -32,9 +32,9 @@ test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
|
||||
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
|
||||
});
|
||||
|
||||
test("isAutoPreviewableArtifactPath limits automatic previews to outputs", () => {
|
||||
test("isAutoPreviewableArtifactPath limits automatic previews to outputs and provisional drafts", () => {
|
||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/outputs/report.html"), true);
|
||||
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), false);
|
||||
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), true);
|
||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/scan_structure.py"), false);
|
||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), false);
|
||||
});
|
||||
@@ -39,17 +39,8 @@ export function provisionalWriteFilePath(content: string): string {
|
||||
export function isProvisionalStreamingArtifactPath(path: string): boolean {
|
||||
const pathname =
|
||||
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
|
||||
return PROVISIONAL_WRITE_FILE_NAMES.has(pathname.replace(/^\/+/, ""));
|
||||
}
|
||||
|
||||
/** A missing/partial tool path is inline text, never a synthesized file. */
|
||||
export function namedStreamingWriteFilePath(path: string): string | null {
|
||||
const normalized = normalizeStreamingArtifactPath(path);
|
||||
return normalized &&
|
||||
hasCompleteArtifactExtension(normalized) &&
|
||||
!isProvisionalStreamingArtifactPath(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
|
||||
}
|
||||
|
||||
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
|
||||
@@ -75,6 +66,9 @@ export function isArtifactApiPath(path: string): boolean {
|
||||
* an empty sandbox before the real output exists.
|
||||
*/
|
||||
export function isAutoPreviewableArtifactPath(path: string): boolean {
|
||||
if (isProvisionalStreamingArtifactPath(path)) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeStreamingArtifactPath(path)
|
||||
.split(/[?#]/, 1)[0]
|
||||
?.replace(/^\/+/, "")
|
||||
|
||||
@@ -327,7 +327,7 @@ test("writing mode still previews follow-up-turn writes after a later user messa
|
||||
assert.equal(preview?.previewKey, "m-edit:w2");
|
||||
});
|
||||
|
||||
test("normal mode leaves pathless streaming drafts in the timeline", () => {
|
||||
test("normal mode keeps previewing pathless streaming drafts", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
@@ -337,7 +337,8 @@ test("normal mode leaves pathless streaming drafts in the timeline", () => {
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(preview, null);
|
||||
assert.ok(preview);
|
||||
assert.match(preview?.artifactPath ?? "", /\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview ignores workspace helper files", () => {
|
||||
@@ -465,40 +466,6 @@ test("latestInFlightWriteFilePreview opens from snapshot-style tool_call_chunks"
|
||||
assert.match(preview?.artifactPath ?? "", /page\.html/);
|
||||
});
|
||||
|
||||
test("artifact renderer deltas use the native write_file live-preview contract", () => {
|
||||
const message = {
|
||||
id: "m-renderer",
|
||||
type: "ai",
|
||||
tool_call_chunks: [
|
||||
{
|
||||
id: "call_deerflow_wfbridge_1",
|
||||
name: "write_file",
|
||||
index: 0,
|
||||
args:
|
||||
'{"description":"写报告","path":"/mnt/user-data/outputs/report.md","append":"__deerflow_stream_not_committed__","content":"# 标',
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
args: "题\\n正文正在生成",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const preview = latestInFlightWriteFilePreview([message]);
|
||||
assert.ok(preview);
|
||||
assert.equal(preview?.previewKey, "m-renderer:call_deerflow_wfbridge_1");
|
||||
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
||||
|
||||
const args = resolveStreamingWriteFileArgs(message, {
|
||||
id: "call_deerflow_wfbridge_1",
|
||||
name: "write_file",
|
||||
index: 0,
|
||||
args: {},
|
||||
});
|
||||
assert.equal(args.path, "/mnt/user-data/outputs/report.md");
|
||||
assert.equal(args.content, "# 标题\n正文正在生成");
|
||||
});
|
||||
|
||||
test("resolveStreamingWriteFileArgs grows content from snapshot chunks", () => {
|
||||
const args = resolveStreamingWriteFileArgs(
|
||||
{
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
} from "./parse-partial-json";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
namedStreamingWriteFilePath,
|
||||
provisionalWriteFilePath,
|
||||
} from "./streaming-write-preview";
|
||||
|
||||
export { resolveStreamingWriteFileArgs } from "./parse-partial-json";
|
||||
@@ -249,7 +250,16 @@ export function latestInFlightWriteFilePreview(
|
||||
if (hasToolResult(thread, toolCall.id)) {
|
||||
continue;
|
||||
}
|
||||
const sourcePath = namedStreamingWriteFilePath(toolCall.path);
|
||||
const rawPath = toolCall.path;
|
||||
const content = toolCall.content;
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
if (!sourcePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
|
||||
export interface AuthenticationOptions {
|
||||
password_login_enabled: boolean;
|
||||
registration_enabled: boolean;
|
||||
sso_enabled: boolean;
|
||||
single_session_enabled?: boolean;
|
||||
needs_setup?: boolean;
|
||||
}
|
||||
|
||||
function accountError(body: unknown, fallback: string): string {
|
||||
const detail = (body as { detail?: unknown } | null)?.detail ?? body;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((item) => item?.msg).filter(Boolean).join(";") || fallback;
|
||||
}
|
||||
if (detail && typeof detail === "object") {
|
||||
const value = detail as { message?: string; msg?: string };
|
||||
return value.message || value.msg || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Bound unauthenticated requests so an unreachable gateway offers a retry. */
|
||||
async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 20_000);
|
||||
try {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(accountError(body, "请求失败,请稍后重试"));
|
||||
return body as T;
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new Error("连接超时,请检查网络后重试");
|
||||
if (error instanceof TypeError) throw new Error("无法连接服务器,请检查网络后重试");
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAuthenticationOptions(): Promise<AuthenticationOptions> {
|
||||
const options = await accountRequest<AuthenticationOptions>("/options", { cache: "no-store" });
|
||||
if (!options || [options.password_login_enabled, options.registration_enabled, options.sso_enabled]
|
||||
.some((value) => typeof value !== "boolean")) {
|
||||
throw new Error("无法读取登录设置,请联系管理员或重试");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function loginWithPassword(username: string, password: string): Promise<LoginByUsernameResponse> {
|
||||
return accountRequest("/login/local", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAccount(username: string, password: string): Promise<LoginByUsernameResponse> {
|
||||
return accountRequest("/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function initializeAdministrator(email: string, password: string): Promise<LoginByUsernameResponse> {
|
||||
return accountRequest<LoginByUsernameResponse>("/initialize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface AuthenticationSettings {
|
||||
password_login_enabled: boolean;
|
||||
registration_enabled: boolean;
|
||||
sso_enabled: boolean;
|
||||
single_session_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordSession {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
export interface AccountPasswordStatus {
|
||||
id: string;
|
||||
has_password: boolean;
|
||||
requires_current_password: boolean;
|
||||
uses_default_password: boolean;
|
||||
}
|
||||
|
||||
async function readResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail = data?.detail;
|
||||
const message = typeof detail === "string"
|
||||
? detail
|
||||
: Array.isArray(detail)
|
||||
? detail.map((item: { msg?: string }) => item.msg).filter(Boolean).join(";")
|
||||
: detail?.message;
|
||||
throw new Error(message || `请求失败(${response.status})`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function getAuthenticationSettings(): Promise<AuthenticationSettings> {
|
||||
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/system-settings/authentication`));
|
||||
}
|
||||
|
||||
export async function saveAuthenticationSettings(settings: AuthenticationSettings): Promise<AuthenticationSettings> {
|
||||
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/system-settings/authentication`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAccountPasswordStatus(): Promise<AccountPasswordStatus> {
|
||||
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/v1/auth/me`));
|
||||
}
|
||||
|
||||
export async function changeAccountPassword(currentPassword: string, newPassword: string): Promise<PasswordSession> {
|
||||
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/v1/auth/change-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
}));
|
||||
}
|
||||
@@ -14,16 +14,11 @@ export interface UserListItem {
|
||||
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
|
||||
approved: boolean;
|
||||
/**
|
||||
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
|
||||
* 是否已设置凭据以 has_login_password 为准。
|
||||
* 账号级「登录口令」(明文,仅管理员可见)。非空 → 该账号用户名直登必须带匹配的
|
||||
* `?password=`;为空/null → 走原有共享口令门/免口令逻辑。管理员据此拼出登录链接
|
||||
* `…/login/<用户名>?password=xxxx` 发给用户。
|
||||
*/
|
||||
login_password: string | null;
|
||||
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
|
||||
has_login_password: boolean;
|
||||
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
@@ -47,7 +42,10 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
|
||||
* 设置 / 清除某个账号的「登录口令」(明文)。Admin only。
|
||||
*
|
||||
* 传非空字符串 → 设置;传 `null` 或空字符串 → 清除。设置后该账号用户名直登需带匹配的
|
||||
* `?password=`。返回更新后的用户项(含明文 `login_password`)。
|
||||
*/
|
||||
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useSetUserApproval() {
|
||||
});
|
||||
}
|
||||
|
||||
/** 设置 / 重置账号的统一密码与登录口令;操作日志不记录密码。 */
|
||||
/** 设置 / 清除某账号的「登录口令」(明文),成功后刷新用户列表缓存。 */
|
||||
export function useSetUserLoginPassword() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { accountPageSearch, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
|
||||
|
||||
const options = { password_login_enabled: true, registration_enabled: true, sso_enabled: true };
|
||||
|
||||
test("password entry never starts a guest login; explicit SSO still works", () => {
|
||||
assert.equal(shouldStartSso(options, false, false), false);
|
||||
assert.equal(shouldStartSso(options, true, false), true);
|
||||
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, false, false), true);
|
||||
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, true, false), true);
|
||||
});
|
||||
|
||||
test("expired sessions wait for an explicit re-login instead of repeatedly kicking the other client", () => {
|
||||
const legacy = { ...options, password_login_enabled: false };
|
||||
assert.equal(shouldStartSso(legacy, false, false, true), false);
|
||||
assert.equal(shouldStartSso(options, true, false, true), false);
|
||||
assert.equal(shouldStartSso(legacy, false, false, false), true);
|
||||
});
|
||||
|
||||
test("external login credentials before the hash are read, and route values take precedence", () => {
|
||||
const params = mergeLoginSearch("?authToken=current&password=&taskId=42", "?authToken=stale&password=old&ticket=t&theme=dark-blue");
|
||||
assert.equal(params.get("authToken"), "current");
|
||||
assert.equal(params.get("password"), "");
|
||||
assert.equal(params.get("ticket"), "t");
|
||||
assert.equal(params.get("theme"), "dark-blue");
|
||||
assert.equal(params.get("taskId"), "42");
|
||||
assert.equal(mergeLoginSearch("", "?authToken=outer").get("authToken"), "outer");
|
||||
});
|
||||
|
||||
test("stored tickets cannot override explicit login links or password entry", () => {
|
||||
assert.equal(shouldUseStoredTicket(options, false), false);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, true), false);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, false), true);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false, sso_enabled: false }, false), false);
|
||||
assert.equal(shouldUseStoredTicket(null, false), false);
|
||||
});
|
||||
|
||||
test("missing configuration, disabled SSO and registration never start SSO", () => {
|
||||
assert.equal(shouldStartSso(null, true, false), false);
|
||||
assert.equal(shouldStartSso({ ...options, sso_enabled: false }, true, false), false);
|
||||
assert.equal(shouldStartSso(options, true, true), false);
|
||||
});
|
||||
|
||||
test("switching forms retains the task destination and presentation without credentials", () => {
|
||||
const search = accountPageSearch("?taskId=42&goPath=agentfx&embed=1&theme=dark-blue&password=secret&authToken=token&ticket=ticket&userId=old&yUserId=old&token=token&access_token=token&routeKey=task");
|
||||
const params = new URLSearchParams(search);
|
||||
assert.deepEqual([...params.keys()], ["taskId", "goPath", "embed", "theme"]);
|
||||
assert.equal(params.get("taskId"), "42");
|
||||
assert.equal(params.get("theme"), "dark-blue");
|
||||
});
|
||||
|
||||
test("switching forms preserves a resolved route-key destination and empty search", () => {
|
||||
assert.equal(accountPageSearch(""), "");
|
||||
const params = new URLSearchParams(accountPageSearch("routeKey=task&embed=1", "/page/workspace/chats/new"));
|
||||
assert.equal(params.get("redirect"), "/page/workspace/chats/new");
|
||||
assert.equal(params.has("routeKey"), false);
|
||||
assert.equal(params.get("embed"), "1");
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { AuthenticationOptions } from "./account-api";
|
||||
|
||||
/** A missing options response must never fall back to a legacy guest login. */
|
||||
export function shouldStartSso(
|
||||
options: AuthenticationOptions | null,
|
||||
explicitSso: boolean,
|
||||
registering: boolean,
|
||||
sessionExpired = false,
|
||||
): boolean {
|
||||
return Boolean(options?.sso_enabled && !registering && !sessionExpired &&
|
||||
(explicitSso || !options.password_login_enabled));
|
||||
}
|
||||
|
||||
/** External callers put login parameters before or after the hash route. */
|
||||
export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLSearchParams {
|
||||
const params = new URLSearchParams(outerSearch);
|
||||
// A route parameter wins even when explicitly empty: do not revive an old
|
||||
// outer credential when the current route has replaced it.
|
||||
new URLSearchParams(routeSearch).forEach((value, key) => params.set(key, value));
|
||||
return params;
|
||||
}
|
||||
|
||||
export const LOGIN_CREDENTIAL_PARAMS = [
|
||||
"ticket", "userId", "yUserId", "token", "access_token", "password", "authToken", "routeKey",
|
||||
] as const;
|
||||
|
||||
/** Cached tickets belong only to the legacy, bare entry with login disabled. */
|
||||
export function shouldUseStoredTicket(
|
||||
options: AuthenticationOptions | null,
|
||||
explicitSso: boolean,
|
||||
): boolean {
|
||||
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso);
|
||||
}
|
||||
|
||||
/** Keep the destination/presentation when switching forms; discard credentials. */
|
||||
export function accountPageSearch(search: string, routeKeyTarget?: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if (routeKeyTarget) params.set("redirect", routeKeyTarget);
|
||||
for (const key of LOGIN_CREDENTIAL_PARAMS) params.delete(key);
|
||||
const result = params.toString();
|
||||
return result ? `?${result}` : "";
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||
import {
|
||||
AUTH_STATE_CHANGED_EVENT,
|
||||
clearStoredAuth,
|
||||
getStoredAuth,
|
||||
setStoredAuth,
|
||||
} from "./index";
|
||||
import { checkAccountAccess, isSameAccountSession, type AccountAccess } from "./session-validation";
|
||||
|
||||
type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: string })
|
||||
| Extract<AccountAccess, { status: "unauthorized" }>
|
||||
| { status: "checking" } | { status: "error"; message: string };
|
||||
|
||||
/** Keep protected pages unmounted until the configured authentication check succeeds. */
|
||||
export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
children: ReactNode;
|
||||
allowLegacyAnonymous?: boolean;
|
||||
}) {
|
||||
const { pathname, search } = useLocation();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState<GateState>({ status: "checking" });
|
||||
const writingVerifiedAccount = useRef(false);
|
||||
const requestInFlight = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
if (!writingVerifiedAccount.current) setAttempt((value) => value + 1);
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (!isEmbedActive() && !isIsolatedSession()
|
||||
&& (event.key === "deerflow.auth" || event.key === null)) refresh();
|
||||
};
|
||||
const recheck = () => { if (!requestInFlight.current) refresh(); };
|
||||
const onVisibility = () => { if (document.visibilityState === "visible") recheck(); };
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener("focus", recheck);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
// A different browser can revoke this account's session. Also refresh the
|
||||
// policy so enabling login or single-session mode takes effect in open tabs.
|
||||
const timer = setInterval(onVisibility, 15_000);
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("focus", recheck);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const requestedAuth = getStoredAuth();
|
||||
let disposed = false;
|
||||
requestInFlight.current = true;
|
||||
const timeout = setTimeout(() => controller.abort(), 20_000);
|
||||
// A background recheck must not unmount an active editor on every poll.
|
||||
// A changed account/token still blocks immediately until its own check ends.
|
||||
setState((previous) => previous.status === "allowed"
|
||||
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
|
||||
? previous : { status: "checking" });
|
||||
|
||||
void checkAccountAccess({
|
||||
auth: requestedAuth,
|
||||
baseURL: getBackendBaseURL(),
|
||||
signal: controller.signal,
|
||||
allowLegacyAnonymous,
|
||||
}).then((result) => {
|
||||
if (disposed) return;
|
||||
if (!isSameAccountSession(getStoredAuth(), requestedAuth)) {
|
||||
setAttempt((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
writingVerifiedAccount.current = true;
|
||||
try {
|
||||
if (result.status === "unauthorized") {
|
||||
if (requestedAuth) clearStoredAuth();
|
||||
} else if (result.auth && JSON.stringify(result.auth) !== JSON.stringify(getStoredAuth())) {
|
||||
setStoredAuth(result.auth);
|
||||
}
|
||||
} finally {
|
||||
writingVerifiedAccount.current = false;
|
||||
}
|
||||
setState(result);
|
||||
}).catch((error: unknown) => {
|
||||
if (disposed) return;
|
||||
const message = controller.signal.aborted ? "验证登录超时,请检查网络后重试"
|
||||
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
|
||||
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
|
||||
setState((previous) => previous.status === "allowed"
|
||||
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
|
||||
&& isSameAccountSession(getStoredAuth(), requestedAuth)
|
||||
? { ...previous, warning: message } : { status: "error", message });
|
||||
}).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
if (!disposed) requestInFlight.current = false;
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
requestInFlight.current = false;
|
||||
controller.abort();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [attempt, allowLegacyAnonymous]);
|
||||
|
||||
if (state.status === "unauthorized") {
|
||||
const params = new URLSearchParams({ redirect: `${pathname}${search}` });
|
||||
if (state.reason) params.set("reason", state.reason);
|
||||
return <Navigate to={`/login?${params.toString()}`} replace />;
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-6 text-center">
|
||||
<p role="alert" className="text-sm text-muted-foreground">{state.message}</p>
|
||||
<button type="button" className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
|
||||
onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
|
||||
</div>;
|
||||
}
|
||||
if (state.status === "allowed" && JSON.stringify(state.auth) === JSON.stringify(getStoredAuth())) {
|
||||
return <div className="contents">
|
||||
{children}
|
||||
{state.warning && <div role="alert" className="fixed right-4 bottom-4 z-50 flex max-w-md items-center gap-3 rounded-md border bg-background p-4 text-sm shadow-lg">
|
||||
<span>{state.warning}</span>
|
||||
<button type="button" className="shrink-0 text-primary underline" onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
return <div role="status" className="flex min-h-screen items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
正在验证登录状态…
|
||||
</div>;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { checkAccountAccess, isSameAccountSession } from "./session-validation";
|
||||
|
||||
const auth = {
|
||||
access_token: "browser-token", token_type: "bearer", expires_in: 3600,
|
||||
user_id: "user-1", email: "claimed@example.com", system_role: "admin", needs_setup: true, created: false,
|
||||
};
|
||||
const user = { id: "user-1", email: "actual@example.com", system_role: "user", needs_setup: false };
|
||||
|
||||
function server(enabled: boolean, me: Response = Response.json(user), singleSession = false) {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const fetcher: typeof fetch = async (input, init) => {
|
||||
calls.push({ url: String(input), init });
|
||||
return String(input).endsWith("/options")
|
||||
? Response.json({ password_login_enabled: enabled, single_session_enabled: singleSession }) : me;
|
||||
};
|
||||
return { calls, fetcher };
|
||||
}
|
||||
|
||||
test("enabled login rejects anonymous accounts including standalone mode before /me", async () => {
|
||||
const { fetcher, calls } = server(true);
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "unauthorized" });
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("a cached token never grants access before /me validates it", async () => {
|
||||
const { fetcher, calls } = server(true, new Response(null, { status: 401 }));
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "https://gateway", fetcher }), { status: "unauthorized", reason: "session-expired" });
|
||||
assert.equal(calls[1]?.url, "https://gateway/api/v1/auth/me");
|
||||
assert.equal(new Headers(calls[1]?.init?.headers).get("Authorization"), "Bearer browser-token");
|
||||
assert.equal(calls[1]?.init?.cache, "no-store");
|
||||
});
|
||||
|
||||
test("verified server permissions replace claimed local permissions", async () => {
|
||||
const { fetcher } = server(true);
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
|
||||
status: "allowed", auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("a token for another account cannot authenticate the cached account", async () => {
|
||||
const { fetcher } = server(true, Response.json({ ...user, id: "another-user" }));
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "unauthorized", reason: "session-expired" });
|
||||
});
|
||||
|
||||
test("disabled login preserves legacy sessions and the standalone anonymous entry", async () => {
|
||||
const { fetcher, calls } = server(false);
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "allowed", auth });
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", fetcher }), { status: "unauthorized" });
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "allowed", auth: null });
|
||||
assert.ok(calls.every(({ url }) => url.endsWith("/options")));
|
||||
});
|
||||
|
||||
test("outages and invalid policy responses fail closed without treating the session as invalid", async () => {
|
||||
const { fetcher } = server(true, new Response(null, { status: 503 }));
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher }), /无法验证登录/);
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => Response.json({}) }), /无法读取登录设置/);
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => { throw new TypeError("offline"); } }), /offline/);
|
||||
});
|
||||
|
||||
test("single-session mode detects revoked sessions even with the password page disabled", async () => {
|
||||
const { fetcher, calls } = server(false, new Response(null, { status: 401 }), true);
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
|
||||
status: "unauthorized", reason: "session-expired",
|
||||
});
|
||||
assert.ok(calls.some(({ url }) => url.endsWith("/me")));
|
||||
});
|
||||
|
||||
test("late failures cannot clear a renewed token or a different account", () => {
|
||||
assert.equal(isSameAccountSession({ ...auth }, auth), true);
|
||||
assert.equal(isSameAccountSession({ ...auth, access_token: "renewed" }, auth), false);
|
||||
assert.equal(isSameAccountSession({ ...auth, user_id: "another-user" }, auth), false);
|
||||
assert.equal(isSameAccountSession(null, auth), false);
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
|
||||
type StoredAccount = LoginByUsernameResponse | null;
|
||||
|
||||
export type AccountAccess =
|
||||
| { status: "allowed"; auth: StoredAccount }
|
||||
| { status: "unauthorized"; reason?: "session-expired" };
|
||||
|
||||
/** Compare credentials before applying a response from an earlier request. */
|
||||
export function isSameAccountSession(current: StoredAccount, requested: StoredAccount): boolean {
|
||||
return current?.access_token === requested?.access_token && current?.user_id === requested?.user_id;
|
||||
}
|
||||
|
||||
export function hasAccountSession(auth: StoredAccount): auth is LoginByUsernameResponse {
|
||||
return Boolean(typeof auth?.user_id === "string" && auth.user_id.trim()
|
||||
&& typeof auth.access_token === "string" && auth.access_token.trim());
|
||||
}
|
||||
|
||||
/** The server owns both the login policy and the account's current permissions. */
|
||||
export async function checkAccountAccess({
|
||||
auth,
|
||||
baseURL,
|
||||
signal,
|
||||
allowLegacyAnonymous = false,
|
||||
fetcher = fetch,
|
||||
}: {
|
||||
auth: StoredAccount;
|
||||
baseURL: string;
|
||||
signal?: AbortSignal;
|
||||
allowLegacyAnonymous?: boolean;
|
||||
fetcher?: typeof fetch;
|
||||
}): Promise<AccountAccess> {
|
||||
const optionsResponse = await fetcher(`${baseURL}/api/v1/auth/options`, {
|
||||
credentials: "include", cache: "no-store", signal,
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error("无法读取登录设置,请稍后重试");
|
||||
const options: unknown = await optionsResponse.json();
|
||||
if (!options || typeof options !== "object"
|
||||
|| !("password_login_enabled" in options)
|
||||
|| typeof options.password_login_enabled !== "boolean") {
|
||||
throw new Error("无法读取登录设置,请稍后重试");
|
||||
}
|
||||
|
||||
const singleSessionEnabled = "single_session_enabled" in options && options.single_session_enabled === true;
|
||||
if (!options.password_login_enabled && !singleSessionEnabled) {
|
||||
return allowLegacyAnonymous || hasAccountSession(auth)
|
||||
? { status: "allowed", auth }
|
||||
: { status: "unauthorized" };
|
||||
}
|
||||
if (!hasAccountSession(auth)) return { status: "unauthorized" };
|
||||
|
||||
const response = await fetcher(`${baseURL}/api/v1/auth/me`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${auth.access_token}` },
|
||||
signal,
|
||||
});
|
||||
if (response.status === 401) return { status: "unauthorized", reason: "session-expired" };
|
||||
if (!response.ok) throw new Error("暂时无法验证登录状态,请稍后重试");
|
||||
|
||||
const user = await response.json() as Record<string, unknown> | null;
|
||||
if (!user || typeof user.id !== "string" || typeof user.email !== "string"
|
||||
|| typeof user.system_role !== "string" || typeof user.needs_setup !== "boolean") {
|
||||
throw new Error("服务器返回的登录信息不完整,请稍后重试");
|
||||
}
|
||||
if (user.id !== auth.user_id) return { status: "unauthorized", reason: "session-expired" };
|
||||
return {
|
||||
status: "allowed",
|
||||
auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: user.needs_setup },
|
||||
};
|
||||
}
|
||||
@@ -58,7 +58,6 @@ const BUSINESS_TASK_BASE: Record<string, string> = {
|
||||
|
||||
export interface ParsedGoPath {
|
||||
businessCode: string;
|
||||
goPath: "3qfx" | "rwfx" | "xdfx";
|
||||
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
|
||||
roundtable: boolean;
|
||||
}
|
||||
@@ -71,7 +70,7 @@ export function parseGoPath(goPath: string): ParsedGoPath | null {
|
||||
const base = roundtable ? raw.slice(0, -2) : raw;
|
||||
const businessCode = GOPATH_BUSINESS_CODE[base];
|
||||
if (!businessCode) return null;
|
||||
return { businessCode, goPath: base as ParsedGoPath["goPath"], roundtable };
|
||||
return { businessCode, roundtable };
|
||||
}
|
||||
|
||||
function consumerBaseUrl(): string {
|
||||
@@ -382,7 +381,6 @@ export async function resolveTaskDeeplinkTarget(
|
||||
openingText: buildOpeningText(detail, taskId, actionInfo),
|
||||
actionId,
|
||||
partitionByAction: isXdfx,
|
||||
goPath: parsed.goPath,
|
||||
});
|
||||
return { path: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { taskThreadMetadata } from "./task-purpose";
|
||||
|
||||
test("3qfx and rwfx histories use the resolved task id", () => {
|
||||
assert.deepEqual(
|
||||
taskThreadMetadata(
|
||||
{
|
||||
taskId: "108",
|
||||
agentId: "agent-1",
|
||||
openingText: "",
|
||||
},
|
||||
"rwfx",
|
||||
),
|
||||
{
|
||||
taskId: "108",
|
||||
taskRecordId: "108",
|
||||
taskGoPath: "rwfx",
|
||||
thread_type: "task_deeplink",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("xdfx history uses the original action id while preserving task id", () => {
|
||||
assert.deepEqual(
|
||||
taskThreadMetadata(
|
||||
{
|
||||
taskId: "108",
|
||||
agentId: "agent-2",
|
||||
openingText: "",
|
||||
actionId: "action-9",
|
||||
partitionByAction: true,
|
||||
},
|
||||
"xdfx",
|
||||
),
|
||||
{
|
||||
taskId: "108",
|
||||
actionId: "action-9",
|
||||
taskRecordId: "action-9",
|
||||
taskGoPath: "xdfx",
|
||||
thread_type: "task_deeplink",
|
||||
},
|
||||
);
|
||||
});
|
||||
Loaded 100 of 857 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user