Compare commits

..
13 Commits
Author SHA1 Message Date
gozeros 264e2200d6 sync update from LAN repo (source dhk-1 @ 3b1ec5b4) 2026-11-05 10:56:34 +08:00
gozeros da6521b6cb sync: complete remaining files (source dhk-1) 2026-10-21 15:14:06 +08:00
gozeros 42f2a26fa8 sync update from LAN repo (source dhk-1 @ 3e87be55) 2026-10-21 15:06:08 +08:00
董宏凯 ab6068a7c1 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-code into dhk-1 2026-09-09 22:29:54 +08:00
董宏凯 5ab3d7f254 代码修改 2026-09-09 22:29:47 +08:00
董宏凯 30e1c2dd90 代码合并 2026-09-09 19:49:38 +08:00
董宏凯 673adef7df 代码提交 2026-09-09 12:48:06 +08:00
董宏凯 6f7919ded2 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-code into dhk-1 2026-09-08 14:41:55 +08:00
董宏凯 014d754707 代码提交 2026-09-08 14:33:03 +08:00
董宏凯 8859c13cfb 代码合并 2026-09-08 09:40:31 +08:00
董宏凯 b0970de677 代码提交 2026-09-07 18:38:51 +08:00
董宏凯andCursor 53cac5f81a Merge branch 'master' into dhk-1
Resolve conflicts by accepting master version for:
- workspace-nav-chat-list.tsx
- ResearchSidebar.tsx

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:55:32 +08:00
董宏凯andCursor 33dea724a1 Hide workspace 岗位会商 nav entry and enable AI writing notebook space entries.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 19:01:24 +08:00
422 changed files with 52829 additions and 3598 deletions

No files matched your search

+21 -8
View File
@@ -85,14 +85,20 @@ offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
# Automatic config.yaml backups written by the admin models page on every save
offline-backend-20260512/backend/.model-config-backups/
skills/
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
# HTML 结构化模板(no.1)引擎/存储/路由回归
!offline-backend-20260512/backend/tests/test_structured_templates.py
!offline-backend-20260512/backend/tests/test_html_runtime_addresses.py
!offline-backend-20260512/backend/tests/test_app_identity.py
!offline-backend-20260512/backend/tests/test_roundtable_room_limit.py
!offline-backend-20260512/backend/tests/test_admin_audit_retention_control.py
!offline-backend-20260512/backend/tests/test_admin_audit_retention_control.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/
@@ -150,6 +156,8 @@ offline-backend-20260512/backend/tests/*
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Keep the model-stream truncation event (persistence + classification) coverage versioned.
!offline-backend-20260512/backend/tests/test_stream_truncation.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
@@ -181,6 +189,10 @@ offline-backend-20260512/backend/tests/*
# Keep database-backed upload-policy, enforcement and bounded-preview regressions versioned.
!offline-backend-20260512/backend/tests/test_upload_policy.py
!offline-backend-20260512/backend/tests/test_uploads_policy_enforcement.py
!offline-backend-20260512/backend/tests/test_upload_responsiveness.py
!offline-backend-20260512/backend/tests/test_conversion_responsiveness.py
!offline-backend-20260512/backend/tests/test_async_agent_entrypoints.py
!offline-backend-20260512/backend/tests/test_async_tool_initialization.py
!offline-backend-20260512/backend/tests/test_artifact_preview.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
@@ -261,12 +273,13 @@ offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
!offline-backend-20260512/backend/tests/test_llmwiki_incremental_sync_scheduler.py
!offline-backend-20260512/backend/tests/test_worker_pool.py
!offline-backend-20260512/backend/tests/test_worker_storage.py
!offline-backend-20260512/backend/tests/test_worker_discovery.py
!offline-backend-20260512/backend/tests/test_worker_diagnostics.py
!offline-backend-20260512/backend/tests/test_worker_deferred_admission.py
!offline-backend-20260512/backend/tests/test_db_loop_isolation.py
!offline-backend-20260512/backend/tests/test_worker_pool.py
!offline-backend-20260512/backend/tests/test_chat_step_limit.py
!offline-backend-20260512/backend/tests/test_worker_storage.py
!offline-backend-20260512/backend/tests/test_worker_discovery.py
!offline-backend-20260512/backend/tests/test_worker_diagnostics.py
!offline-backend-20260512/backend/tests/test_worker_deferred_admission.py
!offline-backend-20260512/backend/tests/test_db_loop_isolation.py
!offline-backend-20260512/backend/tests/test_chat_archive_postgres_integration.py
# Local-only scratch directory (not committed)
@@ -277,4 +290,4 @@ __pycache__/
# misc
.DS_Store
.worker-pool/
.worker-pool/
+6 -2
View File
@@ -88,8 +88,10 @@ 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)
- **智能体对话跳转**: 创建页、智能体管理编辑框和问答页详情面板都可以给智能体配置「对话跳转」链接、打开方式(新窗口 / 当前窗口)和路由参数(登录参数、主题参数,解析方式与应用管理相同)。配置保存在该智能体 `config.yaml` 的 `chat_link`,并镜像到 `agent_extras.chat_link` 供列表读取。留空时智能体管理的「对话」按钮仍进入本系统问答;填了地址后,按钮按解析后的链接跳转。
- **模型流完整性检测 + 截断记录 (stream truncation)**: 模型配置页(`/page/workspace/admin/models`,`ModelManagementPage.tsx`)每张模型卡片新增「流完整性检测」开关 — `PUT /api/admin/models/strict-stream-integrity/{name}` 写 config.yaml 的 `strict_stream_integrity: true/false`(`app/gateway/routers/model_management.py`,仅 OpenAI 兼容 Chat Completions 模型可开;显式 `false` 也会覆盖 qwen3.8-flash-next 的自动开启;卡片显示值 = 显式配置 ?? qwen3.8-flash-next 自动规则,前端 `isAutoStrictIntegrityModel` 复刻后端判定)。开启后上游流未发终止帧就结束时抛可重试 `IncompleteChatStreamError`(`chat_stream_state.py`),LLM 错误中间件按 upstream 重试,避免半截回答静默入库。同时每次流异常结束都由 `patched_openai.py` 的 `_ToolCallStreamDiagnostics._record_truncation_event` 落库 `stream_truncation_events`(分类:`silent_no_terminal` 静默截断 / `incomplete_stream_error` 完整性拦截 / `length_truncated` / `content_filter` / `midstream_error`;write_file 兼容桥故意提前收流与用户取消不计;经 `fire_and_forget_db_write` 从任意 loop/thread 安全写入,store 在 `deerflow/persistence/stream_truncation/`,**每次写入顺手删除 30 天前的行,硬上限一个月**)。查看页 `pages/StreamTruncationLogPage.tsx`(路由 `/page/workspace/admin/stream-truncations`,admin gate 同模型配置页;简洁模式「设置和更多」下拉的「模型截断记录」入口在 `workspace-nav-menu.tsx`),client `core/stream-truncation-log/api.ts`;后端 API `GET /api/admin/stream-truncations`(分页 + 模型/类型过滤)与 `/breakdown`(按模型汇总,`app/gateway/routers/stream_truncations.py`)。测试 `tests/test_stream_truncation.py`、`tests/test_model_management.py`。配套「保留中间正文」(管理员设置弹窗同名分区,`GET/PUT /api/system-settings/retry-keep-partial`,默认开启,关闭即恢复旧行为):①正常路径——原先「消息带可见工具调用就不给正文气泡」的分组规则会把步骤条执行中已流出的正文清掉;现 `groupMessages` 新增 `keepIntermediateText` 选项(MessageList 按开关传入):本轮全部过程正文**合并成一个气泡**挂在步骤条正下方(合成消息剥思考/工具调用,组 id 加 `:keep-text` 后缀避免 React key 冲突),步骤条保持一条不被拆开(正文气泡对工具消息/后续轮次的累积「透明」,`lastOpenGroup`/`lastNonKeepBubbleGroup` 向前跳过),分组完成后把合并气泡**重排到 present-files/subagent/document-rewrite 附属组之后**(正文块与最终回答相邻、不被文件卡片隔开),仅本轮「最终正文消息」(回扫到的最后一条有正文且无可见工具调用的 ai 消息)保留独立气泡;②重试路径——LLM 错误中间件静默重试时,上一次尝试已流出的半截正文从未进 checkpoint、会被前端 values 对账清掉,`core/threads/hooks.ts` 在收到 `llm_retry` 事件时快照在途消息,被丢弃后按原位插回(纯函数 `core/threads/retry-kept-messages.ts`,快照只在本轮流式期间有效,刷新即失)。测试 `tests/test_strategy_project_settings.py` 尾部、`src/core/messages/utils-group.test.ts`、`src/core/threads/retry-kept-messages.test.ts`(`pnpm test:retry-kept`)。
- **知识库深度研究(普通知识库限定问答)**: 每个可读知识库从卡片或详情页进入 `/page/workspace/knowledge/:noteId/research`。它不复用原“深度研究”流程,而是把普通 Lead Agent 问答在服务端强制绑定到单个知识库、经典检索并关闭工具/记忆;私有会话与草稿仅创建者可见。首个完整回答自动成为 Markdown 草稿,后续回答须显式“应用为文档”。可写用户保存时在该知识库 Wiki 下创建保留根目录「深度研究」,用独立 publication id 的稳定 slug 创建/覆盖文章;文件名独立于问答标题。书架文章随知识库读权限共享,但“更多”及原问答仅创建者可见;只读用户可问答和整理草稿,不能发布。前端核心页 `pages/KnowledgeResearchPage.tsx`,client `strategy-components/api/knowledge-research.ts`;后端 REST 在 `app/gateway/routers/llmwiki.py`,运行时约束 `app/gateway/knowledge_research.py`,持久化 `deerflow/persistence/knowledge_research.py`。
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. **素材检索技能池限定白名单**:`researcher_node` 通用检索的候选池 = **ai-writing-researcher 智能体 config.yaml `skills` 白名单里的启用技能**(管理员在智能体管理页增删,内置 `knowledge-base-search` 保底且始终排最前),不再列全部启用技能——300+ 技能部署把选择提示词撑爆导致素材检索不可用;容错兜底 `_fallback_candidate_skills` 同样限定白名单;白名单未配置/加载失败回退仅内置知识库检索。实现 `deerflow/agents/ai_writing/nodes/researcher.py::_load_candidate_skills/_researcher_skill_whitelist`,测试 `tests/test_ai_writing_knowledge_base.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 defaults + personal layouts)**: `MenuManagementPage` (`pages/MenuManagementPage.tsx`) is available from「设置和更多」and the global Header account dropdown. Administrators edit the shared default menu; ordinary users edit only their own full/compact menu snapshot. For users with a saved snapshot, known node ids keep the personal `label`/`disabled`/`parentId`/`sortOrder` (or compact placement/order), while administrator defaults for node ids absent from that snapshot flow through—so newly-added code menus and iframe light apps follow the current default without overwriting older personal choices. Temporarily absent dynamic nodes are preserved when an unrelated personal edit is saved;「恢复默认」deletes only that user's snapshot. The static tree (`core/page-layout/sidebar-menu.ts`) remains the source of icons/routes and administrator defaults remain sparse per-node deltas. Merge/snapshot helpers live in `core/page-layout/menu-overrides.ts` and `compact-menu-overrides.ts`; full-menu writes carry the complete effective parent hierarchy for cycle/strict three-level validation while persisting only the sparse admin delta or full personal snapshot. Backend `/api/menu-overrides` uses `menu_overrides` (default) + `user_menu_override_configs` (per-user snapshot); `/api/compact-menu-overrides` uses `compact_menu_overrides` (type `A` / `B` defaults) + `user_compact_menu_override_configs` (per-user/type snapshots). Each website selects its compact type via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`; `CompactToolbox` renders `toolbox` items.
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
@@ -99,8 +101,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`.
- **HTML 模板管理 (HTML Template Management, admin-only)**: 定时任务「HTML 模板」库的可视化管理——列表/预览(iframe srcDoc sandbox)/在线编辑/上传 .html 覆盖/新建/删除/重置内置,改完**即时生效**(调度器每次执行直接读文件,无需重启)。模板从纯代码注册表升级为**文件型存储**:harness 纯引擎 `deerflow/runtime/scheduler/template_store.py`(`{base_dir}/html-templates/<id>/{template.html,meta.json}`,gitignored),app.py lifespan 把随包 `scheduler/templates/` 的全部内置模板(目前 27 个)seed 进存储——**目录缺失才复制,绝不覆盖管理页/线上修改**;`template_fill.py` 的 `get_template/list_templates/read_template_text` 改为存储优先、随包注册表兜底,写操作后 `invalidate_cache()` 清引擎 lru_cache。**convertible 自动探测**:上传/编辑正文时检测单行 `const DATA={…};` 注入行(当前引擎唯一注入点,`const MANIFEST=` 同理),带注入行即「可转换填充」(定时任务 JSON 转换管线零代码接入),否则「只展示」;数据存 `<script type="application/json">` 元素或 `let DATA`/带空格写法的模板仍探测为只展示。内置模板可编辑/覆盖/重置回随包原版但**不可删除**(防引用它的任务挂掉),自建模板可删。页面 `pages/HtmlTemplatesPage.tsx`(入口**仅官方 DeerFlow 入口**:`OfficialDeerFlowEntry.tsx` 侧栏「模板管理」→ `#/official/workspace/task-analysis/html-templates`,页面 id `html-templates` 的路径判断必须先于 `task-analysis-detail` 的 `/:id` 正则,仅 admin 可见;主应用的简洁/全量模式**不加侧栏入口**,仅保留直链路由 `/page/strategy/admin/html-templates` 作回退,admin-gated),client+hooks `strategy-components/api/html-templates.ts`。后端 `/api/html-templates`(读开放、写仅 admin;`app/gateway/routers/html_templates.py`),旧端点 `GET /api/scheduled-tasks/html-templates` 不变(内部同样读存储),定时任务表单的下拉自动看到新建模板。测试 `tests/test_html_templates_admin.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`。
- **上下文与对话轮数(管理员全局设置)**: 「设置 → 上下文管理」可开启 `system_settings.json -> conversation_context.recent_turn_window_enabled`(默认关闭),并调整 `max_conversation_rounds`(单个普通对话的真实用户提问上限,默认 20,范围 1–50);统一走 `GET/PUT /api/system-settings/conversation-context`,PUT 仅管理员。轮数上限由 `ChatPage` 读取,达到后禁止继续发送并引导新建对话;旧配置缺字段时仍按 20。近 3 轮模式开启后,`RecentTurnsMiddleware` 只改变单次模型请求,保留含当前轮在内的最近 3 个真实用户回合及其 assistant/tool 链;checkpoint 与前端完整聊天记录不删。该模式优先于聊天框「上下文压缩」,且不会调用摘要模型;关闭后沿用原压缩行为。实现 `deerflow/agents/middlewares/recent_turns_middleware.py`,回归 `tests/test_summarization_multiturn.py` / `tests/test_prompt_prefix.py`。
- **输入框选项按窗口隔离(per-tab composer options)**: 输入框工具条的「写作 / 文件 / 参考文献 / 上下文压缩」开关与「模型 / 快速(模式) / 思考强度」选择器原本存 `localStorage`(`deerflow.local-settings`)且 store.ts 监听 `storage` 事件跨窗口同步——一个窗口切换、所有窗口生效。现这 7 个字段拆入 `sessionStorage`(`deerflow.composer-session`):纯函数拆分/合并/解析在 `core/settings/composer-scope.ts`,`core/settings/local.ts` 的 `getLocalSettings`/`saveLocalSettings` 负责路由(全局文档读写时强制剥离这 7 个字段,历史残留值失效)。**每个浏览器窗口独立**——复制链接新开的窗口从默认值开始、互不影响;同一窗口刷新保留;`useLocalSettings` API 与全部使用方(input-box / chat-box / message-list / hooks / IframeChatPage 等)零改动。「选择 Agent」的新对话预选 `deerflow.last-agent-id` 同样改 sessionStorage。其余 LocalSettings(字号 / 布局模式 / composer 宽度等外观偏好)保持浏览器全局并继续跨窗口同步;一次性迁移标志 `deerflow.composer-session.migrated` 把升级前旧值搬进升级后第一个打开的窗口。「经典检索/智能检索」与「知识空间」选择本来就是页面 state + 线程 metadata,天然按窗口隔离,未改动。测试 `pnpm test:composer-session`(`core/settings/composer-scope.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/`
+4 -2
View File
@@ -118,8 +118,10 @@ 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。
- **智能体对话跳转**: 创建页、智能体管理编辑框和问答页详情面板都可以给智能体配置「对话跳转」链接、打开方式(新窗口 / 当前窗口)和路由参数(登录参数、主题参数,解析方式与应用管理相同)。配置保存在该智能体 `config.yaml` 的 `chat_link`,并镜像到 `agent_extras.chat_link` 供列表读取。留空时智能体管理的「对话」按钮仍进入本系统问答;填了地址后,按钮按解析后的链接跳转。
- **普通问答地图可视化**: 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 after recovering common marker/line aliases (`coords`, `from`/`to`, `lng`/`lat`) and dropping unusable lines so markers still render; markers accept per-point `color`; lines are emitted only when the user explicitly asks to connect places, then render as colored SVG arrows that loop along the path (honoring `prefers-reduced-motion`); it 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。设置 → 外观的「HTML 运行地址」保存在 `system_settings.json -> html_runtime`(`GET|PUT /api/system-settings/html-runtime`,管理员可写):`base_url` 是写入新 HTML 的浏览器可访问绝对地址,`map_tile_url` 是地图网页模式读取的瓦片模板。未保存时才回退到旧的 `config.yaml`,最后才用本机默认地址。设置 → 外观的管理员选项「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。
- **系统名称与 Logo**: 管理员在设置弹窗「系统名称」里填写本系统导航、侧栏和欢迎区使用的名称,并可上传一张 PNG/JPG/WebP Logo。配置在 `system_settings.json -> app_identity`,图片在运行目录 `app-identity/`,接口 `GET/PUT/DELETE /api/system-settings/app-identity`(写仅管理员),图片地址 `GET /api/public/app-identity/logo`。名称留空继续用「CM业务中心」/「CM 助手」;未上传 Logo 继续用各主题静态图片和既有 `runtime-config.js` 覆盖。不改已生成的 HTML。
- **定时任务「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`.
+108 -40
View File
@@ -2,6 +2,12 @@ importScripts("config.js", "shared.js");
const ALARM_NAME = "deer-browser-task-poll";
const SERVICE_DISCOVERY_TTL_MS = 10 * 60 * 1000;
const STATUS_CACHE_TTL_MS = 60 * 1000;
const STATUS_RETRY_MS = 5 * 60 * 1000;
const STATUS_MANUAL_MIN_INTERVAL_MS = 30 * 1000;
const statusCache = new Map();
const statusRequests = new Map();
const discoveryRequests = new Map();
const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
let pollTimer = null;
@@ -21,6 +27,7 @@ async function getSettings() {
discoveredWebBaseUrl: "",
lastServiceSyncAt: "",
lastServiceSyncSource: "",
lastServiceSyncContext: "",
});
if (settings.autoDiscoverServiceUrl) {
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
@@ -38,6 +45,7 @@ async function getSettings() {
}
settings.lastServiceSyncAt = discovered.lastServiceSyncAt || "";
settings.lastServiceSyncSource = discovered.lastServiceSyncSource || "";
settings.lastServiceSyncContext = discovered.lastServiceSyncContext || "";
if (Object.keys(updates).length > 0) {
await chrome.storage.sync.set(updates);
Object.assign(settings, updates);
@@ -105,28 +113,77 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
return candidates;
}
async function browserContextStatus(apiBaseUrl) {
try {
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
credentials: "include",
});
// The gateway may enforce authentication globally before this otherwise
// public discovery endpoint is reached. A 401/403 still proves that this
// candidate is the zncm API paired with the open frontend tab.
if (response.status === 401 || response.status === 403) {
return { enabled: true, authenticationRequired: true };
}
if (!response.ok) {
return null;
}
const data = await response.json().catch(() => null);
if (data && typeof data.enabled === "boolean") {
return data;
}
} catch {
return null;
async function serviceCacheKey(settings, apiBaseUrl, webBaseUrl = "") {
const cookie = await getCookieToken({ ...settings, apiBaseUrl });
const auth = JSON.stringify([settings.accessToken || "", cookie?.token || ""]);
// Persist only a credential fingerprint with the discovery timestamp. A new
// login or cookie must not reuse another account's cached status/backoff.
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(auth));
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
return JSON.stringify([normalizeServiceBase(apiBaseUrl), normalizeServiceBase(webBaseUrl), fingerprint]);
}
function statusRetryMs(response) {
const value = response.headers?.get("Retry-After");
if (!value) return STATUS_RETRY_MS;
const seconds = Number(value);
const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
return Number.isFinite(delay) ? Math.max(STATUS_RETRY_MS, Math.min(delay, 60 * 60 * 1000)) : STATUS_RETRY_MS;
}
async function browserContextStatus(apiBaseUrl, settings = {}, force = false) {
const base = normalizeServiceBase(apiBaseUrl);
const key = await serviceCacheKey(settings, base);
const cached = statusCache.get(key);
const now = Date.now();
if (cached && now < (force ? cached.manualRetryAt : cached.expiresAt)) {
return cached.value;
}
if (statusRequests.has(key)) return statusRequests.get(key);
const request = (async () => {
let value = null;
let ttl = STATUS_RETRY_MS;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(`${base}/api/browser-context/status`, {
credentials: "include",
signal: controller.signal,
});
const data = await response.json().catch(() => null);
ttl = statusRetryMs(response);
if (response.status === 403 && data?.detail?.code === "browser_status_disabled") {
value = { enabled: false, statusAccessDisabled: true };
} else if (response.status === 401 || response.status === 403) {
// Authentication still establishes a candidate's API identity. Do not
// mistake the explicit administrative status switch for a login error.
value = { enabled: true, authenticationRequired: true };
} else if (response.ok && data && typeof data.enabled === "boolean") {
value = data;
if (data.enabled) ttl = STATUS_CACHE_TTL_MS;
}
} catch {
// Failed candidates are negative-cached too: an unreachable deployment
// must not cause every open tab's three-second wake-up to probe it again.
} finally {
clearTimeout(timeout);
}
const checkedAt = Date.now();
statusCache.set(key, {
value,
expiresAt: checkedAt + ttl,
manualRetryAt: checkedAt + (ttl === STATUS_CACHE_TTL_MS ? STATUS_MANUAL_MIN_INTERVAL_MS : ttl),
});
if (statusCache.size > 100) statusCache.delete(statusCache.keys().next().value);
return value;
})();
statusRequests.set(key, request);
try {
return await request;
} finally {
statusRequests.delete(key);
}
return null;
}
function tabLooksLikeZncm(tab) {
@@ -178,17 +235,34 @@ async function findOpenZncmTabs(settings) {
}
async function discoverServiceBases(settings, force = false) {
if (!settings.autoDiscoverServiceUrl && !force) return settings;
const key = await serviceCacheKey(settings, settings.apiBaseUrl, settings.webBaseUrl);
if (discoveryRequests.has(key)) return discoveryRequests.get(key);
const request = discoverServiceBasesOnce(settings, force, key);
discoveryRequests.set(key, request);
try {
return await request;
} finally {
discoveryRequests.delete(key);
}
}
async function discoverServiceBasesOnce(settings, force, contextKey) {
if (!settings.autoDiscoverServiceUrl && !force) {
return settings;
}
const lastSync = Date.parse(settings.lastServiceSyncAt || "");
const discoveryAge = Date.now() - lastSync;
const discoveryTtl = /browser_context_disabled|api_not_verified/.test(settings.lastServiceSyncSource || "")
? STATUS_RETRY_MS
: SERVICE_DISCOVERY_TTL_MS;
if (
!force &&
Number.isFinite(lastSync) &&
settings.lastServiceSyncSource &&
settings.lastServiceSyncContext === contextKey &&
discoveryAge >= 0 &&
discoveryAge < SERVICE_DISCOVERY_TTL_MS
discoveryAge < discoveryTtl
) {
return settings;
}
@@ -208,7 +282,7 @@ async function discoverServiceBases(settings, force = false) {
let apiBaseUrl = normalizeServiceBase(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
let verified = false;
for (const candidate of apiCandidatesForWebBase(discoveredWebBaseUrl, apiBaseUrl)) {
const status = await browserContextStatus(candidate);
const status = await browserContextStatus(candidate, settings, force);
if (status) {
apiBaseUrl = candidate;
webBaseUrl = discoveredWebBaseUrl;
@@ -217,7 +291,7 @@ async function discoverServiceBases(settings, force = false) {
break;
}
}
if (!verified && force) {
if (!verified) {
source = `${source}:api_not_verified`;
}
@@ -229,22 +303,16 @@ async function discoverServiceBases(settings, force = false) {
lastServiceSyncSource: source,
};
if (
force ||
settings.apiBaseUrl !== next.apiBaseUrl ||
settings.webBaseUrl !== next.webBaseUrl ||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
) {
// Discovered endpoints are runtime state, not user-authored preferences.
// Keep them in storage.local so Chrome sync write quotas cannot break task
// polling. The options page may still persist explicit user choices.
await chrome.storage.local.set({
discoveredApiBaseUrl: next.apiBaseUrl,
discoveredWebBaseUrl: next.webBaseUrl,
lastServiceSyncAt: next.lastServiceSyncAt,
lastServiceSyncSource: next.lastServiceSyncSource,
});
}
next.lastServiceSyncContext = await serviceCacheKey(next, next.apiBaseUrl, next.webBaseUrl);
// Refresh the timestamp even when the address/source did not change. Without
// this write the first expired TTL causes status probes on every later poll.
await chrome.storage.local.set({
discoveredApiBaseUrl: next.apiBaseUrl,
discoveredWebBaseUrl: next.webBaseUrl,
lastServiceSyncAt: next.lastServiceSyncAt,
lastServiceSyncSource: next.lastServiceSyncSource,
lastServiceSyncContext: next.lastServiceSyncContext,
});
return next;
}
@@ -3,13 +3,17 @@ const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const vm = require("node:vm");
const { webcrypto } = require("node:crypto");
const BACKGROUND_PATH = path.join(__dirname, "background.js");
function createHarness() {
function createHarness(options = {}) {
let storageChangedListener;
let alarmCreateCount = 0;
const localData = {};
let clock = options.now ?? Date.now();
let fetchHandler = options.fetch;
let cookieToken = "";
const localData = options.localData || {};
const fetchUrls = [];
const settings = {
apiBaseUrl: "http://localhost:8001",
@@ -20,6 +24,7 @@ function createHarness() {
taskPollingEnabled: true,
workerId: "test-worker",
taskPollIntervalSeconds: 5,
...options.settings,
};
const chrome = {
@@ -30,7 +35,7 @@ function createHarness() {
},
onAlarm: { addListener: () => {} },
},
cookies: { get: async () => null },
cookies: { get: async () => cookieToken ? { value: cookieToken } : null },
runtime: {
onInstalled: { addListener: () => {} },
onMessage: { addListener: () => {} },
@@ -58,8 +63,9 @@ function createHarness() {
const context = {
chrome,
console,
fetch: async (url) => {
fetch: async (url, init) => {
fetchUrls.push(String(url));
if (fetchHandler) return fetchHandler(url, init);
return {
ok: true,
status: 200,
@@ -70,6 +76,13 @@ function createHarness() {
setTimeout: () => 1,
clearTimeout: () => {},
URL,
AbortController,
TextEncoder,
crypto: webcrypto,
Date: class extends Date {
constructor(...args) { super(...(args.length ? args : [clock])); }
static now() { return clock; }
},
DEER_CLIPPER_DEFAULTS: settings,
deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""),
deerMakeWorkerId: () => "test-worker",
@@ -88,6 +101,13 @@ function createHarness() {
getAlarmCreateCount: () => alarmCreateCount,
getFetchUrls: () => [...fetchUrls],
getSettings: () => context.getSettings(),
setSettings: (updates) => Object.assign(settings, updates),
getLocalData: () => ({ ...localData }),
advanceTime: (ms) => { clock += ms; },
getTime: () => clock,
setFetch: (handler) => { fetchHandler = handler; },
setCookie: (token) => { cookieToken = token; },
status: (url, settings = {}, force = false) => context.browserContextStatus(url, settings, force),
discoverServiceBases: (settings, force = false) =>
context.discoverServiceBases(settings, force),
resetAlarmCreateCount: () => {
@@ -151,6 +171,108 @@ test("automatic service discovery checks status once per ten minutes, not on eve
await harness.discoverServiceBases(await harness.getSettings());
assert.equal(harness.getFetchUrls().length, 1);
await harness.discoverServiceBases(await harness.getSettings(), true);
assert.equal(harness.getFetchUrls().length, 1);
harness.advanceTime(30_000);
await harness.discoverServiceBases(await harness.getSettings(), true);
assert.equal(harness.getFetchUrls().length, 2);
});
test("unchanged service discovery refreshes its timestamp after TTL expiry", async () => {
const harness = createHarness();
await settle();
harness.setSettings({ autoDiscoverServiceUrl: true });
await harness.discoverServiceBases(await harness.getSettings());
const firstSync = harness.getLocalData().lastServiceSyncAt;
harness.advanceTime(10 * 60_000 + 1);
await harness.discoverServiceBases(await harness.getSettings());
assert.notEqual(harness.getLocalData().lastServiceSyncAt, firstSync);
assert.equal(harness.getFetchUrls().length, 2);
for (let i = 0; i < 20; i++) {
harness.advanceTime(3_000);
await harness.discoverServiceBases(await harness.getSettings());
}
assert.equal(harness.getFetchUrls().length, 2);
});
test("concurrent status callers share a request and successful results expire", async () => {
const harness = createHarness();
const url = "http://example.test";
const results = await Promise.all(Array.from({ length: 30 }, () => harness.status(url)));
assert.ok(results.every((result) => result.enabled));
assert.equal(harness.getFetchUrls().length, 1);
harness.advanceTime(59_000);
await harness.status(`${url}/`);
assert.equal(harness.getFetchUrls().length, 1);
harness.advanceTime(1_001);
await harness.status(url);
assert.equal(harness.getFetchUrls().length, 2);
});
test("disabled status respects Retry-After even for manual calls and later recovers", async () => {
const harness = createHarness({
fetch: async () => ({
ok: false,
status: 403,
headers: { get: () => "300" },
json: async () => ({ detail: { code: "browser_status_disabled" } }),
}),
});
const url = "http://example.test";
const status = await harness.status(url);
assert.equal(status.enabled, false);
assert.equal(status.statusAccessDisabled, true);
assert.equal(status.authenticationRequired, undefined);
harness.advanceTime(299_999);
await harness.status(url, {}, true);
assert.equal(harness.getFetchUrls().length, 1);
harness.setFetch(async () => ({ ok: true, status: 200, json: async () => ({ enabled: true }) }));
harness.advanceTime(1);
assert.equal((await harness.status(url)).enabled, true);
assert.equal(harness.getFetchUrls().length, 2);
});
test("network and server failures back off for five minutes", async () => {
for (const fetch of [
async () => { throw new Error("offline"); },
async () => ({ ok: false, status: 500, json: async () => ({ detail: "unavailable" }) }),
]) {
const harness = createHarness({ fetch });
for (let i = 0; i < 10; i++) assert.equal(await harness.status("http://example.test", {}, true), null);
assert.equal(harness.getFetchUrls().length, 1);
harness.advanceTime(300_000);
assert.equal(await harness.status("http://example.test"), null);
assert.equal(harness.getFetchUrls().length, 2);
}
});
test("status cache separates service addresses, login tokens and cookies", async () => {
const harness = createHarness();
await harness.status("http://one.test", { accessToken: "account-a" });
await harness.status("http://one.test", { accessToken: "account-b" });
await harness.status("http://two.test", { accessToken: "account-b" });
harness.setCookie("new-login");
await harness.status("http://two.test", { accessToken: "account-b" });
assert.equal(harness.getFetchUrls().length, 4);
await harness.status("http://two.test", { accessToken: "account-b" });
assert.equal(harness.getFetchUrls().length, 4);
});
test("discovery survives worker restarts but refreshes after account changes", async () => {
const harness = createHarness();
await settle();
harness.setSettings({ autoDiscoverServiceUrl: true, accessToken: "account-a" });
await harness.discoverServiceBases(await harness.getSettings());
const localData = harness.getLocalData();
assert.ok(!JSON.stringify(localData).includes("account-a"));
const restarted = createHarness({
localData,
now: harness.getTime(),
settings: { autoDiscoverServiceUrl: true, accessToken: "account-a" },
});
await restarted.discoverServiceBases(await restarted.getSettings());
assert.equal(restarted.getFetchUrls().length, 0);
restarted.setSettings({ accessToken: "account-b" });
await restarted.discoverServiceBases(await restarted.getSettings());
assert.equal(restarted.getFetchUrls().length, 1);
});
+36
View File
@@ -0,0 +1,36 @@
# 接口性能管理
管理员入口:**设置 → Redis 与性能**。页面集中管理 Redis 读取缓存、浏览器状态接口访问、历史统计数据定时清理。
## 控制项
- Redis 总开关及有效期:1–3600 秒,默认 300 秒。已有管理员设置优先于 YAML 默认值。
- 接口列表:15 组,支持搜索和单独开关。同组路径共用一个开关;总开关关闭时保留每组选择。列表由后端实际接入目录返回。
- 浏览器状态:允许/拒绝 `GET /api/browser-context/status`,保存后生效。为兼容现有插件,升级默认允许;关闭时返回 403,错误码 `browser_status_disabled`,并建议客户端 300 秒后重试。此开关只控制状态查询,不改变 capture/ask/task 接口的原有权限。
- 定时清理:复用原有审计清理调度及持久化暂停标记。关闭时在当前小批次完成后停止;重启保持关闭。清理范围仍是超过 90 天的工具调用、模型调用、已结束运行摘要和排行榜快照,不清理对话消息。页面仅打开时或手动刷新状态,不新增轮询。
## 缓存覆盖
智能体列表、技能列表、展示脱敏词、个人偏好、常用提示词、推荐问题、轻应用列表/侧栏/详情、完整菜单、简洁菜单、任务按钮、业务映射、HTML 模板、模型能力目录、标签及资源标签、浏览器状态。
模型配置仍按当前配置即时组装,缓存的是其数据库能力目录。智能体、技能和推荐问题保留原有可缓存请求条件。用户相关数据保持用户隔离,菜单同时按角色和菜单类型隔离。对话、流式响应和写入操作不缓存。
写入后使相关缓存失效;同键并发未命中请求合并填充。Redis 未配置、超时或不可用时回到原读取逻辑。超出大小限制的结果不缓存。
## 持久化和升级
`api_read_cache_settings` 保存总开关、有效期、逐组配置和浏览器状态开关。迁移 `20261029_01` 增加有效期,`20261029_02` 增加逐组配置、浏览器开关和缓存代次。沿用项目正常数据库迁移流程后再启动新版服务。
每次保存配置都会轮换持久化缓存代次,避免在 Redis 离线时关闭/开启缓存、随后 Redis 恢复或 Gateway 重启后重新命中旧数据。设置写入在进程内串行化,并使用数据库行锁合并更新;其他 Gateway 进程最多约 2 秒刷新设置。
浏览器扩展也应同步更新:修复服务地址不变时探测时间戳未更新的问题,成功状态短暂缓存、同地址鉴权请求合并,禁用/失败至少退避 5 分钟。旧扩展仍可能发送请求,服务端关闭开关会拒绝这些请求。
## 管理员排行榜
移除 `GET /api/admin/users/{user_id}/analytics` 及用户详情展示/调用;排行榜聚合统计和用户管理保留。旧详情 URL 返回 404。
## 回归入口
后端:`test_api_read_cache.py`、`test_api_performance_controls.py`、`test_api_performance_edge_cases.py`、`test_catalog_cache_routes.py`、`test_admin_analytics_removed.py`,以及菜单、模型管理和审计清理现有测试。
浏览器扩展:`node --test browser-extension/deer-web-clipper/background.test.cjs`。
+21
View File
@@ -0,0 +1,21 @@
# 按模型追加全局提示词
管理员打开「设置 → 模型追加提示词」,选择模型,填写提示词并开启开关,点击「保存此模型设置」。每个模型独立保存,关闭开关会保留文本。
设置按模型配置的 `name` 精确匹配,而不是显示名或供应商模型名。所有用户通过平台统一模型入口调用该模型时,都会在系统提示词末尾追加该文本;切换到其他模型不会携带它。未设置的模型保持原有行为。
设置保存在运行目录的 `system_settings.json` 的 `model_prompt_suffixes` 中,默认空、默认关闭。每次模型请求读取最新设置,已经创建的模型实例也能在下一次请求使用新值,无需重启。不会改写用户问题或持久化聊天记录,也不会修改原有智能体提示词文件。提示词用于引导回答方式,无法保证上游模型一定回答。
## 接口与实现
- `GET /api/system-settings/model-prompt-suffixes`:读取全部设置,仅管理员。
- `PUT /api/system-settings/model-prompt-suffixes/{model_name}`:保存单个模型,正文为 `{ "enabled": true, "prompt": "自定义要求" }`,仅管理员;模型名需 URL 编码,支持包含 `/` 的名称。文本最多 20000 字符,启用时不可为空。
- 统一入口 `deerflow.models.factory.create_chat_model` 保留原供应商模型的工具绑定与流式能力;OpenAI 兼容请求在传输适配提示词处理后追加,支持 Chat Completions 和 Responses;其他适配器在输入转换与批量生成入口追加。
- 外部系统自行调用模型、绕过此统一入口的调用不受该设置控制。
- 回归测试:`packages/harness/tests/test_model_prompt_suffix.py`,覆盖权限、持久化、模型隔离、重复追加防护、原始消息不变、即时更新、同步/异步/流式/批量调用及工具绑定。
## 普通问答与智能体问答的末尾顺序验证
两种页面均传递当前输入框的 `model_name`。Lead Agent 按「请求选择 → 智能体默认 → 系统默认」解析实际模型,然后调用同一 `create_chat_model`。普通问答使用通用模板,智能体问答使用其 SOUL.md 模板;主题、知识库范围、项目上下文及运行时中间件继续组装提示词。追加设置在模型请求层处理,因此位于这些内容之后。
回归测试运行两种 Lead Agent 分支的真实 LangGraph 图、模型工厂与 OpenAI 请求序列化,用模拟 HTTP 接口捕获最终请求,覆盖普通/智能体、默认/目标/其他模型、同步响应/流式响应共 12 种组合。测试注入后置系统要求和检索上下文,断言最终系统文本以管理员提示词结尾且只出现一次,用户问题保持原样。另验证 text_bridge、buffered_gateway、artifact_renderer 三种文件传输适配提示词均位于管理员提示词之前。测试不访问真实上游模型。
+12 -10
View File
@@ -77,17 +77,19 @@
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
// Relative production assets need a directory URL. Normalize a mounted
// prefix such as /web to /web/ before the browser resolves asset URLs.
// Relative production assets need a directory URL. A mounted prefix such
// as /web must resolve against /web/. history.replaceState during parsing
// makes old Firefox abort the document (NS_BINDING_ABORTED) before the
// login bundle runs, so install a base URL instead of rewriting history.
(function () {
var path = window.location.pathname;
if (path !== "/" && !path.endsWith("/") && !/\.[^/]+$/.test(path)) {
window.history.replaceState(
window.history.state,
"",
path + "/" + window.location.search + window.location.hash,
);
}
try {
var path = window.location.pathname;
if (path === "/" || /\/$/.test(path) || /\.[^/]+$/.test(path)) return;
if (document.querySelector("base")) return;
var base = document.createElement("base");
base.href = path + "/";
document.head.appendChild(base);
} catch (e) {}
})();
</script>
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
+6 -2
View File
@@ -17,13 +17,17 @@
"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:business-chain": "npx esbuild src/roundtable-planning/lib/business-chain.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/business-chain.test.mjs && node --test .runtime/unit-tests/business-chain.test.mjs",
"test:users-paged": "node scripts/test-users-paged.mjs",
"test:auth-entry": "node scripts/test-auth-entry.mjs",
"test:b5-1-theme": "node --experimental-strip-types --test src/core/auth/b5-1-theme.test.mjs",
"test:task-entry": "node --experimental-strip-types --test src/core/auth/task-entry.test.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:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts src/components/workspace/visualizations/map-style.test.ts",
"test:composer-session": "node --experimental-strip-types --test src/core/settings/composer-scope.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-chrome-css": "node scripts/check-chrome109-css.mjs",
"test:retry-kept": "npx esbuild src/core/threads/retry-kept-messages.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/retry-kept-messages.test.mjs && node --test .runtime/unit-tests/retry-kept-messages.test.mjs"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
+5 -1
View File
@@ -42,7 +42,7 @@ window.__CM_RUNTIME_CONFIG__ = {
// 地图瓦片地址模板:仅供普通问答消息列表中的交互地图使用。
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png",
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
@@ -112,6 +112,9 @@ window.__CM_RUNTIME_CONFIG__ = {
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
// B 系统(XWT检索)头像代理;按部署环境设置为浏览器可访问的地址。
VITE_AIPI_MINIO_PROXY_URL: "http://minio-proxy.b.sit.4.cn/facebook-proxy",
// B 系统 knowes 根地址。
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
@@ -128,6 +131,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_USE_REAL_DATA: "true",
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
// 为 true 时同时拦截 xdfx 的 task-action/actionDetail 与 mainnets/narratives-by-name。
VITE_MOCK_COP_TASK_DETAIL: "false",
// 岗位会商完整演示数据:true=启动时幂等写入后端;后端不可用时直接使用前端 JSON 兜底。
+1
View File
@@ -14,6 +14,7 @@ const testEntries = {
"login-entry": "src/core/auth/login-entry.test.ts",
"session-validation": "src/core/auth/session-validation.test.ts",
"auth-request-policy": "src/core/api/auth-request-policy.test.ts",
"login-transport": "src/core/auth/login-transport.test.ts",
};
const outputDirectory = path.join(root, ".runtime/unit-tests");
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = fileURLToPath(new URL("../", import.meta.url));
const require = createRequire(import.meta.url);
const viteRequire = createRequire(require.resolve("vite/package.json"));
const { build, stop } = viteRequire("esbuild");
const outfile = path.join(root, ".runtime/unit-tests/users-paged-api.mjs");
try {
await build({
absWorkingDir: root,
entryPoints: ["src/core/auth/api.ts"],
outfile,
bundle: true,
platform: "node",
format: "esm",
plugins: [{
name: "mock-user-api-dependencies",
setup(builder) {
builder.onResolve({ filter: /^@\/core\/(api\/fetch-client|config)$/ }, ({ path }) => ({ path, namespace: "mock" }));
builder.onLoad({ filter: /.*/, namespace: "mock" }, ({ path }) => ({
contents: path.endsWith("config")
? 'export const getBackendBaseURL = () => "https://example.test";'
: 'export const apiFetch = (...args) => globalThis.usersPagedFetch(...args);',
}));
},
}],
});
} finally {
stop();
}
const { listUsersPaged } = await import(pathToFileURL(outfile).href);
const fetchPage = (signal) => listUsersPaged({ q: "alice", limit: 20, offset: 40 }, signal);
test("whitelist requests pagination and search without statistics", async () => {
const users = { items: [{ id: "u1", email: "one@example.test", approved: false }], total: 41, approved_count: 40, limit: 20, offset: 40 };
globalThis.usersPagedFetch = async (url, init) => {
assert.equal(url, "https://example.test/api/v1/auth/users?limit=20&offset=40&include_counts=false&q=alice");
assert.ok(init.signal instanceof AbortSignal);
return Response.json(users);
};
assert.deepEqual(await fetchPage(), users);
});
test("server timeout detail reaches the retry UI", async () => {
globalThis.usersPagedFetch = async () => Response.json({ detail: "用户基础信息查询超时,请稍后重试" }, { status: 503 });
await assert.rejects(fetchPage(), /用户基础信息查询超时/);
});
test("client deadline aborts pending request and clears its timer", async (t) => {
let deadline;
const timer = {};
t.mock.method(globalThis, "setTimeout", (callback, delay) => {
assert.equal(delay, 20_000);
deadline = callback;
return timer;
});
const cleared = t.mock.method(globalThis, "clearTimeout", (value) => assert.equal(value, timer));
globalThis.usersPagedFetch = (_url, { signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
const pending = fetchPage();
deadline();
await assert.rejects(pending, /用户列表加载超时/);
assert.equal(cleared.mock.callCount(), 1);
});
test("query cancellation propagates without being reported as a timeout", async () => {
const query = new AbortController();
globalThis.usersPagedFetch = (_url, { signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
const pending = fetchPage(query.signal);
query.abort();
await assert.rejects(pending, { name: "AbortError" });
});
test("non-JSON errors preserve HTTP status", async () => {
globalThis.usersPagedFetch = async () => new Response("unavailable", { status: 502 });
await assert.rejects(fetchPage(), /HTTP 502/);
});
test("already cancelled requests remain cancellation errors", async () => {
const controller = new AbortController();
controller.abort();
globalThis.usersPagedFetch = async (_url, { signal }) => { signal.throwIfAborted(); };
await assert.rejects(fetchPage(controller.signal), { name: "AbortError" });
});
+25 -10
View File
@@ -6,6 +6,7 @@ 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 { Toaster } from "@/components/ui/sonner";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { RequireAccount } from "@/core/auth/require-account";
import { I18nProvider } from "@/core/i18n/context";
@@ -36,6 +37,7 @@ import {
const LandingPage = lazy(() => import("./pages/LandingPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const PageRoutes = lazy(() => import("./pages/PageRoutes"));
const MarkdownDocxEditorPage = lazy(() => import("./pages/MarkdownDocxEditorPage"));
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
const PublicScheduledTaskFullscreenPage = lazy(() =>
import("./pages/PublicScheduledTaskPage").then((module) => ({
@@ -81,6 +83,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
const location = useLocation();
const publicEmbedTheme = usePublicEmbedTheme();
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
const markdownToolMode = location.pathname.replace(/\/+$/, "") === "/tools/md-to-word";
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
useEffect(() => {
@@ -89,9 +92,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
useEffect(() => {
if (positionStandaloneMode) return;
if (positionStandaloneMode || markdownToolMode) return;
void fetchYLogConfig();
}, [positionStandaloneMode]);
}, [positionStandaloneMode, markdownToolMode]);
const forcedTheme = useMemo(() => {
// b5-1 任务分析深链最高优先:接入方传 ?theme= 用传入值,不传默认深蓝。
// 先于 embed 判定,避免 embed=1 缺省 light 盖掉本页的深蓝默认。
@@ -135,15 +138,17 @@ function AppProviders({ children }: { children: React.ReactNode }) {
forcedTheme={forcedTheme}
>
<QueryClientProvider>
<AuthSyncWatcher disabled={positionStandaloneMode}>
<AuthSyncWatcher disabled={positionStandaloneMode || markdownToolMode}>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
<ChatProvider>
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
{markdownToolMode ? children : (
<ThemeProvider>
<ChatProvider>
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
)}
</I18nProvider>
</AuthSyncWatcher>
</QueryClientProvider>
@@ -198,6 +203,16 @@ export function App() {
}
/>
<Route path="/landing" element={<LandingPage />} />
{/* Anonymous standalone tool: no workspace navigation or login gate. */}
<Route
path="/tools/md-to-word"
element={
<div className="h-dvh overflow-hidden">
<MarkdownDocxEditorPage />
<Toaster />
</div>
}
/>
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -998,11 +998,24 @@ export const PromptInputTextarea = ({
// 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.
// stuck at its minimum height. A custom appearance height replaces that range.
let minBound = 64;
let maxBound = 192;
if (textarea.hasAttribute("data-composer-height")) {
const computed = window.getComputedStyle(textarea);
const minHeight = Number.parseFloat(computed.minHeight);
const maxHeight = Number.parseFloat(computed.maxHeight);
if (Number.isFinite(minHeight)) minBound = minHeight;
if (Number.isFinite(maxHeight)) maxBound = maxHeight;
}
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
const height = Math.max(
minBound,
Math.min(textarea.scrollHeight, maxBound),
);
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
textarea.style.overflowY =
textarea.scrollHeight > maxBound ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
@@ -0,0 +1,67 @@
/* DialogContent is rendered in a portal. Use the root theme selectors directly
* so deep-blue styles work regardless of Tailwind's variant compilation order. */
:root.dark .document-upload-dialog,
:root[theme-mode="dark"] .document-upload-dialog {
border-color: #2d4473 !important;
background-color: #0c1836 !important;
color: #c5cfe0 !important;
}
:root.dark .document-upload-dialog__search,
:root[theme-mode="dark"] .document-upload-dialog__search {
border-color: #2d4473 !important;
background-color: #122347 !important;
}
:root.dark .document-upload-dialog__search:hover,
:root[theme-mode="dark"] .document-upload-dialog__search:hover {
border-color: #4a6491 !important;
}
:root.dark .document-upload-dialog__search-input,
:root[theme-mode="dark"] .document-upload-dialog__search-input {
color: #c5cfe0 !important;
}
:root.dark .document-upload-dialog__search-input::placeholder,
:root[theme-mode="dark"] .document-upload-dialog__search-input::placeholder,
:root.dark .document-upload-dialog__search-icon,
:root[theme-mode="dark"] .document-upload-dialog__search-icon {
color: #6e88b0 !important;
}
:root.dark .document-upload-dialog__search-button,
:root[theme-mode="dark"] .document-upload-dialog__search-button {
background-color: #1677ff !important;
}
:root.dark .document-upload-dialog__search-button:hover,
:root[theme-mode="dark"] .document-upload-dialog__search-button:hover {
background-color: #0958d9 !important;
}
:root.dark .document-upload-dialog__tabs,
:root[theme-mode="dark"] .document-upload-dialog__tabs {
border-color: #2d4473 !important;
background-color: #122347 !important;
}
:root.dark .document-upload-dialog__tab,
:root[theme-mode="dark"] .document-upload-dialog__tab {
color: #6e88b0 !important;
}
:root.dark .document-upload-dialog__tab:hover,
:root[theme-mode="dark"] .document-upload-dialog__tab:hover {
color: #c5cfe0 !important;
}
:root.dark .document-upload-dialog__tab--active,
:root[theme-mode="dark"] .document-upload-dialog__tab--active {
color: #91caff !important;
}
:root.dark .document-upload-dialog__tab-indicator,
:root[theme-mode="dark"] .document-upload-dialog__tab-indicator {
background-color: #4096ff !important;
}
@@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { readRuntimeEnv } from "@/env";
import "./document-upload-popup.css";
import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty";
@@ -599,13 +600,13 @@ const DocumentUploadPopupContent: FC<{
};
const yUserId = getYUserId();
const userToken = getStoredAuth()?.access_token;
const iframeTokenParam = userToken ? `&token=${encodeURIComponent(userToken)}` : "";
const notebookToken = getNotebookToken();
const iframeTokenParam = notebookToken ? `&token=${encodeURIComponent(notebookToken)}` : "";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4"
className="document-upload-dialog w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground dark:border-[#2d4473] dark:bg-[#0c1836] dark:text-[#c5cfe0] [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 dark:[&>button]:text-[#c5cfe0]"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
@@ -615,7 +616,7 @@ const DocumentUploadPopupContent: FC<{
<div className="h-full overflow-y-auto">
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
{/* 搜索框 */}
<div className="relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300">
<div className="document-upload-dialog__search relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300 dark:border-[#2d4473] dark:bg-[#122347] dark:hover:border-[#4a6491]">
<Textarea
placeholder="搜索资料..."
value={searchText}
@@ -626,24 +627,24 @@ const DocumentUploadPopupContent: FC<{
searchDataChange();
}
}}
className="max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
className="document-upload-dialog__search-input max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none dark:text-[#c5cfe0] dark:placeholder:text-[#6e88b0]"
rows={1}
/>
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none">
<div className="document-upload-dialog__search-icon absolute left-3 top-3 text-neutral-400 pointer-events-none dark:text-[#6e88b0]">
<Search className="h-4 w-4" strokeWidth={1.5} />
</div>
<Button
size="sm"
onClick={searchDataChange}
disabled={isSearching || !searchText.trim()}
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
className="document-upload-dialog__search-button absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40 dark:bg-[#1677ff] dark:hover:bg-[#0958d9]"
>
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <span>搜索</span>}
</Button>
</div>
{/* Tab 标签页 */}
<div className="border-b border-gray-200 rounded-t-xl px-4 bg-gray-50">
<div className="document-upload-dialog__tabs border-b border-gray-200 rounded-t-xl px-4 bg-gray-50 dark:border-[#2d4473] dark:bg-[#122347]">
<div className="flex flex-nowrap overflow-x-auto">
{(
[
@@ -657,12 +658,12 @@ const DocumentUploadPopupContent: FC<{
<button
key={key}
onClick={() => void searchActiveChange(key)}
className={`relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900" : "text-gray-400 hover:text-gray-600"
className={`document-upload-dialog__tab ${activeTab === key ? "document-upload-dialog__tab--active" : ""} relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900 dark:text-[#91caff]" : "text-gray-400 hover:text-gray-600 dark:text-[#6e88b0] dark:hover:text-[#c5cfe0]"
}`}
>
{label}
{activeTab === key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
<span className="document-upload-dialog__tab-indicator absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full dark:bg-[#4096ff]" />
)}
</button>
))}
@@ -0,0 +1,169 @@
/* 深蓝主题下,XWT 检索面板使用全局 deep-blue token,与弹窗外壳保持一致。 */
:root.dark .network-actor,
:root[theme-mode="dark"] .network-actor {
color: #c5cfe0;
}
:root.dark .network-actor__filter-surface,
:root[theme-mode="dark"] .network-actor__filter-surface {
background-color: #122347 !important;
}
:root.dark .network-actor__filter-panel,
:root[theme-mode="dark"] .network-actor__filter-panel {
border-color: #2d4473 !important;
background-color: #0f1d3e;
}
:root.dark .network-actor__filter-label,
:root.dark .network-actor__section-title,
:root.dark .network-actor__metric-label,
:root[theme-mode="dark"] .network-actor__filter-label,
:root[theme-mode="dark"] .network-actor__section-title,
:root[theme-mode="dark"] .network-actor__metric-label {
color: #c5cfe0 !important;
}
:root.dark .network-actor__filter-chip,
:root[theme-mode="dark"] .network-actor__filter-chip {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
color: #c5cfe0 !important;
}
:root.dark .network-actor__filter-chip:hover,
:root[theme-mode="dark"] .network-actor__filter-chip:hover {
border-color: #4a6491 !important;
background-color: #243b68 !important;
}
:root.dark .network-actor__filter-chip[aria-pressed="true"],
:root[theme-mode="dark"] .network-actor__filter-chip[aria-pressed="true"] {
border-color: #1677ff !important;
background-color: #122347 !important;
color: #91caff !important;
}
:root.dark .network-actor__filter-chip:focus-visible,
:root[theme-mode="dark"] .network-actor__filter-chip:focus-visible,
:root.dark .network-actor__sort-button:focus-visible,
:root[theme-mode="dark"] .network-actor__sort-button:focus-visible {
outline: 2px solid #69b1ff;
outline-offset: 2px;
}
:root.dark .network-actor__platform-divider,
:root[theme-mode="dark"] .network-actor__platform-divider,
:root.dark .network-actor__metric-divider,
:root[theme-mode="dark"] .network-actor__metric-divider {
background-color: #4a6491 !important;
}
:root.dark .network-actor__results-surface,
:root[theme-mode="dark"] .network-actor__results-surface {
background-color: #122347 !important;
}
:root.dark .network-actor__results-header,
:root[theme-mode="dark"] .network-actor__results-header {
border-color: #2d4473 !important;
}
:root.dark .network-actor__title-accent,
:root[theme-mode="dark"] .network-actor__title-accent {
background-color: #1677ff !important;
}
:root.dark .network-actor__sort-button,
:root[theme-mode="dark"] .network-actor__sort-button {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
color: #c5cfe0 !important;
}
:root.dark .network-actor__sort-button:hover,
:root[theme-mode="dark"] .network-actor__sort-button:hover {
border-color: #4a6491 !important;
background-color: #243b68 !important;
}
:root.dark .network-actor__sort-button--active,
:root[theme-mode="dark"] .network-actor__sort-button--active {
border-color: #1677ff !important;
background-color: #122347 !important;
color: #91caff !important;
}
:root.dark .network-actor__sort-button svg path,
:root[theme-mode="dark"] .network-actor__sort-button svg path {
fill: #4a6491;
}
:root.dark .network-actor__sort-button--active svg[data-direction="asc"] path:first-child,
:root[theme-mode="dark"] .network-actor__sort-button--active svg[data-direction="asc"] path:first-child,
:root.dark .network-actor__sort-button--active svg[data-direction="desc"] path:last-child,
:root[theme-mode="dark"] .network-actor__sort-button--active svg[data-direction="desc"] path:last-child {
fill: #4096ff;
}
:root.dark .network-actor__metric-value--selected,
:root[theme-mode="dark"] .network-actor__metric-value--selected {
color: #ffc53d !important;
}
:root.dark .network-actor__metric-value--total,
:root[theme-mode="dark"] .network-actor__metric-value--total,
:root.dark .network-actor__meta-icon,
:root[theme-mode="dark"] .network-actor__meta-icon {
color: #69b1ff !important;
}
:root.dark .network-actor__selection-bar,
:root[theme-mode="dark"] .network-actor__selection-bar {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
color: #c5cfe0;
}
:root.dark .network-actor__card,
:root[theme-mode="dark"] .network-actor__card {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
box-shadow: 0 1px 4px rgba(6, 14, 36, 0.3);
color: #c5cfe0;
}
:root.dark .network-actor__card:hover,
:root[theme-mode="dark"] .network-actor__card:hover {
border-color: #4a6491 !important;
box-shadow: 0 4px 12px rgba(6, 14, 36, 0.45);
}
:root.dark .network-actor__card[data-selected="true"],
:root[theme-mode="dark"] .network-actor__card[data-selected="true"] {
border-color: #1677ff !important;
box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.24);
}
:root.dark .network-actor__avatar,
:root[theme-mode="dark"] .network-actor__avatar {
border-color: #2d4473 !important;
background-color: #122347 !important;
}
:root.dark .network-actor__classification,
:root[theme-mode="dark"] .network-actor__classification {
background-color: rgba(245, 34, 45, 0.16) !important;
color: #ff7875 !important;
}
:root.dark .network-actor__tag,
:root[theme-mode="dark"] .network-actor__tag {
background-color: rgba(19, 194, 194, 0.14) !important;
color: #5cdbd3 !important;
}
:root.dark .network-actor input[type="checkbox"],
:root[theme-mode="dark"] .network-actor input[type="checkbox"] {
accent-color: #1677ff;
}
@@ -6,12 +6,33 @@ import {
useState,
} from "react";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
import {
CirclePlay,
FileText,
FolderTree,
Globe2,
Loader2,
Network,
Plus,
Tag,
Trash2,
UserRoundCheck,
UsersRound,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { authenticatedFetch } from "@/components/assistant-ui/document-upload-popup";
import { readRuntimeEnv } from "@/env";
import xLogo from "@/assets/platform/x.png";
import facebookLogo from "@/assets/platform/facebook.png";
import redditLogo from "@/assets/platform/reddit.png";
import pttLogo from "@/assets/platform/ptt.png";
import dcardLogo from "@/assets/platform/dcard.png";
import mobile01Logo from "@/assets/platform/mobile01.png";
import threadsLogo from "@/assets/platform/threads.png";
import instagramLogo from "@/assets/platform/ins.png";
import "./network-actor.css";
interface NetworkActorProps {
@@ -22,7 +43,6 @@ interface NetworkActorProps {
interface platformItem {
title: string;
icon: string;
checked: boolean;
}
@@ -31,6 +51,14 @@ interface languageItem {
checked: boolean;
}
interface ActorSearchFilters {
platforms: string[];
languages: string[];
direction: string;
sortBy: string;
sortDirection: "asc" | "desc";
}
interface PoliticsDirItem {
地区: string;
语言: string;
@@ -68,6 +96,86 @@ interface hx_info {
用户画像: 用户画像;
}
// 图片必须经 import 走 Vite 打包才能拼上 base(服务器带路径前缀时 public/ 绝对路径会 404)
const PLATFORM_LOGOS: Record<string, string> = {
x: xLogo,
facebook: facebookLogo,
reddit: redditLogo,
ptt: pttLogo,
dcard: dcardLogo,
mobile01: mobile01Logo,
threads: threadsLogo,
instagram: instagramLogo,
};
// 后端返回的平台名与筛选 chips 的大小写/缩写不完全一致(如 FB、INS、YT、TH),统一归一到上面的 key
const PLATFORM_KEY_RULES: Array<[RegExp, string]> = [
[/instagram|\bins\b|\big\b/, "instagram"],
[/facebook|\bfb\b/, "facebook"],
[/youtube|\byt\b|油管/, "youtube"],
[/threads|\bth\b/, "threads"],
[/mobile01|\bmobile\b/, "mobile01"],
[/dcard/, "dcard"],
[/ptt/, "ptt"],
[/reddit|\bre\b/, "reddit"],
[/twitter|推特/, "x"],
];
function platformLogoKey(platform: string): string {
const key = platform.trim().toLowerCase();
if (PLATFORM_LOGOS[key]) return key;
for (const [pattern, canonical] of PLATFORM_KEY_RULES) {
if (pattern.test(key)) return canonical;
}
return key;
}
function PlatformIcon({ platform, className = "" }: { platform: string; className?: string }) {
const key = platformLogoKey(platform);
const src = PLATFORM_LOGOS[key];
if (src) {
return <img src={src} className={`h-5 w-5 shrink-0 object-contain ${className}`} alt="" aria-hidden="true" />;
}
if (key === "youtube") {
return <CirclePlay className={`h-5 w-5 shrink-0 text-red-600 ${className}`} aria-hidden="true" />;
}
return <Globe2 className={`h-5 w-5 shrink-0 text-slate-500 ${className}`} aria-hidden="true" />;
}
function MetaIcon({
type,
className = "",
}: {
type: "followers" | "person" | "category" | "tags" | "summary" | "network";
className?: string;
}) {
const iconClassName = `network-actor__meta-icon h-4 w-4 shrink-0 ${className}`;
const icons = {
followers: <UsersRound className={iconClassName} aria-hidden="true" />,
person: <UserRoundCheck className={iconClassName} aria-hidden="true" />,
category: <FolderTree className={iconClassName} aria-hidden="true" />,
tags: <Tag className={iconClassName} aria-hidden="true" />,
summary: <FileText className={iconClassName} aria-hidden="true" />,
network: <Network className={iconClassName} aria-hidden="true" />,
};
return icons[type];
}
function SortDirectionIcon({ direction, active }: { direction: "asc" | "desc"; active: boolean }) {
const ascendingColor = active && direction === "asc" ? "#cf1322" : "#cbd5e1";
const descendingColor = active && direction === "desc" ? "#cf1322" : "#cbd5e1";
return (
<svg viewBox="0 0 12 18" data-direction={direction} className="h-5 w-3 shrink-0" aria-hidden="true">
<path d="M6 2 10 7H2L6 2Z" fill={ascendingColor} />
<path d="m6 16-4-5h8l-4 5Z" fill={descendingColor} />
</svg>
);
}
interface UserDataItem {
comment_count: number;
country: string | null;
@@ -114,9 +222,9 @@ interface UserDataItem {
birthday: string | null;
is_virtual: number;
isMedia: number;
hx_info: hx_info;
hx_info?: hx_info | null;
gender: string | null;
agentInfo: PoliticsDirItem;
agentInfo?: PoliticsDirItem | null;
profile_url: string | null;
tagId: string[];
user_name: string;
@@ -152,15 +260,15 @@ interface UserDataItem {
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
const [platforms, setPlatforms] = useState<platformItem[]>([
{ title: 'X', icon: 'x.png', checked: false },
{ title: 'Facebook', icon: 'facebook.png', checked: false },
{ title: 'Reddit', icon: 'reddit.png', checked: false },
{ title: 'PTT', icon: 'ptt.png', checked: false },
{ title: 'YouTube', icon: 'youtube.png', checked: false },
{ title: 'Dcard', icon: 'dcard.png', checked: false },
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
{ title: 'Threads', icon: 'threads.png', checked: false },
{ title: 'Instagram', icon: 'ins.png', checked: false },
{ title: 'X', checked: false },
{ title: 'Facebook', checked: false },
{ title: 'Reddit', checked: false },
{ title: 'PTT', checked: false },
{ title: 'YouTube', checked: false },
{ title: 'Dcard', checked: false },
{ title: 'Mobile01', checked: false },
{ title: 'Threads', checked: false },
{ title: 'Instagram', checked: false },
]);
const platformList = useRef<string[]>([]);
const [languages, setLanguages] = useState<languageItem[]>([
@@ -188,25 +296,37 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
const [directionType, setDirectionType] = useState<string>('TW');
const directionValue = useRef<string>('TW');
const consumerUrl = readRuntimeEnv("VITE_AIPI_CONSUMER_URL") || 'https://ch1.b.uat.4.cn/consumer';
const backendUrl = typeof window !== 'undefined'
? (readRuntimeEnv("VITE_BACKEND_BASE_URL") || 'http://localhost:8001')
: 'http://localhost:8001';
const minioProxyUrl = readRuntimeEnv("VITE_AIPI_MINIO_PROXY_URL") || 'http://minio-proxy.b.sit.4.cn/facebook-proxy';
const actorBaseUrl = readRuntimeEnv("VITE_AIPI_B2ACTOR_URL") || 'http://ty.b.uat.4.cn';
const avatarUrl = (profileImage: string | null) => profileImage
? `${minioProxyUrl}?${new URLSearchParams({ url: profileImage, is_avatar: 'true' })}`
: `${actorBaseUrl}/manage/img/default.png`;
const backendUrl = readRuntimeEnv("VITE_AIPI_BACKENP_URL") || 'http://localhost:8001';
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
const [isImporting, setIsImporting] = useState(false);
const searchChange = async () => {
const resolveSearchFilters = (overrides: Partial<ActorSearchFilters> = {}): ActorSearchFilters => ({
platforms: overrides.platforms ?? platformList.current,
languages: overrides.languages ?? languageList.current,
direction: overrides.direction ?? directionValue.current,
sortBy: overrides.sortBy ?? sortTypeValue.current,
sortDirection: overrides.sortDirection ?? sortValue.current as ActorSearchFilters["sortDirection"],
});
const searchChange = async (overrides: Partial<ActorSearchFilters> = {}) => {
setIsSearching(true);
const filters = resolveSearchFilters(overrides);
if (searchValue.current === 'network') {
await agentUserSearch();
await agentUserSearch(filters);
} else {
await agentGroupSearch();
await agentGroupSearch(filters);
}
};
const setDirectionChange = async (type: string) => {
setDirectionType(type);
directionValue.current = type;
await searchChange();
await searchChange({ direction: type });
};
const searchTypeChange = async (value: string) => {
@@ -216,135 +336,128 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
};
const allPlatformChange = async () => {
const nextPlatforms = platforms.map((item) => ({ ...item, checked: false }));
setAllPlatforms(true);
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
setPlatforms(nextPlatforms);
platformList.current = [];
await searchChange();
await searchChange({ platforms: [] });
};
const handleTogglePlatform = async (index: number) => {
setPlatforms((prev) => {
const newPlatforms = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
platformList.current = checkedTitles;
setAllPlatforms(newPlatforms.every((item) => !item.checked));
return newPlatforms;
});
await searchChange();
const nextPlatforms = platforms.map((item, itemIndex) => (
itemIndex === index ? { ...item, checked: !item.checked } : item
));
const checkedTitles = nextPlatforms.filter((item) => item.checked).map((item) => item.title);
setPlatforms(nextPlatforms);
platformList.current = checkedTitles;
setAllPlatforms(checkedTitles.length === 0);
await searchChange({ platforms: checkedTitles });
};
const allLanguageChange = async () => {
const nextLanguages = languages.map((item) => ({ ...item, checked: false }));
setAllLanguage(true);
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
setLanguages(nextLanguages);
languageList.current = [];
await searchChange();
await searchChange({ languages: [] });
};
const handleToggleLanguage = async (index: number) => {
setLanguages((prev) => {
const newItems = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
languageList.current = checkedTitles;
setAllLanguage(newItems.every((item) => !item.checked));
return newItems;
});
await searchChange();
const nextLanguages = languages.map((item, itemIndex) => (
itemIndex === index ? { ...item, checked: !item.checked } : item
));
const checkedTitles = nextLanguages.filter((item) => item.checked).map((item) => item.title);
setLanguages(nextLanguages);
languageList.current = checkedTitles;
setAllLanguage(checkedTitles.length === 0);
await searchChange({ languages: checkedTitles });
};
const sortTypeChange = async (type: string) => {
const nextSortDirection = (sortValue.current === 'desc' ? 'asc' : 'desc') as ActorSearchFilters["sortDirection"];
setSortType(type);
sortTypeValue.current = type;
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
if (type === 'fans_count') setFansSort(sortValue.current);
else if (type === '_score') setRelevanceSort(sortValue.current);
await searchChange();
sortValue.current = nextSortDirection;
if (type === 'fans_count') setFansSort(nextSortDirection);
else if (type === '_score') setRelevanceSort(nextSortDirection);
await searchChange({ sortBy: type, sortDirection: nextSortDirection });
};
const agentUserSearch = async () => {
const agentUserSearch = async (filters: ActorSearchFilters) => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyKey: ['党政要员'],
historyTitle: '',
keyword: searchText,
language: languageList.current,
language: filters.languages,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
platform: filters.platforms,
politicalStand: filters.direction,
sort: filters.sortBy,
asc: filters.sortDirection,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.list;
result.forEach((item) => {
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo['账号描述'];
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data.data.total);
setActorCount(data.data.zs_total);
}
if (!response.ok) return;
const data = await response.json();
const result: UserDataItem[] = Array.isArray(data?.data?.list) ? data.data.list : [];
result.forEach((item) => {
item.profile_image = avatarUrl(item.profile_image);
item.classification = `${item.political_stand ?? ''}-${item.history_key ?? ''}-${item.history_title ?? ''}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo?.['账号描述'] || item.hx_info?.['用户画像']?.['账号描述'] || '';
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data?.data?.total ?? 0);
setActorCount(data?.data?.zs_total ?? 0);
} catch (error) {
console.error('Search error:', error);
} finally {
setIsSearching(false);
}
};
const agentGroupSearch = async () => {
const agentGroupSearch = async (filters: ActorSearchFilters) => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyKey: ['党政要员'],
historyTitle: '',
keyword: searchText,
language: languageList.current,
language: filters.languages,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
platform: filters.platforms,
politicalStand: filters.direction,
sort: filters.sortBy,
asc: filters.sortDirection,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.data;
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}`;
item.title = item.group_name || item.groupName;
if (item.hx_info?.['用户画像']) {
item.content = item.hx_info['用户画像']['账号描述'];
}
item.url = '';
});
setUserList(result);
setActorCount(data.data.total);
}
if (!response.ok) return;
const data = await response.json();
const result: UserDataItem[] = Array.isArray(data?.data?.data) ? data.data.data : [];
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = avatarUrl(item.profile_image);
item.classification = `${item.political_stand ?? ''}`;
item.title = item.group_name || item.groupName;
item.content = item.hx_info?.['用户画像']?.['账号描述'] || item.agentInfo?.['账号描述'] || '';
item.url = '';
});
setUserList(result);
setActorCount(data?.data?.total ?? 0);
} catch (error) {
console.error('Search error:', error);
} finally {
setIsSearching(false);
}
};
@@ -411,122 +524,128 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
}, []);
return (
<div>
<div className="bg-white rounded-[5px] p-[10px]">
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div className="network-actor">
<div className="network-actor__filter-surface bg-white rounded-[5px] p-[10px]">
<div className="network-actor__filter-panel border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">所属平台:</span>
<span className="network-actor__filter-label text-[#3D3D3D]">所属平台:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
<button
type="button"
aria-pressed={allPlatforms}
className={`network-actor__filter-chip flex cursor-pointer items-center rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${allPlatforms ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
onClick={() => void allPlatformChange()}
>全部</span>
>全部</button>
{platforms.map((item, index) => (
<span
<button
type="button"
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
aria-pressed={item.checked}
className={`network-actor__filter-chip flex cursor-pointer items-center rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${item.checked ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
onClick={() => void handleTogglePlatform(index)}
>
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
<span className="ml-[5px]">{item.title}</span>
</span>
<PlatformIcon platform={item.title} className="h-5 w-5" />
<span className="network-actor__platform-divider mx-2 h-3 w-px bg-[#dbdbdb]" aria-hidden="true"></span>
<span>{item.title}</span>
</button>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">常用语言:</span>
<span className="network-actor__filter-label text-[#3D3D3D]">常用语言:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
<button
type="button"
aria-pressed={allLanguage}
className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${allLanguage ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
onClick={() => void allLanguageChange()}
>全部</span>
>全部</button>
{languages.map((item, index) => (
<span
<button
type="button"
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
aria-pressed={item.checked}
className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${item.checked ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
onClick={() => void handleToggleLanguage(index)}
>
<span>{item.title}</span>
</span>
</button>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">方向:</span>
<span className="network-actor__filter-label text-[#3D3D3D]">方向:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
<button type="button" aria-pressed={directionType === 'TW'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${directionType === 'TW' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void setDirectionChange('TW')}>W方向</button>
<button type="button" aria-pressed={directionType === 'US'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${directionType === 'US' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void setDirectionChange('US')}>U方向</button>
<button type="button" aria-pressed={directionType === '其他'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${directionType === '其他' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void setDirectionChange('其他')}>其他</button>
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">类型:</span>
<span className="network-actor__filter-label text-[#3D3D3D]">类型:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
<button type="button" aria-pressed={searchType === 'network'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${searchType === 'network' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void searchTypeChange('network')}>网络XWT</button>
<button type="button" aria-pressed={searchType === 'group'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${searchType === 'group' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void searchTypeChange('group')}>群组XWT</button>
</span>
</span>
</div>
</div>
</div>
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="network-actor__results-surface bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="network-actor__results-header flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="flex items-center ml-[10px]">
<div className="title-bar mr-[8px]"></div>
<div className="text-[#454859]">用户表格管理</div>
<div className="network-actor__title-accent mr-2 h-5 w-1 rounded-full bg-[#cf1322]"></div>
<div className="network-actor__section-title text-[#454859]">用户表格管理</div>
</div>
<div className="flex items-center">
<div
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
<button
type="button"
aria-label={`按粉丝数排序,当前${fansSort === 'asc' ? '升序' : '降序'}`}
className={`network-actor__sort-button ${sortType === 'fans_count' ? 'network-actor__sort-button--active' : ''} relative flex cursor-pointer items-center rounded-md border px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${sortType === 'fans_count' ? 'border-[#cf1322] bg-[#fffafa] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:border-slate-300 hover:bg-slate-50'}`}
onClick={() => void sortTypeChange('fans_count')}
>
<span className="leading-[16px]">粉丝数排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
<div
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
<span className="ml-2"><SortDirectionIcon direction={fansSort as "asc" | "desc"} active={sortType === 'fans_count'} /></span>
</button>
<button
type="button"
aria-label={`按相关度排序,当前${relevanceSort === 'asc' ? '升序' : '降序'}`}
className={`network-actor__sort-button ${sortType === '_score' ? 'network-actor__sort-button--active' : ''} relative ml-2 flex cursor-pointer items-center rounded-md border px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${sortType === '_score' ? 'border-[#cf1322] bg-[#fffafa] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:border-slate-300 hover:bg-slate-50'}`}
onClick={() => void sortTypeChange('_score')}
>
<span className="leading-[16px]">相关度排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
<span className="ml-2"><SortDirectionIcon direction={relevanceSort as "asc" | "desc"} active={sortType === '_score'} /></span>
</button>
{searchType === 'network' && (
<>
<div className="flex items-center ml-[35px]">
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
<div className="network-actor__metric flex items-center ml-[35px]">
<MetaIcon type="network" className="h-6 w-6 text-[#d97b00]" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
<div className="network-actor__metric-label text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="network-actor__metric-value network-actor__metric-value--selected text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
</div>
</div>
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
<div className="network-actor__metric-divider ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
</>
)}
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
<div className={`network-actor__metric flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<MetaIcon type="network" className="h-6 w-6 text-[#cf1322]" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
<div className="network-actor__metric-label text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="network-actor__metric-value network-actor__metric-value--total text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
</div>
</div>
</div>
</div>
{selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<div className="network-actor__selection-bar flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
@@ -551,9 +670,13 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : null}
<div className={`grid grid-cols-4 gap-[10px] user-list${isSearching ? ' hidden' : ''}`}>
<div className={`grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4${isSearching ? ' hidden' : ''}`}>
{userList.map((item, index) => (
<div key={index} className="user-item p-[10px] relative">
<div
key={index}
data-selected={item.checked ?? false}
className={`network-actor__card relative rounded-lg border bg-white p-4 shadow-sm transition-[border-color,box-shadow] duration-200 motion-reduce:transition-none hover:border-[#f1b5ba] hover:shadow-md ${item.checked ? 'border-[#cf1322] ring-1 ring-[#cf1322]/15' : 'border-[#e5e7eb]'}`}
>
<div className="absolute top-[5px] right-[10px]">
<input
type="checkbox"
@@ -563,21 +686,21 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
/>
</div>
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
<img src={item.profile_image ?? ''} className="network-actor__avatar h-[97px] w-[97px] rounded-md border border-[#e5e7eb] bg-slate-50 object-cover" alt="" />
<div className="flex-1 min-w-0 pl-[10px]">
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
<div className="flex justify-between mb-[10px]">
<div className="flex items-center">
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
<MetaIcon type="followers" className="mr-[5px] text-[#d97b00]" />
<span>粉丝数:{item.fans_count || '-'}</span>
</div>
<div className="flex items-center">
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
<PlatformIcon platform={item.platform} className="mr-[5px]" />
<span>{item.platform}</span>
</div>
</div>
<div className="flex items-center">
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
<MetaIcon type="person" className="mr-[5px] text-[#cf1322]" />
<span>实体人:{item.name}</span>
</div>
</div>
@@ -585,29 +708,29 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
<MetaIcon type="category" className="mr-[5px] text-[#cf1322]" />
<span>分类:</span>
</div>
<div className="flex-1 min-w-0">
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
<div className="network-actor__classification inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
</div>
</div>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<MetaIcon type="tags" className="mr-[5px] text-[#1eb07f]" />
<span>标签:</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-[5px]">
{(item.hobbies ?? []).map((hobbie) => (
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
<div key={hobbie} className="network-actor__tag h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
))}
</div>
</div>
</div>
<div className="flex items-baseline mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<MetaIcon type="summary" className="mr-[5px] text-slate-500" />
<span>简介:</span>
</div>
<div className="flex-1 min-w-0">
@@ -38,6 +38,7 @@ export function HtmlPreviewFrame({
return (
<iframe
key={url ?? "html-preview"}
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
@@ -4,7 +4,7 @@ import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useMemo } from "react";
@@ -29,13 +29,13 @@ export function MarkdownCodeEditor({
}) {
const { resolvedTheme } = useTheme();
const extensions = useMemo(
() => [markdown({ base: markdownLanguage, codeLanguages: languages })],
() => [markdown({ base: markdownLanguage, codeLanguages: languages }), EditorView.lineWrapping],
[],
);
return (
<div className={cn("grid min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
<section className="flex min-h-0 flex-col border-b lg:border-r lg:border-b-0">
<div className={cn("grid min-w-0 min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
<section className="flex min-w-0 min-h-0 flex-col border-b lg:border-r lg:border-b-0">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
Markdown 编辑
</div>
@@ -50,14 +50,14 @@ export function MarkdownCodeEditor({
highlightActiveLineGutter: false,
lineNumbers: true,
}}
className="min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
className="min-w-0 min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
/>
</section>
<section className="flex min-h-0 flex-col bg-muted/10">
<section className="flex min-w-0 min-h-0 flex-col bg-muted/10">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
实时渲染
</div>
<div className="min-h-[42vh] flex-1 overflow-auto p-4">
<div className="min-w-0 min-h-[42vh] flex-1 overflow-auto p-4">
<MarkdownContent
content={value || "*暂无正文*"}
isLoading={false}
@@ -60,6 +60,7 @@ import { Textarea } from "@/components/ui/textarea";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
// import { AgentChatLinkFields } from "@/components/workspace/agents/agent-chat-link-fields";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
@@ -76,7 +77,15 @@ import {
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import type { Agent, AgentChatLink, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import {
agentChatLinksEqual,
canonicalAgentChatLink,
isAllowedAgentChatUrl,
openResolvedAgentChatUrl,
} from "@/core/agents/chat-link";
import { emptyAgentChatLink } from "@/core/agents/types";
import { appendResolvedRouteParams } from "@/strategy-components/lib/light-app";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks";
@@ -248,6 +257,12 @@ export function AgentCard({
const [editSeatSkillDirective, setEditSeatSkillDirective] = useState(
agent.seat_skill_directive ?? false,
);
const [editModelPromptSuffixEnabled, setEditModelPromptSuffixEnabled] = useState(
agent.model_prompt_suffix_enabled ?? true,
);
const [editChatLink, setEditChatLink] = useState<AgentChatLink>(
() => agent.chat_link ?? emptyAgentChatLink(),
);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares();
@@ -296,6 +311,8 @@ export function AgentCard({
setSelectedSkills(agent.skills ?? []);
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
setEditSeatSkillDirective(agent.seat_skill_directive ?? false);
setEditModelPromptSuffixEnabled(agent.model_prompt_suffix_enabled ?? true);
setEditChatLink(agent.chat_link ?? emptyAgentChatLink());
}, [
agent.common_questions,
agent.description,
@@ -305,6 +322,8 @@ export function AgentCard({
agent.square_id,
agent.skills,
agent.seat_skill_directive,
agent.model_prompt_suffix_enabled,
agent.chat_link,
editOpen,
]);
@@ -320,12 +339,28 @@ export function AgentCard({
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
// 列表响应不带 config 额外字段(seat_skill_directive 为 null),详情才有真值。
setEditSeatSkillDirective(agentDetail.seat_skill_directive ?? false);
setEditModelPromptSuffixEnabled(agentDetail.model_prompt_suffix_enabled ?? true);
setEditChatLink(agentDetail.chat_link ?? emptyAgentChatLink());
} else {
setEditSoul(agent.soul ?? "");
}
}, [agent.soul, agentDetail, editOpen]);
function handleChat() {
async function handleChat() {
let link = agent.chat_link;
if (link === undefined) {
try {
link = (await getAgent(agent.id)).chat_link ?? null;
} catch {
link = null;
}
}
const target = canonicalAgentChatLink(link);
if (target && isAllowedAgentChatUrl(target.url)) {
const href = appendResolvedRouteParams(target.url, target.route_params);
openResolvedAgentChatUrl(href, target.open_mode, (path) => router.push(path));
return;
}
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
@@ -348,9 +383,11 @@ export function AgentCard({
// row to carry the source agent's personality into the copy. Fall back
// to whatever the list happened to include if the detail fetch fails.
let soul = agent.soul ?? "";
let chatLink = canonicalAgentChatLink(agent.chat_link);
try {
const detail = await getAgent(agent.id);
soul = detail.soul ?? soul;
chatLink = canonicalAgentChatLink(detail.chat_link);
} catch {
// keep the list fallback
}
@@ -361,6 +398,7 @@ export function AgentCard({
skills: agent.skills ?? [],
model: agent.model ?? null,
tool_groups: agent.tool_groups ?? null,
chat_link: chatLink,
// A copy starts as a private, unpublished draft the owner can refine
// before sharing — never inherit the source's published/private state.
published: false,
@@ -413,6 +451,24 @@ export function AgentCard({
request.common_questions = nextQuestions;
if (editSeatSkillDirective !== ((agentDetail?.seat_skill_directive ?? agent.seat_skill_directive) ?? false))
request.seat_skill_directive = editSeatSkillDirective;
if (editModelPromptSuffixEnabled !== ((agentDetail?.model_prompt_suffix_enabled ?? agent.model_prompt_suffix_enabled) ?? true))
request.model_prompt_suffix_enabled = editModelPromptSuffixEnabled;
const nextChatLink = canonicalAgentChatLink(editChatLink);
if (nextChatLink && !isAllowedAgentChatUrl(nextChatLink.url)) {
toast.error("对话链接只支持 http(s) 地址,或以 /、#/ 开头的站内路径");
return;
}
if (
editChatLink.route_params.some((param) => !param.key.trim()) &&
nextChatLink
) {
toast.error("路由参数键名不可为空");
return;
}
const baselineChatLink = agentDetail?.chat_link ?? agent.chat_link;
if (!agentChatLinksEqual(nextChatLink, baselineChatLink)) {
request.chat_link = nextChatLink;
}
if (Object.keys(request).length === 0) {
setEditOpen(false);
@@ -862,7 +918,7 @@ export function AgentCard({
<Button
size="sm"
className={cn(isList ? "shrink-0" : "w-full")}
onClick={handleChat}
onClick={() => void handleChat()}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
@@ -1040,6 +1096,19 @@ export function AgentCard({
onChange={setEditCommonQuestions}
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">使用模型追加提示词</p>
<p className="text-muted-foreground text-xs">
默认开启。关闭后,该智能体的问答不会附加管理员为所选模型设置的全局提示词。
</p>
</div>
<Switch
checked={editModelPromptSuffixEnabled}
onCheckedChange={setEditModelPromptSuffixEnabled}
aria-label="使用模型追加提示词"
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">会商技能识别强化</p>
@@ -1120,6 +1189,7 @@ export function AgentCard({
}}
/>
</div>
{/* <AgentChatLinkFields value={editChatLink} onChange={setEditChatLink} /> */}
</div>
<DialogFooter>
<Button
@@ -0,0 +1,224 @@
"use client";
import { PlusIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AgentChatLink, AgentChatRouteParam } from "@/core/agents/types";
import { emptyAgentChatLink } from "@/core/agents/types";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
parseThemeMap,
stringifyThemeMap,
} from "@/strategy-components/lib/light-app";
function newParam(): AgentChatRouteParam {
return {
key: "",
paramType: "login",
source: "access_token",
customValue: "",
themeValue: null,
};
}
export function AgentChatLinkFields({
value,
onChange,
}: {
value: AgentChatLink;
onChange: (next: AgentChatLink) => void;
}) {
const link = value ?? emptyAgentChatLink();
function patch(partial: Partial<AgentChatLink>) {
onChange({ ...link, ...partial });
}
function patchParam(index: number, partial: Partial<AgentChatRouteParam>) {
patch({
route_params: link.route_params.map((param, i) =>
i === index ? { ...param, ...partial } : param,
),
});
}
return (
<div className="space-y-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">对话跳转</p>
<p className="text-muted-foreground text-xs">
配置后,智能体管理里的「对话」按钮会打开这个地址。留空则仍进入本系统问答。
</p>
</div>
<Input
value={link.url}
onChange={(event) => patch({ url: event.currentTarget.value })}
placeholder="https://... ,或以 / 开头的站内路径"
/>
<div className="space-y-1">
<p className="text-muted-foreground text-xs">打开方式</p>
<Select
value={link.open_mode}
onValueChange={(next) => patch({ open_mode: next === "self" ? "self" : "blank" })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blank">新窗口</SelectItem>
<SelectItem value="self">当前窗口</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">路由参数</p>
<p className="text-muted-foreground text-xs">
点击「对话」时拼到地址后面。登录参数取当前登录信息,主题参数跟随当前主题。
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => patch({ route_params: [...link.route_params, newParam()] })}
>
<PlusIcon className="h-3.5 w-3.5" />
添加
</Button>
</div>
{link.route_params.length === 0 ? (
<p className="text-muted-foreground rounded-md border border-dashed px-3 py-3 text-center text-xs">
暂无路由参数
</p>
) : (
<div className="space-y-2">
{link.route_params.map((param, index) => {
const themeMap = param.paramType === "theme" ? parseThemeMap(param.themeValue) : {};
return (
<div key={index} className="space-y-2 rounded-md border p-2">
<div className="flex items-center gap-2">
<Input
value={param.key}
onChange={(event) => patchParam(index, { key: event.currentTarget.value })}
placeholder="参数名"
className="h-8 font-mono text-xs"
/>
<Select
value={param.paramType}
onValueChange={(next) =>
patchParam(index, {
paramType: next === "theme" ? "theme" : "login",
source: next === "login" ? param.source || "access_token" : null,
themeValue:
next === "theme"
? param.themeValue || stringifyThemeMap({})
: null,
})
}
>
<SelectTrigger className="h-8 w-[120px] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
patch({
route_params: link.route_params.filter((_, i) => i !== index),
})
}
title="移除参数"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{param.paramType === "login" && (
<div className="flex items-center gap-2">
<Select
value={param.source || "access_token"}
onValueChange={(next) => patchParam(index, { source: next })}
>
<SelectTrigger className="h-8 min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOGIN_SOURCE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{param.source === "other" && (
<Input
value={param.customValue ?? ""}
onChange={(event) =>
patchParam(index, { customValue: event.currentTarget.value })
}
placeholder="自定义值"
className="h-8 w-[140px] text-xs"
/>
)}
</div>
)}
{param.paramType === "theme" && (
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px]">
各系统主题色跳转时传给目标地址的值
</p>
{APP_THEME_COLORS.map((color) => (
<div key={color.key} className="flex items-center gap-2">
<span className="flex w-[72px] shrink-0 items-center gap-1.5 text-xs">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: color.dot }}
/>
{color.label}
</span>
<Input
value={themeMap[color.key] ?? color.key}
onChange={(event) =>
patchParam(index, {
themeValue: stringifyThemeMap({
...themeMap,
[color.key]: event.currentTarget.value,
}),
})
}
className="h-7 font-mono text-xs"
/>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import {
DatabaseIcon,
GlobeIcon,
InfoIcon,
// Link2Icon,
Loader2Icon,
LockIcon,
MessageCircleQuestionIcon,
@@ -25,6 +26,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toast } from "sonner";
// import { AgentChatLinkFields } from "@/components/workspace/agents/agent-chat-link-fields";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -59,9 +61,16 @@ import {
useSetAgentFeatured,
useUpdateAgent,
type Agent,
type AgentChatLink,
type AgentQuestion,
type UpdateAgentRequest,
} from "@/core/agents";
import {
agentChatLinksEqual,
canonicalAgentChatLink,
isAllowedAgentChatUrl,
} from "@/core/agents/chat-link";
import { emptyAgentChatLink } from "@/core/agents/types";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { isAgentSkillCandidate } from "@/core/skills";
@@ -81,9 +90,11 @@ interface AgentDraft {
published: boolean;
featured: boolean;
templateName: string;
modelPromptSuffixEnabled: boolean;
/** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean;
knowledgeBaseIds: string[];
chatLink: AgentChatLink;
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -96,8 +107,10 @@ function draftFromAgent(agent: Agent): AgentDraft {
published: agent.published,
featured: agent.featured_order != null,
templateName: agent.template_name ?? "",
modelPromptSuffixEnabled: agent.model_prompt_suffix_enabled ?? true,
seatSkillDirective: agent.seat_skill_directive ?? false,
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
chatLink: agent.chat_link ?? emptyAgentChatLink(),
};
}
@@ -367,8 +380,22 @@ export function AgentDetailPanel({
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
if (draft.templateName.trim() !== (agent.template_name ?? ""))
request.template_name = draft.templateName.trim();
if (draft.modelPromptSuffixEnabled !== (agent.model_prompt_suffix_enabled ?? true))
request.model_prompt_suffix_enabled = draft.modelPromptSuffixEnabled;
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
request.seat_skill_directive = draft.seatSkillDirective;
const nextChatLink = canonicalAgentChatLink(draft.chatLink);
if (nextChatLink && !isAllowedAgentChatUrl(nextChatLink.url)) {
toast.error("对话链接只支持 http(s) 地址,或以 /、#/ 开头的站内路径");
return;
}
if (draft.chatLink.route_params.some((param) => !param.key.trim()) && nextChatLink) {
toast.error("路由参数键名不可为空");
return;
}
if (!agentChatLinksEqual(nextChatLink, agent.chat_link)) {
request.chat_link = nextChatLink;
}
if (
JSON.stringify(draft.knowledgeBaseIds) !==
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
@@ -663,6 +690,13 @@ export function AgentDetailPanel({
</div>
{/* 入库模板名 — view mode shows a hint row when configured. */}
{/* {!editing && agent.chat_link?.url?.trim() && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<Link2Icon className="size-3 shrink-0" />
对话跳转:{agent.chat_link.open_mode === "self" ? "当前窗口" : "新窗口"}
</div>
)} */}
{!editing && agent.template_name?.trim() && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<UploadCloudIcon className="size-3 shrink-0" />
@@ -702,6 +736,21 @@ export function AgentDetailPanel({
}
variant="panel"
/>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3">
<p className="text-sm font-medium">使用模型追加提示词</p>
<p className="text-muted-foreground text-xs">
默认开启。关闭后,该智能体的问答不会附加管理员为所选模型设置的全局提示词。
</p>
</div>
<Switch
checked={draft.modelPromptSuffixEnabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, modelPromptSuffixEnabled: checked })
}
aria-label="使用模型追加提示词"
/>
</div>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3">
<p className="text-sm font-medium">会商技能识别强化</p>
@@ -948,6 +997,13 @@ export function AgentDetailPanel({
/>
</Section>
{/* {editing && draft && (
<AgentChatLinkFields
value={draft.chatLink}
onChange={(chatLink) => setDraft({ ...draft, chatLink })}
/>
)} */}
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
@@ -362,11 +362,27 @@ export function ArtifactFileDetail({
isMock,
writeFileInFlight,
]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
const isVirtualHtmlArtifact = useMemo(() => {
if (!isWriteFileVirtual) return false;
return (writeFileLanguageHint ?? checkCodeFile(filepath).language) === "html";
}, [filepath, isWriteFileVirtual, writeFileLanguageHint]);
/** write_file 流式阶段走 tool args 文本;图片等二进制产物始终走 artifacts API;
* HTML 写完后也必须切回真实文件,因为后续渲染脚本可能在同一路径覆盖最终数据;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)同样回到真实 API(可加载 + PATCH 保存)。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
return (
isWriteFileVirtual &&
!isImageFile(filepath) &&
!editVirtualMarkdown &&
(!isVirtualHtmlArtifact || writeFileInFlight)
);
}, [
filepath,
isWriteFileVirtual,
editVirtualMarkdown,
isVirtualHtmlArtifact,
writeFileInFlight,
]);
const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) {
let nextLanguage =
@@ -398,11 +414,10 @@ export function ArtifactFileDetail({
refetch: refetchArtifactContent,
} = useArtifactContent({
threadId,
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
// 不会被旧的内存 stub 值覆盖回退。
filepath:
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
// 流式结束后统一用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL)。HTML 可能已被后续渲染脚本覆盖;Markdown 保存后的查询缓存也需要
// 命中真实路径,不能继续读取旧的内存 stub。
filepath: useWriteFileStreaming ? filepathFromProps : filepath,
enabled: isCodeFile && !useWriteFileStreaming,
});
@@ -458,9 +473,10 @@ export function ArtifactFileDetail({
const sandboxDisplayText = useMemo(() => {
const raw = displayContent ?? "";
if (disableDesensitize) return raw;
if (language === "html") return raw;
if (streamPerfEnabled && isLiveStreaming) return raw;
return desensitize(raw);
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
}, [disableDesensitize, displayContent, isLiveStreaming, language, streamPerfEnabled]);
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
@@ -2205,6 +2221,7 @@ export function ArtifactFilePreview({
if (language === "html") {
return (
<iframe
key={htmlPreviewUrl ?? "html-preview"}
className={cn(
"size-full bg-white",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
@@ -1,12 +1,11 @@
"use client";
import { useState } from "react";
import { useTheme } from "@/contexts/ThemeContext";
import { getWelcomeBrandAsset } from "@/core/branding/brand-assets";
import { AppBrandImage } from "@/core/branding/app-brand-image";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
import { useAppSystemName } from "@/core/system-settings/hooks";
import { readRuntimeFlag } from "@/env";
import { cn } from "@/lib/utils";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
/**
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
@@ -16,11 +15,8 @@ import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desen
export function CompactWelcome({ className }: { className?: string }) {
const desensitizeText = useDisplayDesensitizer();
const { theme } = useTheme();
const brandTheme = theme;
const welcomeLogo = getWelcomeBrandAsset(brandTheme);
const [failedWelcomeLogo, setFailedWelcomeLogo] = useState<string | null>(null);
const shouldShowWelcomeLogo =
Boolean(welcomeLogo) && failedWelcomeLogo !== welcomeLogo;
const systemName = useAppSystemName("");
const welcomeTitle = systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手";
const isWelcomeLogoVisible = readRuntimeFlag("VITE_WELCOME_LOGO_VISIBLE");
return (
@@ -30,44 +26,24 @@ export function CompactWelcome({ className }: { className?: string }) {
className,
)}
>
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
{/* 与全量模式共用同一套系统 Logo;简洁模式仅保留欢迎区的展示容器。 */}
{/* Controlled by VITE_WELCOME_LOGO_VISIBLE in runtime-config.js. */}
{isWelcomeLogoVisible && (
<div
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
style={{
backgroundColor:
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
border:
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
}}
>
{shouldShowWelcomeLogo && welcomeLogo ? (
<img
src={welcomeLogo}
alt=""
aria-hidden="true"
className="size-16 object-contain"
onError={() => setFailedWelcomeLogo(welcomeLogo)}
/>
) : (
<div
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
style={{
backgroundImage:
"linear-gradient(to top right, var(--workspace-brand), var(--brand-200))",
}}
>
<span className="text-2xl font-black tracking-wide text-white italic">
{desensitizeText("CM")}
</span>
</div>
)}
</div>
<div
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
style={{
backgroundColor:
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
border:
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
}}
>
<AppBrandImage theme={theme} className="size-16 object-contain" />
</div>
)}
<h1 className="flex items-center justify-center text-2xl font-bold tracking-tight text-foreground/90">
{desensitizeText("欢迎使用CM助手")}
{desensitizeText(welcomeTitle)}
</h1>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
@@ -0,0 +1,43 @@
"use client";
import { FileSpreadsheet } from "lucide-react";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
import { consumerThreadMaterialsDownloadUrl } from "@/core/threads/consumer-thread-values-client";
import { useThread } from "./messages/context";
import { Tooltip } from "./tooltip";
/** Downloads the Consumer-maintained source-material workbook for this chat. */
export function ConsumerMaterialsExportTrigger({
threadId,
}: {
threadId: string;
}) {
const { thread } = useThread();
const downloadUrl = consumerThreadMaterialsDownloadUrl(threadId);
const handleDownload = useCallback(() => {
if (!downloadUrl) return;
window.open(downloadUrl, "_blank", "noopener,noreferrer");
}, [downloadUrl]);
if (thread.messages.length === 0) return null;
return (
<Tooltip
content={downloadUrl ? "导出本对话检索资料" : "未配置资料导出服务"}
>
<Button
type="button"
className="text-muted-foreground hover:text-foreground"
variant="ghost"
disabled={!downloadUrl}
onClick={handleDownload}
>
<FileSpreadsheet />
导出资料
</Button>
</Tooltip>
);
}
@@ -1,10 +1,10 @@
"use client";
import type { ChatStatus } from "ai";
import { toast } from "sonner";
import {
import type { ChatStatus } from "ai";
import { toast } from "sonner";
import {
BookMarkedIcon,
BrainCircuitIcon,
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
@@ -26,6 +26,7 @@ import {
useRef,
useState,
type ComponentProps,
type CSSProperties,
type ReactNode,
} from "react";
@@ -63,12 +64,13 @@ 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 { composerMinHeightCss } from "@/core/settings/composer-width";
import { getBackendBaseURL } from "@/core/config";
import {
isDocumentRewriteActiveStage,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
@@ -77,7 +79,7 @@ import {
} 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 type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -103,19 +105,19 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { resolveInputMode, type InputMode } from "./input-mode";
import { hasPromptInputPayload } from "./input-box-submit";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { resolveInputMode, type InputMode } from "./input-mode";
import { hasPromptInputPayload } from "./input-box-submit";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
export function InputBox({
className,
@@ -126,22 +128,23 @@ export function InputBox({
extraHeader,
isNewThread,
threadId,
initialValue,
onContextChange,
onFollowupsPresenceChange,
followupsDisplayAllowed = true,
onSubmit,
initialValue,
onContextChange,
onFollowupsPresenceChange,
followupsDisplayAllowed = true,
onSubmit,
onStop,
promptPrefix,
agentSelector,
knowledgeSelector,
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
knowledgeSelector,
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
extraModels = [],
assistantId,
hideAdminRecommendedQuestions,
compact,
hideContextTools,
composerHeightPercent,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -149,9 +152,14 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
/**
* Appearance height scale. 100 (or omitted) keeps the built-in min-height.
* Other values scale the landing / compact-landing / conversation base.
*/
composerHeightPercent?: number;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -177,15 +185,15 @@ export function InputBox({
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
},
) => void;
/**
* Whether generated follow-up questions may be rendered. Hosts with a
* scrollable transcript disable this while the user is reviewing history;
* the generated questions are retained and reappear on returning to bottom.
*/
followupsDisplayAllowed?: boolean;
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
onFollowupsPresenceChange?: (present: boolean) => void;
) => void;
/**
* Whether generated follow-up questions may be rendered. Hosts with a
* scrollable transcript disable this while the user is reviewing history;
* the generated questions are retained and reappear on returning to bottom.
*/
followupsDisplayAllowed?: boolean;
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
onFollowupsPresenceChange?: (present: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
/**
@@ -207,16 +215,16 @@ export function InputBox({
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
/** 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;
onModeChange: (mode: KnowledgeRetrievalMode) => void;
};
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** 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;
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. */
extraModels?: Model[];
}) {
@@ -233,26 +241,26 @@ export function InputBox({
const modelOptions = useMemo(() => {
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 { 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);
@@ -268,9 +276,9 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const followupsEnabled = userPreferences.followup_suggestions_enabled;
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
@@ -289,12 +297,12 @@ export function InputBox({
const supportsReasoningEffort =
fallbackModel.supports_reasoning_effort ?? false;
const nextModelName = fallbackModel.name;
const nextMode = resolveInputMode(context.mode, supportsThinking);
const nextMode = resolveInputMode(context.mode, supportsThinking);
const nextReasoningEffort = resolveModelReasoningEffort(
supportsReasoningEffort,
context.reasoning_effort,
context.model_name !== nextModelName,
fallbackModel.default_reasoning_effort,
fallbackModel.default_reasoning_effort,
);
if (
@@ -340,10 +348,10 @@ export function InputBox({
resolveModelReasoningEffort(
supportReasoningEffort,
context.reasoning_effort,
false,
selectedModel?.default_reasoning_effort,
false,
selectedModel?.default_reasoning_effort,
),
[context.reasoning_effort, selectedModel?.default_reasoning_effort, supportReasoningEffort],
[context.reasoning_effort, selectedModel?.default_reasoning_effort, supportReasoningEffort],
);
const handleModelSelect = useCallback(
(model_name: string) => {
@@ -354,12 +362,12 @@ export function InputBox({
onContextChange?.({
...context,
model_name,
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: resolveModelReasoningEffort(
model.supports_reasoning_effort ?? false,
context.reasoning_effort,
model_name !== context.model_name,
model.default_reasoning_effort,
model.default_reasoning_effort,
),
execution_profile: model.execution_profile,
});
@@ -375,15 +383,15 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
mode: resolveInputMode(
mode: resolveInputMode(
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
reasoning_effort: resolveModelReasoningEffort(
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
context.reasoning_effort,
false,
ordinaryModel?.default_reasoning_effort,
false,
ordinaryModel?.default_reasoning_effort,
),
execution_profile: undefined,
});
@@ -408,7 +416,7 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ragModel.name,
mode: resolveInputMode(
mode: resolveInputMode(
context.mode,
ragModel.supports_thinking ?? false,
),
@@ -438,13 +446,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 smartKnowledgeRetrievalActive =
knowledgeRetrieval?.mode === "smart_reasoning";
const ragModeLocked =
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
const ragModeEnabled =
!smartKnowledgeRetrievalActive &&
(localSettings.display.ragModeEnabled || ragModeLocked);
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
@@ -460,19 +468,19 @@ 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.");
}
if (!hasPromptInputPayload(message)) {
return;
}
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.");
}
if (!hasPromptInputPayload(message)) {
return;
}
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
@@ -490,7 +498,7 @@ export function InputBox({
onContextChange?.({
...context,
model_name: resolvedModelName,
mode: resolveInputMode(
mode: resolveInputMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
@@ -505,10 +513,10 @@ export function InputBox({
},
[
context,
onContextChange,
onSubmit,
composerStatus,
resolvedModelName,
onContextChange,
onSubmit,
composerStatus,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
@@ -550,9 +558,9 @@ export function InputBox({
[textInput],
);
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (composerStatus === "streaming") {
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (composerStatus === "streaming") {
return;
}
const current = (textInput.value ?? "").trim();
@@ -565,7 +573,7 @@ export function InputBox({
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[composerStatus, requestFormSubmit, textInput],
[composerStatus, requestFormSubmit, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
@@ -596,28 +604,28 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const followupsPresent =
const followupsPresent =
followupsEnabled &&
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
const showFollowups = followupsDisplayAllowed && followupsPresent;
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
useEffect(() => {
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
}, [onFollowupsPresenceChange]);
useEffect(() => {
followupsPresenceChangeRef.current?.(followupsPresent);
}, [followupsPresent]);
useEffect(() => {
return () => followupsPresenceChangeRef.current?.(false);
}, []);
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
const showFollowups = followupsDisplayAllowed && followupsPresent;
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
useEffect(() => {
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
}, [onFollowupsPresenceChange]);
useEffect(() => {
followupsPresenceChangeRef.current?.(followupsPresent);
}, [followupsPresent]);
useEffect(() => {
return () => followupsPresenceChangeRef.current?.(false);
}, []);
useEffect(() => {
if (followupsEnabled) return;
@@ -706,35 +714,46 @@ export function InputBox({
};
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
const composerMinHeight = composerMinHeightCss(
composerHeightPercent ?? 100,
compact ? "84px" : isNewThread ? "110px" : "4rem",
);
const composerHeightStyle: CSSProperties | undefined = composerMinHeight
? {
minHeight: composerMinHeight,
maxHeight: `max(12rem, calc(${composerMinHeight} + 8rem))`,
}
: undefined;
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
{extraHeader && (
{extraHeader && (
<div className="flex items-center justify-center">
{extraHeader}
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
<div className="px-4 mb-[10px]">
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
<div className="flex flex-wrap justify-center gap-2.5">
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
<button
key={question}
type="button"
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
onClick={() => {
)}
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
<div className="px-4 mb-[10px]">
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
<div className="flex flex-wrap justify-center gap-2.5">
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
<button
key={question}
type="button"
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
onClick={() => {
fillPromptForEditing(question);
}}
>
{question}
</button>
))}
</div>
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
}}
>
{question}
</button>
))}
</div>
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
@@ -800,6 +819,9 @@ export function InputBox({
)}
<div className={cn(isNewThread && !compact && "gemini-gradient-border gemini-shadow")}>
<PromptInput
style={
composerMinHeight ? { minHeight: composerMinHeight } : undefined
}
className={cn(
"transition-all duration-300 ease-out",
// 紧凑落地态(CM 参考图):表单作为白卡,内部 input-group 透明无边框,
@@ -832,9 +854,12 @@ 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]",
!composerMinHeight && isNewThread && "min-h-[110px]!",
compact && "px-3 pt-3 pb-1 text-[14px]",
compact && !composerMinHeight && "min-h-[84px]!",
)}
data-composer-height={composerMinHeight ? "" : undefined}
style={composerHeightStyle}
disabled={disabled}
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
@@ -843,12 +868,12 @@ export function InputBox({
</PromptInputBody>
<PromptInputFooter
className={cn(
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3",
)}
>
<PromptInputTools
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
>
{/* TODO: Add more connectors here
<PromptInputActionMenu>
@@ -868,7 +893,7 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && !hideContextTools && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -923,42 +948,42 @@ 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 &&
!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>
)}
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
@@ -1219,6 +1244,7 @@ export function InputBox({
</PromptInputActionMenuItem>
</>
)}
{/* 多轮 RAG 问答选项暂时下线,需要恢复时取消下面注释即可
<PromptInputActionMenuItem
className={cn(
multiRoundRagActive
@@ -1247,12 +1273,13 @@ export function InputBox({
<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")}>
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
@@ -1262,83 +1289,83 @@ export function InputBox({
/>
)}
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && knowledgeRetrieval && (
<DropdownMenu>
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索:综合原文与 Wiki 线索,提供更全面的背景和解释"
: "经典检索:细粒度召回原文,优先回答具体事实与细节"
}
>
<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"
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
>
<BookMarkedIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">经典检索(默认)</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>
<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">智能检索</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>
</DropdownMenuContent>
</DropdownMenu>
)}
{!frontendRagActive && knowledgeRetrieval && (
<DropdownMenu>
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索:综合原文与 Wiki 线索,提供更全面的背景和解释"
: "经典检索:细粒度召回原文,优先回答具体事实与细节"
}
>
<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"
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
>
<BookMarkedIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">经典检索(默认)</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>
<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">智能检索</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>
</DropdownMenuContent>
</DropdownMenu>
)}
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
@@ -1423,21 +1450,21 @@ 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}
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 全文改写" }
: {})}
/>
</PromptInputTools>
</PromptInputFooter>
{!isNewThread && (
@@ -1,102 +1,23 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react";
import { BookOpen } 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 { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding,
} from "@/core/system-settings/api";
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 const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
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 saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
onError: (error) => toast.error(error.message),
});
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 retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
const assistant = location.pathname.includes("/knowledge/assistant");
const ordinary = !assistant && !retrievalDebug;
const ordinary = !location.pathname.includes("/knowledge/assistant") && !location.pathname.includes("/knowledge/retrieval-debug");
return (
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
{assistantAllowed && (
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
)}
{isAdmin && (
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
<SearchCheck />检索调试
</Button>
)}
*/}
{isAdmin && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
<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}
value={assistantVisibility.data === true ? "visible" : "hidden"}
onValueChange={(value) =>
saveAssistantVisibility.mutate(value === "visible", {
onSuccess: (visible) => toast.success(visible ? "已向普通用户开放助手知识库" : "已隐藏普通用户的助手知识库"),
onError: (error) => toast.error(error.message),
})
}
>
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
</Select>
*/}
</div>
)}
</div>
);
}
@@ -32,21 +32,21 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
buildWriteFileVirtualUrl,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
buildWriteFileVirtualUrl,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
@@ -57,11 +57,11 @@ import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
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 { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility";
import {
applyInlineCitations,
@@ -85,26 +85,26 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
export function MessageGroup({
className,
messages,
isLoading = false,
displaySources = [],
ragModeEnabled = false,
threadId,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
threadId?: string;
export function MessageGroup({
className,
messages,
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 { data: citationDisplay } = useCitationDisplaySettings();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
@@ -124,15 +124,15 @@ export function MessageGroup({
messages.length > 0 &&
(ragModeEnabled || citationDisplay?.retrieved_files_enabled === true),
});
const retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
const retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
@@ -227,8 +227,8 @@ export function MessageGroup({
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
),
)}
@@ -240,8 +240,8 @@ export function MessageGroup({
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
</FlipDisplay>
)}
@@ -290,8 +290,8 @@ export function MessageGroup({
</>
)}
</ChainOfThought>
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
<SingleRoundRagCitationDrawer
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
@@ -356,7 +356,7 @@ function ToolCall({
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
displaySources = [],
displaySources = [],
ragModeEnabled = false,
}: {
id?: string;
@@ -367,7 +367,7 @@ function ToolCall({
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
@@ -378,42 +378,42 @@ 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,
)
? syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDisplaySources(result, displayMatch.display).map(
(item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}),
),
),
displaySources,
)
: [];
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 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 wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
@@ -439,17 +439,17 @@ 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 = syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
})),
),
displaySources,
);
return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
@@ -831,10 +831,10 @@ 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 sourcePath = namedStreamingWriteFilePath(rawPath);
const showInlineContent = name === "write_file" && !sourcePath;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Until a real path arrives, both auto-open and manual clicks stay inline.
// Until a real path arrives, both auto-open and manual clicks stay inline.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
@@ -846,10 +846,10 @@ function ArtifactWriteToolCall({
!artifactPath ||
!previewKey ||
!isLoading ||
result !== undefined ||
!sourcePath ||
!languageOfStreamingArtifact(sourcePath) ||
!isAutoPreviewableArtifactPath(sourcePath)
result !== undefined ||
!sourcePath ||
!languageOfStreamingArtifact(sourcePath) ||
!isAutoPreviewableArtifactPath(sourcePath)
) {
return;
}
@@ -875,9 +875,9 @@ function ArtifactWriteToolCall({
artifacts.setOpen(true);
}}
>
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult>
@@ -886,29 +886,29 @@ 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> {
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> {
id?: string;
messageId?: string;
type: T;
@@ -928,10 +928,10 @@ 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>();
// 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);
@@ -944,37 +944,37 @@ 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) {
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)) {
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 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 : "";
if (
(
tool_call.name === "write_file" ||
@@ -992,27 +992,27 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args,
args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
let toolCallResult = findToolCallResult(
let toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: false },
{ 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) {
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 {
@@ -1079,48 +1079,48 @@ 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 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;
@@ -1158,46 +1158,46 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
</div>
)}
</button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"}
</SheetTitle>
{(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</SheetDescription>
)}
{!sourceLabel && !date && (
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"}
</SheetTitle>
{(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</SheetDescription>
)}
{!sourceLabel && !date && (
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
</>
);
}
@@ -38,6 +38,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { useRetryKeepPartialSettings } from "@/core/system-settings/hooks";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import {
buildSkillDisplayMap,
@@ -489,6 +490,10 @@ export function MessageList({
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
// 「保留中间正文」管理员开关(默认开启):步骤条执行中吐出的正文不被清空,
// 模型静默重试时已流出的半截正文同样保留(后者在 useAgentThread 里处理)。
const { data: retryKeepPartialSettings } = useRetryKeepPartialSettings();
const keepIntermediateText = retryKeepPartialSettings?.enabled ?? true;
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
@@ -748,8 +753,10 @@ export function MessageList({
) : null}
{(() => {
let didRenderLiveKnowledgeRetrieval = false;
const grouped = groupMessages(displayMessages, (group) => {
if (group.type === "human" || group.type === "assistant") {
const grouped = groupMessages(
displayMessages,
(group) => {
if (group.type === "human" || group.type === "assistant") {
const items = group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
@@ -1189,7 +1196,7 @@ export function MessageList({
{speakerShell ? speakerShell(group.messages, processingBody) : processingBody}
</div>
);
});
}, { keepIntermediateText });
const leftoverRewriteItems = documentRewriteItems.filter(
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
);
@@ -1,64 +1,112 @@
"use client";
import { useEffect, useState } from "react";
import { DatabaseIcon, RefreshCwIcon } from "lucide-react";
import { Clock3Icon, DatabaseIcon, GlobeIcon, RefreshCwIcon, SaveIcon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { getAuditRetentionStatus, setAuditRetentionPaused, type AuditRetentionStatus } from "@/core/admin/audit-retention";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
type CacheCollection = {
id: string;
label: string;
paths: string[];
scope: string;
enabled: boolean;
ttl_seconds: number;
};
type ApiReadCacheStatus = {
enabled: boolean;
redis_configured: boolean;
ttl_seconds: number;
max_value_bytes: number;
browser_status_enabled: boolean;
collections: CacheCollection[];
};
async function loadStatus(): Promise<ApiReadCacheStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/api-read-cache`);
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : `读取失败(${response.status})`);
type ApiReadCacheUpdate = {
enabled?: boolean;
ttl_seconds?: number;
browser_status_enabled?: boolean;
collection_overrides?: Record<string, boolean>;
};
async function requestStatus(update?: ApiReadCacheUpdate): Promise<ApiReadCacheStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/api-read-cache`, update ? {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(update),
} : undefined);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(typeof data.detail === "string" ? data.detail : `${update ? "保存" : "读取"}失败(${response.status})`);
}
return data as ApiReadCacheStatus;
}
async function saveStatus(enabled: boolean): Promise<ApiReadCacheStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/api-read-cache`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : `保存失败(${response.status})`);
return data as ApiReadCacheStatus;
function retentionLabel(status: string) {
switch (status) {
case "running": return "正在清理";
case "pausing": return "正在停止";
case "paused": return "已关闭";
case "error": return "本轮异常";
default: return "等待执行";
}
}
function dateTime(value?: string | number | null) {
if (!value) return "暂无";
const date = new Date(typeof value === "number" ? value * 1000 : value);
return Number.isNaN(date.getTime()) ? "暂无" : date.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
}
export function ApiReadCacheSettingsPage() {
const [status, setStatus] = useState<ApiReadCacheStatus>();
const [retention, setRetention] = useState<AuditRetentionStatus>();
const [ttlInput, setTtlInput] = useState("300");
const [search, setSearch] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [retentionError, setRetentionError] = useState("");
const refresh = async () => {
setBusy(true);
try {
setStatus(await loadStatus());
const [cacheResult, retentionResult] = await Promise.allSettled([
requestStatus(),
getAuditRetentionStatus(),
]);
if (cacheResult.status === "fulfilled") {
setStatus(cacheResult.value);
setTtlInput(String(cacheResult.value.ttl_seconds));
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "读取设置失败");
} finally {
setBusy(false);
} else {
setError(cacheResult.reason instanceof Error ? cacheResult.reason.message : "读取缓存设置失败");
}
if (retentionResult.status === "fulfilled") {
setRetention(retentionResult.value);
setRetentionError("");
} else {
setRetentionError(retentionResult.reason instanceof Error ? retentionResult.reason.message : "读取清理设置失败");
}
setBusy(false);
};
useEffect(() => { void refresh(); }, []);
const toggle = async (enabled: boolean) => {
const save = async (update: ApiReadCacheUpdate, message: string) => {
setBusy(true);
try {
const saved = await saveStatus(enabled);
const saved = await requestStatus(update);
setStatus(saved);
toast.success(enabled ? "Redis 读取缓存已开启" : "Redis 读取缓存已关闭");
if (update.ttl_seconds !== undefined) setTtlInput(String(saved.ttl_seconds));
setError("");
toast.success(message);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存设置失败");
} finally {
@@ -66,26 +114,184 @@ export function ApiReadCacheSettingsPage() {
}
};
const toggleRetention = async (enabled: boolean) => {
setBusy(true);
try {
const saved = await setAuditRetentionPaused(!enabled);
setRetention(saved);
setRetentionError("");
toast.success(enabled ? "定时清理已开启" : saved.pause_requested ? "已关闭定时清理,当前批次结束后停止" : "定时清理已关闭");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存清理设置失败");
} finally {
setBusy(false);
}
};
const parsedTtl = Number(ttlInput);
const validTtl = Number.isInteger(parsedTtl) && parsedTtl >= 1 && parsedTtl <= 3600;
const collections = status?.collections ?? [];
const searchText = search.trim().toLowerCase();
const visibleCollections = collections.filter((item) =>
[item.label, item.id, item.scope, ...item.paths].join(" ").toLowerCase().includes(searchText),
);
const enabledCount = collections.filter((item) => item.enabled).length;
return (
<section className="space-y-5" data-testid="api-read-cache-settings">
<div>
<h2 className="flex items-center gap-2 text-xl font-semibold"><DatabaseIcon className="size-5 text-primary" />首页接口 Redis 缓存</h2>
<p className="mt-1 text-sm text-muted-foreground">开关保存在 MySQL,服务重启后仍生效。关闭或 Redis 不可用时,接口自动回到 MySQL 查询。</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4">
<section className="space-y-5" data-testid="api-read-cache-settings" aria-busy={busy}>
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-medium">启用常用读取接口缓存</p>
<p className="mt-1 text-sm text-muted-foreground">覆盖智能体、技能、个人偏好、常用提示词和推荐问题列表;缓存按用户隔离,并在数据修改后失效。</p>
<h2 className="flex items-center gap-2 text-xl font-semibold"><DatabaseIcon className="size-5 text-primary" />Redis 与性能管理</h2>
<p className="mt-1 text-sm text-muted-foreground">统一管理接口缓存、浏览器状态查询和历史数据定时清理。</p>
</div>
<Switch checked={status?.enabled ?? false} disabled={!status || busy} onCheckedChange={(value) => void toggle(value)} aria-label="启用 Redis 读取缓存" />
</div>
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void refresh()}>
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />刷新状态
</Button>
</header>
{error && <p role="alert" className="rounded-lg border border-destructive/30 p-3 text-sm text-destructive">{error}</p>}
{status && <div className="rounded-xl border p-4 text-sm">
<p>Redis 连接:<strong>{status.redis_configured ? "已配置" : "未配置"}</strong></p>
<p className="mt-1 text-muted-foreground">单条缓存最长保留 {status.ttl_seconds} 秒,超过 {Math.round(status.max_value_bytes / 1024)} KiB 的结果不会写入缓存。</p>
{!status.redis_configured && <p className="mt-3 text-amber-700">当前配置没有可用的 Redis 地址。开启后设置会保存,但请求会继续直接查询 MySQL;请先在服务配置中配置 Redis。</p>}
</div>}
<Button type="button" variant="outline" disabled={busy} onClick={() => void refresh()}><RefreshCwIcon className="size-4" />刷新状态</Button>
<div className="overflow-hidden rounded-xl border">
<div className="flex items-center justify-between gap-4 bg-muted/30 p-4">
<div>
<h3 className="font-medium">Redis 读取缓存总开关</h3>
<p className="mt-1 text-sm text-muted-foreground">开关和有效期保存在 MySQL,服务重启后仍生效。</p>
</div>
<Switch
checked={status?.enabled ?? false}
disabled={!status || busy}
onCheckedChange={(enabled) => void save({ enabled }, enabled ? "Redis 读取缓存已开启" : "Redis 读取缓存已关闭")}
aria-label="启用 Redis 读取缓存"
/>
</div>
<div className="space-y-3 border-t p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<label htmlFor="api-cache-ttl" className="text-sm font-medium">默认缓存有效期</label>
<p id="api-cache-ttl-help" className="mt-1 text-xs text-muted-foreground">1–3600 秒,默认 300 秒;保存后刷新缓存。</p>
</div>
<div className="flex items-center gap-2">
<Input
id="api-cache-ttl"
type="number"
min={1}
max={3600}
step={1}
value={ttlInput}
disabled={!status || busy}
onChange={(event) => setTtlInput(event.target.value)}
aria-label="Redis 缓存有效期(秒)"
aria-describedby="api-cache-ttl-help"
aria-invalid={!validTtl}
className="w-24"
/>
<span className="text-sm text-muted-foreground">秒</span>
<Button type="button" size="sm" variant="outline" disabled={!status || busy || !validTtl || parsedTtl === status.ttl_seconds} onClick={() => void save({ ttl_seconds: parsedTtl }, "Redis 缓存有效期已保存")}>
<SaveIcon className="size-4" />保存有效期
</Button>
</div>
</div>
{status && <div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
<Badge variant="secondary">Redis 地址{status.redis_configured ? "已配置" : "未配置"}</Badge>
<span>单条结果上限 {Math.round(status.max_value_bytes / 1024)} KiB</span>
<span>关闭或连接失败时自动使用原查询</span>
</div>}
{status && !status.redis_configured && <p className="text-xs text-amber-700 dark:text-amber-400">当前未配置 Redis 地址。可以保存开关,但配置连接后才会使用缓存。</p>}
</div>
</div>
<div className="rounded-xl border p-4" data-testid="browser-status-control">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="flex items-center gap-2 font-medium"><GlobeIcon className="size-4 text-primary" />允许浏览器状态查询</h3>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">GET /api/browser-context/status</p>
<p className="mt-2 text-sm text-muted-foreground">控制浏览器插件能否访问状态接口;关闭后,服务器会拒绝该接口的查询。</p>
</div>
<Switch
checked={status?.browser_status_enabled ?? false}
disabled={!status || busy}
onCheckedChange={(enabled) => void save({ browser_status_enabled: enabled }, enabled ? "浏览器状态查询已允许" : "浏览器状态查询已关闭")}
aria-label="允许浏览器状态查询"
/>
</div>
</div>
<div className="space-y-3 rounded-xl border p-4" data-testid="audit-retention-control">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="flex items-center gap-2 font-medium"><Clock3Icon className="size-4 text-primary" />开启历史数据定时清理</h3>
<p className="mt-1 text-sm text-muted-foreground">分批清理超过 {retention?.retention_days ?? 90} 天的工具调用、模型调用、已结束任务摘要和排行榜快照。</p>
</div>
<Switch
checked={retention ? !retention.paused : false}
disabled={!retention || busy}
onCheckedChange={(enabled) => void toggleRetention(enabled)}
aria-label="开启历史数据定时清理"
/>
</div>
<p className="text-xs text-muted-foreground">包含 tool_call_metrics、llm_call_metrics;关闭后在当前小批次结束时停止,关闭状态在服务重启后保留。</p>
{retentionError && <p role="alert" className="text-sm text-destructive">{retentionError}</p>}
{retention && <div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-muted/40 px-3 py-2 text-xs">
<Badge variant={retention.display_status === "error" ? "destructive" : "secondary"}>{retentionLabel(retention.display_status)}</Badge>
<span className="text-muted-foreground">间隔 {Math.round((retention.interval_seconds ?? 900) / 60)} 分钟</span>
<span className="text-muted-foreground">下次执行:{retention.paused ? "已关闭" : dateTime(retention.next_run_at)}</span>
<span className="text-muted-foreground">最近完成:{dateTime(retention.last_sweep?.finished_at)}</span>
</div>}
{retention?.pause_requested && <p role="status" className="text-sm text-amber-700 dark:text-amber-400">关闭请求已保存,正在等待当前批次结束。可点击“刷新状态”查看结果。</p>}
{retention?.last_error && <p role="alert" className="text-sm text-destructive">{retention.last_error}</p>}
</div>
<div className="space-y-3" data-testid="cache-collection-list">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="font-medium">已接入 Redis 的接口</h3>
<p className="mt-1 text-xs text-muted-foreground">共 {collections.length} 组,单项开启 {enabledCount} 组;同组接口共用开关。</p>
</div>
<div className="relative w-full sm:w-56">
<SearchIcon className="pointer-events-none absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索名称或接口地址" aria-label="搜索缓存接口" className="pl-9" />
</div>
</div>
<p className="text-xs text-muted-foreground">每组可独立开关,数据修改后缓存失效。总开关关闭时仍可配置单项,重新开启总开关后生效;各接口实际有效期见列表。</p>
{status && !status.enabled && <p className="rounded-lg bg-muted px-3 py-2 text-sm">总开关已关闭,当前所有接口均使用原查询。</p>}
<div className="overflow-x-auto rounded-xl border">
<table className="w-full table-fixed text-left text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="w-[56%] px-4 py-3 font-medium" scope="col">接口 / 缓存范围</th>
<th className="w-[18%] px-2 py-3 font-medium" scope="col">有效期</th>
<th className="w-[26%] px-4 py-3 text-right font-medium" scope="col">使用 Redis</th>
</tr>
</thead>
<tbody className="divide-y">
{visibleCollections.map((item) => {
const effective = item.enabled && status?.enabled && status.redis_configured;
const description = !item.enabled ? "单项已关闭" : !status?.enabled ? "总开关关闭" : !status.redis_configured ? "待配置连接" : "缓存已启用";
return <tr key={item.id} data-testid={`cache-collection-${item.id}`}>
<td className="px-4 py-3 align-top">
<p className="font-medium">{item.label}</p>
<div className="mt-1 space-y-1 text-[11px] text-muted-foreground">
{item.paths.map((path) => <p key={path} className="break-all font-mono">{path}</p>)}
</div>
<p className="mt-1 text-xs text-muted-foreground">{item.scope}</p>
</td>
<td className="px-2 py-3 align-top text-xs">{item.ttl_seconds} 秒</td>
<td className="px-4 py-3 text-right align-top">
<Switch
checked={item.enabled}
disabled={busy}
onCheckedChange={(enabled) => void save({ collection_overrides: { [item.id]: enabled } }, `${item.label}缓存${enabled ? "已开启" : "已关闭"}`)}
aria-label={`${item.label}使用 Redis 缓存`}
/>
<p className={`mt-1 text-[11px] ${effective ? "text-primary" : "text-muted-foreground"}`}>{description}</p>
</td>
</tr>;
})}
{!visibleCollections.length && <tr><td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">{!status ? error ? "接口列表读取失败,请刷新重试" : "正在读取接口列表…" : searchText ? "没有匹配的接口" : "暂无已接入的接口"}</td></tr>}
</tbody>
</table>
</div>
</div>
</section>
);
}
@@ -0,0 +1,273 @@
"use client";
import { ImageUp, Loader2, RotateCcw } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useTheme } from "@/contexts/ThemeContext";
import {
getHeaderBrandAsset,
getHeaderBrandFallback,
} from "@/core/branding/brand-assets";
import { resolveAppIdentityLogoUrl } from "@/core/system-settings/api";
import {
useAppIdentitySettings,
useDeleteAppIdentitySettings,
useUpdateAppIdentitySettings,
} from "@/core/system-settings/hooks";
import { SettingsSection } from "./settings-section";
const MAX_LOGO_BYTES = 10 * 1024 * 1024;
const ACCEPTED_LOGO_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
export function AppIdentitySettingsPage() {
const { theme } = useTheme();
const staticLogo = getHeaderBrandAsset(theme);
const staticFallback = getHeaderBrandFallback(theme);
const [staticLogoFailed, setStaticLogoFailed] = useState(false);
const { data: identity, isPending } = useAppIdentitySettings();
const updateIdentity = useUpdateAppIdentitySettings();
const deleteIdentity = useDeleteAppIdentitySettings();
const fileInputRef = useRef<HTMLInputElement>(null);
const [systemName, setSystemName] = useState("");
const [pendingLogo, setPendingLogo] = useState<File | null>(null);
const [removeLogo, setRemoveLogo] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
useEffect(() => {
setSystemName(identity?.system_name ?? "");
setPendingLogo(null);
setRemoveLogo(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [identity?.system_name, identity?.logo_version, identity?.has_logo]);
const previewUrl = useMemo(
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
[pendingLogo],
);
useEffect(
() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
},
[previewUrl],
);
const savedLogoUrl = removeLogo
? undefined
: resolveAppIdentityLogoUrl(identity?.logo_url);
const displayedLogoUrl = previewUrl ?? savedLogoUrl;
const normalizedName = systemName.trim().replace(/\s+/g, " ");
const hasChanges =
normalizedName !== (identity?.system_name ?? "") ||
Boolean(pendingLogo) ||
removeLogo;
const customized = Boolean(identity?.system_name || identity?.has_logo);
const busy = updateIdentity.isPending || deleteIdentity.isPending;
const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!ACCEPTED_LOGO_TYPES.has(file.type)) {
toast.error("Logo 仅支持 PNG、JPG 或 WebP 图片");
event.target.value = "";
return;
}
if (file.size > MAX_LOGO_BYTES) {
toast.error("Logo 文件不能超过 10MB");
event.target.value = "";
return;
}
setPendingLogo(file);
setRemoveLogo(false);
};
const handleUseStaticLogo = () => {
setPendingLogo(null);
setRemoveLogo(true);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSave = async () => {
try {
await updateIdentity.mutateAsync({
systemName: normalizedName,
logo: pendingLogo,
removeLogo,
});
toast.success("系统名称已保存", {
description: "导航、侧栏和欢迎区会使用这里的名称和 Logo。",
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "系统名称保存失败");
}
};
const handleReset = async () => {
try {
await deleteIdentity.mutateAsync();
setResetOpen(false);
toast.success("已恢复默认系统名称和 Logo");
} catch (error) {
toast.error(error instanceof Error ? error.message : "清除系统名称失败");
}
};
return (
<>
<SettingsSection
title="系统名称"
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo。名称留空时继续使用现有名称;没有上传 Logo 时继续使用内置静态图片。这里不会改「生成网页标识」。"
>
<div className="space-y-6">
<div className="space-y-2">
<label htmlFor="app-identity-name" className="text-sm font-medium">
系统名称
</label>
<Input
id="app-identity-name"
value={systemName}
onChange={(event) => setSystemName(event.target.value)}
placeholder="留空则继续使用 CM业务中心 / CM 助手"
maxLength={80}
disabled={isPending || busy}
/>
<div className="flex justify-between gap-4 text-xs text-muted-foreground">
<span>最多 80 个字符。</span>
<span className="tabular-nums">{systemName.length}/80</span>
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">系统 Logo</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
上传后,导航、侧栏和欢迎区都使用这一张图片。未上传时按当前主题使用内置 Logo。
</p>
</div>
<div className="grid gap-4 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[160px_minmax(0,1fr)]">
<div className="flex h-28 items-center justify-center overflow-hidden rounded-lg border border-dashed bg-background p-3">
{displayedLogoUrl ? (
<img
src={displayedLogoUrl}
alt="系统 Logo 预览"
className="max-h-full max-w-full object-contain"
/>
) : (
<img
src={staticLogoFailed ? staticFallback : staticLogo}
alt="当前静态 Logo"
className="max-h-full max-w-full object-contain"
onError={() => setStaticLogoFailed(true)}
/>
)}
</div>
<div className="flex min-w-0 flex-col justify-center gap-3">
<input
ref={fileInputRef}
id="app-identity-logo"
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={handleLogoChange}
disabled={isPending || busy}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => fileInputRef.current?.click()}
disabled={isPending || busy}
>
<ImageUp aria-hidden />
{displayedLogoUrl ? "更换 Logo" : "上传 Logo"}
</Button>
<Button
type="button"
variant="outline"
className="h-11"
onClick={handleUseStaticLogo}
disabled={
isPending ||
busy ||
(!identity?.has_logo && !pendingLogo) ||
(removeLogo && !pendingLogo)
}
>
使用静态 Logo
</Button>
</div>
{pendingLogo && (
<p className="truncate text-xs text-muted-foreground" title={pendingLogo.name}>
待上传:{pendingLogo.name}
</p>
)}
{removeLogo && (
<p className="text-xs text-muted-foreground">
保存后恢复为内置静态 Logo。
</p>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => setResetOpen(true)}
disabled={!customized || busy}
>
<RotateCcw aria-hidden />
清除配置
</Button>
<Button
type="button"
className="h-11 min-w-28"
onClick={handleSave}
disabled={!hasChanges || isPending || busy}
>
{updateIdentity.isPending && <Loader2 className="animate-spin" aria-hidden />}
{updateIdentity.isPending ? "保存中..." : "保存系统名称"}
</Button>
</div>
</div>
</SettingsSection>
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>清除系统名称和 Logo?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
清除后,导航和欢迎区恢复为原来的名称,Logo 恢复为内置静态图片。
</p>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResetOpen(false)}>
取消
</Button>
<Button type="button" onClick={handleReset} disabled={busy}>
清除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -42,6 +42,22 @@ import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-sessio
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import {
AGENT_COMPOSER_WIDTH_MAX,
AGENT_COMPOSER_WIDTH_MIN,
COMPOSER_HEIGHT_MAX,
COMPOSER_HEIGHT_MIN,
COMPOSER_SCALE_DEFAULT,
COMPOSER_WIDTH_MAX,
COMPOSER_WIDTH_MIN,
isComposerWidthChoice,
normalizeAgentComposerWidthPercent,
normalizeComposerHeightPercent,
normalizeComposerWidthPercent,
resolveAgentComposerWidthPercent,
resolveComposerHeightPercent,
resolveComposerWidthPercent,
} from "@/core/settings/composer-width";
import {
useUpdateUserPreferences,
useUserPreferencesQuery,
@@ -50,13 +66,16 @@ import {
useAppearanceSettings,
useCitationDisplaySettings,
useHtmlMapSettings,
useHtmlRuntimeSettings,
useLayoutMode,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
useUpdateHtmlMapSettings,
useUpdateHtmlRuntimeSettings,
} from "@/core/system-settings/hooks";
import type { HtmlMapEngine } from "@/core/system-settings/api";
import type {
AppearanceUpdate,
CompactShortcutButton,
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
@@ -98,6 +117,278 @@ function newShortcutParam(): CompactShortcutRouteParam {
};
}
type ScaleAxisConfig = {
min: number;
max: number;
value: number;
normalize: (raw: string) => number;
onChange: (next: number) => void;
onCommit?: (next: number) => void;
};
function ScaleAxis({
title,
ariaLabel,
low,
high,
preview,
axis,
}: {
title: string;
ariaLabel: string;
low: string;
high: string;
preview: "width" | "height";
axis: ScaleAxisConfig;
}) {
const span = Math.max(1, axis.max - axis.min);
const previewPercent = 8 + ((axis.value - axis.min) / span) * 92;
const readValue = (raw: string) => axis.normalize(raw);
return (
<div className="space-y-2">
<div className="text-muted-foreground text-xs">{title}</div>
<input
type="range"
min={axis.min}
max={axis.max}
step={1}
value={axis.value}
aria-label={ariaLabel}
onChange={(event) => axis.onChange(readValue(event.target.value))}
onPointerUp={(event) =>
axis.onCommit?.(readValue(event.currentTarget.value))
}
onKeyUp={(event) =>
axis.onCommit?.(readValue(event.currentTarget.value))
}
className="accent-primary h-2 w-full cursor-pointer"
/>
{preview === "height" ? (
<div className="bg-muted/60 flex h-14 items-end rounded-lg border px-3 py-1.5">
<div
className="bg-background w-full rounded-md border shadow-sm"
style={{ height: `${previewPercent}%` }}
/>
</div>
) : (
<div className="bg-muted/60 flex h-11 items-center rounded-lg border px-3">
<div
className="bg-background h-7 rounded-md border shadow-sm"
style={{ width: `${previewPercent}%` }}
/>
</div>
)}
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>{low}</span>
<span>{high}</span>
</div>
</div>
);
}
function ComposerSizeCard({
label,
hint,
width,
height,
resetDisabled,
resetLabel = "恢复默认",
onReset,
}: {
label: string;
hint?: string;
width: ScaleAxisConfig;
height: ScaleAxisConfig;
resetDisabled?: boolean;
resetLabel?: string;
onReset: () => void;
}) {
return (
<div className="space-y-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">{label}</div>
{hint ? (
<p className="text-muted-foreground mt-1 text-xs leading-5">{hint}</p>
) : null}
</div>
<ScaleAxis
title="宽度"
ariaLabel={`${label}宽度`}
low="较窄"
high="较宽"
preview="width"
axis={width}
/>
<ScaleAxis
title="高度"
ariaLabel={`${label}高度`}
low="较矮"
high="较高"
preview="height"
axis={height}
/>
<div className="flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
disabled={resetDisabled}
onClick={onReset}
>
{resetLabel}
</Button>
</div>
</div>
);
}
type ComposerDefaultKey =
| "widthFull"
| "heightFull"
| "widthCompact"
| "heightCompact"
| "agentWidth"
| "agentHeight";
const COMPOSER_DEFAULT_FIELDS: Record<ComposerDefaultKey, keyof AppearanceUpdate> = {
widthFull: "default_composer_width_full",
heightFull: "default_composer_height_full",
widthCompact: "default_composer_width_compact",
heightCompact: "default_composer_height_compact",
agentWidth: "default_agent_composer_width",
agentHeight: "default_agent_composer_height",
};
function AdminComposerSizeDefaults({
values,
saving,
onSave,
}: {
values: Record<ComposerDefaultKey, number>;
saving: boolean;
onSave: (patch: AppearanceUpdate) => Promise<void>;
}) {
const [draft, setDraft] = useState<Partial<Record<ComposerDefaultKey, number>>>({});
const shown = {
widthFull: draft.widthFull ?? values.widthFull,
heightFull: draft.heightFull ?? values.heightFull,
widthCompact: draft.widthCompact ?? values.widthCompact,
heightCompact: draft.heightCompact ?? values.heightCompact,
agentWidth: draft.agentWidth ?? values.agentWidth,
agentHeight: draft.agentHeight ?? values.agentHeight,
};
const commit = (key: ComposerDefaultKey, next: number) => {
if (next === values[key]) {
setDraft((prev) => ({ ...prev, [key]: undefined }));
return;
}
const field = COMPOSER_DEFAULT_FIELDS[key];
void onSave({ [field]: next }).finally(() => {
setDraft((prev) => ({ ...prev, [key]: undefined }));
});
};
const axis = (
key: ComposerDefaultKey,
min: number,
max: number,
normalize: (raw: string) => number,
): ScaleAxisConfig => ({
min,
max,
value: shown[key],
normalize,
onChange: (next) => setDraft((prev) => ({ ...prev, [key]: next })),
onCommit: (next) => commit(key, next),
});
const resetCard = (widthKey: ComposerDefaultKey, heightKey: ComposerDefaultKey) => {
setDraft((prev) => ({
...prev,
[widthKey]: COMPOSER_SCALE_DEFAULT,
[heightKey]: COMPOSER_SCALE_DEFAULT,
}));
const widthField = COMPOSER_DEFAULT_FIELDS[widthKey];
const heightField = COMPOSER_DEFAULT_FIELDS[heightKey];
void onSave({
[widthField]: COMPOSER_SCALE_DEFAULT,
[heightField]: COMPOSER_SCALE_DEFAULT,
}).finally(() => {
setDraft((prev) => ({
...prev,
[widthKey]: undefined,
[heightKey]: undefined,
}));
});
};
const cards: Array<{
key: string;
label: string;
hint?: string;
widthKey: ComposerDefaultKey;
heightKey: ComposerDefaultKey;
widthMin: number;
widthMax: number;
normalizeWidth: (raw: string) => number;
}> = [
{
key: "full",
label: "全量模式",
widthKey: "widthFull",
heightKey: "heightFull",
widthMin: COMPOSER_WIDTH_MIN,
widthMax: COMPOSER_WIDTH_MAX,
normalizeWidth: normalizeComposerWidthPercent,
},
{
key: "compact",
label: "简洁模式",
widthKey: "widthCompact",
heightKey: "heightCompact",
widthMin: COMPOSER_WIDTH_MIN,
widthMax: COMPOSER_WIDTH_MAX,
normalizeWidth: normalizeComposerWidthPercent,
},
{
key: "agent",
label: "智能体问答",
hint: "宽度最多铺满输入框所在的中间区域,不会超出两侧面板。",
widthKey: "agentWidth",
heightKey: "agentHeight",
widthMin: AGENT_COMPOSER_WIDTH_MIN,
widthMax: AGENT_COMPOSER_WIDTH_MAX,
normalizeWidth: normalizeAgentComposerWidthPercent,
},
];
return (
<div className="grid gap-3 lg:grid-cols-2">
{cards.map((card) => (
<ComposerSizeCard
key={card.key}
label={card.label}
hint={card.hint}
width={axis(
card.widthKey,
card.widthMin,
card.widthMax,
card.normalizeWidth,
)}
height={axis(
card.heightKey,
COMPOSER_HEIGHT_MIN,
COMPOSER_HEIGHT_MAX,
normalizeComposerHeightPercent,
)}
resetDisabled={
saving ||
(shown[card.widthKey] === COMPOSER_SCALE_DEFAULT &&
shown[card.heightKey] === COMPOSER_SCALE_DEFAULT)
}
onReset={() => resetCard(card.widthKey, card.heightKey)}
/>
))}
</div>
);
}
function newShortcutButton(): CompactShortcutButton {
return {
id: makeShortcutId(),
@@ -196,6 +487,15 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const { data: htmlMap } = useHtmlMapSettings();
const updateHtmlMap = useUpdateHtmlMapSettings();
const { data: htmlRuntime } = useHtmlRuntimeSettings();
const updateHtmlRuntime = useUpdateHtmlRuntimeSettings();
const [htmlRuntimeDraft, setHtmlRuntimeDraft] = useState<{
base_url: string;
map_tile_url: string;
} | null>(null);
const runtimeBaseUrl = htmlRuntimeDraft?.base_url ?? htmlRuntime?.base_url ?? "";
const runtimeTileUrl =
htmlRuntimeDraft?.map_tile_url ?? htmlRuntime?.map_tile_url ?? "";
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
@@ -272,6 +572,14 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
}
};
const handleSetDefaultComposerSize = async (patch: AppearanceUpdate) => {
try {
await updateAppearance.mutateAsync(patch);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const handleSetDefaultLayoutMode = async (value: "full" | "compact") => {
if (value === globalLayoutMode) return;
try {
@@ -536,6 +844,160 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
</div>
</SettingsSection>
<Separator />
<SettingsSection
title="输入框尺寸"
description="分别调整普通问答在全量模式、简洁模式下的输入框,以及智能体问答的输入框。没有自己调整时,使用管理员设置的系统默认。"
>
<div className="grid gap-3 lg:grid-cols-2">
<ComposerSizeCard
label="全量模式"
resetLabel="跟随系统默认"
resetDisabled={
!isComposerWidthChoice(settings.display.composerWidthFull) &&
!isComposerWidthChoice(settings.display.composerHeightFull)
}
width={{
min: COMPOSER_WIDTH_MIN,
max: COMPOSER_WIDTH_MAX,
value: resolveComposerWidthPercent(
settings.display.composerWidthFull,
appearance?.default_composer_width_full,
),
normalize: normalizeComposerWidthPercent,
onChange: (next) =>
setSettings("display", { composerWidthFull: next }),
}}
height={{
min: COMPOSER_HEIGHT_MIN,
max: COMPOSER_HEIGHT_MAX,
value: resolveComposerHeightPercent(
settings.display.composerHeightFull,
appearance?.default_composer_height_full,
),
normalize: normalizeComposerHeightPercent,
onChange: (next) =>
setSettings("display", { composerHeightFull: next }),
}}
onReset={() =>
setSettings("display", {
composerWidthFull: undefined,
composerHeightFull: undefined,
})
}
/>
<ComposerSizeCard
label="简洁模式"
resetLabel="跟随系统默认"
resetDisabled={
!isComposerWidthChoice(settings.display.composerWidthCompact) &&
!isComposerWidthChoice(settings.display.composerHeightCompact)
}
width={{
min: COMPOSER_WIDTH_MIN,
max: COMPOSER_WIDTH_MAX,
value: resolveComposerWidthPercent(
settings.display.composerWidthCompact,
appearance?.default_composer_width_compact,
),
normalize: normalizeComposerWidthPercent,
onChange: (next) =>
setSettings("display", { composerWidthCompact: next }),
}}
height={{
min: COMPOSER_HEIGHT_MIN,
max: COMPOSER_HEIGHT_MAX,
value: resolveComposerHeightPercent(
settings.display.composerHeightCompact,
appearance?.default_composer_height_compact,
),
normalize: normalizeComposerHeightPercent,
onChange: (next) =>
setSettings("display", { composerHeightCompact: next }),
}}
onReset={() =>
setSettings("display", {
composerWidthCompact: undefined,
composerHeightCompact: undefined,
})
}
/>
<ComposerSizeCard
label="智能体问答"
hint="宽度最多铺满输入框所在的中间区域,不会超出两侧面板。"
resetLabel="跟随系统默认"
resetDisabled={
!isComposerWidthChoice(settings.display.agentComposerWidth) &&
!isComposerWidthChoice(settings.display.agentComposerHeight)
}
width={{
min: AGENT_COMPOSER_WIDTH_MIN,
max: AGENT_COMPOSER_WIDTH_MAX,
value: resolveAgentComposerWidthPercent(
settings.display.agentComposerWidth,
appearance?.default_agent_composer_width,
),
normalize: normalizeAgentComposerWidthPercent,
onChange: (next) =>
setSettings("display", { agentComposerWidth: next }),
}}
height={{
min: COMPOSER_HEIGHT_MIN,
max: COMPOSER_HEIGHT_MAX,
value: resolveComposerHeightPercent(
settings.display.agentComposerHeight,
appearance?.default_agent_composer_height,
),
normalize: normalizeComposerHeightPercent,
onChange: (next) =>
setSettings("display", { agentComposerHeight: next }),
}}
onReset={() =>
setSettings("display", {
agentComposerWidth: undefined,
agentComposerHeight: undefined,
})
}
/>
</div>
</SettingsSection>
{isAdmin && (
<>
<Separator />
<SettingsSection
title="系统默认输入框尺寸(管理员)"
description="给还没有自己调整过输入框尺寸的用户使用。已经调整过的人保持自己的尺寸。"
>
<AdminComposerSizeDefaults
values={{
widthFull: normalizeComposerWidthPercent(
appearance?.default_composer_width_full,
),
heightFull: normalizeComposerHeightPercent(
appearance?.default_composer_height_full,
),
widthCompact: normalizeComposerWidthPercent(
appearance?.default_composer_width_compact,
),
heightCompact: normalizeComposerHeightPercent(
appearance?.default_composer_height_compact,
),
agentWidth: normalizeAgentComposerWidthPercent(
appearance?.default_agent_composer_width,
),
agentHeight: normalizeComposerHeightPercent(
appearance?.default_agent_composer_height,
),
}}
saving={updateAppearance.isPending}
onSave={handleSetDefaultComposerSize}
/>
</SettingsSection>
</>
)}
{isAdmin && (
<>
<Separator />
@@ -930,7 +1392,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<div>
<div className="text-sm font-medium">HTML 地图</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
管理员配置。生成带地图的 HTML 网页时使用哪种地图。ECharts 使用本地行政区划;地图网页使用 Leaflet 瓦片,地址在后台 config.yaml 的 html_runtime.map_tile_url。下一次生成的页面生效。
管理员配置。生成带地图的 HTML 网页时使用哪种地图。ECharts 使用本地行政区划;地图网页使用 Leaflet 瓦片,瓦片地址在下面的「地图瓦片地址」里填写。下一次生成的页面生效。
</p>
</div>
<Select
@@ -953,6 +1415,77 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
</Select>
</div>
)}
{isAdmin && (
<div className="rounded-lg border p-4">
<div className="text-sm font-medium">HTML 运行地址</div>
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
管理员配置。画网页时,图表、流程图、地图脚本和上传图片都从这里加载。请填写用户浏览器真正能够打开的地址。只填写域名时,会自动补上 /api/public/html-runtime;如果前面还有访问前缀,请写成完整地址,例如 https://域名/前缀/api/public/html-runtime。保存后只影响之后新画的网页。
</p>
<label className="mt-3 block text-xs font-medium" htmlFor="html-runtime-base-url">
运行库地址
</label>
<Input
id="html-runtime-base-url"
value={runtimeBaseUrl}
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
onChange={(event) =>
setHtmlRuntimeDraft({
base_url: event.target.value,
map_tile_url: runtimeTileUrl,
})
}
placeholder="https://你的域名/api/public/html-runtime"
className="mt-1 font-mono text-xs"
spellCheck={false}
/>
<label className="mt-3 block text-xs font-medium" htmlFor="html-map-tile-url">
地图瓦片地址
</label>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
地图网页使用的瓦片模板,需要包含 {"{z}"}、{"{x}"}、{"{y}"}。浏览器重新打开页面后生效。
</p>
<Input
id="html-map-tile-url"
value={runtimeTileUrl}
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
onChange={(event) =>
setHtmlRuntimeDraft({
base_url: runtimeBaseUrl,
map_tile_url: event.target.value,
})
}
placeholder="https://tiles.example/{z}/{x}/{y}.png"
className="mt-1 font-mono text-xs"
spellCheck={false}
/>
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
disabled={
!htmlRuntime ||
updateHtmlRuntime.isPending ||
(runtimeBaseUrl === htmlRuntime.base_url &&
runtimeTileUrl === htmlRuntime.map_tile_url)
}
onClick={() =>
updateHtmlRuntime.mutate(
{ base_url: runtimeBaseUrl.trim(), map_tile_url: runtimeTileUrl.trim() },
{
onSuccess: () => {
setHtmlRuntimeDraft(null);
toast.success("HTML 运行地址已保存,之后新画的网页会使用这个地址");
},
onError: (error) => toast.error(error.message || "保存失败"),
},
)
}
>
{updateHtmlRuntime.isPending ? "保存中..." : "保存地址"}
</Button>
</div>
</div>
)}
{isAdmin && (
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
@@ -10,6 +10,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { getStoredAuth } from "@/core/auth";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import {
ACTIVE_ARCHIVE_JOB_STATUSES as ACTIVE,
archiveMonthDisplayStatus,
canPurgeArchivedMonth,
isArchiveMonthActive,
} from "@/core/chat-archive-state";
type ArchiveJob = {
job_id: string;
@@ -44,8 +50,6 @@ type Inventory = {
months: ArchiveMonth[];
};
const ACTIVE = new Set(["queued", "running"]);
function formatBytes(bytes: number) {
if (!bytes) return "尚无归档文件";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -70,6 +74,7 @@ function statusLabel(status: string) {
case "copying": return "正在归档";
case "deleting": return "正在清理 PG";
case "restoring": return "正在回滚";
case "cancelling": return "正在停止";
case "partial": return "部分完成";
case "interrupted": return "已中断,可重新归档";
default: return "尚未归档";
@@ -136,8 +141,10 @@ export function ChatArchiveSettingsPage() {
}, [inventory, load]);
const runAction = async (month: ArchiveMonth, action: ArchiveJob["action"]) => {
const labels = { archive: "归档到 SQLite", purge: "删除该月 PostgreSQL 数据", rollback: "回滚到 PostgreSQL" };
if (action !== "archive" && !window.confirm(`确认${labels[action]}?会话记录会保留在月度 SQLite 文件中。`)) return;
const confirmation = action === "purge"
? "确认清理该月已归档的 PostgreSQL 数据?系统会逐会话重新校验 SQLite 备份,只清理校验通过的会话;失败、未归档和正在运行的会话不会清理。"
: "确认回滚到 PostgreSQL?月度 SQLite 文件中的归档记录会保留。";
if (action !== "archive" && !window.confirm(confirmation)) return;
setBusyMonth(month.month);
setError(null);
try {
@@ -171,7 +178,7 @@ export function ChatArchiveSettingsPage() {
</Alert>
<Alert>
<AlertTitle>低负载处理方式</AlertTitle>
<AlertDescription>{inventory?.warning ?? "月份按会话创建时间展示;归档任务在后台逐会话处理,页面关闭或服务重启后可继续。"} 删除 PostgreSQL 前会重新校验目标 SQLite 文件中的检查点;清理按会话逐条执行,每条间隔 0.5 秒,运行中的问答会跳过并计入进度,结束后可重新清理。清理后空间可供 PostgreSQL 后续复用;表文件大小不会保证立即缩小,系统不会自动运行可能带来较大 I/O 的 VACUUM FULL。</AlertDescription>
<AlertDescription>{inventory?.warning ?? "月份按会话创建时间展示;归档任务在后台逐会话处理,页面关闭不影响执行;中断后可重新执行。"} 单个会话失败会记录错误并继续处理其他会话;部分归档完成后也可清理已校验的 PG 数据,未归档或校验失败的会话会保留。磁盘不足等整体故障仍会停止任务。删除 PostgreSQL 前会重新校验目标 SQLite 文件中的检查点;清理按会话逐条执行,每条间隔 0.5 秒,运行中的问答会跳过并计入进度,结束后可重新清理。清理后空间可供 PostgreSQL 后续复用;表文件大小不会保证立即缩小,系统不会自动运行可能带来较大 I/O 的 VACUUM FULL。</AlertDescription>
</Alert>
{error && <Alert variant="destructive"><AlertTitle>操作未完成</AlertTitle><AlertDescription className="break-all">{error}</AlertDescription></Alert>}
@@ -180,8 +187,9 @@ export function ChatArchiveSettingsPage() {
<div className="space-y-3">
{inventory.months.map((month) => {
const job = month.latest_job;
const active = ACTIVE.has(job?.status ?? "") || ["copying", "deleting", "restoring"].includes(month.status);
const canPurge = ["ready", "deleted", "partial", "interrupted"].includes(month.status) && month.copied_threads > 0;
const active = isArchiveMonthActive(month);
const displayStatus = archiveMonthDisplayStatus(month);
const canPurge = canPurgeArchivedMonth(month);
const canRollback = month.deleted_threads > 0;
return (
<Card key={month.month}>
@@ -191,13 +199,18 @@ export function ChatArchiveSettingsPage() {
<CardTitle className="text-base">{month.month}</CardTitle>
<CardDescription className="mt-1">{month.thread_count.toLocaleString()} 个会话 · SQLite:{formatBytes(month.sqlite_bytes)}</CardDescription>
</div>
<Badge variant={statusTone(month.status)}>{statusLabel(month.status)}</Badge>
<Badge variant={statusTone(displayStatus)}>{statusLabel(displayStatus)}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-xs">已复制 {month.copied_threads.toLocaleString()} 个 · PG 已清理 {month.deleted_threads.toLocaleString()} 个</p>
{job && <div className="bg-muted/50 rounded-md p-3 text-sm">
<div>{job.message || "任务状态已更新"} {ACTIVE.has(job.status) && `(${job.processed_threads}/${job.total_threads}${job.action === "purge" && job.failed_threads ? `,跳过 ${job.failed_threads}` : ""})`}</div>
<div>{job.message || "任务状态已更新"}</div>
<div className="text-muted-foreground mt-1 text-xs">
已处理 {job.processed_threads.toLocaleString()} / {job.total_threads.toLocaleString()} 个
{job.failed_threads > 0 && ` · 失败或跳过 ${job.failed_threads.toLocaleString()} 个`}
</div>
{job.failed_threads > 0 && <div className="text-muted-foreground mt-1 text-xs">{ACTIVE.has(job.status) ? "将继续处理其他会话,未完成项可稍后重试。" : "未完成项可重新执行;已有备份不会因单个会话失败而丢弃。"}</div>}
{job.action === "archive" && job.snapshot_at && <div className="text-muted-foreground mt-1 text-xs">本次数据快照截至:{new Date(job.snapshot_at).toLocaleString()}</div>}
{job.action === "archive" && job.processed_through && <div className="text-muted-foreground mt-1 text-xs">已处理到会话创建时间:{new Date(job.processed_through).toLocaleString()}</div>}
{ACTIVE.has(job.status) && <div className="mt-2 space-y-1">
@@ -213,12 +226,12 @@ export function ChatArchiveSettingsPage() {
</div>
<div className="text-muted-foreground text-xs">{job.total_threads ? Math.floor((job.processed_threads / job.total_threads) * 100) : 100}%</div>
</div>}
{job.current_thread_id && <div className="text-muted-foreground mt-1 break-all text-xs">当前会话:{job.current_thread_id}</div>}
{job.error && <div className="text-destructive mt-1 break-all text-xs">{job.error}</div>}
{job.current_thread_id && <div className="text-muted-foreground mt-1 break-all text-xs">{ACTIVE.has(job.status) ? "当前会话" : "最后处理会话"}:{job.current_thread_id}</div>}
{job.error && <div className="text-destructive mt-1 break-all text-xs">最近错误:{job.error}</div>}
</div>}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={active || busyMonth === month.month || month.month > currentLocalMonth()} onClick={() => void runAction(month, "archive")}>
<ArchiveIcon className="mr-2 size-4" />{month.status === "interrupted" ? "从头重新归档" : month.month === currentLocalMonth() ? "归档本月快照" : month.copied_threads ? "重新校验归档" : "归档到 SQLite"}
<ArchiveIcon className="mr-2 size-4" />{displayStatus === "interrupted" || displayStatus === "partial" ? "重新归档并校验" : month.month === currentLocalMonth() ? "归档本月快照" : month.copied_threads ? "重新校验归档" : "归档到 SQLite"}
</Button>
<Button size="sm" variant="destructive" disabled={active || !canPurge || busyMonth === month.month} onClick={() => void runAction(month, "purge")}>
<Trash2Icon className="mr-2 size-4" />{month.status === "deleted" ? "再次清理 PG" : "删除已归档 PG 数据"}
@@ -1,9 +1,11 @@
"use client";
import { Loader2Icon, ShrinkIcon } from "lucide-react";
import { ListOrderedIcon, Loader2Icon, ShrinkIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useConversationContextSettings,
@@ -16,6 +18,35 @@ export function ContextWindowSettingsPage() {
const update = useUpdateConversationContextSettings();
const enabled = data?.recent_turn_window_enabled ?? false;
const recentTurns = data?.recent_turns ?? 3;
const currentRoundLimit = data?.max_conversation_rounds ?? 20;
const [roundLimit, setRoundLimit] = useState(String(currentRoundLimit));
const [roundLimitTouched, setRoundLimitTouched] = useState(false);
const parsedRoundLimit = Number(roundLimit);
const roundLimitInvalid =
!Number.isInteger(parsedRoundLimit) ||
parsedRoundLimit < 1 ||
parsedRoundLimit > 50;
const useQuestionAsTitle = data?.use_question_as_title ?? false;
const [stepDraft, setStepDraft] = useState<string | null>(null);
const stepValue = stepDraft ?? String(data?.max_run_steps ?? 1000);
const steps = Number(stepValue);
const validSteps = stepValue.trim() !== "" && Number.isInteger(steps) && steps >= 20 && steps <= 10000;
useEffect(() => {
setRoundLimit(String(currentRoundLimit));
setRoundLimitTouched(false);
}, [currentRoundLimit]);
const saveSteps = async () => {
if (!validSteps) return;
try {
await update.mutateAsync({ max_run_steps: steps });
setStepDraft(null);
toast.success("单次问答步骤上限已保存,从下一次问答生效");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleChange = async (checked: boolean) => {
try {
@@ -30,6 +61,33 @@ export function ContextWindowSettingsPage() {
}
};
const saveRoundLimit = async () => {
setRoundLimitTouched(true);
if (roundLimitInvalid) {
toast.error("对话轮数限制请填写 1 到 50 的整数");
return;
}
try {
await update.mutateAsync({ max_conversation_rounds: parsedRoundLimit });
toast.success(`单个对话最多可进行 ${parsedRoundLimit} 轮`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleQuestionTitleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ use_question_as_title: checked });
toast.success(
checked
? "已开启:对话标题使用首个问题的前 20 个字"
: "已关闭:恢复回答后的标题生成",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
@@ -38,7 +96,7 @@ export function ContextWindowSettingsPage() {
上下文管理
</h3>
<p className="text-muted-foreground text-sm">
控制长对话发给模型的历史范围。该模式默认关闭,只有管理员显式开启后才会生效。
管理对话历史范围、单个对话轮数、标题生成方式和单次问答步骤上限。设置对所有用户生效,仅管理员可修改。
</p>
</div>
@@ -62,6 +120,105 @@ export function ContextWindowSettingsPage() {
)}
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<div className="min-w-0 space-y-1">
<label
htmlFor="max-conversation-rounds"
className="flex items-center gap-2 font-medium"
>
<ListOrderedIcon className="size-4" />
单个对话轮数限制
</label>
<p
id="max-conversation-rounds-help"
className="text-muted-foreground text-sm"
>
一轮按一条真实用户提问计算。默认 20,可设置为 1 到 50;达到上限后用户需要新建对话才能继续。
</p>
<div className="mt-2 flex items-center gap-2">
<Input
id="max-conversation-rounds"
type="number"
min={1}
max={50}
inputMode="numeric"
value={roundLimit}
disabled={isLoading || isError || update.isPending}
onChange={(event) => setRoundLimit(event.target.value)}
onBlur={() => setRoundLimitTouched(true)}
aria-invalid={roundLimitTouched && roundLimitInvalid}
aria-describedby="max-conversation-rounds-help max-conversation-rounds-error"
className="w-28"
/>
<span className="text-muted-foreground text-sm">轮</span>
</div>
<p
id="max-conversation-rounds-error"
className={
roundLimitTouched && roundLimitInvalid
? "text-destructive min-h-5 text-xs"
: "text-muted-foreground min-h-5 text-xs"
}
role={roundLimitTouched && roundLimitInvalid ? "alert" : undefined}
>
{roundLimitTouched && roundLimitInvalid
? "请输入 1 到 50 的整数。"
: "保存后,新限制会应用到当前及后续普通对话。"}
</p>
</div>
<Button
type="button"
size="sm"
disabled={
isLoading ||
isError ||
update.isPending ||
(!roundLimitInvalid && parsedRoundLimit === currentRoundLimit)
}
onClick={() => void saveRoundLimit()}
>
{update.isPending ? "保存中…" : "保存轮数"}
</Button>
</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">
开启后,取首个问题的前 20 个字作为最终标题,不足 20 个字则完整保留;回答结束后不再额外调用模型生成标题,也不轮询标题状态,避免影响下一轮提问。关闭后恢复原来的回答后标题生成方式。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={useQuestionAsTitle}
disabled={isError || update.isPending}
onCheckedChange={handleQuestionTitleChange}
aria-label="标题直接使用首个问题"
/>
)}
</div>
<div className="space-y-3 rounded-lg border p-4">
<label htmlFor="max-run-steps" className="font-medium">单次问答步骤上限</label>
<p className="text-muted-foreground text-sm">
控制普通问答和智能体问答每次运行的最大执行步数(20–10000,默认 1000)。步骤包括模型、工具及内部处理,不等于界面显示的步骤条数。提高上限可支持更复杂的任务,也会增加耗时和用量。
</p>
<div className="flex items-center gap-3">
<Input id="max-run-steps" type="number" min={20} max={10000} step={1}
className="w-40" value={stepValue}
disabled={isLoading || isError || update.isPending}
onChange={(event) => setStepDraft(event.target.value)} />
<Button disabled={isLoading || isError || update.isPending || !validSteps}
onClick={() => void saveSteps()}>保存上限</Button>
</div>
{!validSteps && <p className="text-destructive text-sm">请输入 20 到 10000 之间的整数。</p>}
<p className="text-muted-foreground text-sm">
达到上限时保留已有对话内容并提示暂停;用户可发送“继续”,在当前对话中接着处理。
</p>
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
@@ -0,0 +1,110 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { useModels } from "@/core/models/hooks";
type ModelPrompt = { enabled: boolean; prompt: string };
const queryKey = ["system-settings", "model-prompt-suffixes"];
const endpoint = () => `${getBackendBaseURL()}/api/system-settings/model-prompt-suffixes`;
async function readResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(typeof body?.detail === "string" ? body.detail : "模型提示词设置请求失败");
}
return response.json() as Promise<T>;
}
export function ModelPromptSettingsPage() {
const client = useQueryClient();
const { models, isLoading: modelsLoading, error: modelsError } = useModels();
const query = useQuery({
queryKey,
queryFn: async () => readResponse<Record<string, ModelPrompt>>(await apiFetch(endpoint())),
});
const [selected, setSelected] = useState("");
const [drafts, setDrafts] = useState<Record<string, ModelPrompt>>({});
const names = [...new Set([...models.map((model) => model.name), ...Object.keys(query.data ?? {})])];
const modelName = selected || names[0] || "";
const saved = query.data?.[modelName] ?? { enabled: false, prompt: "" };
const draft = drafts[modelName] ?? saved;
const mutation = useMutation({
mutationFn: async ({ name, value }: { name: string; value: ModelPrompt }) =>
readResponse<ModelPrompt>(await apiFetch(`${endpoint()}/${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
})),
onSuccess: (value, { name }) => {
client.setQueryData<Record<string, ModelPrompt>>(queryKey, (old) => ({ ...old, [name]: value }));
setDrafts((old) => {
const next = { ...old };
delete next[name];
return next;
});
toast.success("已保存,所有用户下一次调用该模型时生效");
},
onError: (error) => toast.error(error.message),
});
const edit = (patch: Partial<ModelPrompt>) => setDrafts((old) => ({ ...old, [modelName]: { ...draft, ...patch } }));
if (query.isLoading || modelsLoading) return <p>正在加载模型提示词设置…</p>;
if (query.isError || modelsError) return (
<div className="space-y-3">
<p role="alert">加载失败,请重试。</p>
<Button onClick={() => { void query.refetch(); void client.invalidateQueries({ queryKey: ["models"] }); }}>重试</Button>
</div>
);
return (
<section className="space-y-5">
<div className="space-y-2">
<h3 className="text-lg font-semibold">模型追加提示词</h3>
<p className="text-muted-foreground text-sm">
为指定模型设置固定提示词,自动追加到系统提示词末尾。仅管理员可修改,对所有用户生效,保存后无需重启。
</p>
<p className="text-muted-foreground text-sm">
适用于平台统一模型入口的问答、智能体及其他模型调用。追加提示词用于引导回答方式,不能保证模型一定回答。
</p>
</div>
{names.length === 0 ? <p>暂无可配置的模型。</p> : <>
<label className="block space-y-2">
<span className="text-sm font-medium">选择模型</span>
<select className="bg-background w-full rounded-md border px-3 py-2" value={modelName}
disabled={mutation.isPending} onChange={(event) => setSelected(event.target.value)}>
{names.map((name) => {
const model = models.find((item) => item.name === name);
return <option key={name} value={name}>{model ? `${model.display_name || name}(${name})` : `${name}(当前未配置)`}</option>;
})}
</select>
</label>
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
<label htmlFor="model-prompt-enabled" className="text-sm font-medium">启用此模型的追加提示词</label>
<Switch id="model-prompt-enabled" checked={draft.enabled} disabled={mutation.isPending}
onCheckedChange={(enabled) => edit({ enabled })} />
</div>
<label className="block space-y-2">
<span className="text-sm font-medium">追加提示词</span>
<Textarea rows={12} maxLength={20000} value={draft.prompt} disabled={mutation.isPending}
placeholder="填写希望该模型遵循的回答要求"
onChange={(event) => edit({ prompt: event.target.value })} />
</label>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground text-xs">{draft.prompt.length} / 20000 字符;关闭开关可停用并保留内容。</span>
<Button disabled={mutation.isPending || (draft.enabled && !draft.prompt.trim())}
onClick={() => mutation.mutate({ name: modelName, value: draft })}>
{mutation.isPending ? "保存中…" : "保存此模型设置"}
</Button>
</div>
</>}
</section>
);
}
@@ -0,0 +1,74 @@
"use client";
import { HistoryIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useRetryKeepPartialSettings,
useUpdateRetryKeepPartialSettings,
} from "@/core/system-settings/hooks";
export function RetryKeepPartialSettingsPage() {
const { data, isLoading, isError } = useRetryKeepPartialSettings();
const update = useUpdateRetryKeepPartialSettings();
const enabled = data?.enabled ?? true;
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">
<HistoryIcon className="size-5" />
保留中间正文
</h3>
<p className="text-muted-foreground text-sm">
步骤条执行过程中模型有时会先吐出一段正文再发起工具调用或开始下一轮思考。
开启后,本轮所有过程正文会合并成一条,集中显示在步骤条下方(步骤条保持
一个整体不被拆开,最终回答紧随其后);大模型响应中断触发后台自动重试时,
重试前已流出的半截回答同样保留。关闭则恢复原有行为——工具调用到达或
重试开始时清空未完成的正文。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">保留步骤条执行中生成的正文</div>
<p className="text-muted-foreground text-sm">
仅影响问答页的显示,不改变后台执行与落库逻辑;重试保留的半截内容
刷新页面后不会保留。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="自动重试时保留已生成的正文"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取保留中间正文开关,系统将按开启处理。
</p>
)}
<p className="text-muted-foreground text-xs">
保存后无需重启后台,对下一次触发的自动重试立即生效;正在生成中的回答不会中途切换。
</p>
</section>
);
}
@@ -1,6 +1,6 @@
"use client";
import { Clock3Icon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
import { Clock3Icon, FileOutputIcon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -9,6 +9,7 @@ import { Switch } from "@/components/ui/switch";
import {
useRoundtableRoomLimitSettings,
useUpdateRoundtableRoomLimitSettings,
useWriteFileCompatibilitySettings,
} from "@/core/system-settings/hooks";
import { loadRoundtableRoomSummary } from "@/roundtable-planning/api/rooms";
@@ -16,7 +17,10 @@ export function RoundtableRoomSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useRoundtableRoomLimitSettings();
const update = useUpdateRoundtableRoomLimitSettings();
const globalFileStream = useWriteFileCompatibilitySettings();
const enabled = data?.enabled ?? true;
const fileStreamEnabled = data?.write_file_compatibility_enabled ?? false;
const globalFileStreamEnabled = globalFileStream.data?.enabled ?? false;
const [maxRooms, setMaxRooms] = useState(String(data?.max_rooms ?? 3));
const [maxHoldMinutes, setMaxHoldMinutes] = useState(
String(data?.max_hold_minutes ?? 30),
@@ -62,6 +66,7 @@ export function RoundtableRoomSettingsPage() {
max_rooms?: number;
max_hold_minutes?: number;
max_cycles?: number;
write_file_compatibility_enabled?: boolean;
}) => {
try {
await update.mutateAsync(next);
@@ -215,6 +220,37 @@ export function RoundtableRoomSettingsPage() {
</Button>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="flex items-center gap-2 font-medium">
<FileOutputIcon className="size-4" />
会商文件流式兼容
</div>
<p className="text-muted-foreground text-sm">
会商生成文件并打开沙箱时使用兼容写入。全局「文件流式兼容」开着时,会商自动使用;全局关着时,打开本开关后只有会商使用,普通对话保持原来的写入方式。
</p>
<p className="text-muted-foreground text-xs">
{globalFileStreamEnabled
? "当前会商会使用兼容写入,因为全局开关已开启。"
: fileStreamEnabled
? "当前只有会商使用兼容写入。"
: "当前会商使用各模型原来的写入方式。"}
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={fileStreamEnabled}
disabled={isError || update.isPending}
onCheckedChange={(checked) =>
void save({ write_file_compatibility_enabled: checked })
}
aria-label="会商启用文件流式兼容"
/>
)}
</div>
<p className="text-muted-foreground text-sm">
当前全站占用:{activeCount == null ? "暂时无法读取" : `${activeCount} / ${data?.max_rooms ?? maxRooms}`}
</p>
@@ -7,10 +7,12 @@ import {
DatabaseIcon,
ArchiveIcon,
FileOutputIcon,
HistoryIcon,
PaletteIcon,
SearchIcon,
ShrinkIcon,
SparklesIcon,
TypeIcon,
UploadCloudIcon,
UserIcon,
UsersIcon,
@@ -28,11 +30,14 @@ 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 { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { ModelPromptSettingsPage } from "@/components/workspace/settings/model-prompt-settings-page";
import { WorkerPoolSettingsPage } from "@/components/workspace/settings/worker-pool-settings-page";
import { ApiReadCacheSettingsPage } from "@/components/workspace/settings/api-read-cache-settings-page";
import { ChatArchiveSettingsPage } from "@/components/workspace/settings/chat-archive-settings-page";
import { RoundtableRoomSettingsPage } from "@/components/workspace/settings/roundtable-room-settings-page";
import { RetryKeepPartialSettingsPage } from "@/components/workspace/settings/retry-keep-partial-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";
@@ -48,14 +53,17 @@ type SettingsSection =
| "account"
| "appearance"
| "conversation-context"
| "model-prompts"
| "server-diagnostics"
| "api-read-cache"
| "worker-pool"
| "chat-archive"
| "roundtable-rooms"
| "knowledge-retrieval"
| "appIdentity"
| "memory"
| "fileWriting"
| "retryKeepPartial"
| "uploads"
| "tools"
| "encoder"
@@ -97,14 +105,17 @@ export function SettingsDialog(props: SettingsDialogProps) {
...(isAdmin
? [
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
{ id: "model-prompts", label: "模型追加提示词", icon: SparklesIcon },
{ id: "server-diagnostics", label: "服务器诊断", icon: CpuIcon },
{ id: "api-read-cache", label: "Redis 缓存", icon: DatabaseIcon },
{ id: "api-read-cache", label: "Redis 与性能", icon: DatabaseIcon },
{ id: "worker-pool", label: "执行节点管理", icon: CpuIcon },
{ id: "chat-archive", label: "聊天记录归档", icon: ArchiveIcon },
{ id: "roundtable-rooms", label: "会商并发", icon: UsersIcon },
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
{ id: "appIdentity", label: "系统名称", icon: TypeIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
]
: []),
@@ -190,18 +201,27 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "conversation-context" && isAdmin && (
<ContextWindowSettingsPage />
)}
{activeSection === "model-prompts" && isAdmin && (
<ModelPromptSettingsPage />
)}
{activeSection === "roundtable-rooms" && isAdmin && (
<RoundtableRoomSettingsPage />
)}
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "appIdentity" && isAdmin && (
<AppIdentitySettingsPage />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "retryKeepPartial" && isAdmin && (
<RetryKeepPartialSettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
@@ -2,6 +2,7 @@ import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
@@ -20,6 +21,7 @@ export function ThreadTitle({
thread: BaseStream<AgentThreadState>;
}) {
const { t } = useI18n();
const appName = useAppSystemName(t.pages.appName);
const { isNewThread } = useThreadChat();
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
@@ -32,15 +34,15 @@ export function ThreadTitle({
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`;
document.title = `Loading... - ${appName}`;
} else {
document.title = `${_title} - ${t.pages.appName}`;
document.title = `${_title} - ${appName}`;
}
}, [
appName,
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
displayTitle,
@@ -7,6 +7,20 @@ import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
import {
coordinatesMatch,
curvedLineCoordinates,
flowArrowsForLines,
initialMapCamera,
overlayPadding,
resolveLineColor,
resolveMarkerColor,
shortenProjectedPath,
svgPathFromPoints,
type FlowLine,
type MapCamera,
type MapPoint,
} from "./map-style";
import type { MapSpec } from "./visualization-spec";
import { VisualizationCard } from "./visualization-card";
@@ -33,31 +47,162 @@ function allCoordinates(spec: MapSpec): Array<[number, number]> {
];
}
function defaultCenter(spec: MapSpec): [number, number] {
if (spec.center) return spec.center;
const coordinates = allCoordinates(spec);
if (coordinates.length === 0) return [104.1954, 35.8617];
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
return [total[0] / coordinates.length, total[1] / coordinates.length];
function bootCamera(camera: MapCamera): { center: MapPoint; zoom: number } {
if (camera.kind === "center") return { center: camera.center, zoom: camera.zoom };
return {
center: [(camera.sw[0] + camera.ne[0]) / 2, (camera.sw[1] + camera.ne[1]) / 2],
zoom: 4,
};
}
function applyMapCamera(map: maplibregl.Map, camera: MapCamera, padding: number) {
if (camera.kind === "center") {
map.jumpTo({ center: camera.center, zoom: camera.zoom });
return;
}
map.fitBounds([camera.sw, camera.ne], { padding, maxZoom: camera.maxZoom, duration: 0 });
}
function prefersReducedMotion(): boolean {
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
}
function markerColorLookup(spec: MapSpec): Map<string, string> {
const colors = new Map<string, string>();
spec.markers.forEach((marker, index) => {
colors.set(marker.name, resolveMarkerColor(index, marker.color));
});
return colors;
}
function styledLines(spec: MapSpec): FlowLine[] {
const markerColors = markerColorLookup(spec);
return spec.lines.map((line, index) => {
const start = line.coordinates[0];
const startMarker = start
? spec.markers.find((marker) => coordinatesMatch(marker.coordinate, start))
: undefined;
const fromColor = startMarker ? markerColors.get(startMarker.name) : undefined;
return {
coordinates: line.coordinates,
color: resolveLineColor(index, line.color, fromColor),
name: line.name,
arrow: line.arrow,
flow: line.flow,
};
});
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
function projectPoint(map: maplibregl.Map, point: MapPoint): { x: number; y: number } {
const projected = map.project({ lng: point[0], lat: point[1] });
return { x: projected.x, y: projected.y };
}
function paintMapOverlay(
svg: SVGSVGElement,
map: maplibregl.Map,
lines: FlowLine[],
markers: Array<{ coordinate: MapPoint; color: string; name: string }>,
progress: number,
reducedMotion: boolean,
) {
const root = map.getContainer();
const width = root.clientWidth || 1;
const height = root.clientHeight || 1;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
const arrowDefs = lines
.filter((line) => line.arrow !== false)
.map((line) => {
const id = `df-map-arrow-${line.color.slice(1)}`;
return `<marker id="${id}" markerWidth="8" markerHeight="8" refX="6.4" refY="4" orient="auto" markerUnits="userSpaceOnUse"><path d="M0.6,1.1 L7.2,4 L0.6,6.9 Z" fill="${line.color}" stroke="#fff" stroke-width="0.6" stroke-linejoin="round"/></marker>`;
})
.filter((value, index, all) => all.indexOf(value) === index)
.join("");
const paths = lines
.map((line) => {
const projected = curvedLineCoordinates(line.coordinates).map((point) => projectPoint(map, point));
const d = svgPathFromPoints(shortenProjectedPath(projected, 10, 13));
if (!d) return "";
const markerId = `df-map-arrow-${line.color.slice(1)}`;
const end = line.arrow !== false ? ` marker-end="url(#${markerId})"` : "";
const flow =
line.arrow !== false && line.flow !== false && !reducedMotion
? `<path d="${d}" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" stroke-dasharray="6 10" class="df-map-line-flow"/>`
: "";
return `<path d="${d}" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.88"/>
<path d="${d}" fill="none" stroke="${line.color}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"${end}/>
${flow}`;
})
.join("");
const arrows = flowArrowsForLines(lines, progress, { reducedMotion })
.map((arrow) => {
const { x, y } = projectPoint(map, arrow.point);
return `<g transform="translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${arrow.bearing.toFixed(1)})"><path d="M0,-4.5 L3.6,4.2 L0,2 L-3.6,4.2 Z" fill="${arrow.color}" stroke="#fff" stroke-width="0.7" stroke-linejoin="round"/></g>`;
})
.join("");
const dots = markers
.map((marker, index) => {
const { x, y } = projectPoint(map, marker.coordinate);
const label = escapeHtml(marker.name);
return `<g class="df-map-marker" data-marker-index="${index}" transform="translate(${x.toFixed(1)} ${y.toFixed(1)})" style="cursor:pointer;pointer-events:auto">
<title>${label}</title>
<circle r="16" fill="transparent"/>
<circle r="8" fill="${marker.color}" stroke="#fff" stroke-width="2.5" paint-order="stroke" style="filter:drop-shadow(0 1px 3px rgba(15,23,42,0.45))"/>
</g>`;
})
.join("");
svg.innerHTML = `<defs>${arrowDefs}</defs>${paths}${arrows}${dots}`;
}
export default function MapRenderer({ spec }: { spec: MapSpec }) {
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<SVGSVGElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [tileFailed, setTileFailed] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
const lines = useMemo(() => styledLines(spec), [spec]);
const markerColors = useMemo(
() => spec.markers.map((marker, index) => resolveMarkerColor(index, marker.color)),
[spec],
);
useEffect(() => {
const container = containerRef.current;
const overlay = overlayRef.current;
if (!container) return;
setTileFailed(false);
const camera = initialMapCamera(coordinates, { center: spec.center, zoom: spec.zoom });
const boot = bootCamera(camera);
const overlayMarkers = spec.markers.map((marker, index) => ({
coordinate: marker.coordinate,
color: markerColors[index] ?? resolveMarkerColor(index, marker.color),
name: marker.name,
}));
const map = new maplibregl.Map({
container,
style: createStyle(),
center: defaultCenter(spec),
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
center: boot.center,
zoom: boot.zoom,
minZoom: 1,
maxZoom: 20,
attributionControl: { compact: true },
@@ -65,18 +210,59 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
mapRef.current = map;
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
const markers: maplibregl.Marker[] = [];
for (const marker of spec.markers) {
const element = document.createElement("button");
element.type = "button";
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
element.title = marker.name;
element.setAttribute("aria-label", marker.name);
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
);
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
}
let raf = 0;
let lastPaint = 0;
let alive = true;
let viewReady = false;
let applyingView = false;
let popup: maplibregl.Popup | null = null;
const motionQuery = window.matchMedia?.("(prefers-reduced-motion: reduce)");
let reducedMotion = prefersReducedMotion();
const redrawOverlay = (progress = (performance.now() / 4000) % 1) => {
if (!alive || !overlay) return;
paintMapOverlay(overlay, map, lines, overlayMarkers, reducedMotion ? 0.72 : progress, reducedMotion);
};
const onMove = () => redrawOverlay();
map.on("move", onMove);
map.on("resize", onMove);
const tick = (now: number) => {
if (!alive) return;
const flowing = !reducedMotion && lines.some((line) => line.arrow !== false && line.flow !== false);
if (flowing && now - lastPaint >= 32) {
lastPaint = now;
redrawOverlay((now / 4000) % 1);
}
raf = requestAnimationFrame(tick);
};
const onMotionChange = () => {
reducedMotion = prefersReducedMotion();
redrawOverlay();
};
motionQuery?.addEventListener("change", onMotionChange);
const openMarkerPopup = (index: number) => {
const marker = spec.markers[index];
if (!marker) return;
popup?.remove();
popup = new maplibregl.Popup({ offset: 16, closeButton: true })
.setLngLat(marker.coordinate)
.setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
)
.addTo(map);
};
const onOverlayClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target.closest("[data-marker-index]") : null;
if (!target) return;
event.stopPropagation();
openMarkerPopup(Number(target.getAttribute("data-marker-index")));
};
overlay?.addEventListener("click", onOverlayClick);
let baseTileLoaded = false;
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
@@ -91,70 +277,60 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
map.on("sourcedata", onSourceData);
map.on("error", onError);
map.on("load", () => {
if (spec.lines.length > 0) {
map.addSource("visual-lines", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.lines.map((line) => ({
type: "Feature",
properties: { name: line.name ?? "" },
geometry: { type: "LineString", coordinates: line.coordinates },
})),
},
});
map.addLayer({
id: "visual-lines",
type: "line",
source: "visual-lines",
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
});
}
const applyView = () => {
if (!alive) return false;
map.resize();
const width = container.clientWidth;
const height = container.clientHeight;
if (width < 80 || height < 80) return false;
if (!map.loaded() && !map.isStyleLoaded()) return false;
applyMapCamera(map, camera, overlayPadding(width, height));
redrawOverlay();
return true;
};
if (spec.polygons.length > 0) {
map.addSource("visual-polygons", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.polygons.map((polygon) => ({
type: "Feature",
properties: { name: polygon.name ?? "" },
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
})),
},
});
map.addLayer({
id: "visual-polygons-fill",
type: "fill",
source: "visual-polygons",
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
});
map.addLayer({
id: "visual-polygons-border",
type: "line",
source: "visual-polygons",
paint: { "line-color": "#2563eb", "line-width": 2 },
});
const ensureView = () => {
if (!alive || applyingView) return;
if (viewReady) {
map.resize();
redrawOverlay();
return;
}
applyingView = true;
const fitted = applyView();
applyingView = false;
if (!fitted) return;
viewReady = true;
map.once("idle", () => {
if (!alive) return;
map.resize();
redrawOverlay();
});
raf = requestAnimationFrame(tick);
};
if (!spec.center && coordinates.length > 1) {
const bounds = coordinates.reduce(
(current, coordinate) => current.extend(coordinate),
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
);
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
}
});
map.on("load", ensureView);
map.once("idle", ensureView);
const resizeObserver = new ResizeObserver(ensureView);
resizeObserver.observe(container);
requestAnimationFrame(ensureView);
return () => {
alive = false;
cancelAnimationFrame(raf);
resizeObserver.disconnect();
motionQuery?.removeEventListener("change", onMotionChange);
overlay?.removeEventListener("click", onOverlayClick);
popup?.remove();
map.off("move", onMove);
map.off("resize", onMove);
map.off("load", ensureView);
map.off("sourcedata", onSourceData);
map.off("error", onError);
markers.forEach((marker) => marker.remove());
mapRef.current = null;
map.remove();
};
}, [coordinates, reloadToken, spec]);
}, [coordinates, lines, markerColors, reloadToken, spec]);
return (
<VisualizationCard>
@@ -165,6 +341,18 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
</div>
<div className="relative">
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
<style>{`
.df-map-line-flow { animation: df-map-line-flow 1.2s linear infinite; }
@keyframes df-map-line-flow { to { stroke-dashoffset: -32; } }
@media (prefers-reduced-motion: reduce) {
.df-map-line-flow { animation: none; }
}
`}</style>
<svg
ref={overlayRef}
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-[1] h-[420px] w-full overflow-visible"
/>
{tileFailed ? (
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
@@ -185,30 +373,46 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
</div>
) : null}
</div>
{spec.markers.length > 0 ? (
{spec.markers.length > 0 || spec.lines.length > 0 ? (
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看地点列表</summary>
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`}>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</li>
))}
</ul>
{spec.markers.length > 0 ? (
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`} className="flex items-start gap-2">
<span
aria-hidden="true"
className="mt-0.5 inline-block size-2.5 shrink-0 rounded-full border border-black/10"
style={{ background: markerColors[index] }}
/>
<span>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</span>
</li>
))}
</ul>
) : null}
{spec.lines.length > 0 ? (
<ul className="mt-2 space-y-1">
{lines.map((line, index) => (
<li key={`${line.name ?? "line"}-${index}`} className="flex items-start gap-2">
<span
aria-hidden="true"
className="mt-1 inline-block h-0.5 w-4 shrink-0 rounded-full"
style={{ background: line.color }}
/>
<span>
<span className="font-medium text-foreground">{line.name || `连线 ${index + 1}`}</span>
{line.arrow !== false ? <span>,带箭头{line.flow === false ? "" : "并沿线流动"}</span> : null}
</span>
</li>
))}
</ul>
) : null}
</details>
) : null}
</VisualizationCard>
);
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
import {
curvedLineCoordinates,
flowArrowsForLines,
initialMapCamera,
overlayPadding,
pointAndBearing,
polylineMetrics,
resolveMarkerColor,
shortenProjectedPath,
svgPathFromCoordinates,
} from "./map-style.ts";
test("assigns distinct colors when the model omits them", () => {
assert.notEqual(resolveMarkerColor(0), resolveMarkerColor(1));
assert.equal(resolveMarkerColor(0, "#111111"), "#111111");
});
test("walks a line and wraps flowing arrows", () => {
const metrics = polylineMetrics([
[0, 0],
[10, 0],
]);
assert.ok(metrics);
const start = pointAndBearing(metrics!, 0);
const mid = pointAndBearing(metrics!, 5);
const wrapped = pointAndBearing(metrics!, 12);
assert.deepEqual(start.point, [0, 0]);
assert.equal(start.bearing, 90);
assert.deepEqual(mid.point, [5, 0]);
assert.equal(wrapped.point[0], 2);
assert.equal(wrapped.bearing, 90);
const arrows = flowArrowsForLines(
[{ coordinates: [[0, 0], [10, 0]], color: "#2563eb", arrow: true, flow: true }],
0,
);
assert.equal(arrows.length, 3);
const staticArrow = flowArrowsForLines(
[{ coordinates: [[0, 0], [10, 0]], color: "#2563eb", arrow: true, flow: true }],
0,
{ reducedMotion: true },
);
assert.equal(staticArrow.length, 1);
assert.ok(staticArrow[0]!.point[0] > 5);
});
test("builds an svg path from projected coordinates", () => {
const path = svgPathFromCoordinates(
[
[116.4, 39.9],
[121.47, 31.23],
],
([lng, lat]) => ({ x: lng, y: lat }),
);
assert.equal(path, "M116.4 39.9 L121.5 31.2");
});
test("turns a two-point connection into a bulging arc", () => {
const points = curvedLineCoordinates([
[0, 0],
[10, 0],
]);
assert.ok(points.length > 2);
const mid = points[Math.floor(points.length / 2)]!;
assert.ok(mid[1] > 0.8);
assert.deepEqual(points[0], [0, 0]);
assert.equal(points[points.length - 1]![0], 10);
assert.ok(Math.abs(points[points.length - 1]![1]) < 1e-9);
const arrows = flowArrowsForLines(
[{ coordinates: [[0, 0], [10, 0]], color: "#2563eb", arrow: true, flow: true }],
0.2,
);
assert.ok(arrows[0]!.point[1] > 0);
});
test("shortens a projected path so arrows sit off the markers", () => {
const trimmed = shortenProjectedPath(
[
{ x: 0, y: 0 },
{ x: 100, y: 0 },
],
10,
13,
);
assert.equal(trimmed.length, 2);
assert.ok(trimmed[0]!.x >= 9.9);
assert.ok(trimmed[1]!.x <= 87.1);
});
test("frames Beijing and Shanghai even when the model sent a China-wide camera", () => {
const beijing: [number, number] = [116.4074, 39.9042];
const shanghai: [number, number] = [121.4737, 31.2304];
const camera = initialMapCamera([beijing, shanghai], { center: [104.1954, 35.8617], zoom: 4 });
assert.equal(camera.kind, "bounds");
if (camera.kind !== "bounds") return;
assert.ok(camera.sw[0] <= beijing[0] && camera.sw[1] <= shanghai[1]);
assert.ok(camera.ne[0] >= shanghai[0] && camera.ne[1] >= beijing[1]);
assert.equal(camera.maxZoom, 12);
});
test("centers a single marker instead of using the model overview", () => {
const camera = initialMapCamera([[116.4, 39.9]], { center: [104.2, 35.8], zoom: 4 });
assert.deepEqual(camera, { kind: "center", center: [116.4, 39.9], zoom: 8 });
});
test("keeps padding inside a typical map card", () => {
const padding = overlayPadding(800, 420);
assert.ok(padding >= 32 && padding <= 80);
assert.ok(padding * 2 < 420);
});
@@ -0,0 +1,294 @@
export const MAP_MARKER_PALETTE = [
"#2563eb",
"#dc2626",
"#16a34a",
"#d97706",
"#7c3aed",
"#0891b2",
"#db2777",
"#4f46e5",
] as const;
export type MapPoint = [number, number];
export function resolveMarkerColor(index: number, explicit?: string): string {
return explicit ?? MAP_MARKER_PALETTE[index % MAP_MARKER_PALETTE.length]!;
}
export function resolveLineColor(
index: number,
explicit?: string,
fromMarkerColor?: string,
): string {
return explicit ?? fromMarkerColor ?? MAP_MARKER_PALETTE[index % MAP_MARKER_PALETTE.length]!;
}
export function coordinatesMatch(left: MapPoint, right: MapPoint, epsilon = 1e-4): boolean {
return Math.abs(left[0] - right[0]) <= epsilon && Math.abs(left[1] - right[1]) <= epsilon;
}
export type PolylineMetrics = {
coordinates: MapPoint[];
parts: number[];
total: number;
};
export function polylineMetrics(coordinates: MapPoint[]): PolylineMetrics | null {
if (coordinates.length < 2) return null;
const parts: number[] = [];
let total = 0;
for (let index = 1; index < coordinates.length; index += 1) {
const current = coordinates[index]!;
const previous = coordinates[index - 1]!;
const length = Math.hypot(current[0] - previous[0], current[1] - previous[1]);
parts.push(length);
total += length;
}
if (!(total > 0)) return null;
return { coordinates, parts, total };
}
export function pointAndBearing(
metrics: PolylineMetrics,
distance: number,
): { point: MapPoint; bearing: number } {
const wrapped = ((distance % metrics.total) + metrics.total) % metrics.total;
let remaining = wrapped;
for (let index = 0; index < metrics.parts.length; index += 1) {
const part = metrics.parts[index]!;
const start = metrics.coordinates[index]!;
const end = metrics.coordinates[index + 1]!;
if (remaining > part && index < metrics.parts.length - 1) {
remaining -= part;
continue;
}
const t = part === 0 ? 0 : Math.min(1, remaining / part);
const dlng = end[0] - start[0];
const dlat = end[1] - start[1];
return {
point: [start[0] + dlng * t, start[1] + dlat * t],
bearing: (Math.atan2(dlng, dlat) * 180) / Math.PI,
};
}
const last = metrics.coordinates[metrics.coordinates.length - 1]!;
const prev = metrics.coordinates[metrics.coordinates.length - 2]!;
return {
point: last,
bearing: (Math.atan2(last[0] - prev[0], last[1] - prev[1]) * 180) / Math.PI,
};
}
export type FlowLine = {
coordinates: MapPoint[];
color: string;
name?: string;
arrow?: boolean;
flow?: boolean;
};
export type FlowArrow = {
point: MapPoint;
bearing: number;
color: string;
lineName: string;
};
export const LINE_ARC_BULGE = 0.22;
export const LINE_ARC_SAMPLES = 20;
export function quadraticPoint(start: MapPoint, control: MapPoint, end: MapPoint, t: number): MapPoint {
const u = 1 - t;
return [
u * u * start[0] + 2 * u * t * control[0] + t * t * end[0],
u * u * start[1] + 2 * u * t * control[1] + t * t * end[1],
];
}
export function arcControlPoint(start: MapPoint, end: MapPoint, bulge = LINE_ARC_BULGE): MapPoint {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const length = Math.hypot(dx, dy);
if (!(length > 0)) return start;
const offset = length * bulge;
return [(start[0] + end[0]) / 2 - (dy / length) * offset, (start[1] + end[1]) / 2 + (dx / length) * offset];
}
export function curvedLineCoordinates(coordinates: MapPoint[], samples = LINE_ARC_SAMPLES): MapPoint[] {
if (coordinates.length < 2) return coordinates;
const points: MapPoint[] = [];
for (let index = 1; index < coordinates.length; index += 1) {
const start = coordinates[index - 1]!;
const end = coordinates[index]!;
if (coordinatesMatch(start, end)) {
if (points.length === 0) points.push(start);
continue;
}
const control = arcControlPoint(start, end);
const from = index === 1 ? 0 : 1;
for (let step = from; step <= samples; step += 1) {
points.push(quadraticPoint(start, control, end, step / samples));
}
}
return points.length >= 2 ? points : coordinates;
}
export function flowArrowsForLines(
lines: FlowLine[],
progress: number,
options?: { reducedMotion?: boolean },
): FlowArrow[] {
const arrows: FlowArrow[] = [];
const reducedMotion = options?.reducedMotion === true;
for (const line of lines) {
if (line.arrow === false) continue;
const metrics = polylineMetrics(curvedLineCoordinates(line.coordinates));
if (!metrics) continue;
const animate = line.flow !== false && !reducedMotion;
const count = animate ? 3 : 1;
const offset = animate ? progress : 0.72;
for (let index = 0; index < count; index += 1) {
const distance = ((offset + index / count) % 1) * metrics.total;
const placed = pointAndBearing(metrics, distance);
arrows.push({
...placed,
color: line.color,
lineName: line.name ?? "",
});
}
}
return arrows;
}
export function svgPathFromPoints(points: Array<{ x: number; y: number }>): string {
return points
.map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(1)} ${point.y.toFixed(1)}`)
.join(" ");
}
export function svgPathFromCoordinates(
coordinates: MapPoint[],
project: (point: MapPoint) => { x: number; y: number },
): string {
return svgPathFromPoints(coordinates.map(project));
}
function projectedPathLength(points: Array<{ x: number; y: number }>): number {
let total = 0;
for (let index = 1; index < points.length; index += 1) {
const current = points[index]!;
const previous = points[index - 1]!;
total += Math.hypot(current.x - previous.x, current.y - previous.y);
}
return total;
}
function trimProjectedPath(
points: Array<{ x: number; y: number }>,
gap: number,
edge: "start" | "end",
): Array<{ x: number; y: number }> {
if (points.length < 2 || gap <= 0) return points;
let remaining = gap;
if (edge === "start") {
let index = 0;
while (index < points.length - 1 && remaining > 0) {
const current = points[index]!;
const next = points[index + 1]!;
const part = Math.hypot(next.x - current.x, next.y - current.y);
if (part <= remaining) {
remaining -= part;
index += 1;
continue;
}
const t = remaining / part;
const cut = { x: current.x + (next.x - current.x) * t, y: current.y + (next.y - current.y) * t };
return [cut, ...points.slice(index + 1)];
}
return points.slice(-1);
}
let index = points.length - 1;
while (index > 0 && remaining > 0) {
const current = points[index]!;
const previous = points[index - 1]!;
const part = Math.hypot(current.x - previous.x, current.y - previous.y);
if (part <= remaining) {
remaining -= part;
index -= 1;
continue;
}
const t = remaining / part;
const cut = { x: current.x + (previous.x - current.x) * t, y: current.y + (previous.y - current.y) * t };
return [...points.slice(0, index), cut];
}
return points.slice(0, 1);
}
export function shortenProjectedPath(
points: Array<{ x: number; y: number }>,
startGap: number,
endGap: number,
): Array<{ x: number; y: number }> {
if (points.length < 2) return points;
const total = projectedPathLength(points);
if (!(total > 0)) return points;
const maxGap = total / 2 - 4;
if (maxGap <= 0) return points;
return trimProjectedPath(
trimProjectedPath(points, Math.min(startGap, maxGap), "start"),
Math.min(endGap, maxGap),
"end",
);
}
export const FALLBACK_MAP_CENTER: MapPoint = [104.1954, 35.8617];
export const SINGLE_POINT_ZOOM = 8;
export const MULTI_POINT_MAX_ZOOM = 12;
export const FALLBACK_MAP_ZOOM = 4;
export type MapCamera =
| { kind: "center"; center: MapPoint; zoom: number }
| { kind: "bounds"; sw: MapPoint; ne: MapPoint; maxZoom: number };
export function boundsFromCoordinates(coordinates: MapPoint[]): { sw: MapPoint; ne: MapPoint } | null {
if (coordinates.length === 0) return null;
let minLng = coordinates[0]![0];
let minLat = coordinates[0]![1];
let maxLng = minLng;
let maxLat = minLat;
for (const [lng, lat] of coordinates) {
minLng = Math.min(minLng, lng);
minLat = Math.min(minLat, lat);
maxLng = Math.max(maxLng, lng);
maxLat = Math.max(maxLat, lat);
}
return { sw: [minLng, minLat], ne: [maxLng, maxLat] };
}
export function isDegenerateBounds(sw: MapPoint, ne: MapPoint, epsilon = 1e-5): boolean {
return Math.abs(ne[0] - sw[0]) <= epsilon && Math.abs(ne[1] - sw[1]) <= epsilon;
}
export function overlayPadding(width: number, height: number): number {
return Math.max(32, Math.min(80, Math.floor(Math.min(width, height) * 0.14)));
}
export function initialMapCamera(
coordinates: MapPoint[],
hint?: { center?: MapPoint; zoom?: number },
): MapCamera {
if (coordinates.length === 0) {
return {
kind: "center",
center: hint?.center ?? FALLBACK_MAP_CENTER,
zoom: hint?.zoom ?? FALLBACK_MAP_ZOOM,
};
}
if (coordinates.length === 1) {
return { kind: "center", center: coordinates[0]!, zoom: hint?.zoom && hint.zoom >= 6 ? hint.zoom : SINGLE_POINT_ZOOM };
}
const bounds = boundsFromCoordinates(coordinates);
if (!bounds || isDegenerateBounds(bounds.sw, bounds.ne)) {
return { kind: "center", center: coordinates[0]!, zoom: SINGLE_POINT_ZOOM };
}
return { kind: "bounds", sw: bounds.sw, ne: bounds.ne, maxZoom: MULTI_POINT_MAX_ZOOM };
}
@@ -2,7 +2,12 @@ import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
import {
parseMapColor,
parseOptionalBoolean,
parseVisualizationSpec,
splitVisualizationContent,
} from "./visualization-spec.ts";
test("ordinary markdown remains one unchanged segment", () => {
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
@@ -230,6 +235,139 @@ test("rejects invalid map coordinates", () => {
assert.equal(result.ok, false);
});
test("recovers Beijing-Shanghai lines that omit coordinates", () => {
const fromToByName = parseVisualizationSpec(
"map-json",
JSON.stringify({
title: "北京上海位置地图",
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "北京-上海", from: "北京", to: "上海" }],
}),
);
assert.equal(fromToByName.ok, true);
assert.equal(fromToByName.ok && fromToByName.spec.type, "map");
if (fromToByName.ok && fromToByName.spec.type === "map") {
assert.equal(fromToByName.spec.markers.length, 2);
assert.deepEqual(fromToByName.spec.lines[0]?.coordinates, [
[116.4, 39.9],
[121.47, 31.23],
]);
}
const singularCoordinate = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "联络", coordinate: [[116.4, 39.9], [121.47, 31.23]] }],
}),
);
assert.equal(singularCoordinate.ok, true);
if (singularCoordinate.ok && singularCoordinate.spec.type === "map") {
assert.equal(singularCoordinate.spec.lines.length, 1);
}
const echartsCoords = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "联络", coords: [[116.4, 39.9], [121.47, 31.23]] }],
}),
);
assert.equal(echartsCoords.ok, true);
if (echartsCoords.ok && echartsCoords.spec.type === "map") {
assert.equal(echartsCoords.spec.lines.length, 1);
}
const fromToPoints = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", lng: 116.4, lat: 39.9 },
{ name: "上海", longitude: "121.47", latitude: "31.23" },
],
lines: [{ from: [116.4, 39.9], to: [121.47, 31.23] }],
}),
);
assert.equal(fromToPoints.ok, true);
if (fromToPoints.ok && fromToPoints.spec.type === "map") {
assert.equal(fromToPoints.spec.markers.length, 2);
assert.equal(fromToPoints.spec.lines.length, 1);
}
});
test("keeps markers when a line cannot be recovered", () => {
const result = parseVisualizationSpec(
"map-json",
JSON.stringify({
title: "北京上海位置地图",
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "北京-上海" }],
}),
);
assert.equal(result.ok, true);
if (result.ok && result.spec.type === "map") {
assert.equal(result.spec.markers.length, 2);
assert.equal(result.spec.lines.length, 0);
}
});
test("parses named, Chinese and hex marker colors", () => {
assert.equal(parseMapColor("red"), "#dc2626");
assert.equal(parseMapColor("蓝色"), "#2563eb");
assert.equal(parseMapColor("#0f8"), "#00ff88");
assert.equal(parseMapColor("#2563EB"), "#2563eb");
assert.equal(parseMapColor("javascript:alert(1)"), undefined);
assert.equal(parseOptionalBoolean("是"), true);
assert.equal(parseOptionalBoolean("false"), false);
});
test("keeps marker colors and line arrow/flow flags", () => {
const result = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", coordinate: [116.4, 39.9], color: "red" },
{ name: "上海", coordinate: [121.47, 31.23], colour: "#16a34a" },
],
lines: [
{
name: "北京-上海",
from: "北京",
to: "上海",
color: "蓝色",
arrow: "是",
flow: true,
},
],
}),
);
assert.equal(result.ok, true);
if (result.ok && result.spec.type === "map") {
assert.equal(result.spec.markers[0]?.color, "#dc2626");
assert.equal(result.spec.markers[1]?.color, "#16a34a");
assert.equal(result.spec.lines[0]?.color, "#2563eb");
assert.equal(result.spec.lines[0]?.arrow, true);
assert.equal(result.spec.lines[0]?.flow, true);
}
});
test("does not treat a generic json log as a map after dropping invalid lines", () => {
const content = '```json\n{"title":"日志","lines":["error 1","error 2"]}\n```';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("flow and chart fences stay on the ordinary Markdown path", () => {
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
@@ -10,6 +10,10 @@ const optionalText = z.string().trim().max(2_000).optional();
const longitude = z.number().finite().min(-180).max(180);
const latitude = z.number().finite().min(-90).max(90);
const coordinateSchema = z.tuple([longitude, latitude]);
const mapColorSchema = z
.string()
.regex(/^#[0-9a-f]{6}$/i, "颜色必须是 #rrggbb")
.optional();
const mapSchema = z.object({
type: z.literal("map").default("map"),
@@ -22,6 +26,7 @@ const mapSchema = z.object({
name: safeText,
coordinate: coordinateSchema,
description: optionalText,
color: mapColorSchema,
}),
)
.max(500)
@@ -31,6 +36,9 @@ const mapSchema = z.object({
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(2).max(2_000),
color: mapColorSchema,
arrow: z.boolean().default(true),
flow: z.boolean().default(true),
}),
)
.max(100)
@@ -40,6 +48,7 @@ const mapSchema = z.object({
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(3).max(2_000),
color: mapColorSchema,
}),
)
.max(100)
@@ -95,13 +104,300 @@ export type VisualizationParseResult =
| { ok: true; spec: VisualizationSpec }
| { ok: false; error: string };
const NAMED_MAP_COLORS: Record<string, string> = {
red: "#dc2626",
orange: "#ea580c",
amber: "#d97706",
yellow: "#ca8a04",
green: "#16a34a",
emerald: "#059669",
teal: "#0d9488",
cyan: "#0891b2",
blue: "#2563eb",
indigo: "#4f46e5",
violet: "#7c3aed",
purple: "#7c3aed",
pink: "#db2777",
rose: "#e11d48",
gray: "#64748b",
grey: "#64748b",
slate: "#64748b",
black: "#0f172a",
white: "#f8fafc",
红: "#dc2626",
红色: "#dc2626",
橙: "#ea580c",
橙色: "#ea580c",
黄: "#ca8a04",
黄色: "#ca8a04",
绿: "#16a34a",
绿色: "#16a34a",
青: "#0891b2",
青色: "#0891b2",
蓝: "#2563eb",
蓝色: "#2563eb",
紫: "#7c3aed",
紫色: "#7c3aed",
粉: "#db2777",
粉色: "#db2777",
灰: "#64748b",
灰色: "#64748b",
};
const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
export function parseMapColor(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const raw = value.trim();
if (!raw || raw.length > 24) return undefined;
if (/url\s*\(|javascript:|expression\s*\(/i.test(raw)) return undefined;
const named = NAMED_MAP_COLORS[raw.toLowerCase()] ?? NAMED_MAP_COLORS[raw];
if (named) return named;
if (!HEX_COLOR.test(raw)) return undefined;
const hex = raw.slice(1);
if (hex.length === 3) {
return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`.toLowerCase();
}
return `#${hex.slice(0, 6).toLowerCase()}`;
}
export function parseOptionalBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (value === 1 || value === "1") return true;
if (value === 0 || value === "0") return false;
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (["true", "yes", "on", "是"].includes(normalized)) return true;
if (["false", "no", "off", "否"].includes(normalized)) return false;
return undefined;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function parseCoordinate(value: unknown): [number, number] | null {
if (Array.isArray(value) && value.length >= 2) {
const lng = asFiniteNumber(value[0]);
const lat = asFiniteNumber(value[1]);
if (lng != null && lat != null && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90) {
return [lng, lat];
}
return null;
}
const record = asRecord(value);
if (!record) return null;
const nested =
record.coordinate ?? record.coordinates ?? record.coord ?? record.position ?? record.location;
if (nested !== value && (Array.isArray(nested) || asRecord(nested))) {
const nestedCoordinate = parseCoordinate(nested);
if (nestedCoordinate) return nestedCoordinate;
}
const lng = asFiniteNumber(record.lng ?? record.lon ?? record.longitude ?? record.x);
const lat = asFiniteNumber(record.lat ?? record.latitude ?? record.y);
if (lng != null && lat != null && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90) {
return [lng, lat];
}
return null;
}
function parseCoordinateList(value: unknown): Array<[number, number]> {
if (!Array.isArray(value) || value.length === 0) return [];
const direct = value
.map(parseCoordinate)
.filter((item): item is [number, number] => item != null);
if (direct.length >= 2) return direct;
if (value.length === 1 && Array.isArray(value[0])) return parseCoordinateList(value[0]);
return direct;
}
function featureName(record: Record<string, unknown>): string | undefined {
const value = record.name ?? record.label ?? record.title ?? record.city ?? record.text;
if (typeof value !== "string") return undefined;
const name = value.trim();
return name.length > 0 ? name : undefined;
}
function featureColor(record: Record<string, unknown>): string | undefined {
return parseMapColor(
record.color ?? record.colour ?? record.fill ?? record.fillColor ?? record.stroke,
);
}
function withOptionalStyle(
record: Record<string, unknown>,
extra?: Record<string, unknown>,
): Record<string, unknown> {
const color = featureColor(record);
return {
...extra,
...(color ? { color } : {}),
};
}
function normalizeMarkers(value: unknown): Array<Record<string, unknown>> {
const items = Array.isArray(value) ? value : value ? [value] : [];
const markers: Array<Record<string, unknown>> = [];
for (const item of items) {
const record = asRecord(item);
if (!record) continue;
const name = featureName(record);
const coordinate = parseCoordinate(item);
if (!name || !coordinate) continue;
markers.push({
name,
coordinate,
...withOptionalStyle(record, {
...(typeof record.description === "string" ? { description: record.description } : {}),
}),
});
}
return markers;
}
function resolvePoint(
value: unknown,
markersByName: Map<string, [number, number]>,
): [number, number] | null {
const coordinate = parseCoordinate(value);
if (coordinate) return coordinate;
if (typeof value === "string") return markersByName.get(value.trim()) ?? null;
const record = asRecord(value);
if (!record) return null;
const name = featureName(record);
return name ? (markersByName.get(name) ?? null) : null;
}
function normalizeLines(
value: unknown,
markersByName: Map<string, [number, number]>,
): Array<Record<string, unknown>> {
const items = Array.isArray(value) ? value : value ? [value] : [];
const lines: Array<Record<string, unknown>> = [];
for (const item of items) {
if (Array.isArray(item)) {
const coordinates = parseCoordinateList(item);
if (coordinates.length >= 2) lines.push({ coordinates });
continue;
}
const record = asRecord(item);
if (!record) continue;
const listed = parseCoordinateList(
record.coordinates ??
record.coordinate ??
record.coords ??
record.path ??
record.points ??
record.positions,
);
const from = resolvePoint(record.from ?? record.start ?? record.source, markersByName);
const to = resolvePoint(record.to ?? record.end ?? record.target, markersByName);
const coordinates = listed.length >= 2 ? listed : from && to ? [from, to] : [];
if (coordinates.length < 2) continue;
const name = featureName(record);
const arrow = parseOptionalBoolean(record.arrow ?? record.arrows ?? record.directed);
const flow = parseOptionalBoolean(record.flow ?? record.animate ?? record.animated);
lines.push({
...(name ? { name } : {}),
coordinates,
...withOptionalStyle(record),
...(arrow != null ? { arrow } : {}),
...(flow != null ? { flow } : {}),
});
}
return lines;
}
function normalizePolygons(value: unknown): Array<Record<string, unknown>> {
const items = Array.isArray(value) ? value : value ? [value] : [];
const polygons: Array<Record<string, unknown>> = [];
for (const item of items) {
if (Array.isArray(item)) {
const coordinates = parseCoordinateList(item);
if (coordinates.length >= 3) polygons.push({ coordinates });
continue;
}
const record = asRecord(item);
if (!record) continue;
const coordinates = parseCoordinateList(
record.coordinates ??
record.coordinate ??
record.coords ??
record.path ??
record.points ??
record.positions,
);
if (coordinates.length < 3) continue;
const name = featureName(record);
polygons.push({
...(name ? { name } : {}),
coordinates,
...withOptionalStyle(record),
});
}
return polygons;
}
function normalizeMapPayload(value: Record<string, unknown>): Record<string, unknown> {
const markers = Object.prototype.hasOwnProperty.call(value, "markers")
? normalizeMarkers(value.markers)
: undefined;
const markersByName = new Map<string, [number, number]>();
for (const marker of markers ?? []) {
const name = typeof marker.name === "string" ? marker.name : "";
const coordinate = marker.coordinate;
if (name && Array.isArray(coordinate) && coordinate.length === 2) {
markersByName.set(name, coordinate as [number, number]);
}
}
const lines = Object.prototype.hasOwnProperty.call(value, "lines")
? normalizeLines(value.lines, markersByName)
: undefined;
const polygons = Object.prototype.hasOwnProperty.call(value, "polygons")
? normalizePolygons(value.polygons)
: undefined;
const center = parseCoordinate(value.center) ?? undefined;
const zoom = asFiniteNumber(value.zoom) ?? undefined;
return {
...value,
type: "map",
...(center ? { center } : {}),
...(zoom != null ? { zoom } : {}),
...(markers ? { markers } : {}),
...(lines ? { lines } : {}),
...(polygons ? { polygons } : {}),
};
}
function mapHasRenderable(record: Record<string, unknown>): boolean {
return (
(Array.isArray(record.markers) && record.markers.length > 0) ||
(Array.isArray(record.lines) && record.lines.length > 0) ||
(Array.isArray(record.polygons) && record.polygons.length > 0) ||
parseCoordinate(record.center) != null
);
}
function normalizeLanguageType(
language: VisualizationLanguage,
value: unknown,
): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = { ...(value as Record<string, unknown>) };
if (language === "map-json") record.type = "map";
if (language === "map-json") {
const normalized = normalizeMapPayload(record);
return mapHasRenderable(normalized) ? normalized : { ...record, type: "map" };
}
if (language === "mindmap-json") record.type = "mindmap";
return record;
}
@@ -221,18 +517,14 @@ export type VisualizationSegment =
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
function isMapJsonSource(source: string): boolean {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
return false;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
const record = raw as Record<string, unknown>;
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
(field) => Object.prototype.hasOwnProperty.call(record, field),
const parsed = parseVisualizationSpec("map-json", source);
if (!parsed.ok || parsed.spec.type !== "map") return false;
return (
parsed.spec.markers.length > 0 ||
parsed.spec.lines.length > 0 ||
parsed.spec.polygons.length > 0 ||
parsed.spec.center != null
);
return hasMapField && parseVisualizationSpec("map-json", source).ok;
}
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
@@ -6,6 +6,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -50,12 +51,13 @@ export function SidebarCollapseButton({ className }: { className?: string }) {
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const systemName = useAppSystemName(t.pages.appName);
const { state } = useSidebar();
const titleBlock = (
<div className="min-w-0 flex-1">
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{t.pages.appName}
{systemName}
</div>
</div>
);
@@ -13,7 +13,6 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Landmark, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
@@ -188,7 +187,6 @@ export function WorkspaceNavChatList({
showCollapseTrigger?: boolean;
compact?: boolean;
}) {
const { t } = useI18n();
const pathname = usePathname();
const navigate = useNavigate();
const { sidebarItems } = useWorkspaceCompactNavItems();
@@ -219,38 +217,42 @@ export function WorkspaceNavChatList({
{compact ? (
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{newChatItem && (
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{newChatItem.label}</span>
</Link>
</SidebarMenuButton>
)}
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
) : (
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
{newChatItem && (
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<MessageSquarePlus />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
>
<MessageSquarePlus />
<span>{newChatItem.label}</span>
</Link>
</SidebarMenuButton>
)}
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
@@ -18,6 +18,7 @@ import {
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
ShieldAlertIcon,
SparklesIcon,
TagsIcon,
TimerIcon,
@@ -317,6 +318,14 @@ export function WorkspaceNavMenu() {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/stream-truncations")}
>
<ShieldAlertIcon />
模型截断记录
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
-36
View File
@@ -133,20 +133,6 @@ export interface AdminLeaderboardResponse {
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminUserAnalyticsResponse {
range_days: number;
since: string;
until: string;
user: AdminLeaderboardUser;
overview: AdminLeaderboardOverview;
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminLeaderboardReportResponse {
report: string;
generated_at: string;
@@ -294,28 +280,6 @@ export async function refreshTodayLeaderboard(
return result;
}
export async function getUserAnalytics(
userId: string,
range: { since: string; until: string; days?: number },
limit = 20,
): Promise<AdminUserAnalyticsResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
const url = `${base()}/${encodeURIComponent(userId)}/analytics?${params}`;
const result = await parse<AdminUserAnalyticsResponse>(await apiFetch(url), "Failed to load user analytics");
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-管理后台-查看用户分析",
url,
reqParam: { userId, ...range, limit },
});
return result;
}
export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number },
limit = 30,
-13
View File
@@ -7,7 +7,6 @@ import {
deleteAdminV1Fact,
generateUserLeaderboardReport,
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
refreshTodayLeaderboard,
listUserQa,
@@ -162,18 +161,6 @@ export function useRefreshTodayLeaderboard() {
});
}
export function useUserAnalytics(
userId: string | null,
range: { since: string; until: string; days?: number },
limit = 20,
) {
return useQuery({
queryKey: ["admin-activity", "analytics", userId, range.since, range.until, limit],
queryFn: () => getUserAnalytics(userId!, range, limit),
enabled: !!userId,
});
}
export function useGenerateUserLeaderboardReport() {
return useMutation({
mutationFn: ({
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
canonicalAgentChatLink,
isAllowedAgentChatUrl,
} from "./chat-link.ts";
test("allows http(s) and in-app paths only", () => {
assert.equal(isAllowedAgentChatUrl("https://example.test/desk"), true);
assert.equal(isAllowedAgentChatUrl("/page/workspace/agents"), true);
assert.equal(isAllowedAgentChatUrl("#/page/workspace/agents"), true);
assert.equal(isAllowedAgentChatUrl("javascript:alert(1)"), false);
assert.equal(isAllowedAgentChatUrl("//example.test"), false);
assert.equal(isAllowedAgentChatUrl(""), false);
});
test("canonical link drops a blank url and blank param keys", () => {
assert.equal(canonicalAgentChatLink({ url: " ", open_mode: "self", route_params: [] }), null);
const link = canonicalAgentChatLink({
url: " https://example.test/desk ",
open_mode: "self",
route_params: [
{ key: " ", paramType: "login", source: "userName" },
{ key: "userName", paramType: "login", source: "userName" },
],
});
assert.deepEqual(link, {
url: "https://example.test/desk",
open_mode: "self",
route_params: [
{
key: "userName",
paramType: "login",
source: "userName",
customValue: null,
themeValue: null,
},
],
});
});
+113
View File
@@ -0,0 +1,113 @@
const URL_MAX = 2048;
export interface AgentChatLinkDraft {
url?: string | null;
open_mode?: "self" | "blank" | null;
route_params?: Array<{
key?: string | null;
paramType?: string | null;
source?: string | null;
customValue?: string | null;
themeValue?: string | null;
}> | null;
}
export interface CanonicalAgentChatLink {
url: string;
open_mode: "self" | "blank";
route_params: Array<{
key: string;
paramType: "login" | "theme";
source: string | null;
customValue: string | null;
themeValue: string | null;
}>;
}
export function isAllowedAgentChatUrl(url: string): boolean {
const value = url.trim();
if (!value || value.length > URL_MAX) return false;
const lowered = value.toLowerCase();
if (
lowered.startsWith("javascript:") ||
lowered.startsWith("data:") ||
lowered.startsWith("vbscript:") ||
lowered.startsWith("file:")
) {
return false;
}
if (value.startsWith("#/")) return true;
if (value.startsWith("/") && !value.startsWith("//")) return true;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
/** Drop blank rows and normalize the payload sent to the agent API. Empty url clears the link. */
export function canonicalAgentChatLink(
link: AgentChatLinkDraft | null | undefined,
): CanonicalAgentChatLink | null {
const url = (link?.url ?? "").trim();
if (!url) return null;
const routeParams: CanonicalAgentChatLink["route_params"] = [];
for (const raw of link?.route_params ?? []) {
const key = (raw?.key ?? "").trim();
if (!key) continue;
const paramType = raw?.paramType === "theme" ? "theme" : "login";
routeParams.push({
key,
paramType,
source: paramType === "login" ? (raw?.source || "access_token") : null,
customValue:
paramType === "login" && raw?.source === "other" ? (raw.customValue ?? "") : null,
themeValue: paramType === "theme" ? (raw?.themeValue ?? "") : null,
});
}
return {
url,
open_mode: link?.open_mode === "self" ? "self" : "blank",
route_params: routeParams,
};
}
export function agentChatLinksEqual(
left: AgentChatLinkDraft | null | undefined,
right: AgentChatLinkDraft | null | undefined,
): boolean {
return JSON.stringify(canonicalAgentChatLink(left)) === JSON.stringify(canonicalAgentChatLink(right));
}
/**
* Open a fully resolved chat-link URL.
* Paths starting with `/` or `#/` stay inside this HashRouter app.
*/
export function openResolvedAgentChatUrl(
url: string,
openMode: "self" | "blank",
navigate: (path: string) => void,
): void {
const mode = openMode === "self" ? "self" : "blank";
const internal = url.startsWith("#/")
? url.slice(1)
: url.startsWith("/") && !url.startsWith("//")
? url
: null;
if (internal) {
if (mode === "self") {
navigate(internal);
return;
}
const base = window.location.href.split("#")[0];
const hashPath = internal.startsWith("/") ? internal : `/${internal}`;
window.open(`${base}#${hashPath}`, "_blank", "noopener,noreferrer");
return;
}
if (mode === "self") {
window.location.assign(url);
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
+32
View File
@@ -33,6 +33,26 @@ export function normalizeAgentQuestions(
return out;
}
/** 智能体管理「对话」按钮的跳转配置。空链接表示仍进入本系统问答。 */
export interface AgentChatRouteParam {
key: string;
paramType: "login" | "theme";
source?: string | null;
customValue?: string | null;
themeValue?: string | null;
}
export interface AgentChatLink {
url: string;
/** blank = 新窗口,self = 当前窗口。 */
open_mode: "self" | "blank";
route_params: AgentChatRouteParam[];
}
export function emptyAgentChatLink(): AgentChatLink {
return { url: "", open_mode: "blank", route_params: [] };
}
export interface Agent {
id: string;
name: string;
@@ -53,6 +73,8 @@ export interface Agent {
updated_at?: string | null;
soul?: string | null;
model?: string | null;
/** Whether this agent uses the administrator-configured suffix for its model. Defaults to true. */
model_prompt_suffix_enabled?: boolean | null;
tool_groups?: string[] | null;
skills?: string[] | null;
/** Chinese display names keyed by skill name. List responses fill this so
@@ -71,6 +93,8 @@ export interface Agent {
seat_skill_directive?: boolean | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
/** 智能体管理「对话」按钮的跳转地址。null/缺省 = 进入本系统问答。 */
chat_link?: AgentChatLink | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -106,12 +130,16 @@ export interface CreateAgentRequest {
square_id?: string;
skills?: string[] | null;
model?: string | null;
/** Whether this agent uses the administrator-configured suffix for its model. Defaults to true. */
model_prompt_suffix_enabled?: boolean | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
/** 对话跳转;传 null 或空 url 表示清除,仍进入本系统问答。 */
chat_link?: AgentChatLink | null;
}
export interface UpdateAgentRequest {
@@ -123,6 +151,8 @@ export interface UpdateAgentRequest {
square_id?: string | null;
skills?: string[] | null;
model?: string | null;
/** Whether this agent uses the administrator-configured suffix for its model. */
model_prompt_suffix_enabled?: boolean | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
/** 入库模板名;传空字符串表示清除。 */
@@ -130,4 +160,6 @@ export interface UpdateAgentRequest {
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
/** 对话跳转;传 null 或空 url 表示清除,仍进入本系统问答。 */
chat_link?: AgentChatLink | null;
}
+10 -5
View File
@@ -7,10 +7,12 @@ import { shouldAttachStoredAuthorization } from "./auth-request-policy";
import { withCsrfHeaders } from "./csrf";
function withAuthHeaders(input: RequestInfo | URL, headers?: HeadersInit): Headers {
const next = withCsrfHeaders(headers);
const authHeader = shouldAttachStoredAuthorization(requestUrl(input))
? getAuthorizationHeaderValue()
: undefined;
// Login mutations stay free of Authorization and X-CSRF-Token. Either custom
// header forces a CORS preflight, which old Firefox cancels as
// NS_BINDING_ABORTED before the login POST is sent.
const attachSessionHeaders = shouldAttachStoredAuthorization(requestUrl(input));
const next = attachSessionHeaders ? withCsrfHeaders(headers) : new Headers(headers ?? {});
const authHeader = attachSessionHeaders ? getAuthorizationHeaderValue() : undefined;
if (authHeader && !next.has("Authorization")) {
next.set("Authorization", authHeader);
}
@@ -49,7 +51,10 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
typeof window !== "undefined" &&
!window.location.hash.startsWith("#/login")
) {
window.location.replace("#/login?reason=session-expired");
// Hash assignment stays in the same document. location.replace with a
// "?" inside the hash makes old Firefox start a new load and abort the
// in-flight login request (NS_BINDING_ABORTED).
window.location.hash = "#/login?reason=session-expired";
}
}
}
+42 -4
View File
@@ -20,6 +20,11 @@ import {
} from "./write-file-in-flight";
import { updateArtifactContent } from "./api";
function isHtmlArtifactPath(filepath: string): boolean {
const path = filepath.split("?")[0]?.split("#")[0] ?? filepath;
return /\.html?$/i.test(path);
}
function hasToolResult(
thread: ReturnType<typeof useThread>["thread"],
toolCallId?: string,
@@ -152,12 +157,25 @@ export function useArtifactContent({
streaming: writeFileInFlight,
});
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
const isHtmlArtifact = isHtmlArtifactPath(filepath);
const toolCallFallbackContent = useMemo(() => {
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
return undefined;
}
// Facebook/page renderers write the durable HTML through bash after any
// earlier write_file of the same path. The tool-arg snapshot would keep
// showing the template body even after disk already has the filled page.
if (isHtmlArtifact) {
return undefined;
}
return loadArtifactContentFromLatestWriteFile({ filepath, thread });
}, [filepath, thread, useWriteFileStreaming]);
}, [filepath, isHtmlArtifact, thread, useWriteFileStreaming]);
const shouldLoadArtifact =
Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath);
const htmlRunEpoch = !useWriteFileStreaming && isHtmlArtifact
? (thread.isLoading ? "running" : "settled")
: null;
const { data, isLoading, error, refetch } = useQuery({
queryKey: [
@@ -167,15 +185,35 @@ export function useArtifactContent({
isMock,
publicShareCode,
completedArtifactMutationKey,
// Evict legacy entries that may contain HTML captured before a renderer
// replaced the same on-disk path. Keep this last so prefix invalidation
// and mutation cache updates continue to match every artifact variant.
"fresh-disk-v4",
htmlRunEpoch,
],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
},
enabled: Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath),
enabled: shouldLoadArtifact,
refetchInterval: thread.isLoading ? 1000 : false,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
// Opening/reopening a normal artifact must read the current file even when
// the same path was previewed moments ago. A renderer can overwrite that
// path through bash, which does not change the message-derived query key.
refetchOnMount: filepath.endsWith(".skill") ? false : "always",
// HTML is rewritten in place by render scripts; never keep a 5-minute
// snapshot of the template. Skill archives stay cached as before.
staleTime: isHtmlArtifact ? 0 : 5 * 60 * 1000,
});
const wasThreadLoadingRef = useRef(thread.isLoading);
useEffect(() => {
const runJustCompleted = wasThreadLoadingRef.current && !thread.isLoading;
wasThreadLoadingRef.current = thread.isLoading;
if (runJustCompleted && shouldLoadArtifact) {
// The last polling tick can race with a final render/copy command. Read
// once more after the run settles so the iframe receives final HTML.
void refetch();
}
}, [refetch, shouldLoadArtifact, thread.isLoading]);
const pendingEdit = useMemo(() => {
if (useWriteFileStreaming) {
return null;
+17 -3
View File
@@ -17,12 +17,26 @@ export async function loadArtifactContent({
isMock?: boolean;
publicShareCode?: string;
}) {
const isSkillArchive = filepath.endsWith(".skill");
let enhancedFilepath = filepath;
if (filepath.endsWith(".skill")) {
if (isSkillArchive) {
enhancedFilepath = filepath + "/SKILL.md";
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url);
const url = urlOfArtifact({
filepath: enhancedFilepath,
threadId,
isMock,
publicShareCode,
});
// Normal artifacts are mutable: render scripts and bash commands may replace
// the same path without producing a write_file/str_replace revision in the
// message stream. Bypass the browser HTTP cache so a React Query refetch
// always reads the bytes currently on disk. Skill archives are immutable in
// this view and deliberately keep their existing five-minute cache.
const response = await apiFetch(
url,
isSkillArchive ? undefined : { cache: "no-store" },
);
const text = await response.text();
if (!response.ok) {
let detail = text.trim();
+3 -2
View File
@@ -1,4 +1,5 @@
import { apiFetch } from "@/core/api/fetch-client";
import { fetchWithBindingRetry } from "@/core/auth/login-transport";
import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
@@ -29,10 +30,10 @@ async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20_000);
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
const response = await fetchWithBindingRetry(() => 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;
+76 -21
View File
@@ -1,4 +1,5 @@
import { apiFetch } from "@/core/api/fetch-client";
import { fetchWithBindingRetry } from "@/core/auth/login-transport";
import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
@@ -14,24 +15,73 @@ export interface UserListItem {
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
approved: boolean;
/**
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
* 是否已设置凭据以 has_login_password 为准。
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
* 是否已设置凭据以 has_login_password 为准。
*/
login_password: string | null;
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
has_login_password: boolean;
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
access_token?: string;
token_type?: string;
expires_in?: number;
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
has_login_password: boolean;
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
access_token?: string;
token_type?: string;
expires_in?: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
/**
* 拉全部注册用户。`includeCounts=false` 时后端只查 users 表、跳过问答次数
* 统计与 skill/agent 计数(返回 0)——白名单管理页用,避免 runs 表慢查询。
*/
export async function listAllUsers(includeCounts = true): Promise<UserListItem[]> {
const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`);
if (!includeCounts) url.searchParams.set("include_counts", "false");
const res = await apiFetch(url.toString());
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<UserListItem[]>;
}
/** 分页用户列表(后端传 limit 时返回的 UserListPage 形状)。 */
export interface UserListPage {
items: UserListItem[];
total: number;
approved_count: number;
limit: number;
offset: number;
}
/** 服务端分页拉用户(白名单管理页):只查一页 users 表 + 两个 COUNT。 */
export async function listUsersPaged(opts: {
q?: string;
limit: number;
offset: number;
}, signal?: AbortSignal): Promise<UserListPage> {
const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`);
url.searchParams.set("limit", String(opts.limit));
url.searchParams.set("offset", String(opts.offset));
url.searchParams.set("include_counts", "false");
if (opts.q) url.searchParams.set("q", opts.q);
const controller = new AbortController();
const cancel = () => controller.abort();
if (signal?.aborted) cancel();
else signal?.addEventListener("abort", cancel, { once: true });
const timer = setTimeout(cancel, 20_000);
try {
const res = await apiFetch(url.toString(), { signal: controller.signal });
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(extractErrorMessage(data, `用户列表加载失败(HTTP ${res.status})`));
}
return await res.json() as UserListPage;
} catch (error) {
if (controller.signal.aborted && !signal?.aborted) {
throw new Error("用户列表加载超时,请重试");
}
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", cancel);
}
}
/** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */
export async function setUserApproval(userId: string, approved: boolean): Promise<UserListItem> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/approval`, {
@@ -47,7 +97,7 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
}
/**
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
*/
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, {
@@ -95,13 +145,15 @@ export async function loginByUsername(
password != null && password !== ""
? `${base}?password=${encodeURIComponent(password)}`
: base;
const response = await apiFetch(url, {
// application/x-www-form-urlencoded is a CORS simple content type. A JSON
// body makes old Firefox preflight the login and then abort it.
const response = await fetchWithBindingRetry(() => apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: JSON.stringify({ username }),
});
body: new URLSearchParams({ username }),
}));
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
@@ -131,12 +183,15 @@ export async function loginByToken(
for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) {
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
const response = await apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
let response: Response;
try {
// No JSON content type and no custom header: old Firefox can send this
// POST without a preflight. A dropped connection is retried once.
response = await fetchWithBindingRetry(() => apiFetch(url, { method: "POST" }));
} catch (error) {
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
throw error;
}
if (response.ok) {
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
return response.json() as Promise<LoginByUsernameResponse>;
+25 -5
View File
@@ -1,17 +1,37 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { OptType, writeYLog } from "@/core/log/y-log";
import { listAllUsers, setUserApproval, setUserLoginPassword } from "./api";
import { listAllUsers, listUsersPaged, setUserApproval, setUserLoginPassword } from "./api";
export function useAllUsers(enabled: boolean) {
export function useAllUsers(enabled: boolean, includeCounts = true) {
return useQuery({
queryKey: ["admin", "users"],
queryFn: listAllUsers,
// "full"/"lite" 后缀区分缓存;放行/改密后按 ["admin","users"] 前缀失效,两种都刷新。
queryKey: ["admin", "users", includeCounts ? "full" : "lite"],
queryFn: () => listAllUsers(includeCounts),
enabled,
});
}
/** 服务端分页的用户列表(白名单管理页)。q 为搜索词,page 从 1 开始。 */
export function useUsersPage(
enabled: boolean,
params: { q: string; page: number; pageSize: number },
) {
return useQuery({
queryKey: ["admin", "users", "page", params.q, params.page, params.pageSize],
queryFn: ({ signal }) =>
listUsersPaged({
q: params.q.trim() || undefined,
limit: params.pageSize,
offset: (params.page - 1) * params.pageSize,
}, signal),
enabled,
placeholderData: keepPreviousData,
retry: false,
});
}
/** 放行 / 取消放行某个用户(登录白名单),成功后刷新用户列表缓存。 */
export function useSetUserApproval() {
const queryClient = useQueryClient();
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fetchWithBindingRetry, isBrowserBindingAbort } from "./login-transport";
test("recognizes the Firefox binding abort and a dropped fetch", () => {
assert.equal(isBrowserBindingAbort(new TypeError("NetworkError when attempting to fetch resource.")), true);
assert.equal(isBrowserBindingAbort(Object.assign(new Error("NS_BINDING_ABORTED"), { name: "NS_ERROR_NET_RESET" })), true);
const timeout = new Error("The operation was aborted");
timeout.name = "AbortError";
assert.equal(isBrowserBindingAbort(timeout), false);
assert.equal(isBrowserBindingAbort(new Error("口令错误")), false);
});
test("retries a binding abort once", async () => {
let calls = 0;
const response = await fetchWithBindingRetry(async () => {
calls += 1;
if (calls === 1) throw new TypeError("Failed to fetch");
return new Response("ok");
});
assert.equal(calls, 2);
assert.equal(await response.text(), "ok");
});
test("does not retry an application error", async () => {
let calls = 0;
await assert.rejects(
() => fetchWithBindingRetry(async () => {
calls += 1;
throw new Error("口令错误");
}),
/口令错误/,
);
assert.equal(calls, 1);
});
@@ -0,0 +1,23 @@
/**
* Old Firefox cancels a credentialed POST that needed a CORS preflight and
* reports NS_BINDING_ABORTED. Fetch rejects that as a TypeError, not an
* HTTP response. A caller-owned AbortController uses AbortError instead, so
* an intentional timeout is not retried here.
*/
export function isBrowserBindingAbort(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const text = `${error.name} ${error.message}`.toLowerCase();
if (text.includes("ns_binding_aborted") || text.includes("binding aborted")) return true;
if (error.name !== "TypeError") return false;
return /networkerror|failed to fetch|load failed|network error/.test(text);
}
export async function fetchWithBindingRetry(run: () => Promise<Response>): Promise<Response> {
try {
return await run();
} catch (error) {
if (!isBrowserBindingAbort(error)) throw error;
await new Promise((resolve) => setTimeout(resolve, 300));
return await run();
}
}
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import test from "node:test";
import { requestCrudService } from "@/service/request-axios-service";
import {
buildTaskOpeningText,
ensureNarrativeSummary,
fetchTaskActionStructure,
} from "./task-deeplink";
const CONSUMER = "https://ch1.b.uat.4.cn/consumer";
const memory = new Map<string, string>();
Object.assign(globalThis, {
window: {
__CM_RUNTIME_CONFIG__: { VITE_AIPI_CONSUMER_URL: CONSUMER },
},
sessionStorage: {
getItem: (key: string) => memory.get(key) ?? null,
setItem: (key: string, value: string) => {
memory.set(key, String(value));
},
removeItem: (key: string) => {
memory.delete(key);
},
clear: () => {
memory.clear();
},
key: (index: number) => [...memory.keys()][index] ?? null,
get length() {
return memory.size;
},
},
});
interface RecordedCall {
method: "GET";
endpoint: string;
params: Record<string, unknown>;
}
const calls: RecordedCall[] = [];
const actionDetails: Record<string, unknown> = {
"9001": {
state: 200,
msg: "操作成功",
data: {
actionId: "9001",
purpose: null,
actor: null,
expectedBehavior: null,
drivingFactor: null,
narrationList: null,
},
},
"9002": {
state: 200,
msg: "操作成功",
data: {
actionId: "9002",
purpose: "扩大影响",
actor: "媒体账号",
expectedBehavior: "转发评论",
drivingFactor: "认同感",
narrationList: ["叙事03"],
},
},
};
requestCrudService.get = async (endpoint: string, params: Record<string, unknown> = {}) => {
calls.push({ method: "GET", endpoint, params: { ...params } });
if (endpoint.endsWith("/task-action/actionDetail")) {
const actionId = String(params.actionId ?? "");
const body = actionDetails[actionId];
if (!body) throw new Error(`no mock for actionId=${actionId}`);
return body;
}
if (endpoint.endsWith("/mainnets/narratives-by-name")) {
return {
state: 200,
msg: "操作成功",
data: { summary: "这条传播网主要讨论暑期文旅消费。" },
};
}
throw new Error(`unexpected GET ${endpoint}`);
};
function queryString(params: Record<string, unknown>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : "";
}
test("模拟 xdfx 行动结构与传播网简介的 consumer 调用", async () => {
const task = {
id: "2055",
overview: "",
taskDirection: "",
taskName: "任务01:暑期文旅舆情综合研判",
taskContent: "围绕暑期文旅消费高峰做综合研判。",
taskType: "1",
cbwName: "暑期文旅传播网",
};
await fetchTaskActionStructure("9001");
await ensureNarrativeSummary("9001", task.cbwName);
const missingPurpose = buildTaskOpeningText(task, "2055", {
actionId: "9001",
actionName: "暑期引导行动",
narration: "引导公众讨论文旅消费。",
cbwId: "88",
cbwName: task.cbwName,
});
await fetchTaskActionStructure("9002");
await ensureNarrativeSummary("9002", task.cbwName);
const complete = buildTaskOpeningText(task, "2055", {
actionId: "9002",
actionName: "暑期引导行动",
});
console.log("\n--- 模拟调用 ---");
for (const call of calls) {
console.log(`${call.method} ${call.endpoint}${queryString(call.params)}`);
console.log(`params ${JSON.stringify(call.params)}`);
}
console.log("\n--- purpose 为 null 时的用户消息 ---");
console.log(missingPurpose);
console.log("\n--- purpose 和 actor 都有值时的用户消息 ---");
console.log(complete);
console.log("---");
assert.deepEqual(
calls.map((call) => ({ endpoint: call.endpoint, params: call.params })),
[
{ endpoint: `${CONSUMER}/task-action/actionDetail`, params: { actionId: "9001" } },
{
endpoint: `${CONSUMER}/mainnets/narratives-by-name`,
params: { cbwName: "暑期文旅传播网" },
},
{ endpoint: `${CONSUMER}/task-action/actionDetail`, params: { actionId: "9002" } },
],
);
for (const label of ["目的:", "行为体:", "预期行为:", "驱动因素:", "叙事:"]) {
assert.equal(missingPurpose.includes(label), false, label);
}
assert.match(missingPurpose, /传播网简介:这条传播网主要讨论暑期文旅消费。/);
assert.equal(complete.includes("传播网简介"), false);
assert.match(complete, /目的:扩大影响/);
assert.match(complete, /叙事:叙事03/);
});
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
actionNeedsNarrative,
parseNarrativeSummary,
parseTaskActionStructure,
taskActionStructureLines,
} from "./task-deeplink";
test("purpose 或 actor 为 null 时需要传播网简介", () => {
const missingPurpose = parseTaskActionStructure(
{
state: 200,
msg: "操作成功",
data: {
actionId: "9",
purpose: null,
actor: "媒体",
expectedBehavior: "转发",
drivingFactor: "认同",
narrationList: ["叙事01", "叙事02"],
},
},
"9",
);
assert.equal(missingPurpose?.purpose, null);
assert.equal(missingPurpose?.actor, "媒体");
assert.equal(actionNeedsNarrative(missingPurpose), true);
assert.equal(missingPurpose?.narration, "叙事01;叙事02");
const bothPresent = parseTaskActionStructure(
{
state: 200,
data: { purpose: "扩大影响", actor: "媒体", narrationList: [] },
},
"9",
);
assert.equal(actionNeedsNarrative(bothPresent), false);
});
test("目的和行为体等字段为 null 时不写入用户消息", () => {
const detail = parseTaskActionStructure(
{
state: 200,
data: {
actionId: "9",
purpose: null,
actor: null,
expectedBehavior: null,
drivingFactor: null,
narrationList: null,
},
},
"9",
);
assert.ok(detail);
detail.narrativeSummary = "这条传播网主要讨论暑期文旅。";
assert.deepEqual(taskActionStructureLines(detail), ["传播网简介:这条传播网主要讨论暑期文旅。"]);
});
test("传播网简介拼进用户消息,空简介不占行", () => {
const summary = parseNarrativeSummary({
state: "200",
msg: "操作成功",
data: { summary: "这条传播网主要讨论暑期文旅。" },
});
assert.equal(summary, "这条传播网主要讨论暑期文旅。");
const lines = taskActionStructureLines({
actionId: "9",
purpose: null,
actor: "媒体",
expectedBehavior: "",
drivingFactor: "",
narration: "",
narrativeSummary: summary,
});
assert.deepEqual(lines, ["行为体:媒体", "传播网简介:这条传播网主要讨论暑期文旅。"]);
});
+310 -20
View File
@@ -20,7 +20,10 @@ import { requestCrudService } from "@/service/request-axios-service";
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
import { readRuntimeEnv, readRuntimeFlag } from "@/env";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
import {
listBusinessMappings,
type BusinessMapping,
} from "@/strategy-components/api/business-mapping";
/** xdfx 业务码:深链传入的是「行动id」,需先调 action 接口解析出真正的「任务id」。 */
const XDFX_BUSINESS_CODE = "7BF";
@@ -37,6 +40,8 @@ export interface CopTaskDetail {
* 1 打击 / 2 塑势 / 3 防御;缺省或无法识别时为空,发送时省略该行。
*/
taskType?: string;
/** 传播网名称。xdfx 在目的或行为体为 null 时,用它请求传播网简介。 */
cbwName?: string;
}
/** cop-task-detail ``taskType`` 字典码 → 发给模型的任务类型。 */
@@ -73,6 +78,12 @@ export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail";
/** 行动详情整包暂存的 sessionStorage key(xdfx 行动分析:会商/单智能体读取行动名称/叙事)。 */
export const ACTION_DETAIL_STORAGE_KEY = "login.actionDetail";
/**
* consumer ``GET /task-action/actionDetail`` 的结构化行动详情。
* 与 ``login.actionDetail``(行动名称/任务 id/传播网)不是同一份数据。
*/
export const TASK_ACTION_STRUCTURE_STORAGE_KEY = "login.taskActionStructure";
/** goPath 基础码 → 业务码(配置管理 businessCode)。 */
const GOPATH_BUSINESS_CODE: Record<string, string> = {
"3qfx": "3Q", // 三情分析
@@ -92,20 +103,33 @@ const BUSINESS_TASK_BASE: Record<string, string> = {
export interface ParsedGoPath {
businessCode: string;
goPath: "3qfx" | "rwfx" | "xdfx";
/** Base goPath, normalized to lowercase and without the optional `-A`. */
goPath: string;
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
roundtable: boolean;
}
/** 解析 goPath(大小写不敏感);``-A`` 后缀表示多智能体会商。无法识别返回 null。 */
export function parseGoPath(goPath: string): ParsedGoPath | null {
/** Normalize any syntactically valid goPath before resolving the mapping. */
function parseGoPathToken(goPath: string): Pick<ParsedGoPath, "goPath" | "roundtable"> | null {
const raw = goPath.trim().toLowerCase();
if (!raw) return null;
const roundtable = raw.endsWith("-a");
const base = roundtable ? raw.slice(0, -2) : raw;
if (!base || base.length > 32) return null;
return { goPath: base, roundtable };
}
/**
* Parse the built-in goPaths (case-insensitive). Custom goPaths are resolved
* from the persisted business-mapping catalogue by `resolveTaskDeeplinkTarget`.
*/
export function parseGoPath(goPath: string): ParsedGoPath | null {
const token = parseGoPathToken(goPath);
if (!token) return null;
const { goPath: base, roundtable } = token;
const businessCode = GOPATH_BUSINESS_CODE[base];
if (!businessCode) return null;
return { businessCode, goPath: base as ParsedGoPath["goPath"], roundtable };
return { businessCode, goPath: base, roundtable };
}
function consumerBaseUrl(): string {
@@ -128,6 +152,7 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
taskContent:
"围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。",
taskType: "1",
cbwName: "暑期文旅传播网",
};
}
@@ -163,6 +188,7 @@ export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
taskType: readTaskTypeCode(d.taskType),
cbwName: normalizeCbwName(d.cbwName),
};
} catch (error) {
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
@@ -228,6 +254,208 @@ export function normalizeCbwName(value: unknown): string {
return text;
}
/**
* consumer ``GET {VITE_AIPI_CONSUMER_URL}/task-action/actionDetail?actionId=`` 的 data。
* 写入 xdfx 用户消息时:purpose→目的,actor→行为体,expectedBehavior→预期行为,
* drivingFactor→驱动因素,narrationList→叙事(列表元素拼成一句)。
*/
export interface TaskActionStructure {
/** 本次请求使用的行动 id(深链 URL 的 actionId)。 */
actionId: string;
/** JSON null / 缺字段保持 null,用来决定要不要补拉传播网简介。 */
purpose: string | null;
actor: string | null;
expectedBehavior: string;
drivingFactor: string;
/** ``narrationList`` 拼成的一句话。 */
narration: string;
/** ``mainnets/narratives-by-name`` 的 data.summary。仅在目的或行为体为 null 时才有。 */
narrativeSummary: string;
}
function textField(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value !== "string") return "";
return value.trim();
}
/** null / 缺字段保持 null;空串仍是空串,不触发传播网简介。 */
function nullableText(value: unknown): string | null {
if (value == null) return null;
return textField(value);
}
/** purpose 或 actor 有一个为 null,就补拉传播网简介。 */
export function actionNeedsNarrative(detail: {
purpose: string | null;
actor: string | null;
} | null | undefined): boolean {
if (!detail) return false;
return detail.purpose === null || detail.actor === null;
}
/** 把叙事列表收成一句。空元素丢掉,条目之间用分号连接。 */
export function joinNarrationList(value: unknown): string {
if (!Array.isArray(value)) return "";
return value.map((item) => textField(item)).filter(Boolean).join(";");
}
function isConsumerOk(state: unknown): boolean {
return state === 200 || state === "200";
}
/** 解析 ``{state, msg, data}``。state 不是 200、或 data 不是对象时返回 null。 */
export function parseTaskActionStructure(body: unknown, actionId: string): TaskActionStructure | null {
if (!body || typeof body !== "object") return null;
const envelope = body as { state?: unknown; data?: unknown };
if (!isConsumerOk(envelope.state)) return null;
const data = envelope.data;
if (!data || typeof data !== "object") return null;
const row = data as Record<string, unknown>;
return {
actionId,
purpose: nullableText(row.purpose),
actor: nullableText(row.actor),
expectedBehavior: textField(row.expectedBehavior),
drivingFactor: textField(row.drivingFactor),
narration: joinNarrationList(row.narrationList),
narrativeSummary: "",
};
}
/** 解析 ``mainnets/narratives-by-name`` 的 ``data.summary``。失败或无简介返回空串。 */
export function parseNarrativeSummary(body: unknown): string {
if (!body || typeof body !== "object") return "";
const envelope = body as { state?: unknown; data?: unknown };
if (!isConsumerOk(envelope.state)) return "";
const data = envelope.data;
if (!data || typeof data !== "object") return "";
return textField((data as { summary?: unknown }).summary);
}
export function taskActionStructureLines(detail: TaskActionStructure | null | undefined): string[] {
if (!detail) return [];
return [
detail.purpose ? `目的:${detail.purpose}` : "",
detail.actor ? `行为体:${detail.actor}` : "",
detail.expectedBehavior ? `预期行为:${detail.expectedBehavior}` : "",
detail.drivingFactor ? `驱动因素:${detail.drivingFactor}` : "",
detail.narration ? `叙事:${detail.narration}` : "",
detail.narrativeSummary ? `传播网简介:${detail.narrativeSummary}` : "",
].filter(Boolean);
}
function persistTaskActionStructure(detail: TaskActionStructure): void {
try {
sessionStorage.setItem(TASK_ACTION_STRUCTURE_STORAGE_KEY, JSON.stringify(detail));
} catch {
/* noop */
}
}
export function readTaskActionStructure(): TaskActionStructure | null {
try {
const raw = sessionStorage.getItem(TASK_ACTION_STRUCTURE_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<TaskActionStructure>;
if (!parsed || typeof parsed !== "object") return null;
return {
actionId: typeof parsed.actionId === "string" ? parsed.actionId : "",
purpose: parsed.purpose == null ? null : typeof parsed.purpose === "string" ? parsed.purpose : "",
actor: parsed.actor == null ? null : typeof parsed.actor === "string" ? parsed.actor : "",
expectedBehavior: typeof parsed.expectedBehavior === "string" ? parsed.expectedBehavior : "",
drivingFactor: typeof parsed.drivingFactor === "string" ? parsed.drivingFactor : "",
narration: typeof parsed.narration === "string" ? parsed.narration : "",
narrativeSummary: typeof parsed.narrativeSummary === "string" ? parsed.narrativeSummary : "",
};
} catch {
return null;
}
}
/** 只取与当前行动 id 匹配的结构化详情,避免串到上一次深链。 */
export function readTaskActionStructureFor(actionId: string): TaskActionStructure | null {
const id = actionId.trim();
if (!id) return null;
const stored = readTaskActionStructure();
if (!stored || stored.actionId !== id) return null;
return stored;
}
const taskActionStructureRequests = new Map<string, Promise<TaskActionStructure | null>>();
const narrativeSummaryRequests = new Map<string, Promise<string>>();
function narrativeCbwName(task: { cbwName?: string | null } | null | undefined, action: { cbwName?: string | null } | null | undefined): string {
return normalizeCbwName(task?.cbwName) || normalizeCbwName(action?.cbwName);
}
async function loadNarrativeSummary(cbwName: string): Promise<string> {
const base = consumerBaseUrl();
if (!base) return "";
try {
const body = await requestCrudService.get(`${base}/mainnets/narratives-by-name`, { cbwName });
return parseNarrativeSummary(body);
} catch (error) {
console.warn("[task-deeplink] narratives-by-name failed", { cbwName, error });
return "";
}
}
/** 按传播网名称取简介。同一名称的在途请求会复用。 */
export function fetchNarrativeSummary(cbwName: string): Promise<string> {
const name = normalizeCbwName(cbwName);
if (!name) return Promise.resolve("");
const existing = narrativeSummaryRequests.get(name);
if (existing) return existing;
const pending = loadNarrativeSummary(name);
narrativeSummaryRequests.set(name, pending);
return pending;
}
/**
* 行动结构里目的或行为体为 null 时,用任务详情(没有则用行动详情)的 cbwName
* 拉传播网简介,写回同一份行动结构,供用户消息拼接。
*/
export async function ensureNarrativeSummary(actionId: string, cbwName: string): Promise<void> {
const id = actionId.trim();
const name = normalizeCbwName(cbwName);
if (!id || !name) return;
const structure = readTaskActionStructureFor(id) ?? (await fetchTaskActionStructure(id));
if (!actionNeedsNarrative(structure)) return;
const summary = await fetchNarrativeSummary(name);
const latest = readTaskActionStructureFor(id) ?? structure;
if (!latest) return;
persistTaskActionStructure({ ...latest, narrativeSummary: summary });
}
async function loadTaskActionStructure(actionId: string): Promise<TaskActionStructure | null> {
const base = consumerBaseUrl();
if (!base) return null;
try {
const body = await requestCrudService.get(`${base}/task-action/actionDetail`, { actionId });
const detail = parseTaskActionStructure(body, actionId);
if (detail) persistTaskActionStructure(detail);
return detail;
} catch (error) {
console.warn("[task-deeplink] task-action/actionDetail failed", { actionId, error });
return null;
}
}
/**
* xdfx 深链:按行动 id 拉 consumer 行动结构。同一 actionId 的在途/已完成请求会复用,
* 登录预取和开场词拼装不会各打一次。失败返回 null,不阻断跳转。
*/
export function fetchTaskActionStructure(actionId: string): Promise<TaskActionStructure | null> {
const id = actionId.trim();
if (!id) return Promise.resolve(null);
const existing = taskActionStructureRequests.get(id);
if (existing) return existing;
const pending = loadTaskActionStructure(id);
taskActionStructureRequests.set(id, pending);
return pending;
}
/** xdfx 发给模型的传播网两行。``cbwId`` 无效时返回空数组,名称也不带。 */
export function cbwModelLines(cbwId: unknown, cbwName: unknown): string[] {
const id = normalizeCbwId(cbwId);
@@ -330,13 +558,16 @@ export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Prom
*
* 失败语义与登录页原逻辑一致:行动详情失败按原始 id 回退拉任务详情、
* realTaskId 给 ""(调用方回退原始 id);任务详情失败为 null(调用方
* 走 stub)。两个 promise 均不 reject。
* 走 stub)。xdfx 同时预取 consumer ``task-action/actionDetail``,失败只是
* 开场词里不带目的/行为体等行。这些 promise 均不 reject。
*/
export interface TaskDeeplinkPrefetch {
/** 真实任务 id(xdfx:行动→任务;其余原样返回)。 */
realTaskId: Promise<string>;
/** 任务详情(xdfx 已串好「行动详情→任务详情」链;无数据/失败为 null)。 */
taskDetail: Promise<CopTaskDetail | null>;
/** xdfx:consumer 行动结构。其它业务为已完成的 null。失败不 reject。 */
actionStructure: Promise<TaskActionStructure | null>;
}
export function prefetchTaskDeeplink(taskId: string, goPath: string): TaskDeeplinkPrefetch {
@@ -346,14 +577,23 @@ export function prefetchTaskDeeplink(taskId: string, goPath: string): TaskDeepli
console.warn("[task-deeplink] prefetch action detail failed", { taskId, error });
return null;
});
const taskDetail = actionDetail.then((ad) => fetchCopTaskDetail(ad?.taskId || taskId));
const actionStructure = fetchTaskActionStructure(taskId).then(async (structure) => {
if (!actionNeedsNarrative(structure)) return structure;
const [ad, task] = await Promise.all([actionDetail, taskDetail]);
await ensureNarrativeSummary(taskId, narrativeCbwName(task, ad));
return readTaskActionStructureFor(taskId) ?? structure;
});
return {
realTaskId: actionDetail.then((ad) => ad?.taskId ?? ""),
taskDetail: actionDetail.then((ad) => fetchCopTaskDetail(ad?.taskId || taskId)),
taskDetail,
actionStructure,
};
}
return {
realTaskId: Promise.resolve(taskId),
taskDetail: fetchCopTaskDetail(taskId),
actionStructure: Promise.resolve(null),
};
}
@@ -381,6 +621,8 @@ export interface ActionOpeningInfo {
* 用 taskName / taskContent 拼出开场信息文本;末尾附上任务 id(task_id),供后端识别任务。
* ``taskType`` 能映射时插入「任务类型:打击/塑势/防御」。
* xdfx(行动分析)额外附上「行动标题 / 行动内容 / 行动id(action_id)」——传入 action 时追加这几行。
* 同一行动已取到的 consumer 行动结构会再追加「目的 / 行为体 / 预期行为 / 驱动因素 / 叙事」。
* 目的或行为体为 null 时,再追加「传播网简介」。
* 传播网 id、传播网名称仅在 ``cbwId`` 有值时追加;为 null 时两行都不写。
*/
function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: ActionOpeningInfo): string {
@@ -393,6 +635,7 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: Action
action?.actionName ? `行动标题:${action.actionName}` : "",
action?.narration ? `行动内容:${action.narration}` : "",
action?.actionId ? `行动id(action_id)为${action.actionId}` : "",
...taskActionStructureLines(action?.actionId ? readTaskActionStructureFor(action.actionId) : null),
...cbwModelLines(action?.cbwId, action?.cbwName),
]
.filter(Boolean)
@@ -430,10 +673,36 @@ export async function resolveTaskDeeplinkTarget(
taskId: string,
rawId?: string,
): Promise<TaskDeeplinkTarget | null> {
const parsed = parseGoPath(goPath);
if (!parsed) return null;
const builtIn = parseGoPath(goPath);
const token = parseGoPathToken(goPath);
if (!token) return null;
// Built-ins retain their task-workspace routes. Any other valid base is a
// custom mapping only when it exists in the globally configured catalogue.
let mapping: BusinessMapping | null = null;
const isCustom = !builtIn;
const parsed: ParsedGoPath = builtIn ?? {
businessCode: "",
goPath: token.goPath,
roundtable: token.roundtable,
};
if (isCustom) {
try {
const mappings = await listBusinessMappings();
mapping = mappings.find(
(row) => row.isCustom && row.businessCode.trim().toLowerCase() === token.goPath,
) ?? null;
} catch (error) {
console.warn("[task-deeplink] custom business mapping failed", error);
}
if (!mapping) {
console.warn("[task-deeplink] custom goPath is not configured", token.goPath);
return null;
}
parsed.businessCode = mapping.businessCode;
}
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
if (!taskBase) return null;
if (!isCustom && !taskBase) return null;
// 清掉上一次深链可能残留的任务上下文(单智能体分支会用本次任务重新写入)。
clearTaskContext();
@@ -447,11 +716,19 @@ export async function resolveTaskDeeplinkTarget(
let actionId = "";
if (parsed.businessCode === XDFX_BUSINESS_CODE && rawId) {
actionId = rawId;
const ad = readActionDetail() ?? (await fetchActionDetail(rawId));
const [, ad] = await Promise.all([
fetchTaskActionStructure(rawId),
readActionDetail() ? Promise.resolve(readActionDetail()) : fetchActionDetail(rawId),
]);
await ensureNarrativeSummary(rawId, narrativeCbwName(detail, ad));
actionName = ad?.actionName ?? "";
}
return {
path: `${taskBase}/roundtable`,
// Custom mappings have no host business page. They intentionally open a
// clean surface; hideChrome survives the subsequent in-page navigation.
path: isCustom
? "/page/strategy/qa/multi-agent-roundtable?hideChrome=1"
: `${taskBase}/roundtable`,
state: {
roundtablePrefill: {
taskTitle: detail.taskName,
@@ -466,14 +743,15 @@ export async function resolveTaskDeeplinkTarget(
};
}
let agentId: string | null = null;
try {
const mappings = await listBusinessMappings();
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
} catch (error) {
console.warn("[task-deeplink] business mapping failed", error);
agentId = null;
if (!mapping) {
try {
const mappings = await listBusinessMappings();
mapping = mappings.find((row) => row.businessCode === parsed.businessCode) ?? null;
} catch (error) {
console.warn("[task-deeplink] business mapping failed", error);
}
}
const agentId = mapping?.agentId ?? null;
if (!agentId) {
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
return null;
@@ -489,7 +767,12 @@ export async function resolveTaskDeeplinkTarget(
const actionId = rawId || taskId;
let actionInfo: ActionOpeningInfo | undefined;
if (isXdfx) {
const ad = rawId ? (readActionDetail() ?? (await fetchActionDetail(rawId))) : null;
const structureId = rawId || actionId;
const [, ad] = await Promise.all([
structureId ? fetchTaskActionStructure(structureId) : Promise.resolve(null),
rawId ? (readActionDetail() ? Promise.resolve(readActionDetail()) : fetchActionDetail(rawId)) : Promise.resolve(null),
]);
if (structureId) await ensureNarrativeSummary(structureId, narrativeCbwName(detail, ad));
actionInfo = {
actionId,
actionName: ad?.actionName,
@@ -506,5 +789,12 @@ export async function resolveTaskDeeplinkTarget(
partitionByAction: isXdfx,
goPath: parsed.goPath,
});
if (isCustom) {
// This route deliberately renders AgentChatPage without its left agent
// list. The query also removes the global header and PageSidebar.
return {
path: `/page/strategy/custom-deeplink/${encodeURIComponent(agentId)}/chats/new?hideChrome=1`,
};
}
return { path: `${taskBase}/agent/${agentId}/chats` };
}
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
TaskEntryInputError,
buildMainAppUrl,
buildTaskEntryDestination,
buildTaskEntrySessionSearch,
parseTaskEntryParams,
} from "./task-entry.ts";
test("parses the dedicated single-agent task entry", () => {
const params = parseTaskEntryParams(
"?username=admin&taskId=108&goPath=XDFX&theme=dark-blue&hideJump=1",
);
assert.deepEqual(params, {
username: "admin",
password: "",
taskId: "108",
goPath: "xdfx",
theme: "dark-blue",
hideJump: true,
});
});
test("rejects missing identity, task, and roundtable routes", () => {
assert.throws(() => parseTaskEntryParams("?taskId=108&goPath=xdfx"), TaskEntryInputError);
assert.throws(() => parseTaskEntryParams("?username=admin&goPath=xdfx"), TaskEntryInputError);
assert.throws(
() => parseTaskEntryParams("?username=admin&taskId=108&goPath=xdfx-A"),
TaskEntryInputError,
);
});
test("forces isolated embed state and an allowlisted task target", () => {
const params = parseTaskEntryParams(
"?username=admin&taskId=108&goPath=xdfx&hideJump=true",
);
const session = new URLSearchParams(buildTaskEntrySessionSearch(params));
assert.equal(session.get("embed"), "1");
assert.equal(session.get("hideChrome"), "1");
assert.equal(session.get("isolate"), "1");
assert.equal(session.get("hideJump"), "1");
const destination = buildTaskEntryDestination(
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
params,
);
assert.equal(
destination,
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats?embed=1&hideChrome=1&isolate=1&hideJump=1",
);
assert.throws(() => buildTaskEntryDestination("https://example.com", params));
assert.throws(() => buildTaskEntryDestination("/page/workspace/chats/new", params));
});
test("returns to the main app at the same deployment prefix", () => {
assert.equal(
buildMainAppUrl(
"https://example.com/deerflow/task-entry.html?username=admin",
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
),
"https://example.com/deerflow/#/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
);
});
+82
View File
@@ -0,0 +1,82 @@
export const TASK_ENTRY_SUPPORTED_GOPATHS = ["3qfx", "rwfx", "xdfx"] as const;
export type TaskEntryGoPath = (typeof TASK_ENTRY_SUPPORTED_GOPATHS)[number];
export interface TaskEntryParams {
username: string;
password: string;
taskId: string;
goPath: TaskEntryGoPath;
theme: string;
hideJump: boolean;
}
export class TaskEntryInputError extends Error {
constructor(message: string) {
super(message);
this.name = "TaskEntryInputError";
}
}
function readFlag(value: string | null): boolean {
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
export function parseTaskEntryParams(search: string): TaskEntryParams {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const username = (params.get("username") ?? "").trim();
const password = params.get("password") ?? "";
const taskId = (params.get("taskId") ?? "").trim();
const rawGoPath = (params.get("goPath") ?? "").trim().toLowerCase();
const theme = (params.get("theme") ?? "").trim();
if (!username) throw new TaskEntryInputError("入口缺少 username,无法登录任务问答。");
if (!taskId) throw new TaskEntryInputError("入口缺少 taskId,无法定位任务。");
if (!TASK_ENTRY_SUPPORTED_GOPATHS.includes(rawGoPath as TaskEntryGoPath)) {
throw new TaskEntryInputError("轻量入口仅支持 3qfx、rwfx 或 xdfx 单智能体任务问答。");
}
return {
username,
password,
taskId,
goPath: rawGoPath as TaskEntryGoPath,
theme,
hideJump: readFlag(params.get("hideJump")),
};
}
export function buildTaskEntrySessionSearch(params: TaskEntryParams): string {
const session = new URLSearchParams({
embed: "1",
hideChrome: "1",
isolate: "1",
});
if (params.hideJump) session.set("hideJump", "1");
if (params.theme) session.set("theme", params.theme);
return `?${session.toString()}`;
}
export function buildTaskEntryDestination(path: string, params: TaskEntryParams): string {
if (!path.startsWith("/page/strategy/task-management/") || path.includes("\\")) {
throw new Error("任务深链解析出了不受允许的目标页面。");
}
const url = new URL(path, "https://task-entry.invalid");
if (url.origin !== "https://task-entry.invalid") {
throw new Error("任务深链目标必须是系统内部页面。");
}
url.searchParams.set("embed", "1");
url.searchParams.set("hideChrome", "1");
url.searchParams.set("isolate", "1");
if (params.hideJump) url.searchParams.set("hideJump", "1");
if (params.theme) url.searchParams.set("theme", params.theme);
return `${url.pathname}${url.search}`;
}
export function buildMainAppUrl(currentHref: string, destination: string): string {
const appUrl = new URL(".", currentHref);
appUrl.search = "";
appUrl.hash = destination;
return appUrl.toString();
}
+17 -8
View File
@@ -33,7 +33,22 @@ export interface PurposeTreeNode {
children: PurposeTreeNode[];
}
export type TaskDeeplinkGoPath = "3qfx" | "rwfx" | "xdfx" | "b5-1";
/**
* The base (without `-A`) of a task deep-link goPath.
*
* Besides the four built-ins, administrators may now create a short custom
* mapping name. Keeping this a string lets its dedicated conversation history
* partition follow the configured goPath without adding frontend code again.
*/
export type TaskDeeplinkGoPath = string;
function readTaskDeeplinkGoPath(value: unknown): TaskDeeplinkGoPath | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
return normalized && normalized.length <= 32 && !normalized.endsWith("-a")
? normalized
: undefined;
}
/** 当前任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
export interface TaskContext {
@@ -376,13 +391,7 @@ export function readTaskContext(): TaskContext | null {
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
partitionByAction: parsed.partitionByAction === true,
taskType: typeof parsed.taskType === "string" ? parsed.taskType.trim() : undefined,
goPath:
parsed.goPath === "3qfx" ||
parsed.goPath === "rwfx" ||
parsed.goPath === "xdfx" ||
parsed.goPath === "b5-1"
? parsed.goPath
: undefined,
goPath: readTaskDeeplinkGoPath(parsed.goPath),
};
} catch {
return null;
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import {
getHeaderBrandAsset,
getHeaderBrandFallback,
} from "@/core/branding/brand-assets";
import { resolveAppIdentityLogoUrl } from "@/core/system-settings/api";
import { useAppIdentitySettings } from "@/core/system-settings/hooks";
export function AppBrandImage({
theme,
className,
alt = "",
}: {
theme: Parameters<typeof getHeaderBrandAsset>[0];
className?: string;
alt?: string;
}) {
const { data } = useAppIdentitySettings();
const uploadedLogo = data?.has_logo
? resolveAppIdentityLogoUrl(data.logo_url)
: undefined;
const themeLogo = getHeaderBrandAsset(theme);
const bundledLogo = getHeaderBrandFallback(theme);
const candidates = [uploadedLogo, themeLogo, bundledLogo].filter(
(value, index, all): value is string =>
Boolean(value) && all.indexOf(value) === index,
);
const candidateKey = candidates.join("|");
const [index, setIndex] = useState(0);
useEffect(() => {
setIndex(0);
}, [candidateKey]);
const src =
candidates[Math.min(index, Math.max(candidates.length - 1, 0))] ??
bundledLogo;
return (
<img
src={src}
alt={alt}
aria-hidden={alt ? undefined : true}
className={className}
onError={() => {
setIndex((current) =>
current < candidates.length - 1 ? current + 1 : current,
);
}}
/>
);
}
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
archiveMonthDisplayStatus,
canPurgeArchivedMonth,
isArchiveMonthActive,
} from "./chat-archive-state.ts";
test("partial and interrupted batches keep verified copies available for cleanup", () => {
for (const status of ["partial", "failed", "interrupted"]) {
const month = {
status: "copying",
copied_threads: 3,
latest_job: { action: "archive", status },
};
assert.equal(isArchiveMonthActive(month), false);
assert.equal(canPurgeArchivedMonth(month), true);
assert.equal(archiveMonthDisplayStatus(month), status === "partial" ? "partial" : "interrupted");
}
});
test("all active job states block cleanup even when month summary is ready", () => {
for (const status of ["queued", "running", "cancelling"]) {
const month = {
status: "ready",
copied_threads: 3,
latest_job: { action: "purge", status },
};
assert.equal(isArchiveMonthActive(month), true);
assert.equal(canPurgeArchivedMonth(month), false);
assert.equal(archiveMonthDisplayStatus(month), status === "cancelling" ? "cancelling" : "deleting");
}
});
test("no archived copies never enables cleanup and legacy active months remain busy", () => {
assert.equal(canPurgeArchivedMonth({ status: "partial", copied_threads: 0 }), false);
assert.equal(canPurgeArchivedMonth({ status: "copying", copied_threads: 3 }), false);
assert.equal(canPurgeArchivedMonth({ status: "partial", copied_threads: 3 }), true);
});
test("completed job corrects a stale in-progress badge", () => {
assert.equal(archiveMonthDisplayStatus({
status: "deleting",
copied_threads: 3,
latest_job: { action: "purge", status: "completed" },
}), "deleted");
});
@@ -0,0 +1,37 @@
/** Terminal job state wins over a stale month summary after interruption. */
export const ACTIVE_ARCHIVE_JOB_STATUSES = new Set(["queued", "running", "cancelling"]);
const ACTIVE_MONTH_STATUSES = new Set(["copying", "deleting", "restoring"]);
type ArchiveMonthState = {
status: string;
copied_threads: number;
latest_job?: {
status: string;
action: "archive" | "purge" | "rollback";
};
};
export function isArchiveMonthActive(month: ArchiveMonthState): boolean {
return month.latest_job
? ACTIVE_ARCHIVE_JOB_STATUSES.has(month.latest_job.status)
: ACTIVE_MONTH_STATUSES.has(month.status);
}
export function canPurgeArchivedMonth(month: ArchiveMonthState): boolean {
// Only catalogued copies qualify; the server re-verifies each copy before
// deletion and serializes mutations. Never require the whole month to finish.
return !isArchiveMonthActive(month) && month.copied_threads > 0;
}
export function archiveMonthDisplayStatus(month: ArchiveMonthState): string {
const job = month.latest_job;
if (!job) return month.status;
if (job.status === "cancelling") return "cancelling";
if (ACTIVE_ARCHIVE_JOB_STATUSES.has(job.status)) {
return { archive: "copying", purge: "deleting", rollback: "restoring" }[job.action];
}
if (!ACTIVE_MONTH_STATUSES.has(month.status)) return month.status;
if (job.status === "partial") return "partial";
if (job.status === "completed") return job.action === "purge" ? "deleted" : "ready";
return "interrupted";
}
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyEmbeddedHostDocumentMarker } from "./frame-context";
test("embedded host marks the document so iframe blurs can be disabled", () => {
const root = { dataset: {} as { dfFramed?: string } };
applyEmbeddedHostDocumentMarker(root, true);
assert.equal(root.dataset.dfFramed, "1");
applyEmbeddedHostDocumentMarker(root, false);
assert.equal(root.dataset.dfFramed, undefined);
});
test("missing document root is ignored", () => {
assert.doesNotThrow(() => applyEmbeddedHostDocumentMarker(null, true));
});
@@ -22,3 +22,29 @@ export function isEmbeddedHost(): boolean {
return true;
}
}
type FramedRoot = {
dataset: Record<string, string | undefined> & { dfFramed?: string };
};
/**
* Mark the document when this app is painted inside another page.
* globals.css uses the marker to drop backdrop-filter: low-end GPUs crash
* the browser tab when a large blur is composited inside an iframe.
*/
export function applyEmbeddedHostDocumentMarker(
root: FramedRoot | null | undefined,
embedded: boolean,
): void {
if (!root) return;
if (embedded) {
root.dataset.dfFramed = "1";
return;
}
delete root.dataset.dfFramed;
}
export function markEmbeddedHostDocument(): void {
if (typeof document === "undefined") return;
applyEmbeddedHostDocumentMarker(document.documentElement, isEmbeddedHost());
}
@@ -1,57 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-ignore Node runs this test directly with TypeScript stripping.
import { defaultEmbeddedKnowledgeTab } from "./knowledge-base-navigation.ts";
import {
defaultEmbeddedKnowledgeTab,
isEmptyKnowledgeBase,
shouldOpenNativeWikiIndex,
} from "./knowledge-base-navigation.ts";
test("an empty Wiki base opens embedded document management", () => {
const knowledgeBase = { knowledge_count: 0, processing_count: 0 };
assert.equal(isEmptyKnowledgeBase(knowledgeBase), true);
assert.equal(
defaultEmbeddedKnowledgeTab({ knowledgeBase, wikiEnabled: true }),
"documents",
);
assert.equal(
shouldOpenNativeWikiIndex({
defaultEntry: true,
landingMode: "wiki",
knowledgeBase,
wikiEnabled: true,
}),
false,
);
test("ordinary knowledge entries always open native documents", () => {
assert.equal(defaultEmbeddedKnowledgeTab(), "documents");
assert.equal(defaultEmbeddedKnowledgeTab(null), "documents");
});
test("a processing upload is not treated as an empty base", () => {
const knowledgeBase = { knowledge_count: 0, processing_count: 1 };
assert.equal(isEmptyKnowledgeBase(knowledgeBase), false);
assert.equal(
defaultEmbeddedKnowledgeTab({ knowledgeBase, wikiEnabled: true }),
"wiki",
);
});
test("a populated Wiki base follows the administrator landing mode", () => {
const knowledgeBase = { knowledge_count: 2, processing_count: 0 };
assert.equal(
shouldOpenNativeWikiIndex({
defaultEntry: true,
landingMode: "wiki",
knowledgeBase,
wikiEnabled: true,
}),
true,
);
assert.equal(
shouldOpenNativeWikiIndex({
defaultEntry: true,
landingMode: "weknora",
knowledgeBase,
wikiEnabled: true,
}),
false,
);
test("explicit document, Wiki and graph deep links keep their target", () => {
for (const tab of ["documents", "wiki", "graph"] as const) {
assert.equal(defaultEmbeddedKnowledgeTab(tab), tab);
}
});
@@ -1,44 +1,8 @@
export type KnowledgeBaseCounts = {
knowledge_count: number;
processing_count: number;
};
type EmbeddedKnowledgeTab = "documents" | "wiki" | "graph";
export function isEmptyKnowledgeBase(
knowledgeBase: KnowledgeBaseCounts | null | undefined,
): boolean {
return Boolean(
knowledgeBase &&
knowledgeBase.knowledge_count === 0 &&
knowledgeBase.processing_count === 0,
);
}
export function defaultEmbeddedKnowledgeTab({
knowledgeBase,
wikiEnabled,
}: {
knowledgeBase: KnowledgeBaseCounts | null | undefined;
wikiEnabled: boolean;
}): "documents" | "wiki" {
if (isEmptyKnowledgeBase(knowledgeBase)) return "documents";
return wikiEnabled ? "wiki" : "documents";
}
export function shouldOpenNativeWikiIndex({
defaultEntry,
landingMode,
knowledgeBase,
wikiEnabled,
}: {
defaultEntry: boolean;
landingMode: "wiki" | "weknora";
knowledgeBase: KnowledgeBaseCounts | null | undefined;
wikiEnabled: boolean;
}): boolean {
return (
defaultEntry &&
landingMode === "wiki" &&
wikiEnabled &&
!isEmptyKnowledgeBase(knowledgeBase)
);
/** Ordinary entries always show WeKnora documents; explicit deep links keep their tab. */
export function defaultEmbeddedKnowledgeTab(
explicitTab?: EmbeddedKnowledgeTab | null,
): EmbeddedKnowledgeTab {
return explicitTab ?? "documents";
}
@@ -164,3 +164,13 @@ test("private research query caches are isolated by live account", () => {
knowledgeResearchPublicationQueryKey("bob", "kb-one", "book-one"),
);
});
test("research drafts exclude inline reasoning and unfinished thinking", () => {
assert.equal(latestKnowledgeResearchAnswer([{ id: "thinking", type: "ai", content: "<think>Need write_file but tools unavailable</think>" }] as Message[]), null);
assert.equal(latestKnowledgeResearchAnswer([{ id: "thinking", type: "ai", content: "<think>unfinished internal reasoning" }] as Message[]), null);
assert.deepEqual(latestKnowledgeResearchAnswer([{ id: "answer", type: "ai", content: "<think>internal</think>\n# 正文\n研究结论" }] as Message[]), { messageId: "answer", content: "# 正文\n研究结论" });
});
test("research drafts accept text blocks only", () => {
assert.deepEqual(latestKnowledgeResearchAnswer([{ id: "answer", type: "ai", content: [{ type: "reasoning", text: "private thoughts" }, { type: "text", text: "<think>hidden</think>正文" }] }] as unknown as Message[]), { messageId: "answer", content: "正文" });
});
@@ -112,12 +112,20 @@ function isHidden(message: Message): boolean {
);
}
function cleanResearchAnswer(content: string): string {
return content
.replace(/<think\b[^>]*>[\s\S]*?<\/think\s*>/gi, "")
.replace(/<think\b[^>]*>[\s\S]*$/gi, "")
.trim();
}
function textContent(message: Message): string {
if (typeof message.content === "string") return message.content;
if (typeof message.content === "string") return cleanResearchAnswer(message.content);
if (!Array.isArray(message.content)) return "";
return message.content
.map((block) => {
if (!block || typeof block !== "object") return "";
if ((block as { type?: string }).type !== "text") return "";
const text = (block as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
@@ -150,7 +158,7 @@ export function knowledgeResearchAnswerCandidates(
) {
continue;
}
const content = textContent(message).trim();
const content = cleanResearchAnswer(textContent(message));
if (content) answers.push({ messageId: message.id, content });
}
return answers;
@@ -423,3 +423,168 @@ test("isViewedImagesContextMessage hides legacy view_image injection bubbles", (
);
assert.deepEqual(types, ["assistant"]);
});
function groupTypesWith(
messages: Message[],
options?: { keepIntermediateText?: boolean },
) {
const types: string[] = [];
const groups = groupMessages(messages, (group) => {
types.push(group.type);
return group;
}, options);
return { types, groups };
}
function midRunTurn(): Message[] {
return [
{ type: "human", id: "h1", content: "查一下最近的动态" } as Message,
{
type: "ai",
id: "a1",
content: "我先检索最近的公开报道。",
additional_kwargs: { reasoning_content: "思考一" },
tool_calls: [{ id: "c1", name: "web_search", args: { query: "最近动态" } }],
} as Message,
{
type: "tool",
id: "t1",
name: "web_search",
content: "结果",
tool_call_id: "c1",
} as Message,
{
type: "ai",
id: "a2",
content: "再补一下社交媒体的讨论。",
additional_kwargs: { reasoning_content: "思考二" },
tool_calls: [{ id: "c2", name: "web_search", args: { query: "社媒讨论" } }],
} as Message,
{
type: "tool",
id: "t2",
name: "web_search",
content: "结果二",
tool_call_id: "c2",
} as Message,
{
type: "ai",
id: "a3",
content: "综合以上信息,近期动态如下……",
additional_kwargs: { reasoning_content: "收尾思考" },
} as Message,
];
}
test("keepIntermediateText merges all mid-run text into one bubble below the single step bar", () => {
const { types, groups } = groupTypesWith(midRunTurn(), {
keepIntermediateText: true,
});
// 一条步骤条(三轮 ai + 工具消息全部累积)+ 一条合并正文 + 最终回答。
assert.deepEqual(types, [
"human",
"assistant:processing",
"assistant",
"assistant",
]);
const processing = groups[1]!;
assert.deepEqual(
processing.messages.map((message) => message.id),
["a1", "t1", "a2", "t2", "a3"],
);
const merged = groups[2]!;
assert.equal(merged.id, "a1:keep-text");
assert.equal(
merged.messages[0]?.content,
"我先检索最近的公开报道。\n\n再补一下社交媒体的讨论。",
);
const final = groups[3]!;
assert.equal(final.messages[0]?.id, "a3");
assert.equal(final.messages[0]?.content, "综合以上信息,近期动态如下……");
});
test("keepIntermediateText=false keeps the legacy clear-on-tool-calls behavior", () => {
const { types, groups } = groupTypesWith(midRunTurn(), {
keepIntermediateText: false,
});
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
assert.equal(groups[2]?.messages[0]?.id, "a3");
});
test("keepIntermediateText default (off) is unchanged legacy behavior", () => {
const { types } = groupTypesWith(midRunTurn());
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
});
test("streaming mid-run turn shows one merged text block without a final bubble", () => {
const { types, groups } = groupTypesWith(
[
{ type: "human", id: "h1", content: "查一下" } as Message,
{
type: "ai",
id: "a1",
content: "先检索一轮。",
additional_kwargs: { reasoning_content: "思考" },
tool_calls: [{ id: "c1", name: "web_search", args: {} }],
} as Message,
],
{ keepIntermediateText: true },
);
// 本轮还没有收尾正文:全部正文只在合并气泡里,步骤条不拆分。
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
assert.equal(groups[2]?.messages[0]?.content, "先检索一轮。");
});
test("merged text bubble moves below present-files card so all body text stays together", () => {
const { types, groups } = groupTypesWith(
[
{ type: "human", id: "h1", content: "写个报告" } as Message,
{
type: "ai",
id: "a1",
content: "我先检索一下资料。",
additional_kwargs: { reasoning_content: "思考" },
tool_calls: [{ id: "c1", name: "web_search", args: {} }],
} as Message,
{
type: "ai",
id: "a2",
content: "检索完成,现在整理交付。",
additional_kwargs: { reasoning_content: "收尾" },
tool_calls: [{ id: "c2", name: "present_files", args: {} }],
} as Message,
{
type: "tool",
id: "t1",
name: "present_files",
content: "ok",
tool_call_id: "c2",
} as Message,
{
type: "ai",
id: "a3",
content: "报告已生成,要点如下……",
} as Message,
],
{ keepIntermediateText: true },
);
// 步骤条 + 文件卡片在上,合并正文紧挨最终回答,不被文件卡片隔开。
assert.deepEqual(types, [
"human",
"assistant:processing",
"assistant:present-files",
"assistant",
"assistant",
]);
assert.equal(groups[2]?.type, "assistant:present-files");
assert.equal(groups[3]?.id, "a1:keep-text");
assert.equal(
groups[3]?.messages[0]?.content,
"我先检索一下资料。\n\n检索完成,现在整理交付。",
);
assert.equal(groups[4]?.messages[0]?.id, "a3");
});
+208 -71
View File
@@ -29,11 +29,11 @@ interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:
interface AssistantTaskReportImportGroup extends GenericMessageGroup<"assistant:task-report-import"> {}
interface AssistantBusinessCardGroup extends GenericMessageGroup<"assistant:business-card"> {}
interface AssistantDocumentRewriteGroup extends GenericMessageGroup<"assistant:document-rewrite"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
interface AssistantBusinessCardGroup extends GenericMessageGroup<"assistant:business-card"> {}
interface AssistantDocumentRewriteGroup extends GenericMessageGroup<"assistant:document-rewrite"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
| HumanMessageGroup
@@ -45,39 +45,117 @@ type MessageGroup =
| AssistantSetupWritingApprovalGroup
| AssistantSetupReportStructureApprovalGroup
| AssistantResearchApprovalGroup
| AssistantTaskReportImportGroup
| AssistantBusinessCardGroup
| AssistantDocumentRewriteGroup
| AssistantSubagentGroup;
| AssistantTaskReportImportGroup
| AssistantBusinessCardGroup
| AssistantDocumentRewriteGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
messages: Message[],
mapper: (group: MessageGroup) => T,
options?: { keepIntermediateText?: boolean },
): T[] {
if (messages.length === 0) {
return [];
}
// 「保留中间正文」:步骤条执行中模型吐出的正文**全部合成一个气泡**,挂在步骤条
// (处理组)正下方——步骤条始终是一条不被拆开的整体,过程正文集中在它下面,
// 最终回答再单独一条。关闭选项时行为与旧版完全一致。
const keepIntermediateText = options?.keepIntermediateText ?? false;
const keepTextBubbleGroups = new Set<MessageGroup>();
let mergedTextState: { group: MessageGroup; message: Message } | null = null;
// 从消息尾部回扫本轮(到 human 为止)找「最终正文消息」:最后一条有正文且无
// 可见工具调用的 ai 消息保留为独立气泡(通常是最终回答或正在流式的收尾消息);
// 先遇到带可见工具调用的 ai 消息说明本轮还没有收尾,全部正文并入合并气泡。
let finalTextMessage: Message | null = null;
if (keepIntermediateText) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.type === "human") break;
if (message.type !== "ai" || isHiddenFromUIMessage(message)) continue;
if (hasVisibleToolCalls(message)) break;
if (hasContent(message)) {
finalTextMessage = message;
break;
}
}
}
function mergeMidRunText(message: Message) {
const text = extractContentFromMessage(message);
if (typeof text !== "string" || !text.trim()) return;
if (mergedTextState) {
const synthetic = mergedTextState.message;
const existing = typeof synthetic.content === "string" ? synthetic.content : "";
synthetic.content = existing ? `${existing}\n\n${text}` : text;
return;
}
// 合成消息只保留正文:剥掉思考与工具调用(分别留在步骤条里),避免合并
// 气泡重复渲染它们。
const source = message as Message & Record<string, unknown>;
const additional = { ...(source.additional_kwargs ?? {}) } as Record<string, unknown>;
delete additional.reasoning_content;
delete additional.reasoning;
const syntheticMessage = {
...source,
additional_kwargs: additional,
id: `${message.id ?? "mid"}:keep-text`,
content: text,
} as unknown as Message;
delete (syntheticMessage as Record<string, unknown>).tool_calls;
delete (syntheticMessage as Record<string, unknown>).tool_call_chunks;
const group: MessageGroup = {
id: `${message.id ?? "mid"}:keep-text`,
type: "assistant",
messages: [syntheticMessage],
};
keepTextBubbleGroups.add(group);
mergedTextState = { group, message: syntheticMessage };
// 当前处理组是数组里最后一个非透明分组,合并气泡紧随其后;后续工具轮次经
// 透明跳过继续并入同一条步骤条。
groups.push(group);
}
const groups: MessageGroup[] = [];
const internalToolCallIds = new Set<string>();
// Returns the last group if it can still accept tool messages
// (i.e. it's an in-flight processing group, not a terminal human/assistant group).
// 保留正文气泡视为透明:向前跳过它们寻找进行中的处理组。
function lastOpenGroup() {
const last = groups[groups.length - 1];
if (
last &&
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:setup-report-structure-approval" &&
last.type !== "assistant:research-approval" &&
last.type !== "assistant:business-card" &&
last.type !== "assistant:document-rewrite"
) {
return last;
for (let index = groups.length - 1; index >= 0; index -= 1) {
const last = groups[index];
if (keepTextBubbleGroups.has(last)) {
continue;
}
if (
last &&
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:setup-report-structure-approval" &&
last.type !== "assistant:research-approval" &&
last.type !== "assistant:business-card" &&
last.type !== "assistant:document-rewrite"
) {
return last;
}
return null;
}
return null;
}
// 处理组累积判断同样跳过保留正文气泡:气泡夹在两轮工具调用之间时,
// 下一轮仍并入同一条步骤条而不是新开一条。
function lastNonKeepBubbleGroup(): MessageGroup | null {
for (let index = groups.length - 1; index >= 0; index -= 1) {
if (!keepTextBubbleGroups.has(groups[index])) {
return groups[index];
}
}
return null;
}
@@ -108,6 +186,8 @@ export function groupMessages<T>(
}
if (message.type === "human") {
// 新一轮对话开始:上一轮的合并正文状态作废。
mergedTextState = null;
groups.push({ id: message.id, type: "human", messages: [message] });
continue;
}
@@ -198,30 +278,35 @@ export function groupMessages<T>(
// 通用业务卡(报告协作方案组/命令确认/报告入口等):虚拟 ai 消息
// (additional_kwargs.report_collaboration_card),由宿主注入并经
// businessCardSlot 渲染;不传插槽的页面零渲染、零行为变化。
if (hasBusinessCard(message)) {
groups.push({
id: message.id,
type: "assistant:business-card",
messages: [message],
});
continue;
}
// Browser-local rewrite progress is a standalone assistant row. This
// keeps it in chronological chat order without persisting UI state.
if (hasDocumentRewritePresentation(message)) {
groups.push({
id: message.id,
type: "assistant:document-rewrite",
messages: [message],
});
continue;
}
if (hasPresentFiles(message)) {
if (hasBusinessCard(message)) {
groups.push({
id: message.id,
type: "assistant:business-card",
messages: [message],
});
continue;
}
// Browser-local rewrite progress is a standalone assistant row. This
// keeps it in chronological chat order without persisting UI state.
if (hasDocumentRewritePresentation(message)) {
groups.push({
id: message.id,
type: "assistant:document-rewrite",
messages: [message],
});
continue;
}
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
type: "assistant:present-files",
messages: [message],
});
// present_files 消息里的过渡正文("检索完成,现在整理交付")同样并入
// 合并气泡,保证正文集中在文件卡片之下、不被隔开。
if (keepIntermediateText && message !== finalTextMessage) {
mergeMidRunText(message);
}
} else if (hasOnlyDeepResearchProgress(message)) {
// 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
// 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
@@ -243,9 +328,11 @@ export function groupMessages<T>(
messages: [message],
});
} else if (hasReasoning(message) || hasVisibleToolCalls(message)) {
const lastGroup = groups[groups.length - 1];
const lastGroup = lastNonKeepBubbleGroup();
// Accumulate consecutive intermediate AI messages into one processing group.
if (lastGroup?.type !== "assistant:processing") {
// 新步骤条开始:本轮的过程正文随之归入新的合并气泡。
mergedTextState = null;
groups.push({
id: message.id,
type: "assistant:processing",
@@ -254,12 +341,62 @@ export function groupMessages<T>(
} else {
lastGroup.messages.push(message);
}
// 过程正文并入本轮的合并气泡(保留开关关闭时不收集,正文随工具调用消失)。
if (keepIntermediateText && message !== finalTextMessage) {
mergeMidRunText(message);
}
}
// Not an else-if: a message with reasoning + content (but no tool calls) goes
// into the processing group above AND gets its own assistant bubble here.
if (hasContent(message) && !hasVisibleToolCalls(message)) {
// 保留开关开启时只有本轮「最终正文消息」保留独立气泡,其余正文全部并入
// 合并气泡(见 mergeMidRunText)。
if (
hasContent(message) &&
!hasVisibleToolCalls(message) &&
(!keepIntermediateText || message === finalTextMessage)
) {
groups.push({ id: message.id, type: "assistant", messages: [message] });
} else if (
keepIntermediateText &&
message !== finalTextMessage &&
hasContent(message) &&
!hasReasoning(message) &&
!hasVisibleToolCalls(message) &&
!hasPresentFiles(message) &&
!hasOnlyDeepResearchProgress(message) &&
!hasSubagent(message)
) {
// 无思考、无工具调用的裸过程正文(没进上面任何分支):同样并入合并
// 气泡,不另开独立气泡,保证步骤条不被拆开。
mergeMidRunText(message);
}
}
}
// 重排:合并正文气泡移到本轮附属展示组(文件卡片 / 子代理 / 文档改写)之后,
// 避免「过程正文 → 文件卡片 → 最终回答」把正文从中间隔开;重排后正文块与
// 最终回答相邻,步骤条与附属组保持在上方。
// mergedTextState 只在闭包里赋值,TS 会把它收窄成 never,这里显式放宽。
const mergedText = mergedTextState as { group: MessageGroup; message: Message } | null;
if (keepIntermediateText && mergedText) {
const mergedGroup = mergedText.group;
const mergedIndex = groups.indexOf(mergedGroup);
if (mergedIndex !== -1) {
let insertAfter = mergedIndex;
for (let index = mergedIndex + 1; index < groups.length; index += 1) {
const type = groups[index].type;
const isAttachment =
type === "assistant:present-files" ||
type === "assistant:subagent" ||
type === "assistant:document-rewrite";
if (isAttachment) {
insertAfter = index + 1;
}
}
if (insertAfter !== mergedIndex) {
groups.splice(mergedIndex, 1);
groups.splice(insertAfter - 1, 0, mergedGroup);
}
}
}
@@ -585,22 +722,22 @@ export function hasTaskReportImportApproval(message: Message) {
* (additional_kwargs.report_collaboration_card),经 MessageList 的
* businessCardSlot 渲染。集中在此识别,业务卡 payload 结构由各页面自定义。
*/
export function hasBusinessCard(message: Message) {
export function hasBusinessCard(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.report_collaboration_card === "object" &&
message.additional_kwargs.report_collaboration_card !== null
);
}
function hasDocumentRewritePresentation(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.document_rewrite_presentation_id === "string"
);
}
export function extractPresentFilesFromMessage(message: Message) {
}
function hasDocumentRewritePresentation(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.document_rewrite_presentation_id === "string"
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
}
@@ -629,7 +766,7 @@ export function findToolCallResult(
toolCallId: string,
messages: Message[],
toolName?: string,
options?: { allowNameFallback?: boolean; nameFallbackIndex?: number },
options?: { allowNameFallback?: boolean; nameFallbackIndex?: number },
): string | Record<string, unknown> | undefined {
for (const message of messages) {
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
@@ -642,18 +779,18 @@ export function findToolCallResult(
}
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
if (toolName && options?.allowNameFallback !== false) {
let fallbackIndex = 0;
const targetFallbackIndex = Math.max(options?.nameFallbackIndex ?? 0, 0);
if (toolName && options?.allowNameFallback !== false) {
let fallbackIndex = 0;
const targetFallbackIndex = Math.max(options?.nameFallbackIndex ?? 0, 0);
for (const message of messages) {
if (
message.type !== "tool" ||
message.name !== toolName ||
// Name fallback is only for provider frames that omitted the binding.
// A result already bound to another call must never be consumed by a
// later unbound parallel call with the same tool name.
Boolean(message.tool_call_id)
) {
if (
message.type !== "tool" ||
message.name !== toolName ||
// Name fallback is only for provider frames that omitted the binding.
// A result already bound to another call must never be consumed by a
// later unbound parallel call with the same tool name.
Boolean(message.tool_call_id)
) {
continue;
}
if (message.tool_call_id && message.tool_call_id !== toolCallId) {
@@ -661,10 +798,10 @@ export function findToolCallResult(
}
const payload = extractToolResultPayload(message);
if (payload !== undefined && hasSearchResultPayload(payload)) {
if (fallbackIndex === targetFallbackIndex) {
return payload;
}
fallbackIndex += 1;
if (fallbackIndex === targetFallbackIndex) {
return payload;
}
fallbackIndex += 1;
}
}
}
@@ -1,6 +1,14 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export type ModelWriteFileTransport =
| "native"
| "buffered_gateway"
| "artifact_renderer"
| "text_bridge";
export type ModelWriteFileTransportToggle = "native" | "buffered_gateway";
export interface ManagedModel {
name: string;
display_name: string | null;
@@ -23,6 +31,10 @@ export interface ManagedModel {
thinking: Record<string, unknown> | null;
use_responses_api: boolean | null;
output_version: string | null;
write_file_transport: ModelWriteFileTransport;
// null = 跟随运行时默认(仅 qwen3.8-flash-next 自动开启);
// true/false 为管理员显式设置(config.yaml 持久化)。
strict_stream_integrity: boolean | null;
extra_settings: Record<string, unknown>;
api_key_configured: boolean;
api_key_source: string | null;
@@ -120,6 +132,38 @@ export async function updateManagedModelReasoningEffortCapability(
return response.json() as Promise<ManagedModel>;
}
export async function updateManagedModelWriteFileTransport(
name: string,
writeFileTransport: ModelWriteFileTransportToggle,
): Promise<ManagedModel> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/models/write-file-transport/${encodeURIComponent(name)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ write_file_transport: writeFileTransport }),
},
);
if (!response.ok) return readError(response, "保存文件流式兼容设置失败");
return response.json() as Promise<ManagedModel>;
}
export async function updateManagedModelStrictStreamIntegrity(
name: string,
strictStreamIntegrity: boolean,
): Promise<ManagedModel> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/models/strict-stream-integrity/${encodeURIComponent(name)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ strict_stream_integrity: strictStreamIntegrity }),
},
);
if (!response.ok) return readError(response, "保存流完整性检测设置失败");
return response.json() as Promise<ManagedModel>;
}
export async function deleteManagedModel(name: string): Promise<void> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
method: "DELETE",
@@ -8,7 +8,10 @@ import {
testManagedModel,
updateManagedModel,
updateManagedModelReasoningEffortCapability,
updateManagedModelStrictStreamIntegrity,
updateManagedModelWriteFileTransport,
type ModelSavePayload,
type ModelWriteFileTransportToggle,
} from "./api";
export const MANAGED_MODELS_QUERY_KEY = ["admin", "model-config"] as const;
@@ -66,6 +69,44 @@ export function useUpdateManagedModelReasoningEffortCapability() {
});
}
export function useUpdateManagedModelWriteFileTransport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
name,
writeFileTransport,
}: {
name: string;
writeFileTransport: ModelWriteFileTransportToggle;
}) => updateManagedModelWriteFileTransport(name, writeFileTransport),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
export function useUpdateManagedModelStrictStreamIntegrity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
name,
strictStreamIntegrity,
}: {
name: string;
strictStreamIntegrity: boolean;
}) => updateManagedModelStrictStreamIntegrity(name, strictStreamIntegrity),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
export function useDeleteManagedModel() {
const queryClient = useQueryClient();
return useMutation({
@@ -31,8 +31,8 @@ test("applyCompactMenuOverrides splits sidebar, settings, and toolbox", () => {
{ id: "c", label: "技能", icon: I, path: "/c" },
];
const applied = applyCompactMenuOverrides(items, [
{ nodeId: "b", placement: "settings", sortOrder: 0, label: "智能体设置" },
{ nodeId: "c", placement: "toolbox", sortOrder: 0, label: "技能工具" },
{ nodeId: "b", placement: "settings", sortOrder: 0, label: "智能体设置", disabled: false },
{ nodeId: "c", placement: "toolbox", sortOrder: 0, label: "技能工具", disabled: true },
]);
assert.deepEqual(
applied.sidebarItems.map((item) => item.id),
@@ -40,8 +40,7 @@ test("applyCompactMenuOverrides splits sidebar, settings, and toolbox", () => {
);
assert.equal(applied.settingsItems[0]?.id, "b");
assert.equal(applied.settingsItems[0]?.label, "智能体设置");
assert.equal(applied.toolboxItems[0]?.id, "c");
assert.equal(applied.toolboxItems[0]?.label, "技能工具");
assert.equal(applied.toolboxItems.length, 0);
});
test("snapshotCompactMenuOverrides keeps every personal item and lane order", () => {
@@ -53,6 +52,7 @@ test("snapshotCompactMenuOverrides keeps every personal item and lane order", ()
defaultIndex: 0,
compactPlacement: "sidebar" as const,
compactOrder: 0,
disabled: false,
icon: I,
path: "/a",
},
@@ -63,6 +63,7 @@ test("snapshotCompactMenuOverrides keeps every personal item and lane order", ()
defaultIndex: 1,
compactPlacement: "toolbox" as const,
compactOrder: 0,
disabled: true,
icon: I,
path: "/b",
},
@@ -73,6 +74,7 @@ test("snapshotCompactMenuOverrides keeps every personal item and lane order", ()
defaultIndex: 2,
compactPlacement: "sidebar" as const,
compactOrder: 1,
disabled: false,
icon: I,
path: "/c",
},
@@ -86,6 +88,7 @@ test("snapshotCompactMenuOverrides keeps every personal item and lane order", ()
],
);
assert.equal(snapshotCompactMenuOverrides(items)[0]?.label, "我的对话");
assert.equal(snapshotCompactMenuOverrides(items)[1]?.disabled, true);
});
test("personal compact snapshot preserves a temporarily absent old item", () => {
@@ -97,12 +100,19 @@ test("personal compact snapshot preserves a temporarily absent old item", () =>
defaultIndex: 0,
compactPlacement: "sidebar",
compactOrder: 0,
disabled: false,
icon: I,
path: "/a",
},
]);
const combined = preserveUnknownCompactMenuOverrides(visible, [
{ nodeId: "temporarily-hidden", placement: "toolbox", sortOrder: 0, label: "旧工具" },
{
nodeId: "temporarily-hidden",
placement: "toolbox",
sortOrder: 0,
label: "旧工具",
disabled: true,
},
]);
assert.deepEqual(combined.map((item) => item.nodeId), ["a", "temporarily-hidden"]);
});
@@ -41,11 +41,14 @@ export interface CompactMenuOverride {
placement: CompactMenuPlacement;
sortOrder: number;
label?: string | null;
/** 停用后不在简洁模式的任何菜单泳道中渲染。 */
disabled: boolean;
}
export interface CompactMenuItem extends MenuItem {
compactPlacement: CompactMenuPlacement;
compactOrder: number;
disabled: boolean;
}
function firstRoutePath(items?: MenuItem[]): string | undefined {
@@ -77,6 +80,7 @@ export function applyCompactMenuOverrides(
label: override?.label != null && override.label !== "" ? override.label : item.label,
compactPlacement: override?.placement ?? "sidebar",
compactOrder: override?.sortOrder ?? index,
disabled: override?.disabled ?? false,
};
});
@@ -84,9 +88,15 @@ export function applyCompactMenuOverrides(
list.slice().sort((a, b) => a.compactOrder - b.compactOrder || a.id.localeCompare(b.id));
return {
sidebarItems: sortItems(enhanced.filter((item) => item.compactPlacement === "sidebar")),
settingsItems: sortItems(enhanced.filter((item) => item.compactPlacement === "settings")),
toolboxItems: sortItems(enhanced.filter((item) => item.compactPlacement === "toolbox")),
sidebarItems: sortItems(
enhanced.filter((item) => !item.disabled && item.compactPlacement === "sidebar"),
),
settingsItems: sortItems(
enhanced.filter((item) => !item.disabled && item.compactPlacement === "settings"),
),
toolboxItems: sortItems(
enhanced.filter((item) => !item.disabled && item.compactPlacement === "toolbox"),
),
};
}
@@ -95,12 +105,13 @@ export function diffCompactMenuOverrides(items: CompactMenuEditorItem[]): Compac
const renamed = item.label !== item.defaultLabel;
const moved = item.compactPlacement !== "sidebar";
const reordered = index !== item.defaultIndex || item.compactOrder !== item.defaultIndex;
if (!renamed && !moved && !reordered) return [];
if (!renamed && !moved && !item.disabled && !reordered) return [];
return {
nodeId: item.id,
placement: item.compactPlacement,
sortOrder: index,
label: renamed ? item.label : null,
disabled: item.disabled,
};
});
}
@@ -121,6 +132,7 @@ export function snapshotCompactMenuOverrides(items: CompactMenuEditorItem[]): Co
placement: item.compactPlacement,
sortOrder: order,
label: item.label,
disabled: item.disabled,
};
});
}
@@ -24,6 +24,9 @@ export type HtmlPageConfig = {
model_name?: string | null;
/** 仅当参考选了内置模板(tpl:<id>)时生效:关闭则直接用模板默认数据,开启才检索抽取。 */
template_extract?: boolean;
/** 仅当参考选了内置模板(tpl:<id>)且开启抽取时生效:抽取结果写回模板 data.json
* (默认数据持久化,页面/后续任务沿用);系统名称等 MANIFEST 不受影响。 */
template_data_writeback?: boolean;
};
/** 选中内置「模板数据转换器」agent 后,要把源 JSON 填充进的 HTML 模板。
@@ -0,0 +1,148 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
mergeComposerScope,
parseComposerScopeInput,
pickComposerScope,
resetComposerScope,
} from "./composer-scope.ts";
import type { LocalSettings } from "./local";
// Mirrors DEFAULT_LOCAL_SETTINGS without importing ./local at runtime (its
// dependency chain uses extensionless imports node --test cannot resolve).
const DEFAULTS = {
notification: { enabled: true },
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: false,
artifactDirectoryModeEnabled: false,
contextCompressionEnabled: false,
citationCleanupWords: [],
fontScale: 1,
fontScaleMode: "text",
},
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
},
} as LocalSettings;
function sampleSettings(): LocalSettings {
return {
...DEFAULTS,
display: {
...DEFAULTS.display,
ragModeEnabled: true,
contextCompressionEnabled: true,
fontScale: 1.25,
},
context: {
...DEFAULTS.context,
model_name: "MiniMax-M2.7-fash",
mode: "thinking",
writing_mode: true,
},
} as LocalSettings;
}
const COMPOSER_ONLY_JSON = {
context: {
model_name: "MiniMax-M2.7-fash",
mode: "thinking",
writing_mode: true,
},
display: {
ragModeEnabled: true,
artifactDirectoryModeEnabled: false,
contextCompressionEnabled: true,
},
};
test("pickComposerScope extracts only the composer fields", () => {
const picked = JSON.parse(JSON.stringify(pickComposerScope(sampleSettings())));
assert.deepEqual(picked, COMPOSER_ONLY_JSON);
});
test("resetComposerScope keeps global preferences, drops composer fields", () => {
const reset = resetComposerScope(sampleSettings(), DEFAULTS);
assert.equal(reset.display.fontScale, 1.25);
assert.equal(reset.display.ragModeEnabled, false);
assert.equal(reset.display.artifactDirectoryModeEnabled, false);
assert.equal(reset.display.contextCompressionEnabled, false);
assert.equal(reset.context.model_name, undefined);
assert.equal(reset.context.mode, undefined);
assert.equal(reset.context.writing_mode, false);
});
test("mergeComposerScope overlays the session document on the base", () => {
const merged = mergeComposerScope(DEFAULTS, {
context: { mode: "pro" },
display: { contextCompressionEnabled: true },
});
assert.equal(merged.context.mode, "pro");
assert.equal(merged.context.writing_mode, false);
assert.equal(merged.display.contextCompressionEnabled, true);
assert.equal(merged.display.ragModeEnabled, false);
assert.equal(merged.display.fontScale, 1);
});
test("parseComposerScopeInput degrades junk to defaults", () => {
const empty = parseComposerScopeInput(null);
assert.equal(empty.context.model_name, undefined);
assert.equal(empty.context.mode, undefined);
assert.equal(empty.context.writing_mode, false);
assert.equal(empty.display.ragModeEnabled, false);
const parsed = parseComposerScopeInput({
context: {
model_name: 42,
mode: "bogus",
reasoning_effort: "medium",
writing_mode: "yes",
},
display: { ragModeEnabled: 1, contextCompressionEnabled: true },
});
assert.equal(parsed.context.model_name, undefined);
assert.equal(parsed.context.mode, undefined);
assert.equal(parsed.context.reasoning_effort, "medium");
assert.equal(parsed.context.writing_mode, false);
assert.equal(parsed.display.ragModeEnabled, false);
assert.equal(parsed.display.contextCompressionEnabled, true);
});
test("save/read round trip keeps composer choices out of the global doc", () => {
const settings = sampleSettings();
// What saveLocalSettings persists: composer fields → sessionStorage,
// everything else → localStorage with composer fields at defaults.
const sessionDoc = JSON.parse(JSON.stringify(pickComposerScope(settings)));
const globalDoc = JSON.parse(JSON.stringify(
resetComposerScope(settings, DEFAULTS),
));
// What getLocalSettings reconstructs: merge the parsed global doc over the
// defaults (mergeLocalSettings also resets its composer fields), then
// overlay the parsed session doc.
const globalView = resetComposerScope(
{
...DEFAULTS,
...globalDoc,
notification: { ...DEFAULTS.notification, ...globalDoc.notification },
context: { ...DEFAULTS.context, ...globalDoc.context },
display: { ...DEFAULTS.display, ...globalDoc.display },
} as LocalSettings,
DEFAULTS,
);
const restored = mergeComposerScope(
globalView,
parseComposerScopeInput(sessionDoc),
);
assert.deepEqual(pickComposerScope(restored), pickComposerScope(settings));
assert.equal(restored.display.fontScale, 1.25);
// The persisted global document itself carries no composer state: dropped
// keys for the undefined defaults, default values for the booleans.
assert.equal(globalDoc.context.mode, undefined);
assert.equal("model_name" in globalDoc.context, false);
assert.equal(globalDoc.context.writing_mode, false);
assert.equal(globalDoc.display.ragModeEnabled, false);
});
@@ -0,0 +1,146 @@
import type { StoredReasoningEffort } from "../models/reasoning-effort";
import type { LocalSettings } from "./local";
/**
* Composer-scoped options (the input-box toolbar toggles + model selector)
* live in a per-tab session document instead of the browser-global
* localStorage document. Users copy links into new windows and expect each
* window to carry its own 写作 / 文件 / 参考文献 / 上下文压缩 / 模型 / 快速
* choices, so these fields must never round-trip through localStorage.
*
* This module is pure (storage-free) so node:test can exercise the
* split/merge invariants directly; core/settings/local.ts owns the actual
* localStorage + sessionStorage reads and writes.
*/
export const COMPOSER_CONTEXT_FIELDS = [
"model_name",
"mode",
"reasoning_effort",
"writing_mode",
] as const;
export const COMPOSER_DISPLAY_FIELDS = [
"ragModeEnabled",
"artifactDirectoryModeEnabled",
"contextCompressionEnabled",
] as const;
export type ComposerContextField = (typeof COMPOSER_CONTEXT_FIELDS)[number];
export type ComposerDisplayField = (typeof COMPOSER_DISPLAY_FIELDS)[number];
/** The per-tab document. Missing keys fall back to the built-in defaults. */
export type ComposerSessionSettings = {
context: Partial<Pick<LocalSettings["context"], ComposerContextField>>;
display: Partial<Pick<LocalSettings["display"], ComposerDisplayField>>;
};
const COMPOSER_MODES: readonly string[] = [
"flash",
"thinking",
"pro",
"ultra",
];
/** Extract only the composer-scoped fields, leaving everything else out. */
export function pickComposerScope(
settings: Partial<LocalSettings> | undefined,
): ComposerSessionSettings {
return {
context: {
model_name: settings?.context?.model_name,
mode: settings?.context?.mode,
reasoning_effort: settings?.context?.reasoning_effort,
writing_mode: settings?.context?.writing_mode,
},
display: {
ragModeEnabled: settings?.display?.ragModeEnabled,
artifactDirectoryModeEnabled: settings?.display?.artifactDirectoryModeEnabled,
contextCompressionEnabled: settings?.display?.contextCompressionEnabled,
},
};
}
/**
* Copy of ``settings`` with every composer-scoped field back at its default,
* i.e. the document localStorage is allowed to persist. Defaults are passed
* in (not imported) so this module stays dependency-free.
*/
export function resetComposerScope(
settings: LocalSettings,
defaults: LocalSettings,
): LocalSettings {
return {
...settings,
context: {
...settings.context,
model_name: defaults.context.model_name,
mode: defaults.context.mode,
reasoning_effort: defaults.context.reasoning_effort,
writing_mode: defaults.context.writing_mode,
},
display: {
...settings.display,
ragModeEnabled: defaults.display.ragModeEnabled,
artifactDirectoryModeEnabled: defaults.display.artifactDirectoryModeEnabled,
contextCompressionEnabled: defaults.display.contextCompressionEnabled,
},
};
}
/** Overlay the per-tab document on top of the global defaults. */
export function mergeComposerScope(
base: LocalSettings,
session: ComposerSessionSettings,
): LocalSettings {
return {
...base,
context: {
...base.context,
...session.context,
},
display: {
...base.display,
...session.display,
},
};
}
/**
* Leniently normalize a parsed session document: unknown shapes degrade to
* the defaults instead of throwing, mirroring how the global document is
* parsed in core/settings/local.ts.
*/
export function parseComposerScopeInput(
input: unknown,
): ComposerSessionSettings {
const source = (input ?? {}) as {
context?: Record<string, unknown>;
display?: Record<string, unknown>;
};
const context = source.context ?? {};
const display = source.display ?? {};
const mode = COMPOSER_MODES.includes(context.mode as string)
? (context.mode as LocalSettings["context"]["mode"])
: undefined;
const reasoningEffort =
typeof context.reasoning_effort === "string"
? (context.reasoning_effort as StoredReasoningEffort)
: undefined;
return {
context: {
model_name:
typeof context.model_name === "string" && context.model_name
? context.model_name
: undefined,
mode,
reasoning_effort: reasoningEffort,
writing_mode: context.writing_mode === true,
},
display: {
ragModeEnabled: display.ragModeEnabled === true,
artifactDirectoryModeEnabled: display.artifactDirectoryModeEnabled === true,
contextCompressionEnabled: display.contextCompressionEnabled === true,
},
};
}
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
COMPOSER_WIDTH_DEFAULT,
agentComposerMaxWidthCss,
composerMaxWidthCss,
composerMinHeightCss,
normalizeAgentComposerWidthPercent,
normalizeComposerHeightPercent,
normalizeComposerWidthPercent,
resolveAgentComposerWidthPercent,
resolveComposerHeightPercent,
resolveComposerWidthMode,
resolveComposerWidthPercent,
} from "./composer-width.ts";
test("missing widths stay at the current default", () => {
assert.equal(normalizeComposerWidthPercent(undefined), COMPOSER_WIDTH_DEFAULT);
assert.equal(normalizeComposerWidthPercent("nope"), COMPOSER_WIDTH_DEFAULT);
assert.equal(composerMaxWidthCss(100, true), undefined);
assert.equal(composerMaxWidthCss(100, false), undefined);
});
test("scales the existing landing and conversation widths separately", () => {
assert.equal(
composerMaxWidthCss(120, true),
"calc((var(--container-width-md) + 150px) * 1.2)",
);
assert.equal(
composerMaxWidthCss(80, false),
"calc(var(--container-width-lg) * 0.8)",
);
});
test("clamps out-of-range widths before they reach the layout", () => {
assert.equal(normalizeComposerWidthPercent(10), 60);
assert.equal(normalizeComposerWidthPercent(400), 150);
assert.equal(
composerMaxWidthCss(400, true),
"calc((var(--container-width-md) + 150px) * 1.5)",
);
});
test("a personal width wins over the administrator default", () => {
assert.equal(resolveComposerWidthPercent(undefined, undefined), 100);
assert.equal(resolveComposerWidthPercent(undefined, 130), 130);
assert.equal(resolveComposerWidthPercent(80, 130), 80);
assert.equal(resolveComposerWidthPercent(100, 130), 100);
});
test("height stays on the built-in min-height until it is changed", () => {
assert.equal(normalizeComposerHeightPercent(undefined), 100);
assert.equal(composerMinHeightCss(100, "110px"), undefined);
assert.equal(composerMinHeightCss(100, "84px"), undefined);
assert.equal(composerMinHeightCss(100, "4rem"), undefined);
assert.equal(composerMinHeightCss(160, "110px"), "calc(110px * 1.6)");
assert.equal(composerMinHeightCss(80, "84px"), "calc(84px * 0.8)");
assert.equal(composerMinHeightCss(140, "4rem"), "calc(4rem * 1.4)");
assert.equal(normalizeComposerHeightPercent(10), 70);
assert.equal(normalizeComposerHeightPercent(500), 200);
});
test("a personal height wins over the administrator default", () => {
assert.equal(resolveComposerHeightPercent(undefined, undefined), 100);
assert.equal(resolveComposerHeightPercent(undefined, 140), 140);
assert.equal(resolveComposerHeightPercent(80, 140), 80);
});
test("agent width can grow past the ordinary cap but stops at the parent", () => {
assert.equal(agentComposerMaxWidthCss(100, true), undefined);
assert.equal(agentComposerMaxWidthCss(100, false), undefined);
assert.equal(
agentComposerMaxWidthCss(180, true),
"min(100%, calc(var(--container-width-md) * 1.8))",
);
assert.equal(
agentComposerMaxWidthCss(250, false),
"min(100%, calc(var(--container-width-lg) * 2.5))",
);
assert.equal(normalizeAgentComposerWidthPercent(20), 60);
assert.equal(normalizeAgentComposerWidthPercent(900), 400);
assert.equal(
agentComposerMaxWidthCss(900, false),
"min(100%, calc(var(--container-width-lg) * 4))",
);
assert.equal(resolveAgentComposerWidthPercent(undefined, 220), 220);
assert.equal(resolveAgentComposerWidthPercent(90, 220), 90);
});
test("full chrome and compact layout pick independent widths", () => {
assert.equal(
resolveComposerWidthMode({
fullChatMode: false,
wxFullChat: false,
layoutMode: "compact",
}),
"compact",
);
assert.equal(
resolveComposerWidthMode({
fullChatMode: false,
wxFullChat: false,
layoutMode: "full",
}),
"full",
);
assert.equal(
resolveComposerWidthMode({
fullChatMode: true,
wxFullChat: false,
layoutMode: "compact",
}),
"full",
);
});
@@ -0,0 +1,168 @@
export const COMPOSER_SCALE_DEFAULT = 100;
export const COMPOSER_WIDTH_MIN = 60;
export const COMPOSER_WIDTH_MAX = 150;
export const COMPOSER_WIDTH_DEFAULT = COMPOSER_SCALE_DEFAULT;
export const COMPOSER_HEIGHT_MIN = 70;
export const COMPOSER_HEIGHT_MAX = 200;
/** High enough that a 4K agent column can be filled; CSS still stops at the parent. */
export const AGENT_COMPOSER_WIDTH_MIN = 60;
export const AGENT_COMPOSER_WIDTH_MAX = 400;
export type ComposerWidthMode = "full" | "compact";
/** A saved choice, including an explicit 100. Missing values are not a choice. */
export function isComposerWidthChoice(value: unknown): boolean {
if (typeof value === "number") {
return Number.isFinite(value);
}
if (typeof value === "string" && value.trim() !== "") {
return Number.isFinite(Number(value));
}
return false;
}
function normalizeScale(value: unknown, min: number, max: number): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) {
return COMPOSER_SCALE_DEFAULT;
}
return Math.min(max, Math.max(min, Math.round(parsed)));
}
function resolveScale(
userValue: unknown,
adminValue: unknown,
normalize: (value: unknown) => number,
): number {
if (isComposerWidthChoice(userValue)) {
return normalize(userValue);
}
if (isComposerWidthChoice(adminValue)) {
return normalize(adminValue);
}
return COMPOSER_SCALE_DEFAULT;
}
/** Missing or invalid values stay at the current built-in width. */
export function normalizeComposerWidthPercent(value: unknown): number {
return normalizeScale(value, COMPOSER_WIDTH_MIN, COMPOSER_WIDTH_MAX);
}
/**
* A user's own width wins. Otherwise the administrator default applies.
* Neither choice keeps the built-in width.
*/
export function resolveComposerWidthPercent(
userValue: unknown,
adminValue: unknown,
): number {
return resolveScale(
userValue,
adminValue,
normalizeComposerWidthPercent,
);
}
export function normalizeComposerHeightPercent(value: unknown): number {
return normalizeScale(value, COMPOSER_HEIGHT_MIN, COMPOSER_HEIGHT_MAX);
}
export function resolveComposerHeightPercent(
userValue: unknown,
adminValue: unknown,
): number {
return resolveScale(
userValue,
adminValue,
normalizeComposerHeightPercent,
);
}
export function normalizeAgentComposerWidthPercent(value: unknown): number {
return normalizeScale(
value,
AGENT_COMPOSER_WIDTH_MIN,
AGENT_COMPOSER_WIDTH_MAX,
);
}
export function resolveAgentComposerWidthPercent(
userValue: unknown,
adminValue: unknown,
): number {
return resolveScale(
userValue,
adminValue,
normalizeAgentComposerWidthPercent,
);
}
/**
* Full chrome (全局顶栏 + 页面侧栏) uses the full-mode width.
* Compact layout, including the built-in default, uses the compact-mode width.
*/
export function resolveComposerWidthMode(input: {
fullChatMode: boolean;
wxFullChat: boolean;
layoutMode: ComposerWidthMode;
}): ComposerWidthMode {
if (input.fullChatMode || input.wxFullChat || input.layoutMode === "full") {
return "full";
}
return "compact";
}
/**
* 100% returns undefined so the existing max-width class stays the source of
* truth. Other values scale that same expression.
*/
export function composerMaxWidthCss(
percent: number,
isLandingState: boolean,
): string | undefined {
const normalized = normalizeComposerWidthPercent(percent);
if (normalized === COMPOSER_WIDTH_DEFAULT) {
return undefined;
}
const ratio = normalized / 100;
if (isLandingState) {
return `calc((var(--container-width-md) + 150px) * ${ratio})`;
}
return `calc(var(--container-width-lg) * ${ratio})`;
}
export type ComposerHeightBase = "110px" | "84px" | "4rem";
/**
* 100 keeps the textarea's existing min-height class. Other values scale that
* same base (landing 110px, compact landing 84px, conversation 4rem).
*/
export function composerMinHeightCss(
percent: number,
base: ComposerHeightBase,
): string | undefined {
const normalized = normalizeComposerHeightPercent(percent);
if (normalized === COMPOSER_SCALE_DEFAULT) {
return undefined;
}
return `calc(${base} * ${normalized / 100})`;
}
/**
* 100 keeps the agent page's built-in max-width class. Otherwise scale that
* width, but never past the composer column: `100%` is the parent.
*/
export function agentComposerMaxWidthCss(
percent: number,
isLandingState: boolean,
): string | undefined {
const normalized = normalizeAgentComposerWidthPercent(percent);
if (normalized === COMPOSER_SCALE_DEFAULT) {
return undefined;
}
const ratio = normalized / 100;
const base = isLandingState
? "var(--container-width-md)"
: "var(--container-width-lg)";
return `min(100%, calc(${base} * ${ratio}))`;
}
+149 -35
View File
@@ -3,6 +3,13 @@ import {
type StoredReasoningEffort,
} from "../models/reasoning-effort";
import type { AgentThreadContext } from "../threads";
import {
mergeComposerScope,
parseComposerScopeInput,
pickComposerScope,
resetComposerScope,
type ComposerSessionSettings,
} from "./composer-scope";
export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
@@ -26,6 +33,19 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
};
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
/**
* Per-tab document for the composer-scoped options (输入框工具条开关 + 模型
* 选择器, see composer-scope.ts). Stored in sessionStorage so every browser
* window keeps its own choices — a copied-link new window starts from the
* defaults instead of inheriting whatever another window toggled.
*/
export const COMPOSER_SESSION_KEY = "deerflow.composer-session";
/**
* Browser-global flag marking that the legacy localStorage composer values
* were already carried into the first tab's session document after the
* split; later windows must not reseed from them.
*/
const COMPOSER_SESSION_MIGRATED_KEY = "deerflow.composer-session.migrated";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
export const LAST_AGENT_ID_KEY = "deerflow.last-agent-id";
@@ -46,17 +66,20 @@ export interface LocalSettings {
*/
streamSpeedMeterEnabled: boolean;
/**
* Global switch for skill result display adapters. When disabled, chat
* messages render tool results with the legacy generic search UI only.
* Per-tab switch for skill result display adapters (参考文献). When
* disabled, chat messages render tool results with the legacy generic
* search UI only. Toggled from the chat input box; lives in the
* per-tab session document, never in localStorage.
*/
ragModeEnabled: boolean;
/**
* When enabled, the artifact panel scans every generated output and keeps
* a directory tree visible beside the selected file preview.
* Per-tab switch for the 文件 composer button. When enabled, the artifact
* panel scans every generated output and keeps a directory tree visible
* beside the selected file preview.
*/
artifactDirectoryModeEnabled: boolean;
/**
* Per-user switch for context compression (上下文压缩). When enabled, long
* Per-tab switch for context compression (上下文压缩). When enabled, long
* conversations are summarized transiently before the model call to save
* tokens. Default off; toggled from the chat input box (below 参考文献).
*/
@@ -88,7 +111,34 @@ export interface LocalSettings {
* for a cleaner work area. Defaults to "full" (undefined treated as "full").
*/
layoutMode?: "full" | "compact";
/**
* Ordinary-chat composer max-width as a percent of the built-in width.
* `undefined` means no personal choice (follow the admin default, else 100).
* An explicit number, including 100, is a personal choice.
* Full and compact layout modes are stored separately.
*/
composerWidthFull?: number;
composerWidthCompact?: number;
/**
* Ordinary-chat composer height as a percent of the built-in textarea
* min-height. `undefined` follows the admin default, else the current height.
*/
composerHeightFull?: number;
composerHeightCompact?: number;
/**
* Agent-chat composer width. The applied CSS is `min(100%, built-in * scale)`,
* so it can grow on a wide screen and still stops at the parent column.
*/
agentComposerWidth?: number;
/** Agent-chat composer height, same percent scale as the ordinary height. */
agentComposerHeight?: number;
};
/**
* Default thread context for new conversations in this window. The
* composer-facing fields (model_name / mode / reasoning_effort /
* writing_mode) are per-tab — see composer-scope.ts; the rest round-trip
* through the browser-global localStorage document.
*/
context: Omit<
AgentThreadContext,
| "thread_id"
@@ -97,9 +147,9 @@ export interface LocalSettings {
| "subagent_enabled"
| "model_name"
| "reasoning_effort"
// Per-thread retrieval mode is restored from server metadata and must not
// be copied into the browser-wide local settings document.
| "knowledge_retrieval_mode"
// Per-thread retrieval mode is restored from server metadata and must not
// be copied into the browser-wide local settings document.
| "knowledge_retrieval_mode"
// Session-only sidebar switch (core/settings/knowledge-rag.ts) — must not
// round-trip through localStorage.
| "knowledge_rag_enabled"
@@ -123,19 +173,24 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
// Drop the value persisted by the retired per-thread header toggle; the
// knowledge-RAG switch now lives in the sidebar and is session-only.
delete (context as Record<string, unknown>).knowledge_rag_enabled;
delete (context as Record<string, unknown>).knowledge_retrieval_mode;
return {
...DEFAULT_LOCAL_SETTINGS,
context,
notification: {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
delete (context as Record<string, unknown>).knowledge_retrieval_mode;
// The global document never owns the composer-scoped fields: values saved
// there before the split (or by an older build) must not leak back in.
return resetComposerScope(
{
...DEFAULT_LOCAL_SETTINGS,
context,
notification: {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
},
display: {
...DEFAULT_LOCAL_SETTINGS.display,
...settings?.display,
},
},
display: {
...DEFAULT_LOCAL_SETTINGS.display,
...settings?.display,
},
};
DEFAULT_LOCAL_SETTINGS,
);
}
function getThreadModelStorageKey(threadId: string): string {
@@ -199,15 +254,17 @@ export function applyThreadModelOverride(
}
/**
* The id of the agent the user last started a conversation with. New-chat
* landing screens pre-select this agent so the user doesn't have to re-pick it
* every time. Returns ``null`` when nothing was stored yet.
* The id of the agent the user last started a conversation with, in this
* tab. New-chat landing screens pre-select this agent so the user doesn't
* have to re-pick it every time. Session-scoped (sessionStorage): a copied
* link opened in a new window starts without the preselect. Returns
* ``null`` when nothing was stored yet.
*/
export function getLastAgentId(): string | null {
if (!isBrowser()) {
return null;
}
return localStorage.getItem(LAST_AGENT_ID_KEY) || null;
return sessionStorage.getItem(LAST_AGENT_ID_KEY) || null;
}
export function saveLastAgentId(agentId: string | null | undefined) {
@@ -215,29 +272,86 @@ export function saveLastAgentId(agentId: string | null | undefined) {
return;
}
if (!agentId) {
localStorage.removeItem(LAST_AGENT_ID_KEY);
sessionStorage.removeItem(LAST_AGENT_ID_KEY);
return;
}
localStorage.setItem(LAST_AGENT_ID_KEY, agentId);
sessionStorage.setItem(LAST_AGENT_ID_KEY, agentId);
}
/**
* One-time carry-over of the composer values that used to live in the
* browser-global document: the first tab opened after this change keeps its
* choices, the migration flag keeps every later window from reseeding.
*/
function ensureComposerSessionSeeded() {
if (sessionStorage.getItem(COMPOSER_SESSION_KEY)) {
return;
}
if (localStorage.getItem(COMPOSER_SESSION_MIGRATED_KEY) === "1") {
return;
}
try {
localStorage.setItem(COMPOSER_SESSION_MIGRATED_KEY, "1");
const json = localStorage.getItem(LOCAL_SETTINGS_KEY);
if (json) {
// Read the raw legacy document — mergeLocalSettings strips the
// composer fields, but they are exactly what needs carrying over.
const legacy = JSON.parse(json) as Partial<LocalSettings>;
sessionStorage.setItem(
COMPOSER_SESSION_KEY,
JSON.stringify(pickComposerScope(legacy)),
);
}
} catch {}
}
function readComposerSessionSettings(): ComposerSessionSettings {
const json = sessionStorage.getItem(COMPOSER_SESSION_KEY);
try {
if (json) {
const session = parseComposerScopeInput(JSON.parse(json));
if (session.context.reasoning_effort !== undefined) {
session.context.reasoning_effort = normalizeReasoningEffort(
session.context.reasoning_effort,
);
}
return session;
}
} catch {}
return { context: {}, display: {} };
}
function readGlobalLocalSettings(): LocalSettings {
const json = localStorage.getItem(LOCAL_SETTINGS_KEY);
try {
if (json) {
return mergeLocalSettings(JSON.parse(json) as Partial<LocalSettings>);
}
} catch {}
return DEFAULT_LOCAL_SETTINGS;
}
export function getLocalSettings(): LocalSettings {
if (!isBrowser()) {
return DEFAULT_LOCAL_SETTINGS;
}
const json = localStorage.getItem(LOCAL_SETTINGS_KEY);
try {
if (json) {
const settings = JSON.parse(json) as Partial<LocalSettings>;
return mergeLocalSettings(settings);
}
} catch {}
return DEFAULT_LOCAL_SETTINGS;
ensureComposerSessionSeeded();
const globalSettings = readGlobalLocalSettings();
return mergeComposerScope(globalSettings, readComposerSessionSettings());
}
export function saveLocalSettings(settings: LocalSettings) {
if (!isBrowser()) {
return;
}
localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
// Composer-scoped options are per-tab (sessionStorage); everything else
// stays browser-global (localStorage) and keeps syncing across windows.
sessionStorage.setItem(
COMPOSER_SESSION_KEY,
JSON.stringify(pickComposerScope(settings)),
);
localStorage.setItem(
LOCAL_SETTINGS_KEY,
JSON.stringify(resetComposerScope(settings, DEFAULT_LOCAL_SETTINGS)),
);
}
@@ -0,0 +1,111 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export type StreamTruncationEventType =
| "silent_no_terminal"
| "incomplete_stream_error"
| "length_truncated"
| "content_filter"
| "midstream_error";
export const STREAM_TRUNCATION_EVENT_LABELS: Record<StreamTruncationEventType, string> = {
silent_no_terminal: "静默截断",
incomplete_stream_error: "完整性检测拦截",
length_truncated: "长度截断",
content_filter: "内容过滤",
midstream_error: "生成中断",
};
export interface StreamTruncationEvent {
id: string;
created_at: string | null;
model_name: string | null;
provider_completion_id: string | null;
run_id: string | null;
event_type: StreamTruncationEventType;
strict_mode: boolean;
finish_reason: string;
status: string;
error_type: string | null;
chunk_count: number;
duration_ms: number;
first_chunk_ms: number;
detail: string | null;
}
export interface StreamTruncationListResponse {
items: StreamTruncationEvent[];
total: number;
limit: number;
offset: number;
}
export interface StreamTruncationBreakdownItem {
model_name: string;
total: number;
last_at: string | null;
silent_no_terminal: number;
incomplete_stream_error: number;
length_truncated: number;
content_filter: number;
midstream_error: number;
}
export interface StreamTruncationQueryParams {
model_name?: string;
event_type?: StreamTruncationEventType | "";
limit?: number;
offset?: number;
}
async function readError(response: Response, fallback: string): Promise<never> {
const payload = await response.json().catch(() => null) as { detail?: string } | null;
throw new Error(payload?.detail || fallback);
}
export async function loadStreamTruncations(
params: StreamTruncationQueryParams,
): Promise<StreamTruncationListResponse> {
const search = new URLSearchParams();
if (params.model_name) search.set("model_name", params.model_name);
if (params.event_type) search.set("event_type", params.event_type);
search.set("limit", String(params.limit ?? 50));
search.set("offset", String(params.offset ?? 0));
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/stream-truncations?${search.toString()}`,
);
if (!response.ok) return readError(response, "加载截断记录失败");
return response.json() as Promise<StreamTruncationListResponse>;
}
export async function loadStreamTruncationBreakdown(): Promise<StreamTruncationBreakdownItem[]> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/stream-truncations/breakdown`,
);
if (!response.ok) return readError(response, "加载截断统计失败");
const payload = await response.json() as { items: StreamTruncationBreakdownItem[] };
return payload.items ?? [];
}
export const STREAM_TRUNCATION_QUERY_KEY = ["admin", "stream-truncations"] as const;
export const STREAM_TRUNCATION_BREAKDOWN_KEY = ["admin", "stream-truncations", "breakdown"] as const;
export function useStreamTruncations(params: StreamTruncationQueryParams) {
return useQuery({
queryKey: [...STREAM_TRUNCATION_QUERY_KEY, params],
queryFn: () => loadStreamTruncations(params),
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useStreamTruncationBreakdown() {
return useQuery({
queryKey: STREAM_TRUNCATION_BREAKDOWN_KEY,
queryFn: loadStreamTruncationBreakdown,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
+218 -2
View File
@@ -11,6 +11,8 @@ export type WordHeadingNumberingStyle =
| "none";
export interface PromptPrefixSettings {
image_generation_unavailable_hint_enabled: boolean;
python_dependency_delete_protection_enabled: boolean;
enabled: boolean;
switch_label: string;
prompt_prefix: string;
@@ -144,6 +146,8 @@ export interface PluginManagementUpdate {
}
export interface PromptPrefixUpdate {
image_generation_unavailable_hint_enabled?: boolean;
python_dependency_delete_protection_enabled?: boolean;
enabled?: boolean;
switch_label?: string;
prompt_prefix?: string;
@@ -302,6 +306,24 @@ export interface HtmlMapSettings {
engine: HtmlMapEngine;
}
export interface HtmlRuntimeSettings {
base_url: string;
map_tile_url: string;
}
export interface AppIdentitySettings {
system_name: string;
logo_url: string | null;
logo_version: string | null;
has_logo: boolean;
}
export interface AppIdentityUpdate {
systemName: string;
logo?: File | null;
removeLogo?: boolean;
}
export interface LeaderboardSettings {
exclude_user_ids: string[];
include_scheduled: boolean;
@@ -496,6 +518,12 @@ export interface AppearanceSettings {
default_skill_view: ViewMode;
default_agent_view: ViewMode;
default_layout_mode: LayoutMode;
default_composer_width_full?: number;
default_composer_width_compact?: number;
default_composer_height_full?: number;
default_composer_height_compact?: number;
default_agent_composer_width?: number;
default_agent_composer_height?: number;
compact_shortcut_buttons: CompactShortcutButton[];
custom_themes: CustomThemePreset[];
}
@@ -505,6 +533,12 @@ export interface AppearanceUpdate {
default_skill_view?: ViewMode;
default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode;
default_composer_width_full?: number;
default_composer_width_compact?: number;
default_composer_height_full?: number;
default_composer_height_compact?: number;
default_agent_composer_width?: number;
default_agent_composer_height?: number;
compact_shortcut_buttons?: CompactShortcutButton[];
custom_themes?: CustomThemePreset[];
}
@@ -665,6 +699,134 @@ export async function updateHtmlMapSettings(
return data;
}
export async function loadAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
);
if (!response.ok) {
throw new Error(`Failed to load app identity: ${response.status}`);
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-系统名称查看",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export async function updateAppIdentitySettings(
update: AppIdentityUpdate,
): Promise<AppIdentitySettings> {
const body = new FormData();
body.set("system_name", update.systemName);
if (update.logo) body.set("logo", update.logo);
if (update.removeLogo) body.set("remove_logo", "true");
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "PUT", body },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update app identity" }));
throw new Error(detail.detail ?? "Failed to update app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-系统名称编辑",
url: "/api/system-settings/app-identity",
reqParam: {
system_name: update.systemName,
logo_name: update.logo?.name,
remove_logo: Boolean(update.removeLogo),
},
returnParam: data,
});
return data;
}
export async function deleteAppIdentitySettings(): Promise<AppIdentitySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "DELETE" },
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to reset app identity" }));
throw new Error(detail.detail ?? "Failed to reset app identity");
}
const data = (await response.json()) as AppIdentitySettings;
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-系统设置-系统名称清除配置",
url: "/api/system-settings/app-identity",
returnParam: data,
});
return data;
}
export function resolveAppIdentityLogoUrl(
logoUrl: string | null | undefined,
): string | undefined {
if (!logoUrl) return undefined;
if (/^https?:\/\//i.test(logoUrl)) return logoUrl;
const base = getBackendBaseURL();
return `${base}${logoUrl.startsWith("/") ? "" : "/"}${logoUrl}`;
}
export async function loadHtmlRuntimeSettings(): Promise<HtmlRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/html-runtime`,
);
if (!response.ok) {
throw new Error(`Failed to load HTML runtime settings: ${response.status}`);
}
const data = (await response.json()) as HtmlRuntimeSettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-HTML运行地址查看",
url: "/api/system-settings/html-runtime",
returnParam: data,
});
return data;
}
export async function updateHtmlRuntimeSettings(
body: HtmlRuntimeSettings,
): Promise<HtmlRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/html-runtime`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update HTML runtime settings" }));
const message =
typeof detail.detail === "string"
? detail.detail
: "Failed to update HTML runtime settings";
throw new Error(message);
}
const data = (await response.json()) as HtmlRuntimeSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-HTML运行地址编辑",
url: "/api/system-settings/html-runtime",
reqParam: body,
returnParam: data,
});
return data;
}
export async function updateCitationDisplaySettings(
body: CitationDisplayUpdate,
): Promise<CitationDisplaySettings> {
@@ -726,12 +888,18 @@ export interface MemoryRuntimeSettings {
}
export interface ConversationContextSettings {
max_run_steps: number;
recent_turn_window_enabled: boolean;
recent_turns: number;
max_conversation_rounds: number;
use_question_as_title: boolean;
}
export interface ConversationContextUpdate {
recent_turn_window_enabled: boolean;
max_run_steps?: number;
recent_turn_window_enabled?: boolean;
max_conversation_rounds?: number;
use_question_as_title?: boolean;
}
export interface RoundtableRoomLimitSettings {
@@ -739,6 +907,7 @@ export interface RoundtableRoomLimitSettings {
max_rooms: number;
max_hold_minutes: number;
max_cycles: number;
write_file_compatibility_enabled: boolean;
}
export interface RoundtableRoomLimitUpdate {
@@ -746,6 +915,7 @@ export interface RoundtableRoomLimitUpdate {
max_rooms?: number;
max_hold_minutes?: number;
max_cycles?: number;
write_file_compatibility_enabled?: boolean;
}
export interface OrdinaryKnowledgeRetrievalSettings {
@@ -876,7 +1046,7 @@ export async function updateConversationContextSettings(
const data = (await response.json()) as ConversationContextSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-近3轮上下文开关",
optDetail: "CM助手-全量-系统设置-上下文管理",
url: "/api/system-settings/conversation-context",
reqParam: body,
returnParam: data,
@@ -1166,3 +1336,49 @@ export async function updatePrivateSquareSettings(
});
return data;
}
export interface RetryKeepPartialSettings {
enabled: boolean;
}
export async function loadRetryKeepPartialSettings(): Promise<RetryKeepPartialSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/retry-keep-partial`,
);
if (!response.ok) {
throw new Error(
`Failed to load retry-keep-partial setting: ${response.status}`,
);
}
return (await response.json()) as RetryKeepPartialSettings;
}
export async function updateRetryKeepPartialSettings(
body: RetryKeepPartialSettings,
): Promise<RetryKeepPartialSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/retry-keep-partial`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response.json().catch(() => ({
detail: "Failed to update retry-keep-partial setting",
}));
throw new Error(
detail.detail ?? "Failed to update retry-keep-partial setting",
);
}
const data = (await response.json()) as RetryKeepPartialSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-重试保留正文开关",
url: "/api/system-settings/retry-keep-partial",
reqParam: body,
returnParam: data,
});
return data;
}
@@ -9,7 +9,9 @@ import {
loadCitationDisplaySettings,
loadConversationContextSettings,
loadRoundtableRoomLimitSettings,
loadAppIdentitySettings,
loadHtmlMapSettings,
loadHtmlRuntimeSettings,
loadLeaderboardSettings,
loadModelConcurrencySettings,
loadOrdinaryKnowledgeRetrievalSettings,
@@ -18,6 +20,7 @@ import {
loadPrivateSquareStatus,
loadPromptPrefixSettings,
loadPublishSquares,
loadRetryKeepPartialSettings,
loadSkillCompressionSettings,
loadStrategyProjectSettings,
loadWxCommonAgents,
@@ -27,7 +30,9 @@ import {
updateCitationDisplaySettings,
updateConversationContextSettings,
updateRoundtableRoomLimitSettings,
updateAppIdentitySettings,
updateHtmlMapSettings,
updateHtmlRuntimeSettings,
updateLeaderboardSettings,
updateModelConcurrencySettings,
updateOrdinaryKnowledgeRetrievalSettings,
@@ -36,16 +41,20 @@ import {
updatePrivateSquareSettings,
updatePromptPrefixSettings,
updatePublishSquares,
updateRetryKeepPartialSettings,
updateSkillCompressionSettings,
updateStrategyProjectSettings,
updateWxCommonAgents,
updateWriteFileCompatibilitySettings,
saveAssistantKnowledgeVisibility,
deleteAppIdentitySettings,
type AppearanceUpdate,
type AppIdentityUpdate,
type CitationDisplayUpdate,
type ConversationContextUpdate,
type RoundtableRoomLimitUpdate,
type HtmlMapEngine,
type HtmlRuntimeSettings,
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
type OrdinaryKnowledgeRetrievalUpdate,
@@ -54,6 +63,7 @@ import {
type PrivateSquareUpdate,
type PromptPrefixUpdate,
type PublishSquaresUpdate,
type RetryKeepPartialSettings,
type SkillCompressionUpdate,
type StrategyProjectSettingsUpdate,
type LayoutMode,
@@ -65,8 +75,10 @@ const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const APP_IDENTITY_KEY = ["systemSettings", "appIdentity"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const HTML_MAP_KEY = ["systemSettings", "htmlMap"] as const;
const HTML_RUNTIME_KEY = ["systemSettings", "htmlRuntime"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
@@ -219,6 +231,7 @@ export function useConversationContextSettings() {
queryFn: () => loadConversationContextSettings(),
staleTime: 10_000,
refetchOnWindowFocus: true,
refetchInterval: 30_000,
retry: false,
});
}
@@ -448,6 +461,65 @@ export function useUpdateHtmlMapSettings() {
});
}
export function useAppIdentitySettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: APP_IDENTITY_KEY,
queryFn: loadAppIdentitySettings,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useAppSystemName(fallback: string) {
const { data } = useAppIdentitySettings();
const configured = data?.system_name.trim();
return configured || fallback;
}
export function useUpdateAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AppIdentityUpdate) => updateAppIdentitySettings(body),
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
export function useDeleteAppIdentitySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteAppIdentitySettings,
onSuccess: (data) => {
queryClient.setQueryData(APP_IDENTITY_KEY, data);
},
});
}
export function useHtmlRuntimeSettings() {
return useQuery({
queryKey: HTML_RUNTIME_KEY,
queryFn: () => loadHtmlRuntimeSettings(),
staleTime: 60_000,
refetchOnWindowFocus: true,
retry: false,
});
}
export function useUpdateHtmlRuntimeSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: HtmlRuntimeSettings) => updateHtmlRuntimeSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(HTML_RUNTIME_KEY, data);
},
});
}
export function useUpdateCitationDisplaySettings() {
const queryClient = useQueryClient();
return useMutation({
@@ -545,3 +617,25 @@ export function useUpdateLeaderboardSettings() {
},
});
}
const RETRY_KEEP_PARTIAL_KEY = ["system-settings", "retry-keep-partial"] as const;
export function useRetryKeepPartialSettings() {
return useQuery({
queryKey: RETRY_KEEP_PARTIAL_KEY,
queryFn: loadRetryKeepPartialSettings,
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateRetryKeepPartialSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: RetryKeepPartialSettings) =>
updateRetryKeepPartialSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(RETRY_KEEP_PARTIAL_KEY, data);
},
});
}
@@ -0,0 +1,69 @@
import type { Message } from "@langchain/langgraph-sdk";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeEnv } from "@/env";
import { requestCrudService } from "@/service/request-axios-service";
import { collectConsumerThreadValues } from "./consumer-thread-values";
function consumerBaseUrl(): string {
return readRuntimeEnv("VITE_AIPI_CONSUMER_URL")?.replace(/\/+$/, "") ?? "";
}
export function hasConsumerThreadValuesService(): boolean {
return Boolean(consumerBaseUrl());
}
/** Fire-and-forget by design: consumer outages must never affect chat. */
export function queueConsumerThreadValuesSave(
threadId: string,
messages: Message[],
): void {
const baseUrl = consumerBaseUrl();
if (!baseUrl || !threadId || threadId === "new") return;
const payload = {
threadId,
values: collectConsumerThreadValues(messages),
};
void requestCrudService
.post(`${baseUrl}/chat/thread/saveValues`, payload)
.catch(() => undefined);
}
/**
* Records a first Consumer-save attempt for an existing thread.
* Errors intentionally resolve to false so opening a conversation is never
* delayed or disturbed by this best-effort backfill guard.
*/
export async function claimConsumerThreadValuesSave(
threadId: string,
): Promise<boolean> {
if (!hasConsumerThreadValuesService() || !threadId || threadId === "new") {
return false;
}
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/consumer-values-save-claim`,
{ method: "POST" },
);
if (!response.ok) return false;
const payload: unknown = await response.json();
return Boolean(
payload &&
typeof payload === "object" &&
(payload as { should_save?: unknown }).should_save === true,
);
} catch {
return false;
}
}
export function consumerThreadMaterialsDownloadUrl(
threadId: string,
): string | null {
const baseUrl = consumerBaseUrl();
if (!baseUrl || !threadId || threadId === "new") return null;
return `${baseUrl}/chat/thread/downloadExcel?threadId=${encodeURIComponent(threadId)}`;
}
Loaded 100 of 422 files, more files were not shown because too many files have changed in this diff. Show more