Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d19260673 |
No files matched your search
@@ -85,19 +85,14 @@ 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
|
||||
!offline-backend-20260512/backend/tests/test_agent_skill_migration.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/
|
||||
@@ -132,9 +127,6 @@ offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_gongwen_documents.py
|
||||
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
||||
!offline-backend-20260512/backend/tests/test_gauss_mysql_compat.py
|
||||
!offline-backend-20260512/backend/tests/test_chat_history_migration_seed.py
|
||||
!offline-backend-20260512/backend/tests/test_gauss_data_adaptation_seed.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_smart_reasoning.py
|
||||
@@ -158,8 +150,6 @@ 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.
|
||||
@@ -191,10 +181,6 @@ 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
|
||||
@@ -264,51 +250,8 @@ docs/backups
|
||||
offline-backend-20260512/backend/skills/*
|
||||
!offline-backend-20260512/backend/skills/public/
|
||||
offline-backend-20260512/backend/skills/public/*
|
||||
!offline-backend-20260512/backend/skills/public/_shared/
|
||||
!offline-backend-20260512/backend/skills/public/_shared/**
|
||||
!offline-backend-20260512/backend/skills/public/analysis-mindmap/
|
||||
!offline-backend-20260512/backend/skills/public/analysis-mindmap/SKILL.md
|
||||
!offline-backend-20260512/backend/skills/public/facebook-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/facebook-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_facebook_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/x-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/x-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_x_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/tiktok-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/tiktok-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_tiktok_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/youtube-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/youtube-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_youtube_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/threads-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/threads-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_threads_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/cnn-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/cnn-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_cnn_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/bbc-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/bbc-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_bbc_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/xinhua-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/xinhua-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_xinhua_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/white-house-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/white-house-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_white_house_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/manila-times-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/manila-times-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_manila_times_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/gma-news-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/gma-news-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_gma_news_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_yahoo_japan_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/
|
||||
!offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/**
|
||||
!offline-backend-20260512/backend/tests/test_yomiuri_news_page_generator_skill.py
|
||||
!offline-backend-20260512/backend/tests/test_fast_page_generator_specs.py
|
||||
!offline-backend-20260512/backend/tests/test_sync_builtin_skills.py
|
||||
# Built-in skill-creator ported from bytedance/deer-flow (product source too).
|
||||
!offline-backend-20260512/backend/skills/public/skill-creator/
|
||||
!offline-backend-20260512/backend/skills/public/skill-creator/**
|
||||
@@ -318,13 +261,12 @@ 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_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_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_chat_archive_postgres_integration.py
|
||||
|
||||
# Local-only scratch directory (not committed)
|
||||
@@ -335,4 +277,4 @@ __pycache__/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.worker-pool/
|
||||
.worker-pool/
|
||||
@@ -88,30 +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`. **素材检索技能池限定白名单**:`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`。
|
||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (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.
|
||||
- **生成网页标识 (Generated Page Branding, admin-only write)**: 管理员在设置弹窗「生成网页标识」中填写网页展示名称、上传 PNG/JPG/WebP Logo(≤10MB)、单独移除 Logo 或整体清除配置。配置保存在 runtime `system_settings.json -> generated_page_branding`,图片保存在 runtime `generated-page-branding/`,`GET /api/system-settings/generated-page-branding` 全员可读,`PUT/DELETE` 仅管理员;生成 HTML 通过公开只读 `/api/public/html-runtime/generated-page-logo` 获取图片。**只有网页展示名称非空且 Logo 文件真实存在时功能才启用**;缺少任意一项(含单独移除 Logo)都不注入任何内容,完全保持原有网页生成逻辑。该配置只约束后续生成的 HTML 网页内容,不修改 DeerFlow 自身的系统名称、主导航、欢迎区、工作区标题或 Logo。启用时,HTML 写入与 `page_report_template validate` 会由 `html_runtime/generated_page_chrome.py` 确定性注入平台套壳:页头**通栏全宽、贴顶、无圆角**,**背景直接用全量模式导航栏设计图**(`html_runtime/assets/topbar-light.svg` 浅色红调 / `topbar-dark.svg` 深色深蓝,已注册进网关 `html_runtime.py` 的 `_STATIC_ASSETS` 白名单以 `/api/public/html-runtime/topbar-*.svg` 提供;页头运行时按 body 明暗 tone 自动切图),**只注入页头、不注入页脚**;设置弹窗不再提供「品牌位置」选择,新配置默认使用 `center`:「Logo+系统名称」居中锁孔(26px 白色金属渐变字 + 深浅辉光,落在设计图中央发光基座上)+ 页面导航**分列两侧**白字页签(当前项白色加粗 + 白色墨条)。`generated_page_branding.header_layout` 字段继续兼容存量配置,既有 `left` 仍按品牌居左 + 导航居中单行渲染。页签均为通栏等高下划线式(当前项主色 + 底部渐变墨条),多页强制首个逻辑页初始可见。开关 `chrome_enabled` **默认开启**;明确关闭时完全回归无注入:不注入页头、也不对页面提任何品牌要求(校验不检查品牌,模型不会被迫自写)。页脚与「网页页脚文字」设置项均已按需求移除(`FOOTER_MARKER` 常量仅为旧页面兼容保留)。模板套件的兜底导航条同款(品牌左 + 导航中、通栏贴顶),平台注入时经 `body:has([data-df-generated-header])` + 兜底脚本双保险自动隐藏。后处理会隐藏旧的多页导航、移除模型写入的 `data-df-brand` 重复标识且保持幂等;lead prompt 只让模型生成正文和逻辑页。注入点共四处且全部**账号无关**(admin 配置对全部账号生效,非 admin 仅无配置入口):**`page_report_template prepare`(工作副本创建时即预注入页头,模型从第一秒起编辑的就是带品牌导航的页面,brief 的 `platform_branding.pre_injected` 标记此状态)**、`write_file`/`str_replace`(`sandbox/tools.py` 的 `maybe_fix_generated_html`)、`page_report_template validate`、以及 `present_files` 交付兜底(`present_file_tool._ensure_generated_html_chrome`,覆盖模型经 bash 等旁路写出的 HTML;幂等,>4MB 跳过);注入时还会**归一化 body/html 顶部 padding/margin(清零)**(防弱模型为自想页头留白)并**隐藏模型自写的顶部 nav/header**(仅正文首个内容容器之前的,正文内导航不受影响);品牌配置每次实时读取 `system_settings.json`,无缓存无账号隔离。
|
||||
- **生成网页默认明暗模式 (Generated Page Theme)**: `ChatPage` 与 `AgentChatPage` 每次发送只把当前界面的明暗模式作为 `presentation_theme=light|dark` 上下文传入。用户未明确指定网页主题时,自由生成 HTML、显式模板的 `auto` 选择以及平台注入的品牌页眉/页脚按该明暗模式生成;用户明确指定浅色、深色或其他配色时以用户要求为准。不得跟随系统的红、蓝、绿等主题主色或自定义主色,网页强调色由页面内容与可读性决定;科技、数据、看板、驾驶舱或地图题材本身不得再触发深色默认。
|
||||
- **系统名称与 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` 覆盖。不读写 `generated_page_branding`,也不改已生成的 HTML。
|
||||
- **平台模板套件 (Bootstrap page-template-suite 技能)**: 内置技能 `skills/public/page-template-suite`(public 类,缺省启用)让「画页面」走平台预制 Bootstrap 模板而不是从零手写。**套件只提供多页面门户(最低 2 个逻辑页),不做单页面 HTML**(单页请求自动回落共享响应式文档模板):5 套设计 × 明暗两版共 10 个 `deerflow/page_reports/assets/report-bootstrap-*-template.html`(图表门户 3 页 / 甘特双页「进度总览+里程碑风险」/ 瓦片地图门户 3 页 / 文字杂志双页「正文论述+脉络结论」/ 研判简报双页「核心结论+风险行动」),profile 注册于 `page_reports/templates.py`(id `bootstrap-charts-portal-{light,dark}`、`bootstrap-gantt-{light,dark}`、`bootstrap-map-portal-{light,dark}`、`bootstrap-text-magazine-{light,dark}`、`bootstrap-text-briefing-{light,dark}`)。显式 `template_id` 时**页数以模板为准**(prepare 会把 brief 的页数对齐到模板页数,技能无需依赖用户说出「多页」)。模板源文件用 `__DF_HTML_RUNTIME_BASE__` 占位符引用 Gateway 运行库(Bootstrap/Alpine/ECharts/Leaflet/deerflow-runtime.js),`page_report_template prepare` 在会话副本中替换为设置里保存的 HTML 运行库地址,部署无关;图表走 `data-df-chart` 严格 JSON(甘特 = 时间轴 + 阶段重叠柱 `barGap:-100%`),地图门户走 `data-df-map` Leaflet 瓦片契约(瓦片地址来自设置里保存的地图瓦片地址,需管理员在「外观设置 → HTML 地图」选择「地图网页」引擎,引擎不符时 prepare 的 brief 会带 `map_engine_warning`)。prepare 的 brief 还带 `platform_branding`(管理员「生成网页标识」的名称/Logo/页脚文字与注入规则,正文禁止自写品牌)和 `runtime_base_url`;`_choose_page_profile` 现在**显式 `template_id` 优先**(单页文档模式也能选中套件模板,此前单页一律回落共享文档模板),未显式指定时旧选择行为不变。多页门户模板自带锚点切页兜底脚本(检测 `data-df-generated-header` 让位平台套壳,勿用 `data-df-generated-chrome` 字面量——会被注入幂等检查误判)。测试 `tests/test_page_template_suite.py`(10 模板 prepare→validate 全绿 + 品牌注入 + 居左/居中布局 + 引擎门控)。
|
||||
- **Docker 内置技能同步**: Linux Docker 启动时不能让旧的持久化 `/app/skills` 遮住当前版本的 `backend/skills/public`。共享实现 `app/builtin_skill_sync.py` 会把 bundled public 覆盖同步到 live public 根、保留 `custom/`,并强制校验 Facebook/X/TikTok/YouTube/Threads/CNN/BBC/新华社/白宫官网/马尼拉时报/GMA News/雅虎日本/读卖新闻共 13 个硬路由页面技能;它使用跨进程文件锁和必需包内容摘要,多 worker 首次只复制一次,后续相同版本只校验跳过。两个受支持启动脚本仍在 Gateway 前调用 `backend/scripts/sync_builtin_skills.py`,把当前源码 `backend/skills` 只读挂载为 `/app/bundled-skills`、live skills 读写挂载到 `/app/skills`;同时 Gateway lifespan 在加载配置后、技能 ownership 迁移/MySQL 目录投影/提示词缓存之前再次调用同一共享实现,所以镜像默认入口、Compose 或外部发版脚本绕过 Shell 时也会自动补齐。任一硬路由技能缺失会 fail-fast 并打印名单,不能静默启动成“技能不可用”。
|
||||
- **Facebook 页面生成技能**: 内置公共技能 `skills/public/facebook-page-generator` 只在用户明确要求 Facebook/Facebook 风格网页时触发;`lead_agent/prompt.py` 的普通问答 HTML 分流会强制读取该技能,并排除通用 `page-report-generation`、模板套件和从零手写 HTML。单账号单动态场景强制只写小型中文规格 JSON,再调用一次 `scripts/fast_single_post.py --spec`;脚本可将总评论数与实际展示数分离,按 `commentTopics` 自动补齐最多 100 条中文热评,也接受用户指定的逐条评论,并在一次进程内完成七模块 JSON、内部校验和 HTML 渲染。用户已提供素材或明确不搜索时禁止联网、图像生成、读模板/脚本源码和临时构建脚本;动态与评论正文必须为简体中文。多帖子场景仍使用 `scripts/render_facebook_page.py` 的 `init → validate → render` 流程确定性注入 `assets/facebook-template.html`,模型不得重写模板 DOM/CSS;渲染器拒绝覆盖已有文件并对 `</script>` 注入做转义。最终单文件 HTML 保留右上角设置按钮、分模块 JSON 编辑弹窗、实时校验与「应用并渲染」,以及响应式、深浅主题、发布/点赞/评论/搜索交互;浏览器缓存键包含初始 JSON 的 FNV-1a 指纹,同一制品刷新保留手动编辑,不同新制品不会再被沙箱同源旧 JSON 覆盖。测试 `tests/test_facebook_page_generator_skill.py`。
|
||||
- **X 页面生成技能**: 内置公共技能 `skills/public/x-page-generator` 只在用户明确要求 X 平台、Twitter/推特或 X 风格社交时间线网页时触发;专用路由会强制读取技能并绕开通用 HTML 生成器。单账号单推文场景强制只写小型中文规格 JSON,再调用一次 `scripts/fast_single_post.py --spec`;脚本可将总回复数与实际展示数分离,按 `replyTopics` 自动补齐最多 100 条中文热评,也接受用户指定的逐条回复,并在一次进程内完成六模块 JSON、内部校验和 HTML 渲染。用户已提供素材或明确不搜索时禁止联网、图像生成、读模板/脚本源码和临时构建脚本;推文与回复正文必须为简体中文。多帖子场景仍通过 `scripts/render_x_page.py` 的 `init → validate → render` 流程注入固定三栏模板与内联运行时。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、实时校验和「应用并渲染」,保留深浅主题、发布、回复、点赞、转发、收藏、搜索、关注与响应式移动布局;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_x_page_generator_skill.py`。
|
||||
- **TikTok 页面生成技能**: 内置公共技能 `skills/public/tiktok-page-generator` 只在用户明确要求 TikTok、抖音国际版或 TikTok 风格短视频信息流网页时触发。默认只写小型六模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_tiktok_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、实时校验和「应用并渲染」,保留滚动吸附、播放/暂停、双击点赞、静音、收藏、关注、评论、分享、搜索、方向键切换和移动端全屏布局;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_tiktok_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **YouTube 页面生成技能**: 内置公共技能 `skills/public/youtube-page-generator` 只在用户明确要求 YouTube 或 YouTube 风格视频门户、视频首页、频道页时触发。默认只写小型七模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_youtube_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、分类筛选、搜索、响应式视频网格、Shorts 横向列表、观看弹窗、点赞、收藏、订阅和评论;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_youtube_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **Threads 页面生成技能**: 内置公共技能 `skills/public/threads-page-generator` 只在用户明确要求 Threads 或 Threads 风格串文首页、文本社交信息流时触发。默认只写 `accountName`、`postText`、`topic`、`displayReplyCount` 等高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动映射六模块、补齐作者/统计/中文热评、保留示例视觉并一次完成校验与渲染,禁止模型猜测 `$set` 深层路径或读取默认数据。只有结构改造才回退 `scripts/render_threads_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、深浅主题、发帖、搜索、点赞、回复、转发、分享、关注、动态面板和移动端导航;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_threads_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **CNN 页面生成技能**: 内置公共技能 `skills/public/cnn-page-generator` 只在用户明确要求 CNN 或 CNN 风格全球新闻门户、新闻首页时触发。默认只写以 `topic` 为核心、可选 `headline`/`breaking`/栏目标题数组的高层规格 JSON,并调用一次专用 `scripts/fast_page.py --spec`;脚本自动生成完整中文十模块与明确标注的非实时示意数据,禁止模型猜测 `$set` 深层路径、读取生成后的合并数据或探路后二次渲染。只有结构改造才回退 `scripts/render_cnn_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、突发条、搜索、直播弹窗、字幕切换、收藏、分享、移动菜单、市场快照和订阅表单;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_cnn_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **BBC 页面生成技能**: 内置公共技能 `skills/public/bbc-page-generator` 只在用户明确要求 BBC 或 BBC 风格国际公共新闻门户、新闻首页时触发。默认只写小型十模块补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_bbc_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、搜索、快讯、直播弹窗、字幕切换、音频进度、移动抽屉、新闻排行、地区天气和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_bbc_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **新华社页面生成技能**: 内置公共技能 `skills/public/xinhua-page-generator` 只在用户明确要求新华社、新华网或新华社风格中文通讯社门户、新闻首页时触发。默认只写小型十模块中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_xinhua_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多语种顶栏、报道专集、焦点图、要闻分类、名专栏、专题、记者工作室、地方切换、行业频道、媒体矩阵、直播字幕和订阅反馈;缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_xinhua_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **白宫官网页面生成技能**: 内置公共技能 `skills/public/white-house-page-generator` 只在用户明确要求白宫官网、White House 风格政府首页或美国行政机构门户时触发。技能把材料整理为 `site/navigation/hero/notice/updates/priorities/administration/calendar/participation/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_white_house_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、透明转吸顶导航、建筑主视觉、公告、官方动态、优先事项、行政团队、公共日历、全屏菜单搜索、直播字幕和订阅反馈;默认内容明确为概念模板,不冒充政府官网,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_white_house_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **马尼拉时报页面生成技能**: 内置公共技能 `skills/public/manila-times-page-generator` 只在用户明确要求马尼拉时报、The Manila Times 或其风格的菲律宾大报门户时触发。技能把材料整理为 `site/navigation/ticker/lead/pulse/regions/opinion/sections/media/footer` 十模块 JSON;键名、`id`、锚点、视觉和涨跌枚举保留英文技术值,所有读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_manila_times_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、菲律宾旗色带、衬线报头、三栏头条、地区切换、评论、商业体育、视频弹窗、数字报翻页和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_manila_times_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **GMA News 页面生成技能**: 内置公共技能 `skills/public/gma-news-page-generator` 只在用户明确要求 GMA News、GMA 新闻或菲律宾广播新闻门户风格页面时触发。技能把材料整理为 `site/navigation/justIn/lead/news/services/broadcast/channels/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌缩写 `GMA` 可保留,其余读者可见内容强制使用简体中文,英文或菲律宾语来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_gma_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、即时新闻条、天气切换、吸顶导航、头条视频、最新/热门/地方切换、公共服务、电视直播字幕、频道卡片、播客、彩票开奖和订阅反馈;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_gma_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **雅虎日本页面生成技能**: 内置公共技能 `skills/public/yahoo-japan-page-generator` 只在用户明确要求雅虎日本、Yahoo! JAPAN、Yahoo Japan 或日本综合门户风格页面时触发。技能把材料整理为 `site/search/services/news/account/weather/utilities/shopping/pulse/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌 `Yahoo! JAPAN` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yahoo_japan_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、多类型搜索、服务展开、分类新闻、推荐轮换、演示登录、城市天气、路线查询、市场切换、商品收藏、体育比分、实时热榜、移动抽屉和返回顶部;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yahoo_japan_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **读卖新闻页面生成技能**: 内置公共技能 `skills/public/yomiuri-news-page-generator` 只在用户明确要求读卖新闻、读买新闻、Yomiuri、Yomiuri Shimbun 或日本全国大报风格页面时触发。技能把材料整理为 `site/navigation/breaking/lead/edition/features/news/opinion/media/footer` 十模块 JSON;键名、`id`、锚点和视觉枚举保留英文技术值,品牌罗马字 `YOMIURI ONLINE` 可保留,其余读者可见内容强制使用简体中文,日文或英文来源先翻译,`site.language` 固定为 `zh-CN`。默认只写小型中文补丁规格 JSON,并调用一次 `scripts/fast_page.py --spec` 在内部合并、校验和渲染;只有结构改造才回退 `scripts/render_yomiuri_news_page.py` 的 `init → validate → render`。最终单文件 HTML 自带右上角设置按钮、按模块 JSON 编辑弹窗、字号切换、速报展开、三栏头条、文章收藏、吸顶导航、全屏菜单、搜索、演示登录、数字报翻页、专题与分类切换、社论、视频字幕和邮件订阅;默认内容明确为概念模板,不冒充官方网站,缓存键包含初始 JSON 指纹并兼容禁止 localStorage 的沙箱。测试 `tests/test_yomiuri_news_page_generator_skill.py` 与 `tests/test_fast_page_generator_specs.py`。
|
||||
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
|
||||
- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。
|
||||
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
||||
@@ -119,10 +99,8 @@ 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` 只建表、**不迁移/回填旧数据**。
|
||||
- **上下文与对话轮数(管理员全局设置)**: 「设置 → 上下文管理」可开启 `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`)。
|
||||
- **上下文近 3 轮滑窗(管理员全局开关)**: 「设置 → 上下文管理」可开启 `system_settings.json -> conversation_context.recent_turn_window_enabled`(默认关闭;`GET/PUT /api/system-settings/conversation-context`,PUT 仅管理员)。开启后 `RecentTurnsMiddleware` 只改变单次模型请求,保留含当前轮在内的最近 3 个真实用户回合及其 assistant/tool 链;checkpoint 与前端完整聊天记录不删。该模式优先于聊天框「上下文压缩」,且不会调用摘要模型;关闭后沿用原压缩行为。实现 `deerflow/agents/middlewares/recent_turns_middleware.py`,回归 `tests/test_summarization_multiturn.py` / `tests/test_prompt_prefix.py`。
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
|
||||
@@ -118,10 +118,8 @@ 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` 供列表读取。留空时智能体管理的「对话」按钮仍进入本系统问答;填了地址后,按钮按解析后的链接跳转。
|
||||
- **普通问答地图可视化**: 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。
|
||||
- **普通问答地图可视化**: 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。
|
||||
- **定时任务「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`.
|
||||
|
||||
@@ -2,12 +2,6 @@ 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;
|
||||
@@ -27,7 +21,6 @@ async function getSettings() {
|
||||
discoveredWebBaseUrl: "",
|
||||
lastServiceSyncAt: "",
|
||||
lastServiceSyncSource: "",
|
||||
lastServiceSyncContext: "",
|
||||
});
|
||||
if (settings.autoDiscoverServiceUrl) {
|
||||
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
|
||||
@@ -45,7 +38,6 @@ 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);
|
||||
@@ -113,77 +105,28 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
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);
|
||||
async function browserContextStatus(apiBaseUrl) {
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
statusRequests.delete(key);
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tabLooksLikeZncm(tab) {
|
||||
@@ -235,34 +178,17 @@ 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 < discoveryTtl
|
||||
discoveryAge < SERVICE_DISCOVERY_TTL_MS
|
||||
) {
|
||||
return settings;
|
||||
}
|
||||
@@ -282,7 +208,7 @@ async function discoverServiceBasesOnce(settings, force, contextKey) {
|
||||
let apiBaseUrl = normalizeServiceBase(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||
let verified = false;
|
||||
for (const candidate of apiCandidatesForWebBase(discoveredWebBaseUrl, apiBaseUrl)) {
|
||||
const status = await browserContextStatus(candidate, settings, force);
|
||||
const status = await browserContextStatus(candidate);
|
||||
if (status) {
|
||||
apiBaseUrl = candidate;
|
||||
webBaseUrl = discoveredWebBaseUrl;
|
||||
@@ -291,7 +217,7 @@ async function discoverServiceBasesOnce(settings, force, contextKey) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!verified) {
|
||||
if (!verified && force) {
|
||||
source = `${source}:api_not_verified`;
|
||||
}
|
||||
|
||||
@@ -303,16 +229,22 @@ async function discoverServiceBasesOnce(settings, force, contextKey) {
|
||||
lastServiceSyncSource: source,
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,13 @@ 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(options = {}) {
|
||||
function createHarness() {
|
||||
let storageChangedListener;
|
||||
let alarmCreateCount = 0;
|
||||
let clock = options.now ?? Date.now();
|
||||
let fetchHandler = options.fetch;
|
||||
let cookieToken = "";
|
||||
const localData = options.localData || {};
|
||||
const localData = {};
|
||||
const fetchUrls = [];
|
||||
const settings = {
|
||||
apiBaseUrl: "http://localhost:8001",
|
||||
@@ -24,7 +20,6 @@ function createHarness(options = {}) {
|
||||
taskPollingEnabled: true,
|
||||
workerId: "test-worker",
|
||||
taskPollIntervalSeconds: 5,
|
||||
...options.settings,
|
||||
};
|
||||
|
||||
const chrome = {
|
||||
@@ -35,7 +30,7 @@ function createHarness(options = {}) {
|
||||
},
|
||||
onAlarm: { addListener: () => {} },
|
||||
},
|
||||
cookies: { get: async () => cookieToken ? { value: cookieToken } : null },
|
||||
cookies: { get: async () => null },
|
||||
runtime: {
|
||||
onInstalled: { addListener: () => {} },
|
||||
onMessage: { addListener: () => {} },
|
||||
@@ -63,9 +58,8 @@ function createHarness(options = {}) {
|
||||
const context = {
|
||||
chrome,
|
||||
console,
|
||||
fetch: async (url, init) => {
|
||||
fetch: async (url) => {
|
||||
fetchUrls.push(String(url));
|
||||
if (fetchHandler) return fetchHandler(url, init);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -76,13 +70,6 @@ function createHarness(options = {}) {
|
||||
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",
|
||||
@@ -101,13 +88,6 @@ function createHarness(options = {}) {
|
||||
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: () => {
|
||||
@@ -171,108 +151,6 @@ 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);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
# 接口性能管理
|
||||
|
||||
管理员入口:**设置 → 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`。
|
||||
@@ -1,21 +0,0 @@
|
||||
# 按模型追加全局提示词
|
||||
|
||||
管理员打开「设置 → 模型追加提示词」,选择模型,填写提示词并开启开关,点击「保存此模型设置」。每个模型独立保存,关闭开关会保留文本。
|
||||
|
||||
设置按模型配置的 `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 三种文件传输适配提示词均位于管理员提示词之前。测试不访问真实上游模型。
|
||||
@@ -77,19 +77,17 @@
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script>
|
||||
// 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.
|
||||
// Relative production assets need a directory URL. Normalize a mounted
|
||||
// prefix such as /web to /web/ before the browser resolves asset URLs.
|
||||
(function () {
|
||||
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) {}
|
||||
var path = window.location.pathname;
|
||||
if (path !== "/" && !path.endsWith("/") && !/\.[^/]+$/.test(path)) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
path + "/" + window.location.search + window.location.hash,
|
||||
);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||
|
||||
@@ -17,16 +17,13 @@
|
||||
"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: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 src/components/workspace/visualizations/map-style.test.ts",
|
||||
"test:composer-session": "node --experimental-strip-types --test src/core/settings/composer-scope.test.ts",
|
||||
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
|
||||
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs",
|
||||
"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"
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.6 MiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.6 MiB After Width: | Height: | Size: 27 KiB |
@@ -13,11 +13,7 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// 也可以把下方图片地址改为其他 PNG 地址,无需重新打包。
|
||||
// 是否显示整块欢迎页图标区域:"true" 显示,"false" 隐藏,默认隐藏。
|
||||
VITE_WELCOME_LOGO_VISIBLE: "false",
|
||||
// 红白主题是否使用浅红色科技感顶栏,并完整复用深色主题的导航布局。
|
||||
// "true" 启用;"false" 保持原来的红白主题顶栏。
|
||||
VITE_LIGHT_RED_TOP_NAV_ENABLED: "true",
|
||||
// 科技感顶栏 Logo 位置:"center" 显示在中间,"left" 显示在左上角,默认中间。
|
||||
// 同时作用于深色主题和启用后的浅红色顶栏。
|
||||
// 深色主题顶栏 Logo 位置:"center" 显示在中间,"left" 显示在左上角,默认中间。
|
||||
VITE_DARK_LOGO_POSITION: "center",
|
||||
// 红白主题的顶栏系统 Logo 图片地址。
|
||||
VITE_BRAND_LOGO_LIGHT_URL: "brand-assets/brand-logo-light.png",
|
||||
@@ -27,7 +23,13 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_BRAND_LOGO_LIGHTGREEN_URL: "brand-assets/brand-logo-light.png",
|
||||
// 深色主题的顶栏系统 Logo 图片地址。
|
||||
VITE_BRAND_LOGO_DARK_URL: "brand-assets/brand-logo-dark.png",
|
||||
// 简洁模式欢迎区复用上面的 VITE_BRAND_LOGO_* 系统 Logo,不再单独配置欢迎页图片。
|
||||
// 红白主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
|
||||
VITE_BRAND_WELCOME_LIGHT_URL: "",
|
||||
// 浅蓝主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
|
||||
VITE_BRAND_WELCOME_LIGHTBLUE_URL: "",
|
||||
VITE_BRAND_WELCOME_LIGHTGREEN_URL: "",
|
||||
// 深色主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
|
||||
VITE_BRAND_WELCOME_DARK_URL: "",
|
||||
|
||||
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
|
||||
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
|
||||
@@ -110,9 +112,6 @@ 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",
|
||||
|
||||
@@ -129,7 +128,6 @@ 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 兜底。
|
||||
|
||||
@@ -14,7 +14,6 @@ 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");
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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" });
|
||||
});
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
} from "@/core/embed/embed-session";
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
import {
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
@@ -35,9 +35,7 @@ import {
|
||||
|
||||
const LandingPage = lazy(() => import("./pages/LandingPage"));
|
||||
const LoginPage = lazy(() => import("./pages/LoginPage"));
|
||||
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
|
||||
const PageRoutes = lazy(() => import("./pages/PageRoutes"));
|
||||
const MarkdownDocxEditorPage = lazy(() => import("./pages/MarkdownDocxEditorPage"));
|
||||
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
|
||||
const PublicScheduledTaskFullscreenPage = lazy(() =>
|
||||
import("./pages/PublicScheduledTaskPage").then((module) => ({
|
||||
@@ -83,7 +81,6 @@ 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(() => {
|
||||
@@ -92,9 +89,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
|
||||
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
|
||||
useEffect(() => {
|
||||
if (positionStandaloneMode || markdownToolMode) return;
|
||||
if (positionStandaloneMode) return;
|
||||
void fetchYLogConfig();
|
||||
}, [positionStandaloneMode, markdownToolMode]);
|
||||
}, [positionStandaloneMode]);
|
||||
const forcedTheme = useMemo(() => {
|
||||
// b5-1 任务分析深链最高优先:接入方传 ?theme= 用传入值,不传默认深蓝。
|
||||
// 先于 embed 判定,避免 embed=1 缺省 light 盖掉本页的深蓝默认。
|
||||
@@ -138,17 +135,15 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<QueryClientProvider>
|
||||
<AuthSyncWatcher disabled={positionStandaloneMode || markdownToolMode}>
|
||||
<AuthSyncWatcher disabled={positionStandaloneMode}>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
{markdownToolMode ? children : (
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
)}
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</AuthSyncWatcher>
|
||||
</QueryClientProvider>
|
||||
@@ -202,21 +197,7 @@ export function App() {
|
||||
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={<RegisterPage />}
|
||||
/>
|
||||
<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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="152" height="42.359" viewBox="0 0 152 42.359"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 536 150"><image width="536" height="150" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhgAAACWCAYAAACcofGRAABDtklEQVR42u2dW5bkOJJkVUC6R2RlzRLmu9fSK56t9Hz3EqarMjLcCJ0PgCQA4kkCfJjD8sTJCHcz2hu4FBUVBfVLvzS6/J//+N94g6eBb3Kf73Dhb3KfVS//+X//m/tHp1/6QtYvHSie+z3o37XvByPcgaNfOmD0S7+8P1DgDb5D/ft6/sbN3x0+OnD0S1+w+qVDRZvPNfp3r0PJScflDhv90gGjXzpQvNfnF/071S83BgbuwNEvHTD6pYPFcz636N+ffjlxg7/LMTpo9EsHjH7pUFHpM4pv/v1A91Pc+jnwu71+HTb6pQNGv9wNKNCViP7dfDNQuVK14A4c/dIXsX75rmCBb6ZGoH+3b7Uh8jdTPbiDRr90wOiXDhTXKxpX32f/Xt4DGp4U3MUdOPqlA0a/dLC4l7LxLt0r/fJ+3RxnKBUdNPqlA0a/PAYsWgLF3UOt+vfp+wLJXcO/HgMcHTQ6YPRLB4p3gAm86/eFH/6dxjO6Su5ovHw76OjA0QGjX74fWLSAhDvBBDogdEA5ET7uAB18Z9jooNEBo1/eFyqeAhR3LbVUhwZ80+8tV97ccG9PBXfg6LDRAaNf3hUungAWt4OKWjCB/t28BD5OhI4rN/FHg0aHjA4Y/dKh4qoZH7gjPHRgeB8gwfPKGjWO1WGjXzpgdKi4dNM+s30TdwGIp8LDWX4PPDQOfC+E4LryxtntuFfAUIeNDhj9cmOwqLl544kwUbKxops+u6mzEoTgmlLHmXHlZ0JQB40OGP3ypnDxOLC4O1TcASRqPe/aZsyngMeFsHEn0OiQ0S8dML4JVNRSIs4ACpy5UeNhps/u42gDMGeBSO5jxnllDr7RfXCHjQ4Y/fJeYNF6w28OFDmbN25o+mwNCk8tq7Te7G/mqch+TLgXDLQ8fgeNDhj98lCoaF2SQKvP2RUgsWeTxgnPs1/aA8EeEKkNISeBB1847fUMhaXDRgeMDhYXQsXtgOJMmCjd0Pfcb4eG94ORUgCpBR8nQMdVwHEZbHTQ6IDR4eJecHEJWFwBFaX3+Y4wkXoN7mDovBo6rjB3pu7zhqDRIaMDRr90qLgeKM6EiZL7eqdOD+oGz1MA5ESPRfS+bgYcHTY6YPRLh4o6x2upTtSEiLMB4omdLE9sJT0bSnKfV2v4aKRydNjolw4YDweLI9BweemjlTpRCyZabrg4qTumX9oAS0sYyXl8V0IH6k6CbVFKuQQ2Omh0wPjuYFEbKqqpFHs33BqqR4uNutXz6ZdnwEgLAKkBHrWfT2V1ozZsdNDogNHh4iQFoWYJ5HKl4ugx7zAavcNE7yi5CjZagFNFZePMkkyHjA4Y3x4szlQsmkJF7Y04o3vhsoTMGwIE3tfT+T26S47ed23oqKhsnAEbpysaHTQ6YDwVLGqqFYeh4g5AUWNDf0i3SP8+PhhManeXHLm/mwJHLdi4jarRQaMvaHcBi7PUiiZQgRN9GUc2+Bt2jDS5H/zjz2/13eV//4ufDCM1O0tqqxy1gONmsHGqqtFBowPGHcGillqBFqUPnFA+aQ0TDUECbwMILNs8Fgh+I0DhO3eW1FQ5apSCKpVS+GmqRgeNDhgdLi4Gi6ObfutySu3vymkw0QoUTl+VzgGTg9BxOnAcLauEjn0j0OiQ0QGjg0UjsHgMVJylVJxp9DzyfTgMEO8CBm8GKgcA5NTOktrD2WoAx41ho4NGB4zHwkUpWOAsb0ULqKgJFCfDBE6Bhw4Obw8ihRDCd4WOUuA4ETZqeDVKYYU7ZHTAeIJicalacQQqSlSK2upEJZhoM9TsH392gPjOAPLvf7VqU+W7lVd8xzuqbjSAjZaqRlc0OmBcBhctyyC3gYpaPorGQIHqIPHHH+jfXeqZGLmXv/5q0aLKdwGOEv/GA2DjsvLJd4MMdLh4X7hoUQK5EVjUhYp7AkVXSJ4KH5nAcQZstAaN2iWUDhkdML4rXJwJFk0Vi1ZqRaPOEbyJOnGr7xszn/J4AHAHkiYqB5/VUXKGqlFZ0bg1aHwXyEAHiyY+i6ZqRWuouBgo8FBVAu8CBLdftM4BFn6o2sF3Ao4LYKO2qsFdzeiAcRfF4lSwuAIqzgaK5HNsDxHooNDB5DIQScBHBgBcAhwtYeNmoNEVje8EGDdWLN4KKrjuwLU8VaItTKBDQ79UhBG+UvHgyhtiSUfJG8FGVzQ6YHS4KL3NBWrFXcECHSj65QTg4HcAjTNUjQ4Z7wUZ6HBxus+iCBJqKRZ3USsuKnmgQ0Tww4PGKwx3+DgRPPaXVC5VNWooGjtB45b+jHeBDHS4uIdicVStuLgEght4KN4fIri3rT4NWi6Hj2MeDr5DCaWRqnF7ReMdIANdtciGgepg0aIEwge6MGpAxUlAgbcAiA4M3x5ICgGEn6BwHCmlpJSNBrDRGjS+tZqBrlpUybJAa7XCvF0tqGiuUtQvd+ARUHEDcAA9q7zDdIOsjAtB5HTQyICOq9WNEGzsTQrdoWrs7dDhrmY8GDBOhIs9qkVVuGilWPCxltr44z4OFs9RKC6AiafBw6Mh5GL141TwiKgbR5WN2pCxd+hbA8g4Tc14ImTgm8FFrkJxa7B4Q6i4pzLREB6+KyS8JZycDCEF0MHvChsPAQ2u/b48DTLQVYv7lEOOqhWnQcUJCsWpMFERJK4CB36zlnNcFOddFUROBI9M6KiucFwBG0dUjYvKJt9WzcA3VS2agsXVagXvN6S28FPcR52oABKtAYL7gLNbg0kVADkBPJqrHOW+Da7RunqhqnE70HgCZKDDxXVwcaFigW8DFgehohVQdJB4D/A4DByNYeNmoMEPVzQ6ZLwTYNwALpqUQ46AxZOhohlM3EyZ6PDQIeTOSkcGdDwSNvaCRuOyybeGDHSwqKta7FUsStWKm0DFuUBxACQ6QPTL2wBIZfBoAhyNYaNE1diraHQ14w0B4wBctAAL1Ij0jl2vgVrREipuDxQ1QKIDRL9cBSC7wOPuwFERNmqqGpVAw33Ml4LG3SAD3wguqigWez0WR8CiGlRUVCmalDsKgOIoSLwRRODtO0w7fJSBR4PSSgQ6uGE3CtdSNWqARuK6RxWNt4QMdOWifjmklseiahmkolpRFS5OUikeDBRdTXlDANkDHFerGwllg58MGbkejYplk2+hZOAb+C2qlESOlkNKPBYXKhZtoeIEmHgASHRg6EBSDTyugo67w8Zej0YDf8ZlSsYdQANdtbgOLI6WQipAxa2A4g1gAtTh5R0ggL8VdNwJOPJgo4mqcSFovK2agW+oWlT1WpwAFmm14i5Q0dBDwd9j8+7qxnMAgd8WOg4CRyvYKFU1WoJGI2/G26kZ+MZGTpROLa0JFierFW2gIhMoHuSZQAeHDiJPhI+mno4DwFEFNiqrGq0UjSeUTc6GDDxYubisJHI5XFRQLDpYNPsOdJjo0HEZbDwQNGoqGpdAxkUlk0coGfgGykVxSeQNwOIRUPFQmOgA0QHkbaHjEbDxbNAoeayPVzLwjeDiNLA44rF4ElS8OUx0kOiXM6HhudDxUNjI9WicABpvCxl4EFzs9Vvs9lqUBmVdABZ1oeKZQNFVCSIC81sBEQPcoeM88LgDcByCjcagURrUtaNsctSXcUvIwBvCRTPVolIpBC3Uim8CFW+vSrwbKHQweZ7acWPYqKFqcK3SyQ3VjD2ZKPSf/9UOMvBmZZGqcFFZtWhWCtkFFxmeily4uDlYoMNEh443hY1bgEaWb2MHaDSEjOBxjqgZJ0BGznPgPatjK8jAd1QuWngtdoHFmVDxHKDA00HibHB41+FsONmXcDMQYboZdFwJHLVgYw9o1PZm3ErJMFaOFpAx3jgFcQ9c7FItGoFFjXJIU7C4AVA8EiZaAkSf5HrstTgCJan39WQAwc4NFa2gwxM+yDnrihc45nUpEzTmdc8BDWQ9vz/+wAwZRrmbjWNw5GSUPbeJXsf8ubtvxH7vPB9Enpf7u9S/3yedcId6cQvV4hTFIg8smqsVKbC4GVDgHUDiMnC4exnmItWgpjpysfLBdBOVI/Wa1vZr7FI1KpROShSN26sZzupQW8XAxWWRZnBRU7U4rFjsUCveQKl4jDpxFCSawkP3aVwGKIdHrF8DHpeXVqqUUo7DBh8onew2gpaUTTLKKu0hI7C61AKN8aHqCXKPcxJcHPFZNFMscsCC75FkiacpE02gosNEndes0sburBN89HN2EnBgJzSgFmjkvm7z+hQsoRRABjOjuHzilE6csgmFSidY1Qf4yia+35s/d0smifeE71b2OH1hb6BenOq3eBxY3K8EgrsCxRGYqPY6dXB4a/XjiNJxssrBdGEp5awSytHSSa2ySW47a2GXyd4oci5diWuoGHiQ5+I0v0VO7gWF7qsQLIrUimcqFbeHiSqvzftBBOjBI87vAB97weOm0MFnl5+CwFGgbBSXTsIejaJcjFJfxomtrFzyLT8KGXgHuLhKtTiiWNRQK05WKnAnheISmLgfRKDHcD8LPm4OHXxHz0YNr8YR0MjxZ5yoZtSFjIwV5Ahk4EFwUaxavCNYxKDiXYHidJi4DiTuAAx4AxjgJ4HHjaHjdsBxVNV4GmgcKJnkQUbmt30vZIxPPhO7Yt7FXrg4Wgo5Ua3At4OLine truncated
|
||||
|
Before Width: | Height: | Size: 23 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><image width="1920" height="64" preserveAspectRatio="none" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB4AAAABACAIAAAB0j/2rAADncElEQVR42uz9aZctSXYdBp59zG/Em3POrEKhJoAkKE4tCmyBk4ZufQDYf1X9mb2WunuxRUlscqnJJZKCCBQKqEJVzpmV05si4l4/uz/Y7G7mw414WZCIt0hUpF+/fn0wNztnn332xi9/+cs3X3tNWv9IAiApIgDSFv/HZGP6qHmQ3n+GjSKQff/8V+b/N30qrWNOfogi4k8+bkHx3cnBJzsIKcWFhO31D0yOJrOzahy2/rv5b74D6qNtP+b8DFf3nGxBfWO5eNqTZ7d6sZNP58+Xi1+Z33YuXtHGf2d/8S/Uv7/IV7Fwbs2XevUgWNt//qb3fnr7UOfaYOZs5+3v/sK3Ns4P513almd39ov2v9M36/8YE8Jf/vvLf3/57z+2medbnrvu6udwR7Hc9vWRfwFuI9aCir9c9b61s/3LWOXst+z2r5t863kc9gf8f/lK/uW7+Rf/lfy1LCi3XE8X8uuN578dHMDmjb3Dso8EkpSIh/bwxjme2QM/d+GozZ23oLVNyLdEhicQce8gIvLF118PIvJ//2//2/kvTP5evTCaVb9Cto9ZHo0EYGb5P8vb7RFe0m+nCCgSAfFwKH9tpN+ab0Y8uAhBCinwhwwbxZhGQPMGi3A+xGC2dIF+hzx80rCx4lAMaHe6uuJQqI5JoQiAkZ27z+5p9G5+/8yn322fUjUE0/CKr0r+CuNQ6H19eg7l3Sg2pq/78b4yodg4jifnBhGQpsyDrTVVxbO1+NPozzakoHiEJqrNC4KEEQoRi+9hH46jgKQD/HCbvKW5wjGN2WAULS6iMZFAUMxUJuJEKEKIUvwoVpmM/XQXFutC9fTov+GvPT7zVNAJbx4EaM3XFP/SYvIzTQhz8kUgHjO844T2akxCCQ8sDSHEk58/6uo/EZ63P7hxUnhhus9QCMVMoKLo1sF6z6v3ckNAHG5eutO1QUFCzE9OIn54QEBRGCBKNQkLmr+0cI/Sq+q/ZUJM1ls0Hn8cJWkUAUKj4ObeExsuyVFElILxNJxuRDi6S4MTMYmzbX0/4yuc3/R4CcWDQPpo/uzD1+MywcY9BUVg1W1k+WwZtoAIU3N4gIxzux++KF9AxpmfDA+XIloNE44EBSoki/c1hxMQChGXsfR6hDNDXkH8mIr7LcyfVaQg+fEWxVUIKMUCCr/khRNEnMjhLyqticXkQTK+wgh/CyHpzWUzoUS+z2GEpjU9zrPlwwvLvOSSLiQvtcU9CrNNsTQDpKGu+oJxefJ3T0EzoSG+uWC8HeFp+btNkGBYbGJ4GMMS0igA/V3It86/S/T32uJt95+G+wkyPHv/mIyI9wRCf2R/M/31QcTS1My02IYb6wethvNL0ZRfiONt9qdDIoyBMJn44Q5heAUZZgjkmdQ/WX+qoL8hiA+Ms7pXMd2ymCziWfFEUcSNFEthjcgYT36kDIAJR8oBAGQkLxSkjKSJOEBERtIoFLnQ8CRPFoaCA3S2YlwZy8XEf2shGB+ZZ0K/8cbYiOuZ3itRkaGgL8ThLC4uTlyMekTk2lgeeFAMOabPu43kkTwAAnGCATiRfjsACtO3TqQK/N07kiZ0gjGu0APkGBctkbxSqcjC7ZnMplosjgyzRVzLBTFwz2txfLdzAE+gnEolLTDIo0jS/AwRwiQMJE0PA3m2S3/H40DytIv0TAFlNWv72R7xT//qgxAIADFBXg4AA5AmkfDGxnsJFPcEfkYlNazFPsRTpPHFFJCE44NChI15SQi/7gOeHIbG4CfF3+GMqnOIt6EaSOHFR1w9KExfXBurMgmN8jqOvJrHuKV+lyrGTxlclVF/kYsXr5yWkXcxao15+JahQrylVSwZH1s+LuYkorjy+fHnoyTEX2SMFVD/4ceYAhaXQh+mpUNPIQS/5ORpxS/KRqqq/yON2PQWicVAqbwoY76E+C6Vt5xW7j3lS/nLJOcfhZUF0HrtATgOp6OQJ3UcDmEMjKfDzQsXwg/GjDxHGMKc3iEMvBiVxvUKBBimB/HLofoFM3wZYbGMyzIJ+onBH58CVQUppm4cLgiNj6fImzJEEC86XrcZ2/NfmeCIQBgeBYrkghAtolj/Fatj3nLK9MPe0lodxxIhGl8QFbE0jzLPhsXIbpxwGuWSblgjwQPEwh8gLQFEYek3AQQKmt9LQ4LqkQwSmqd8pDc9PLUQV4jfLX7ElC8p5vklTcr8Ok25aSDR/KgqIqQivJQ0J1ZzF32c5E8bABRmhhAEh8dh/sjIaIof1MZiorCwpNACYijFTVi6LfF9Cue/QKeC0NITteIGqUAINZJChY52UndQUU5TVUgBvIjUA26VxjXJeeerwGp4gDwiubwdRYQIzGbhGglJk3nr0ppfSeBo3qc8h7RGJ9AHYFrOyt3Kr6efCFNnXjEmRxYJaAOrr1cZSgAr4ltH55q3y78wxXYpog4/WwZIk/FG+ZWDYW1NURhZPpQCvSrZt5TZe1Q/iOWhsjKEWjv/X//JPxlE5NiMNeqNXIlHhGnBy0gC6xGf1iUJcDE9FIzJiZuU6EnMPMMBUi4TIlMROQET7JvFjEewfM99LikqiIlfea3MMEb6ueKfCxULs1HqQkGONkqAMYIpZilcNCkTDJ+ejj6y9Ykzi9xXRAQamdoFSmoxd00hfjmfMn7EnANYcWb+o/RpXZthBS+mOykUyXc1rJU252VmuD+HnNKgbaKAaor/LR5vREDidGGUIpUtfyK8fuPJ5KjDwUdmbjTRAiFFHj25JmOUoXg1/QgOS59KChTzU64DmZCrCBNITcRHRoiyjDYzFptw2eLJiU8uXIhwIDHO1cYKkrAYWBlW5dAYrL6okq9LKOrTOvi0Tnzyns8wBlpCSWEc3EplkUUSmV5mWpxDQ8Aen51FYKS+wHSEHMMyRN9MZ6IxhYrjgyLqI1FUGAnqs0JVXUCJJswvZEbvj+sOpMDvp5GfiwAYBKILN6rOs1DkN8oMD4MkHO6P/O6LZw/t+ht3eS16AA5wHrgCjKJ6gjiMjDc84WITQDD/nX8hpMPF6CmzM+FJcshHg97nzTcvv/zFa+/e3Huofng7gZ7uH68Pxit3ODmdYujpJRUNS6MP5SAB2RcjPehH88/Kv/aoJhbzCYr5XSOyZ6xmF0SYSzRm1xbLOxhDXKEWsCw/cZgIqeJRrPSao0gZ4yJS1EYn2Z5LS5RFENKfZDmfwsQyZMsapg2lrYQqUFE+txDZ+rJRhIEoVCsg3dEYAOIAVurJjBnVmQ5uxEQ7JA8z2CHXF1MFt5i2y5WqWiv9a4V4jUhV8knQqCF+igtkCgKMozaCTgmJRUq3Gf728yZzAFBUtY0jNWTaAph5LOhkogHZEjFzoIr4nMsYIXGaxvJDeG38mDfzEQ/8e0gK/SREJY0UMyWFJhz9awKKGUnzv3IhPJFO/OlFEMwfSsTIwa8nNA81+GhpDHilqUAZF2ihgTAZSYiMwiFGQQDGWH4IqDZ9xixKMYRtoHj812dbTiiAExl9rh8HoRMZKfRImsiRIbj1mZeTACKHpSguimnd1TrWMhbAXQhW6CAKHP28klZLkUGQIgV/M4FcMU4Luoo4VAW09EPhJAHUU7OGegQdSmzKr4SYIhcSYgpFWYTKQFJ606qfQD6TAfCouoo4wA+RA0I92cUEHQF3ThMAFXARmLKYaap4TJRjXEscRCAR1uYFlBIGhqWpJXw35OYD8lqncX5QVE9HQok15mgUP0KAkOafKPH0JN5hX0uIKwsEMYxQFVp8gCpjxqegIfoC4hxiAkvziQAKmihgEAAmft4Wgfjyb6iXACoY/dGgEBDifKQDp+qJBzABFQYMgEBJ8f9XnZpAnTP1yxGgMIBQAUQB0VEFUKV6lEagAgKOQlHnXw6NyZ7RT80qhCjgB6xipKk6hEwTJCxUsv29cEU5KQScqioAeYJf6qB+QTKBms93/OMDVSUhOIRBQUsvPxGBo4QDECM8VyHXURmj8VidUhELZT7xQFVRzQoDDGWlGUSsQBgFowa4SikGn4lYCEYBesAqothKpFLnWCQQ/nCkouIFBBCc4mLcmILFMHj9tKp+4IiOpI9LfdAUMhuQDgmdFQOEooBCCD0ICYqJSgb0nJB+t1ha8EMcSDSFcDcdGNCxgGR5GlZiWOVLEYTg3PkLsVRcVAhdyn00FZxT1BLqzQzLFgvwGpLBW1EwhPoOvjDkZ3wtcVe/so73xhtHfXZxOLoLX6M53Lz8wbPnl3b6lWAg7iucMx9kSfhRJCQwzQMxWkFdqhcRMWCMCwWgQIhQ6AO6kUKqc2o5AguVpPF0nzcv3PDRxcOX7v40tW0F4enxxvmaOYZknWOiTYsJ76RWcWPr/2LG25mCEilHi4GKTHeQelFpXhXKDIjLiOc0MfDvsieVNOlHVVEhxbdzilTKIuO6rqm4XeFDBa6yRnbV2RVj8U6gvl0ubgxrWHFJaQuK+jNnFYjJ5ZeDgbPhgRoXzuefaj75jpeBiMwKBv62jarplTYenTtAXeYIQgPLBIGWgEhewWTgzUHCDjtv/kUPd2KJFYYQixQwkXqEVBUoq1CB9pBZJT44YIm05kqyv550SsT0V8N9RpXXh5w/1nFlwlIpCiEx0SguL8zi/vdRFbKBirKMFK8kzNdnaZJ/WkpUBDXliOo0BpCYILVh7CBjwfBVDhSwvGr5JUQmASp6mWZoHtWbrcQUemZV0ui+ZD7ya4yrBtmwzoOrV2MIV4XZhBMTGFZcMJb8xkQ0yGLine truncated
|
||||
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
@@ -998,24 +998,11 @@ 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. 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;
|
||||
}
|
||||
// stuck at its minimum height.
|
||||
textarea.style.height = "auto";
|
||||
const height = Math.max(
|
||||
minBound,
|
||||
Math.min(textarea.scrollHeight, maxBound),
|
||||
);
|
||||
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
|
||||
textarea.style.height = `${height}px`;
|
||||
textarea.style.overflowY =
|
||||
textarea.scrollHeight > maxBound ? "auto" : "hidden";
|
||||
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
|
||||
}, []);
|
||||
|
||||
// onInput does not run for a controlled value filled by a suggested question
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/* 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,7 +27,6 @@ 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";
|
||||
@@ -600,13 +599,13 @@ const DocumentUploadPopupContent: FC<{
|
||||
};
|
||||
|
||||
const yUserId = getYUserId();
|
||||
const notebookToken = getNotebookToken();
|
||||
const iframeTokenParam = notebookToken ? `&token=${encodeURIComponent(notebookToken)}` : "";
|
||||
const userToken = getStoredAuth()?.access_token;
|
||||
const iframeTokenParam = userToken ? `&token=${encodeURIComponent(userToken)}` : "";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
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]"
|
||||
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"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
@@ -616,7 +615,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="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]">
|
||||
<div className="relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300">
|
||||
<Textarea
|
||||
placeholder="搜索资料..."
|
||||
value={searchText}
|
||||
@@ -627,24 +626,24 @@ const DocumentUploadPopupContent: FC<{
|
||||
searchDataChange();
|
||||
}
|
||||
}}
|
||||
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]"
|
||||
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"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="document-upload-dialog__search-icon absolute left-3 top-3 text-neutral-400 pointer-events-none dark:text-[#6e88b0]">
|
||||
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none">
|
||||
<Search className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={searchDataChange}
|
||||
disabled={isSearching || !searchText.trim()}
|
||||
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]"
|
||||
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"
|
||||
>
|
||||
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <span>搜索</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab 标签页 */}
|
||||
<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="border-b border-gray-200 rounded-t-xl px-4 bg-gray-50">
|
||||
<div className="flex flex-nowrap overflow-x-auto">
|
||||
{(
|
||||
[
|
||||
@@ -658,12 +657,12 @@ const DocumentUploadPopupContent: FC<{
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => void searchActiveChange(key)}
|
||||
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]"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{activeTab === key && (
|
||||
<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]" />
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/* 深蓝主题下,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,33 +6,12 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
CirclePlay,
|
||||
FileText,
|
||||
FolderTree,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Network,
|
||||
Plus,
|
||||
Tag,
|
||||
Trash2,
|
||||
UserRoundCheck,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Plus, Search, Trash2 } 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 {
|
||||
@@ -43,6 +22,7 @@ interface NetworkActorProps {
|
||||
|
||||
interface platformItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
@@ -51,14 +31,6 @@ interface languageItem {
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface ActorSearchFilters {
|
||||
platforms: string[];
|
||||
languages: string[];
|
||||
direction: string;
|
||||
sortBy: string;
|
||||
sortDirection: "asc" | "desc";
|
||||
}
|
||||
|
||||
interface PoliticsDirItem {
|
||||
地区: string;
|
||||
语言: string;
|
||||
@@ -96,86 +68,6 @@ 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;
|
||||
@@ -222,9 +114,9 @@ interface UserDataItem {
|
||||
birthday: string | null;
|
||||
is_virtual: number;
|
||||
isMedia: number;
|
||||
hx_info?: hx_info | null;
|
||||
hx_info: hx_info;
|
||||
gender: string | null;
|
||||
agentInfo?: PoliticsDirItem | null;
|
||||
agentInfo: PoliticsDirItem;
|
||||
profile_url: string | null;
|
||||
tagId: string[];
|
||||
user_name: string;
|
||||
@@ -260,15 +152,15 @@ interface UserDataItem {
|
||||
|
||||
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
|
||||
const [platforms, setPlatforms] = useState<platformItem[]>([
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
]);
|
||||
const platformList = useRef<string[]>([]);
|
||||
const [languages, setLanguages] = useState<languageItem[]>([
|
||||
@@ -296,37 +188,25 @@ 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 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 backendUrl = typeof window !== 'undefined'
|
||||
? (readRuntimeEnv("VITE_BACKEND_BASE_URL") || 'http://localhost:8001')
|
||||
: 'http://localhost:8001';
|
||||
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
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> = {}) => {
|
||||
const searchChange = async () => {
|
||||
setIsSearching(true);
|
||||
const filters = resolveSearchFilters(overrides);
|
||||
if (searchValue.current === 'network') {
|
||||
await agentUserSearch(filters);
|
||||
await agentUserSearch();
|
||||
} else {
|
||||
await agentGroupSearch(filters);
|
||||
await agentGroupSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const setDirectionChange = async (type: string) => {
|
||||
setDirectionType(type);
|
||||
directionValue.current = type;
|
||||
await searchChange({ direction: type });
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const searchTypeChange = async (value: string) => {
|
||||
@@ -336,128 +216,135 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
|
||||
};
|
||||
|
||||
const allPlatformChange = async () => {
|
||||
const nextPlatforms = platforms.map((item) => ({ ...item, checked: false }));
|
||||
setAllPlatforms(true);
|
||||
setPlatforms(nextPlatforms);
|
||||
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
platformList.current = [];
|
||||
await searchChange({ platforms: [] });
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const handleTogglePlatform = async (index: number) => {
|
||||
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 });
|
||||
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 allLanguageChange = async () => {
|
||||
const nextLanguages = languages.map((item) => ({ ...item, checked: false }));
|
||||
setAllLanguage(true);
|
||||
setLanguages(nextLanguages);
|
||||
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
languageList.current = [];
|
||||
await searchChange({ languages: [] });
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const handleToggleLanguage = async (index: number) => {
|
||||
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 });
|
||||
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 sortTypeChange = async (type: string) => {
|
||||
const nextSortDirection = (sortValue.current === 'desc' ? 'asc' : 'desc') as ActorSearchFilters["sortDirection"];
|
||||
setSortType(type);
|
||||
sortTypeValue.current = type;
|
||||
sortValue.current = nextSortDirection;
|
||||
if (type === 'fans_count') setFansSort(nextSortDirection);
|
||||
else if (type === '_score') setRelevanceSort(nextSortDirection);
|
||||
await searchChange({ sortBy: type, sortDirection: nextSortDirection });
|
||||
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
|
||||
if (type === 'fans_count') setFansSort(sortValue.current);
|
||||
else if (type === '_score') setRelevanceSort(sortValue.current);
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const agentUserSearch = async (filters: ActorSearchFilters) => {
|
||||
const agentUserSearch = async () => {
|
||||
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: filters.languages,
|
||||
language: languageList.current,
|
||||
page: 1,
|
||||
limit: 12,
|
||||
platform: filters.platforms,
|
||||
politicalStand: filters.direction,
|
||||
sort: filters.sortBy,
|
||||
asc: filters.sortDirection,
|
||||
platform: platformList.current,
|
||||
politicalStand: directionValue.current,
|
||||
sort: sortTypeValue.current,
|
||||
asc: sortValue.current,
|
||||
typeKeyword: 9,
|
||||
}),
|
||||
});
|
||||
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);
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const agentGroupSearch = async (filters: ActorSearchFilters) => {
|
||||
const agentGroupSearch = async () => {
|
||||
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: filters.languages,
|
||||
language: languageList.current,
|
||||
page: 1,
|
||||
limit: 12,
|
||||
platform: filters.platforms,
|
||||
politicalStand: filters.direction,
|
||||
sort: filters.sortBy,
|
||||
asc: filters.sortDirection,
|
||||
platform: platformList.current,
|
||||
politicalStand: directionValue.current,
|
||||
sort: sortTypeValue.current,
|
||||
asc: sortValue.current,
|
||||
typeKeyword: 9,
|
||||
}),
|
||||
});
|
||||
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);
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -524,128 +411,122 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="bg-white rounded-[5px] p-[10px]">
|
||||
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="network-actor__filter-label text-[#3D3D3D]">所属平台:</span>
|
||||
<span className="text-[#3D3D3D]">所属平台:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<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'}`}
|
||||
<span
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
|
||||
onClick={() => void allPlatformChange()}
|
||||
>全部</button>
|
||||
>全部</span>
|
||||
{platforms.map((item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
<span
|
||||
key={index}
|
||||
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'}`}
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
|
||||
onClick={() => void handleTogglePlatform(index)}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="network-actor__filter-label text-[#3D3D3D]">常用语言:</span>
|
||||
<span className="text-[#3D3D3D]">常用语言:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<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'}`}
|
||||
<span
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
|
||||
onClick={() => void allLanguageChange()}
|
||||
>全部</button>
|
||||
>全部</span>
|
||||
{languages.map((item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
<span
|
||||
key={index}
|
||||
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'}`}
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
|
||||
onClick={() => void handleToggleLanguage(index)}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="network-actor__filter-label text-[#3D3D3D]">方向:</span>
|
||||
<span className="text-[#3D3D3D]">方向:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<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 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>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="network-actor__filter-label text-[#3D3D3D]">类型:</span>
|
||||
<span className="text-[#3D3D3D]">类型:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<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 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>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="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="flex items-center ml-[10px]">
|
||||
<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 className="title-bar mr-[8px]"></div>
|
||||
<div className="text-[#454859]">用户表格管理</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<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'}`}
|
||||
<div
|
||||
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
|
||||
onClick={() => void sortTypeChange('fans_count')}
|
||||
>
|
||||
<span className="leading-[16px]">粉丝数排序</span>
|
||||
<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'}`}
|
||||
<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]`}
|
||||
onClick={() => void sortTypeChange('_score')}
|
||||
>
|
||||
<span className="leading-[16px]">相关度排序</span>
|
||||
<span className="ml-2"><SortDirectionIcon direction={relevanceSort as "asc" | "desc"} active={sortType === '_score'} /></span>
|
||||
</button>
|
||||
<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>
|
||||
{searchType === 'network' && (
|
||||
<>
|
||||
<div className="network-actor__metric flex items-center ml-[35px]">
|
||||
<MetaIcon type="network" className="h-6 w-6 text-[#d97b00]" />
|
||||
<div className="flex items-center ml-[35px]">
|
||||
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
|
||||
<div className="ml-[5px]">
|
||||
<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 className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
|
||||
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="network-actor__metric-divider ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
|
||||
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
|
||||
</>
|
||||
)}
|
||||
<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={`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="ml-[5px]">
|
||||
<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 className="text-[#454859] leading-[16px]">网络XWT总数</div>
|
||||
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedSearchSources.length > 0 && (
|
||||
<div className="network-actor__selection-bar flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<div className="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">
|
||||
@@ -670,13 +551,9 @@ 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-1 gap-3 sm:grid-cols-2 xl:grid-cols-4${isSearching ? ' hidden' : ''}`}>
|
||||
<div className={`grid grid-cols-4 gap-[10px] user-list${isSearching ? ' hidden' : ''}`}>
|
||||
{userList.map((item, index) => (
|
||||
<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 key={index} className="user-item p-[10px] relative">
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -686,21 +563,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="network-actor__avatar h-[97px] w-[97px] rounded-md border border-[#e5e7eb] bg-slate-50 object-cover" alt="" />
|
||||
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" 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">
|
||||
<MetaIcon type="followers" className="mr-[5px] text-[#d97b00]" />
|
||||
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
|
||||
<span>粉丝数:{item.fans_count || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<PlatformIcon platform={item.platform} className="mr-[5px]" />
|
||||
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
|
||||
<span>{item.platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MetaIcon type="person" className="mr-[5px] text-[#cf1322]" />
|
||||
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>实体人:{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -708,29 +585,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">
|
||||
<MetaIcon type="category" className="mr-[5px] text-[#cf1322]" />
|
||||
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>分类:</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="network-actor__classification inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
|
||||
<div className="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">
|
||||
<MetaIcon type="tags" className="mr-[5px] text-[#1eb07f]" />
|
||||
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||
<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="network-actor__tag h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
|
||||
<div key={hobbie} className="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">
|
||||
<MetaIcon type="summary" className="mr-[5px] text-slate-500" />
|
||||
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>简介:</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
|
||||
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
|
||||
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
|
||||
* 默认倍率为 `1.375`,即 22px。
|
||||
* `fontScale === 1` 即默认 16px。
|
||||
*/
|
||||
export const BASE_FONT_PX = 16;
|
||||
|
||||
@@ -24,7 +24,6 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
|
||||
{ value: 1, label: "标准", px: 16 },
|
||||
{ value: 1.125, label: "大", px: 18 },
|
||||
{ value: 1.25, label: "特大", px: 20 },
|
||||
{ value: 1.375, label: "超大", px: 22 },
|
||||
];
|
||||
|
||||
export const FONT_SCALE_MODE_OPTIONS: {
|
||||
|
||||
@@ -38,7 +38,6 @@ export function HtmlPreviewFrame({
|
||||
|
||||
return (
|
||||
<iframe
|
||||
key={url ?? "html-preview"}
|
||||
title="HTML 预览"
|
||||
sandbox="allow-scripts allow-forms"
|
||||
src={url}
|
||||
|
||||
@@ -443,22 +443,6 @@
|
||||
background-size: 152px 42px;
|
||||
}
|
||||
|
||||
/* 浅红科技感顶栏保持深色版的尺寸与排布,只替换文字光效和选中底图。 */
|
||||
.strategy-app-header--light-red-top-nav .top-submenu-item {
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.strategy-app-header--light-red-top-nav .top-submenu-item:hover {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 10px rgba(196, 51, 40, 0.4);
|
||||
}
|
||||
|
||||
.strategy-app-header--light-red-top-nav .top-submenu-item--active {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 12px rgba(177, 38, 28, 0.48);
|
||||
background-image: url("../assets/img/nav-active-light-red.svg");
|
||||
}
|
||||
|
||||
.third-submenu-bar {
|
||||
position: relative;
|
||||
z-index: 34;
|
||||
@@ -769,7 +753,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
||||
color: var(--brand-900);
|
||||
}
|
||||
|
||||
/* 侧边栏底部:设置入口 + 收起面板按钮 */
|
||||
/* 侧边栏底部:设置入口(浅色模式不支持收起,故无收起按钮) */
|
||||
.light-sidebar__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
@@ -779,58 +763,6 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* 侧边栏收起后的左下角浮动展开入口(交互同深色 sidebar-collapse-btn--floating,配色用浅色主题变量) */
|
||||
.light-sidebar-expand-btn {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 60;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--brand-200);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: var(--brand-900);
|
||||
font-family: var(--font-sans);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: width 0.22s ease, background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.light-sidebar-expand-btn:hover {
|
||||
width: 104px;
|
||||
background: var(--brand-200);
|
||||
}
|
||||
|
||||
.light-sidebar-expand-btn__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
color: var(--brand-900);
|
||||
}
|
||||
|
||||
.light-sidebar-expand-btn__label {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: max-width 0.22s ease, opacity 0.16s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
.light-sidebar-expand-btn:hover .light-sidebar-expand-btn__label {
|
||||
max-width: 64px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */
|
||||
.light-sidebar-subnav {
|
||||
display: flex;
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
MessageSquareText,
|
||||
MousePointerClick,
|
||||
BriefcaseBusiness,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
@@ -102,24 +100,14 @@ export function PageSidebar({
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SecondaryNavSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
headerHidden={headerHidden}
|
||||
/>
|
||||
);
|
||||
return <SecondaryNavSidebar headerHidden={headerHidden} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
|
||||
* 收起交互与深色侧边栏一致:收起后整栏隐藏,仅左下角保留浮动展开入口。
|
||||
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
|
||||
*/
|
||||
function SecondaryNavSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
headerHidden,
|
||||
}: PageSidebarProps) {
|
||||
function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const { t } = useI18n();
|
||||
@@ -163,22 +151,6 @@ function SecondaryNavSidebar({
|
||||
const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
|
||||
const expanded = thirdItems.length > 0;
|
||||
|
||||
// 收起时整栏不占宽度,仅保留左下角浮动入口以便随时恢复(交互同深色侧边栏)。
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="展开侧边栏"
|
||||
aria-label="展开侧边栏"
|
||||
className="light-sidebar-expand-btn"
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<PanelLeftOpen className="light-sidebar-expand-btn__icon" aria-hidden="true" />
|
||||
<span className="light-sidebar-expand-btn__label">展开面板</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
@@ -339,16 +311,6 @@ function SecondaryNavSidebar({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
title="收起侧边栏"
|
||||
aria-label="收起侧边栏"
|
||||
onClick={onToggleCollapsed}
|
||||
className="light-sidebar-item"
|
||||
>
|
||||
<PanelLeftClose className="light-sidebar-item__icon h-7 w-7" />
|
||||
<span className="light-sidebar-item__label">收起面板</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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, { EditorView } from "@uiw/react-codemirror";
|
||||
import CodeMirror 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 }), EditorView.lineWrapping],
|
||||
() => [markdown({ base: markdownLanguage, codeLanguages: languages })],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<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={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="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-w-0 min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
|
||||
className="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-w-0 min-h-0 flex-col bg-muted/10">
|
||||
<section className="flex 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-w-0 min-h-[42vh] flex-1 overflow-auto p-4">
|
||||
<div className="min-h-[42vh] flex-1 overflow-auto p-4">
|
||||
<MarkdownContent
|
||||
content={value || "*暂无正文*"}
|
||||
isLoading={false}
|
||||
|
||||
@@ -60,7 +60,6 @@ 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";
|
||||
@@ -77,15 +76,7 @@ import {
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import { getAgent } from "@/core/agents/api";
|
||||
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 type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -96,8 +87,6 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: Agent;
|
||||
/** Optional visual state supplied by management surfaces (for example selection). */
|
||||
className?: string;
|
||||
/** "card" (default) renders the full gallery card; "list" renders a compact
|
||||
* single-row layout for the list view. All dialogs/actions are shared. */
|
||||
layout?: "card" | "list";
|
||||
@@ -222,7 +211,6 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
|
||||
|
||||
export function AgentCard({
|
||||
agent,
|
||||
className,
|
||||
layout = "card",
|
||||
onPublisherClick,
|
||||
onToggleHidden,
|
||||
@@ -260,12 +248,6 @@ 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();
|
||||
@@ -314,8 +296,6 @@ 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,
|
||||
@@ -325,8 +305,6 @@ export function AgentCard({
|
||||
agent.square_id,
|
||||
agent.skills,
|
||||
agent.seat_skill_directive,
|
||||
agent.model_prompt_suffix_enabled,
|
||||
agent.chat_link,
|
||||
editOpen,
|
||||
]);
|
||||
|
||||
@@ -342,28 +320,12 @@ 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]);
|
||||
|
||||
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;
|
||||
}
|
||||
function handleChat() {
|
||||
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
|
||||
}
|
||||
|
||||
@@ -386,11 +348,9 @@ 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
|
||||
}
|
||||
@@ -401,7 +361,6 @@ 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,
|
||||
@@ -454,24 +413,6 @@ 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);
|
||||
@@ -921,7 +862,7 @@ export function AgentCard({
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(isList ? "shrink-0" : "w-full")}
|
||||
onClick={() => void handleChat()}
|
||||
onClick={handleChat}
|
||||
>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
@@ -931,9 +872,8 @@ export function AgentCard({
|
||||
const cardLayout = (
|
||||
<Card
|
||||
className={cn(
|
||||
"group flex min-w-0 flex-col transition-[border-color,box-shadow,background-color] hover:shadow-md",
|
||||
"group flex min-w-0 flex-col transition-shadow hover:shadow-md",
|
||||
agent.hidden && "opacity-60",
|
||||
className,
|
||||
)}
|
||||
data-agent-guide="agent-card"
|
||||
>
|
||||
@@ -996,9 +936,8 @@ export function AgentCard({
|
||||
const listLayout = (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-[border-color,box-shadow,background-color] hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
"group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
agent.hidden && "opacity-60",
|
||||
className,
|
||||
)}
|
||||
data-agent-guide="agent-card"
|
||||
>
|
||||
@@ -1101,19 +1040,6 @@ 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>
|
||||
@@ -1194,7 +1120,6 @@ export function AgentCard({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* <AgentChatLinkFields value={editChatLink} onChange={setEditChatLink} /> */}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
"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,7 +7,6 @@ import {
|
||||
DatabaseIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
// Link2Icon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
@@ -26,7 +25,6 @@ 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 {
|
||||
@@ -61,16 +59,9 @@ 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";
|
||||
@@ -90,11 +81,9 @@ interface AgentDraft {
|
||||
published: boolean;
|
||||
featured: boolean;
|
||||
templateName: string;
|
||||
modelPromptSuffixEnabled: boolean;
|
||||
/** 会商席位「技能识别强化」开关。 */
|
||||
seatSkillDirective: boolean;
|
||||
knowledgeBaseIds: string[];
|
||||
chatLink: AgentChatLink;
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: Agent): AgentDraft {
|
||||
@@ -107,10 +96,8 @@ 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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -380,22 +367,8 @@ 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 ?? [])
|
||||
@@ -690,13 +663,6 @@ 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" />
|
||||
@@ -736,21 +702,6 @@ 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>
|
||||
@@ -997,13 +948,6 @@ export function AgentDetailPanel({
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* {editing && draft && (
|
||||
<AgentChatLinkFields
|
||||
value={draft.chatLink}
|
||||
onChange={(chatLink) => setDraft({ ...draft, chatLink })}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
||||
{belowSkillsSlot}
|
||||
|
||||
|
||||
@@ -362,27 +362,11 @@ export function ArtifactFileDetail({
|
||||
isMock,
|
||||
writeFileInFlight,
|
||||
]);
|
||||
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 保存)。 */
|
||||
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
|
||||
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
|
||||
const useWriteFileStreaming = useMemo(() => {
|
||||
return (
|
||||
isWriteFileVirtual &&
|
||||
!isImageFile(filepath) &&
|
||||
!editVirtualMarkdown &&
|
||||
(!isVirtualHtmlArtifact || writeFileInFlight)
|
||||
);
|
||||
}, [
|
||||
filepath,
|
||||
isWriteFileVirtual,
|
||||
editVirtualMarkdown,
|
||||
isVirtualHtmlArtifact,
|
||||
writeFileInFlight,
|
||||
]);
|
||||
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
|
||||
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
||||
const { isCodeFile, language } = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
let nextLanguage =
|
||||
@@ -414,10 +398,11 @@ export function ArtifactFileDetail({
|
||||
refetch: refetchArtifactContent,
|
||||
} = useArtifactContent({
|
||||
threadId,
|
||||
// 流式结束后统一用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||
// URL)。HTML 可能已被后续渲染脚本覆盖;Markdown 保存后的查询缓存也需要
|
||||
// 命中真实路径,不能继续读取旧的内存 stub。
|
||||
filepath: useWriteFileStreaming ? filepathFromProps : filepath,
|
||||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||||
// 不会被旧的内存 stub 值覆盖回退。
|
||||
filepath:
|
||||
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
|
||||
enabled: isCodeFile && !useWriteFileStreaming,
|
||||
});
|
||||
|
||||
@@ -473,10 +458,9 @@ 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, language, streamPerfEnabled]);
|
||||
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
|
||||
const artifactContentPending =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
@@ -2221,7 +2205,6 @@ 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,258 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
exportAllThreadsInBackground,
|
||||
importTransferThreads,
|
||||
parseTransferBundle,
|
||||
skipReasonLabel,
|
||||
type SkippedThread,
|
||||
type TransferProgress,
|
||||
} from "@/core/threads/transfer";
|
||||
|
||||
function ProgressPanel({
|
||||
progress,
|
||||
label,
|
||||
}: {
|
||||
progress: TransferProgress | null;
|
||||
label: string;
|
||||
}) {
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-6 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const percent =
|
||||
progress.total > 0
|
||||
? Math.round((progress.processed / progress.total) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div className="space-y-3 py-2">
|
||||
<Progress value={percent} />
|
||||
<p className="text-sm text-neutral-600">
|
||||
已处理 {progress.processed} / {progress.total} 条,成功{" "}
|
||||
{progress.succeeded},跳过 {progress.skipped}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkippedList({ items }: { items: SkippedThread[] }) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="max-h-40 space-y-1 overflow-auto rounded-lg border p-2 text-xs text-neutral-600">
|
||||
{items.slice(0, 20).map((item) => (
|
||||
<p key={`${item.thread_id}-${item.reason}`}>
|
||||
{item.title || item.thread_id}:{skipReasonLabel(item.reason)}
|
||||
</p>
|
||||
))}
|
||||
{items.length > 20 && <p>其余 {items.length - 20} 条已省略</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatHistoryExportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<TransferProgress | null>(null);
|
||||
const [skipped, setSkipped] = useState<SkippedThread[]>([]);
|
||||
|
||||
async function handleExport() {
|
||||
if (running) return;
|
||||
setRunning(true);
|
||||
setProgress(null);
|
||||
setSkipped([]);
|
||||
try {
|
||||
setProgress({
|
||||
total: 0,
|
||||
processed: 0,
|
||||
succeeded: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
const result = await exportAllThreadsInBackground(setProgress);
|
||||
setSkipped(result.skipped);
|
||||
if (result.succeeded === 0) {
|
||||
toast.message(
|
||||
result.total === 0 ? "没有可导出的对话" : "没有成功导出的对话",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const extras = [
|
||||
result.skipped.length > 0 ? `跳过 ${result.skipped.length} 条` : "",
|
||||
result.skipped_files > 0 ? `跳过 ${result.skipped_files} 个过大附件` : "",
|
||||
].filter(Boolean);
|
||||
toast.success(
|
||||
extras.length > 0
|
||||
? `已导出 ${result.succeeded} 条,${extras.join(",")}`
|
||||
: `已导出 ${result.succeeded} 条对话`,
|
||||
);
|
||||
if (result.skipped.length === 0) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "导出失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={running ? undefined : onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导出对话记录</DialogTitle>
|
||||
<DialogDescription>
|
||||
后台逐条打包当前账号的对话消息和附件,进度按条前进。单条上限 50MB,超出部分会跳过大附件,消息仍导出;一条失败不影响其余记录。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{running || progress ? (
|
||||
<ProgressPanel progress={progress} label="正在准备导出…" />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
导出文件可拷到另一台机器,在本页「导入记录」还原。
|
||||
</p>
|
||||
)}
|
||||
<SkippedList items={skipped} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={running}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={running} onClick={() => void handleExport()}>
|
||||
{running && <Loader2 className="size-4 animate-spin" />}
|
||||
{running ? "导出中" : "开始导出"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatHistoryImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<TransferProgress | null>(null);
|
||||
const [skipped, setSkipped] = useState<SkippedThread[]>([]);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (running) return;
|
||||
setRunning(true);
|
||||
setProgress(null);
|
||||
setSkipped([]);
|
||||
try {
|
||||
const threads = parseTransferBundle(await file.text());
|
||||
setProgress({
|
||||
total: threads.length,
|
||||
processed: 0,
|
||||
succeeded: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
const result = await importTransferThreads(threads, setProgress);
|
||||
setSkipped(result.skipped);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
if (result.imported === 0) {
|
||||
toast.message(
|
||||
result.skipped.length > 0
|
||||
? `没有导入新对话,跳过 ${result.skipped.length} 条`
|
||||
: "没有导入新对话",
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
result.skipped.length > 0
|
||||
? `已导入 ${result.imported} 条,跳过 ${result.skipped.length} 条`
|
||||
: `已导入 ${result.imported} 条对话`,
|
||||
);
|
||||
if (result.skipped.length === 0) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "导入失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={running ? undefined : onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入对话记录</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择从另一台机器导出的 JSON 文件。逐条写入,进度按条前进。已存在或失败的记录会跳过,不影响其余记录。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{running || progress ? (
|
||||
<ProgressPanel progress={progress} label="正在读取文件…" />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
导入后归属当前登录账号,可继续查看和提问。附件会一并还原。
|
||||
</p>
|
||||
)}
|
||||
<SkippedList items={skipped} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={running}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={running}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-4" />
|
||||
)}
|
||||
{running ? "导入中" : "选择文件"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
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 { getWelcomeBrandAsset } from "@/core/branding/brand-assets";
|
||||
import { readRuntimeFlag } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
|
||||
|
||||
/**
|
||||
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
|
||||
@@ -15,8 +16,11 @@ import { cn } from "@/lib/utils";
|
||||
export function CompactWelcome({ className }: { className?: string }) {
|
||||
const desensitizeText = useDisplayDesensitizer();
|
||||
const { theme } = useTheme();
|
||||
const systemName = useAppSystemName("");
|
||||
const welcomeTitle = systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手";
|
||||
const brandTheme = theme;
|
||||
const welcomeLogo = getWelcomeBrandAsset(brandTheme);
|
||||
const [failedWelcomeLogo, setFailedWelcomeLogo] = useState<string | null>(null);
|
||||
const shouldShowWelcomeLogo =
|
||||
Boolean(welcomeLogo) && failedWelcomeLogo !== welcomeLogo;
|
||||
const isWelcomeLogoVisible = readRuntimeFlag("VITE_WELCOME_LOGO_VISIBLE");
|
||||
|
||||
return (
|
||||
@@ -26,24 +30,44 @@ export function CompactWelcome({ className }: { className?: string }) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 与全量模式共用同一套系统 Logo;简洁模式仅保留欢迎区的展示容器。 */}
|
||||
{/* CM 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%)",
|
||||
}}
|
||||
>
|
||||
<AppBrandImage theme={theme} className="size-16 object-contain" />
|
||||
</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%)",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<h1 className="flex items-center justify-center text-2xl font-bold tracking-tight text-foreground/90">
|
||||
{desensitizeText(welcomeTitle)}
|
||||
{desensitizeText("欢迎使用CM助手")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
|
||||
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"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,7 +26,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
@@ -64,13 +63,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { apiFetch } from "@/core/api";
|
||||
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 { 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 {
|
||||
@@ -79,7 +77,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";
|
||||
@@ -105,19 +103,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,
|
||||
@@ -128,23 +126,22 @@ 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;
|
||||
@@ -152,14 +149,9 @@ 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,
|
||||
@@ -185,15 +177,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;
|
||||
/**
|
||||
@@ -215,16 +207,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[];
|
||||
}) {
|
||||
@@ -241,26 +233,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);
|
||||
@@ -276,9 +268,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;
|
||||
|
||||
@@ -297,12 +289,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 (
|
||||
@@ -348,10 +340,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) => {
|
||||
@@ -362,12 +354,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,
|
||||
});
|
||||
@@ -383,15 +375,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,
|
||||
});
|
||||
@@ -416,7 +408,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ragModel.name,
|
||||
mode: resolveInputMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
ragModel.supports_thinking ?? false,
|
||||
),
|
||||
@@ -446,13 +438,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;
|
||||
@@ -468,19 +460,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);
|
||||
@@ -498,7 +490,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: resolveInputMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
@@ -513,10 +505,10 @@ export function InputBox({
|
||||
},
|
||||
[
|
||||
context,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
composerStatus,
|
||||
resolvedModelName,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
composerStatus,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.execution_profile,
|
||||
@@ -558,9 +550,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();
|
||||
@@ -573,7 +565,7 @@ export function InputBox({
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
},
|
||||
[composerStatus, requestFormSubmit, textInput],
|
||||
[composerStatus, requestFormSubmit, textInput],
|
||||
);
|
||||
|
||||
const confirmReplaceAndSend = useCallback(() => {
|
||||
@@ -604,28 +596,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;
|
||||
@@ -714,46 +706,35 @@ 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
|
||||
@@ -819,9 +800,6 @@ 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 透明无边框,
|
||||
@@ -854,12 +832,9 @@ export function InputBox({
|
||||
<PromptInputTextarea
|
||||
className={cn(
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
|
||||
!composerMinHeight && isNewThread && "min-h-[110px]!",
|
||||
compact && "px-3 pt-3 pb-1 text-[14px]",
|
||||
compact && !composerMinHeight && "min-h-[84px]!",
|
||||
isNewThread && "min-h-[110px]!",
|
||||
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
|
||||
)}
|
||||
data-composer-height={composerMinHeight ? "" : undefined}
|
||||
style={composerHeightStyle}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
autoFocus={autoFocus}
|
||||
@@ -868,12 +843,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>
|
||||
@@ -893,7 +868,7 @@ export function InputBox({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
||||
@@ -948,42 +923,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
|
||||
@@ -1244,7 +1219,6 @@ export function InputBox({
|
||||
</PromptInputActionMenuItem>
|
||||
</>
|
||||
)}
|
||||
{/* 多轮 RAG 问答选项暂时下线,需要恢复时取消下面注释即可
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
multiRoundRagActive
|
||||
@@ -1273,13 +1247,12 @@ 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}
|
||||
@@ -1289,83 +1262,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={
|
||||
@@ -1450,21 +1423,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,23 +1,102 @@
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
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 ordinary = !location.pathname.includes("/knowledge/assistant") && !location.pathname.includes("/knowledge/retrieval-debug");
|
||||
// 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;
|
||||
|
||||
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 {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -449,7 +449,7 @@ function HumanMessageBlock({
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
className="min-h-20 resize-none text-[calc(15px*var(--ui-font-scale,1))] leading-7"
|
||||
className="min-h-20 resize-none text-[15px] leading-7"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
@@ -473,7 +473,7 @@ function HumanMessageBlock({
|
||||
}
|
||||
|
||||
const messageResponse = text ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[calc(15px*var(--ui-font-scale,1))] leading-7">
|
||||
<div className="whitespace-pre-wrap break-words text-[15px] leading-7">
|
||||
{text}
|
||||
</div>
|
||||
) : null;
|
||||
@@ -1353,7 +1353,7 @@ function AssistantDisplayContent({
|
||||
content={part.content}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="chat-assistant-markdown my-3"
|
||||
className="my-3"
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -38,7 +38,6 @@ 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,
|
||||
@@ -490,10 +489,6 @@ 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 =
|
||||
@@ -729,9 +724,7 @@ export function MessageList({
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg)",
|
||||
hasSingleRoundRag
|
||||
? "gap-4 pt-4"
|
||||
: "chat-message-list gap-8 pt-12",
|
||||
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
@@ -754,18 +747,9 @@ export function MessageList({
|
||||
</div>
|
||||
) : null}
|
||||
{(() => {
|
||||
const rewriteItemsAboveFile = documentRewriteItems.filter(
|
||||
(item) => item.operation !== "generate",
|
||||
);
|
||||
const rewriteItemsBelowFile = documentRewriteItems.filter(
|
||||
(item) => item.operation === "generate",
|
||||
);
|
||||
let didRenderRewriteAboveFile = false;
|
||||
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 到达时立即更新该普通消息行。
|
||||
@@ -1205,7 +1189,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,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRightLeftIcon, Loader2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useAgentSkillMigrationSettings,
|
||||
useUpdateAgentSkillMigrationSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export function AgentSkillMigrationSettingsPage() {
|
||||
const { data, isLoading, isError } = useAgentSkillMigrationSettings();
|
||||
const update = useUpdateAgentSkillMigrationSettings();
|
||||
const enabled = data?.enabled ?? false;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ enabled: checked });
|
||||
toast.success(
|
||||
checked
|
||||
? "智能体与技能迁移功能已开启"
|
||||
: "智能体与技能迁移功能已关闭",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<ArrowRightLeftIcon 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,112 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Clock3Icon, DatabaseIcon, GlobeIcon, RefreshCwIcon, SaveIcon, SearchIcon } from "lucide-react";
|
||||
import { DatabaseIcon, RefreshCwIcon } 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[];
|
||||
};
|
||||
|
||||
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})`);
|
||||
}
|
||||
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})`);
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
const [cacheResult, retentionResult] = await Promise.allSettled([
|
||||
requestStatus(),
|
||||
getAuditRetentionStatus(),
|
||||
]);
|
||||
if (cacheResult.status === "fulfilled") {
|
||||
setStatus(cacheResult.value);
|
||||
setTtlInput(String(cacheResult.value.ttl_seconds));
|
||||
try {
|
||||
setStatus(await loadStatus());
|
||||
setError("");
|
||||
} else {
|
||||
setError(cacheResult.reason instanceof Error ? cacheResult.reason.message : "读取缓存设置失败");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "读取设置失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
if (retentionResult.status === "fulfilled") {
|
||||
setRetention(retentionResult.value);
|
||||
setRetentionError("");
|
||||
} else {
|
||||
setRetentionError(retentionResult.reason instanceof Error ? retentionResult.reason.message : "读取清理设置失败");
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
const save = async (update: ApiReadCacheUpdate, message: string) => {
|
||||
const toggle = async (enabled: boolean) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const saved = await requestStatus(update);
|
||||
const saved = await saveStatus(enabled);
|
||||
setStatus(saved);
|
||||
if (update.ttl_seconds !== undefined) setTtlInput(String(saved.ttl_seconds));
|
||||
setError("");
|
||||
toast.success(message);
|
||||
toast.success(enabled ? "Redis 读取缓存已开启" : "Redis 读取缓存已关闭");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "保存设置失败");
|
||||
} finally {
|
||||
@@ -114,184 +66,26 @@ 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" aria-busy={busy}>
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<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">
|
||||
<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">统一管理接口缓存、浏览器状态查询和历史数据定时清理。</p>
|
||||
<p className="font-medium">启用常用读取接口缓存</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">覆盖智能体、技能、个人偏好、常用提示词和推荐问题列表;缓存按用户隔离,并在数据修改后失效。</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void refresh()}>
|
||||
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />刷新状态
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Switch checked={status?.enabled ?? false} disabled={!status || busy} onCheckedChange={(value) => void toggle(value)} aria-label="启用 Redis 读取缓存" />
|
||||
</div>
|
||||
{error && <p role="alert" className="rounded-lg border border-destructive/30 p-3 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<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>
|
||||
{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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -41,23 +41,7 @@ import { migrateAuthToSession } from "@/core/auth/scoped-storage";
|
||||
import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-session";
|
||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { DEFAULT_FONT_SCALE, 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 { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useUpdateUserPreferences,
|
||||
useUserPreferencesQuery,
|
||||
@@ -66,24 +50,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";
|
||||
import {
|
||||
DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM,
|
||||
DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM,
|
||||
MESSAGE_MAP_DISPLAY_MAX_ZOOM,
|
||||
} from "@/core/utils/map-tile-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import {
|
||||
@@ -122,278 +98,6 @@ 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(),
|
||||
@@ -492,45 +196,6 @@ 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;
|
||||
message_map_tile_min_zoom: string;
|
||||
message_map_tile_max_zoom: string;
|
||||
} | null>(null);
|
||||
const runtimeBaseUrl = htmlRuntimeDraft?.base_url ?? htmlRuntime?.base_url ?? "";
|
||||
const runtimeTileUrl =
|
||||
htmlRuntimeDraft?.map_tile_url ?? htmlRuntime?.map_tile_url ?? "";
|
||||
const runtimeTileMinZoom =
|
||||
htmlRuntimeDraft?.message_map_tile_min_zoom ??
|
||||
String(
|
||||
htmlRuntime?.message_map_tile_min_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM,
|
||||
);
|
||||
const runtimeTileMaxZoom =
|
||||
htmlRuntimeDraft?.message_map_tile_max_zoom ??
|
||||
String(
|
||||
htmlRuntime?.message_map_tile_max_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM,
|
||||
);
|
||||
const parsedRuntimeTileMinZoom = Number(runtimeTileMinZoom);
|
||||
const parsedRuntimeTileMaxZoom = Number(runtimeTileMaxZoom);
|
||||
const runtimeTileZoomValid =
|
||||
runtimeTileMinZoom.trim() !== "" &&
|
||||
runtimeTileMaxZoom.trim() !== "" &&
|
||||
Number.isInteger(parsedRuntimeTileMinZoom) &&
|
||||
Number.isInteger(parsedRuntimeTileMaxZoom) &&
|
||||
parsedRuntimeTileMinZoom >= 0 &&
|
||||
parsedRuntimeTileMaxZoom <= MESSAGE_MAP_DISPLAY_MAX_ZOOM &&
|
||||
parsedRuntimeTileMinZoom <= parsedRuntimeTileMaxZoom;
|
||||
const htmlRuntimeDirty =
|
||||
!!htmlRuntime &&
|
||||
(runtimeBaseUrl !== htmlRuntime.base_url ||
|
||||
runtimeTileUrl !== htmlRuntime.map_tile_url ||
|
||||
parsedRuntimeTileMinZoom !==
|
||||
(htmlRuntime.message_map_tile_min_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM) ||
|
||||
parsedRuntimeTileMaxZoom !==
|
||||
(htmlRuntime.message_map_tile_max_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM));
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||
@@ -607,14 +272,6 @@ 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 {
|
||||
@@ -879,160 +536,6 @@ 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 />
|
||||
@@ -1255,11 +758,11 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
<SettingsSection
|
||||
title="字号"
|
||||
description="调整界面字号。默认 22px(超大)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。"
|
||||
description="调整界面字号。默认 16px(标准)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
value={String(settings.display.fontScale ?? DEFAULT_FONT_SCALE)}
|
||||
value={String(settings.display.fontScale ?? 1)}
|
||||
onValueChange={(value) => {
|
||||
const scale = Number(value);
|
||||
if (Number.isFinite(scale) && scale > 0) {
|
||||
@@ -1427,7 +930,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 瓦片,瓦片地址在下面的「地图瓦片地址」里填写。下一次生成的页面生效。
|
||||
管理员配置。生成带地图的 HTML 网页时使用哪种地图。ECharts 使用本地行政区划;地图网页使用 Leaflet 瓦片,地址在后台 config.yaml 的 html_runtime.map_tile_url。下一次生成的页面生效。
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
@@ -1450,147 +953,6 @@ 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,
|
||||
message_map_tile_min_zoom: runtimeTileMinZoom,
|
||||
message_map_tile_max_zoom: runtimeTileMaxZoom,
|
||||
})
|
||||
}
|
||||
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,
|
||||
message_map_tile_min_zoom: runtimeTileMinZoom,
|
||||
message_map_tile_max_zoom: runtimeTileMaxZoom,
|
||||
})
|
||||
}
|
||||
placeholder="https://tiles.example/{z}/{x}/{y}.png"
|
||||
className="mt-1 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="mt-4 rounded-lg border bg-muted/20 p-3">
|
||||
<div className="text-xs font-medium">消息列表地图瓦片层级</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
|
||||
只作用于普通问答消息列表里的地图,生成 HTML 网页暂不受影响。请填写瓦片服务真实存在的层级;地图仍可放大到 {MESSAGE_MAP_DISPLAY_MAX_ZOOM} 级,超过最大真实层级后会放大已有瓦片。
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium" htmlFor="message-map-tile-min-zoom">
|
||||
最小真实层级
|
||||
</label>
|
||||
<Input
|
||||
id="message-map-tile-min-zoom"
|
||||
type="number"
|
||||
min={0}
|
||||
max={MESSAGE_MAP_DISPLAY_MAX_ZOOM}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={runtimeTileMinZoom}
|
||||
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
|
||||
onChange={(event) =>
|
||||
setHtmlRuntimeDraft({
|
||||
base_url: runtimeBaseUrl,
|
||||
map_tile_url: runtimeTileUrl,
|
||||
message_map_tile_min_zoom: event.target.value,
|
||||
message_map_tile_max_zoom: runtimeTileMaxZoom,
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium" htmlFor="message-map-tile-max-zoom">
|
||||
最大真实层级
|
||||
</label>
|
||||
<Input
|
||||
id="message-map-tile-max-zoom"
|
||||
type="number"
|
||||
min={0}
|
||||
max={MESSAGE_MAP_DISPLAY_MAX_ZOOM}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={runtimeTileMaxZoom}
|
||||
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
|
||||
onChange={(event) =>
|
||||
setHtmlRuntimeDraft({
|
||||
base_url: runtimeBaseUrl,
|
||||
map_tile_url: runtimeTileUrl,
|
||||
message_map_tile_min_zoom: runtimeTileMinZoom,
|
||||
message_map_tile_max_zoom: event.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!runtimeTileZoomValid ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
请输入 0~{MESSAGE_MAP_DISPLAY_MAX_ZOOM} 的整数,且最小真实层级不能大于最大真实层级。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={
|
||||
!htmlRuntime ||
|
||||
updateHtmlRuntime.isPending ||
|
||||
!runtimeTileZoomValid ||
|
||||
!htmlRuntimeDirty
|
||||
}
|
||||
onClick={() =>
|
||||
updateHtmlRuntime.mutate(
|
||||
{
|
||||
base_url: runtimeBaseUrl.trim(),
|
||||
map_tile_url: runtimeTileUrl.trim(),
|
||||
message_map_tile_min_zoom: parsedRuntimeTileMinZoom,
|
||||
message_map_tile_max_zoom: parsedRuntimeTileMaxZoom,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHtmlRuntimeDraft(null);
|
||||
toast.success("地址与消息列表地图瓦片层级已保存");
|
||||
},
|
||||
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,12 +10,6 @@ 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;
|
||||
@@ -50,6 +44,8 @@ type Inventory = {
|
||||
months: ArchiveMonth[];
|
||||
};
|
||||
|
||||
const ACTIVE = new Set(["queued", "running"]);
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!bytes) return "尚无归档文件";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
@@ -74,7 +70,6 @@ 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 "尚未归档";
|
||||
@@ -141,10 +136,8 @@ export function ChatArchiveSettingsPage() {
|
||||
}, [inventory, load]);
|
||||
|
||||
const runAction = async (month: ArchiveMonth, action: ArchiveJob["action"]) => {
|
||||
const confirmation = action === "purge"
|
||||
? "确认清理该月已归档的 PostgreSQL 数据?系统会逐会话重新校验 SQLite 备份,只清理校验通过的会话;失败、未归档和正在运行的会话不会清理。"
|
||||
: "确认回滚到 PostgreSQL?月度 SQLite 文件中的归档记录会保留。";
|
||||
if (action !== "archive" && !window.confirm(confirmation)) return;
|
||||
const labels = { archive: "归档到 SQLite", purge: "删除该月 PostgreSQL 数据", rollback: "回滚到 PostgreSQL" };
|
||||
if (action !== "archive" && !window.confirm(`确认${labels[action]}?会话记录会保留在月度 SQLite 文件中。`)) return;
|
||||
setBusyMonth(month.month);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -178,7 +171,7 @@ export function ChatArchiveSettingsPage() {
|
||||
</Alert>
|
||||
<Alert>
|
||||
<AlertTitle>低负载处理方式</AlertTitle>
|
||||
<AlertDescription>{inventory?.warning ?? "月份按会话创建时间展示;归档任务在后台逐会话处理,页面关闭不影响执行;中断后可重新执行。"} 单个会话失败会记录错误并继续处理其他会话;部分归档完成后也可清理已校验的 PG 数据,未归档或校验失败的会话会保留。磁盘不足等整体故障仍会停止任务。删除 PostgreSQL 前会重新校验目标 SQLite 文件中的检查点;清理按会话逐条执行,每条间隔 0.5 秒,运行中的问答会跳过并计入进度,结束后可重新清理。清理后空间可供 PostgreSQL 后续复用;表文件大小不会保证立即缩小,系统不会自动运行可能带来较大 I/O 的 VACUUM FULL。</AlertDescription>
|
||||
<AlertDescription>{inventory?.warning ?? "月份按会话创建时间展示;归档任务在后台逐会话处理,页面关闭或服务重启后可继续。"} 删除 PostgreSQL 前会重新校验目标 SQLite 文件中的检查点;清理按会话逐条执行,每条间隔 0.5 秒,运行中的问答会跳过并计入进度,结束后可重新清理。清理后空间可供 PostgreSQL 后续复用;表文件大小不会保证立即缩小,系统不会自动运行可能带来较大 I/O 的 VACUUM FULL。</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{error && <Alert variant="destructive"><AlertTitle>操作未完成</AlertTitle><AlertDescription className="break-all">{error}</AlertDescription></Alert>}
|
||||
@@ -187,9 +180,8 @@ export function ChatArchiveSettingsPage() {
|
||||
<div className="space-y-3">
|
||||
{inventory.months.map((month) => {
|
||||
const job = month.latest_job;
|
||||
const active = isArchiveMonthActive(month);
|
||||
const displayStatus = archiveMonthDisplayStatus(month);
|
||||
const canPurge = canPurgeArchivedMonth(month);
|
||||
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 canRollback = month.deleted_threads > 0;
|
||||
return (
|
||||
<Card key={month.month}>
|
||||
@@ -199,18 +191,13 @@ 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(displayStatus)}>{statusLabel(displayStatus)}</Badge>
|
||||
<Badge variant={statusTone(month.status)}>{statusLabel(month.status)}</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 || "任务状态已更新"}</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>}
|
||||
<div>{job.message || "任务状态已更新"} {ACTIVE.has(job.status) && `(${job.processed_threads}/${job.total_threads}${job.action === "purge" && job.failed_threads ? `,跳过 ${job.failed_threads}` : ""})`}</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">
|
||||
@@ -226,12 +213,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">{ACTIVE.has(job.status) ? "当前会话" : "最后处理会话"}:{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">当前会话:{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" />{displayStatus === "interrupted" || displayStatus === "partial" ? "重新归档并校验" : month.month === currentLocalMonth() ? "归档本月快照" : month.copied_threads ? "重新校验归档" : "归档到 SQLite"}
|
||||
<ArchiveIcon className="mr-2 size-4" />{month.status === "interrupted" ? "从头重新归档" : 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,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { ListOrderedIcon, Loader2Icon, ShrinkIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2Icon, ShrinkIcon } from "lucide-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,
|
||||
@@ -18,35 +16,6 @@ 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 {
|
||||
@@ -61,33 +30,6 @@ 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">
|
||||
@@ -96,7 +38,7 @@ export function ContextWindowSettingsPage() {
|
||||
上下文管理
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
管理对话历史范围、单个对话轮数、标题生成方式和单次问答步骤上限。设置对所有用户生效,仅管理员可修改。
|
||||
控制长对话发给模型的历史范围。该模式默认关闭,只有管理员显式开启后才会生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -120,105 +62,6 @@ 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">
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Image,
|
||||
ImageOff,
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useDeleteGeneratedPageBrandingSettings,
|
||||
useGeneratedPageBrandingSettings,
|
||||
useUpdateGeneratedPageBrandingSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import {
|
||||
resolveGeneratedPageLogoUrl,
|
||||
type GeneratedPageBrandLogoMode,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
const MAX_LOGO_BYTES = 10 * 1024 * 1024;
|
||||
const ACCEPTED_LOGO_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
]);
|
||||
const DEFAULT_LOGO_URL = "/api/public/html-runtime/brand-logo-dark.png";
|
||||
|
||||
export function GeneratedPageBrandingSettingsPage() {
|
||||
const { data: branding, isPending } = useGeneratedPageBrandingSettings();
|
||||
const updateBranding = useUpdateGeneratedPageBrandingSettings();
|
||||
const deleteBranding = useDeleteGeneratedPageBrandingSettings();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [systemName, setSystemName] = useState("");
|
||||
const [chromeEnabled, setChromeEnabled] = useState(true);
|
||||
const [headerLayout, setHeaderLayout] = useState<"left" | "center">("center");
|
||||
const [logoMode, setLogoMode] =
|
||||
useState<GeneratedPageBrandLogoMode>("custom");
|
||||
const [pendingLogo, setPendingLogo] = useState<File | null>(null);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setChromeEnabled(branding?.chrome_enabled ?? true);
|
||||
setHeaderLayout(branding?.header_layout === "left" ? "left" : "center");
|
||||
setSystemName(branding?.system_name ?? "");
|
||||
setLogoMode(branding?.logo_mode ?? "custom");
|
||||
setPendingLogo(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [
|
||||
branding?.logo_version,
|
||||
branding?.system_name,
|
||||
branding?.footer_text,
|
||||
branding?.logo_mode,
|
||||
branding?.chrome_enabled,
|
||||
branding?.header_layout,
|
||||
]);
|
||||
|
||||
const previewUrl = useMemo(
|
||||
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
|
||||
[pendingLogo],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
},
|
||||
[previewUrl],
|
||||
);
|
||||
|
||||
const currentLogoUrl = resolveGeneratedPageLogoUrl(branding?.logo_url);
|
||||
const defaultLogoUrl = resolveGeneratedPageLogoUrl(DEFAULT_LOGO_URL);
|
||||
const displayedLogoUrl =
|
||||
previewUrl ??
|
||||
(logoMode === "default"
|
||||
? defaultLogoUrl
|
||||
: logoMode === "custom" && branding?.logo_mode === "custom"
|
||||
? currentLogoUrl
|
||||
: undefined);
|
||||
const normalizedName = systemName.trim();
|
||||
const configurationWillBeActive = Boolean(
|
||||
normalizedName &&
|
||||
chromeEnabled &&
|
||||
(logoMode !== "custom" || displayedLogoUrl),
|
||||
);
|
||||
const hasChanges =
|
||||
chromeEnabled !== (branding?.chrome_enabled ?? true) ||
|
||||
headerLayout !== (branding?.header_layout ?? "center") ||
|
||||
normalizedName !== (branding?.system_name ?? "") ||
|
||||
logoMode !== (branding?.logo_mode ?? "custom") ||
|
||||
Boolean(pendingLogo);
|
||||
const busy = updateBranding.isPending || deleteBranding.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);
|
||||
setLogoMode("custom");
|
||||
};
|
||||
|
||||
const handleLogoModeChange = (mode: GeneratedPageBrandLogoMode) => {
|
||||
setLogoMode(mode);
|
||||
if (mode !== "custom") {
|
||||
setPendingLogo(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const logoModeDescription =
|
||||
logoMode === "default"
|
||||
? "保存后,生成网页会使用系统内置的默认头像。"
|
||||
: logoMode === "none"
|
||||
? "保存后,生成网页的品牌页头仅显示系统名称,不显示头像。"
|
||||
: "上传的自定义 Logo 将显示在生成网页的品牌页头。";
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const saved = await updateBranding.mutateAsync({
|
||||
chromeEnabled,
|
||||
headerLayout,
|
||||
systemName: normalizedName,
|
||||
logoMode,
|
||||
logo: pendingLogo,
|
||||
});
|
||||
toast.success("生成网页标识已保存", {
|
||||
description: saved.active
|
||||
? "名称和头像显示方式已配置,之后新生成的 HTML 页面将自动加上品牌页头(不加页脚)。"
|
||||
: "请填写系统名称,并在上传自定义、默认头像或不显示头像中完成选择。",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "生成网页标识保存失败",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await deleteBranding.mutateAsync();
|
||||
setResetOpen(false);
|
||||
toast.success("已清除生成网页标识", {
|
||||
description: "之后生成网页时将恢复原有生成逻辑。",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "清除生成网页标识失败",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title="生成网页标识"
|
||||
description="配置 AI 后续生成的 HTML 网页使用的名称与头像显示方式,并可单独开关平台的自动注入(只注入品牌页头,不加页脚)。这里只影响生成网页内容,不会修改 DeerFlow 自身的名称、导航或 Logo。"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<label
|
||||
htmlFor="generated-page-chrome-enabled"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
自动注入品牌页头
|
||||
</label>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
开启后,平台会给新生成的网页自动加上统一品牌页头(多页面含页面导航),不加页脚。
|
||||
关闭后,生成网页完全不加页头、保持原有输出,也不要求页面自行展示名称或头像。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="generated-page-chrome-enabled"
|
||||
checked={chromeEnabled}
|
||||
onCheckedChange={setChromeEnabled}
|
||||
disabled={isPending || busy}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/20 px-4 py-3 text-sm leading-6">
|
||||
<span className="font-medium">
|
||||
{configurationWillBeActive
|
||||
? "配置完整:"
|
||||
: !chromeEnabled
|
||||
? "自动注入已关闭:"
|
||||
: "当前未启用:"}
|
||||
</span>
|
||||
{configurationWillBeActive
|
||||
? logoMode === "none"
|
||||
? "保存后,新生成网页将自动加上仅含系统名称的品牌页头(不显示头像、不加页脚)。"
|
||||
: "保存后,新生成网页将自动加上品牌页头(名称 + 头像,不加页脚)。"
|
||||
: !chromeEnabled
|
||||
? "生成网页完全不加页头,也不要求页面自行展示名称或头像。"
|
||||
: "请填写系统名称,并选择上传自定义、使用默认头像或不显示头像。"}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="generated-page-brand-name"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
网页中的系统名称
|
||||
</label>
|
||||
<Input
|
||||
id="generated-page-brand-name"
|
||||
value={systemName}
|
||||
onChange={(event) => setSystemName(event.target.value)}
|
||||
placeholder="请输入要在网页页头展示的系统名称"
|
||||
maxLength={80}
|
||||
disabled={isPending || busy}
|
||||
aria-describedby="generated-page-brand-name-help"
|
||||
/>
|
||||
<div
|
||||
id="generated-page-brand-name-help"
|
||||
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-2 sm:grid-cols-3"
|
||||
role="group"
|
||||
aria-label="网页 Logo 显示方式"
|
||||
>
|
||||
{[
|
||||
["custom", "上传自定义"],
|
||||
["default", "使用默认头像"],
|
||||
["none", "不显示头像"],
|
||||
].map(([mode, label]) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
variant={logoMode === mode ? "default" : "outline"}
|
||||
className="h-11 justify-center"
|
||||
aria-pressed={logoMode === mode}
|
||||
onClick={() =>
|
||||
handleLogoModeChange(mode as GeneratedPageBrandLogoMode)
|
||||
}
|
||||
disabled={isPending || busy}
|
||||
>
|
||||
{mode === "custom" ? (
|
||||
<ImageUp aria-hidden />
|
||||
) : mode === "default" ? (
|
||||
<Image aria-hidden />
|
||||
) : (
|
||||
<ImageOff aria-hidden />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-xs leading-5 text-muted-foreground">
|
||||
{logoMode === "none" ? (
|
||||
<ImageOff className="mx-auto mb-1.5 size-6" aria-hidden />
|
||||
) : (
|
||||
<ImageUp className="mx-auto mb-1.5 size-6" aria-hidden />
|
||||
)}
|
||||
{logoMode === "none"
|
||||
? "当前选择不显示头像"
|
||||
: "当前未上传自定义 Logo"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col justify-center gap-3">
|
||||
{logoMode === "custom" ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="generated-page-brand-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>
|
||||
</div>
|
||||
{pendingLogo && (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={pendingLogo.name}
|
||||
>
|
||||
待上传:{pendingLogo.name}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{logoModeDescription}
|
||||
</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={!branding?.customized || busy}
|
||||
>
|
||||
<RotateCcw aria-hidden />
|
||||
清除配置
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11 min-w-28"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isPending || busy}
|
||||
>
|
||||
{updateBranding.isPending && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{updateBranding.isPending ? "保存中..." : "保存网页标识"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
|
||||
<DialogContent aria-describedby="generated-page-brand-reset-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>清除生成网页标识</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p
|
||||
id="generated-page-brand-reset-description"
|
||||
className="text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
将移除网页展示名称与 Logo/头像显示方式,并把自动注入开关恢复为开启。DeerFlow 自身界面不会发生变化,之后生成的 HTML 页面将恢复原有生成逻辑。
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setResetOpen(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleReset} disabled={busy}>
|
||||
{deleteBranding.isPending && (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
)}
|
||||
{deleteBranding.isPending ? "清除中..." : "确认清除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"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, FileOutputIcon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
|
||||
import { Clock3Icon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useRoundtableRoomLimitSettings,
|
||||
useUpdateRoundtableRoomLimitSettings,
|
||||
useWriteFileCompatibilitySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { loadRoundtableRoomSummary } from "@/roundtable-planning/api/rooms";
|
||||
|
||||
@@ -17,10 +16,7 @@ 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),
|
||||
@@ -66,7 +62,6 @@ export function RoundtableRoomSettingsPage() {
|
||||
max_rooms?: number;
|
||||
max_hold_minutes?: number;
|
||||
max_cycles?: number;
|
||||
write_file_compatibility_enabled?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
await update.mutateAsync(next);
|
||||
@@ -220,37 +215,6 @@ 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>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowRightLeftIcon,
|
||||
BellIcon,
|
||||
BrainIcon,
|
||||
CpuIcon,
|
||||
DatabaseIcon,
|
||||
ArchiveIcon,
|
||||
FileOutputIcon,
|
||||
HistoryIcon,
|
||||
ImageIcon,
|
||||
PaletteIcon,
|
||||
SearchIcon,
|
||||
ShieldCheckIcon,
|
||||
ShrinkIcon,
|
||||
SparklesIcon,
|
||||
TypeIcon,
|
||||
UploadCloudIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
@@ -31,25 +27,18 @@ import {
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AgentSkillMigrationSettingsPage } from "@/components/workspace/settings/agent-skill-migration-settings-page";
|
||||
import { AuthenticationSettingsPage } from "@/components/workspace/settings/authentication-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
|
||||
import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page";
|
||||
import { GeneratedPageBrandingSettingsPage } from "@/components/workspace/settings/generated-page-branding-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";
|
||||
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
|
||||
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
||||
@@ -58,21 +47,15 @@ import { ServerDiagnosticsPanel } from "@/pages/ServerDiagnosticsPage";
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "authentication"
|
||||
| "conversation-context"
|
||||
| "model-prompts"
|
||||
| "server-diagnostics"
|
||||
| "api-read-cache"
|
||||
| "worker-pool"
|
||||
| "chat-archive"
|
||||
| "roundtable-rooms"
|
||||
| "knowledge-retrieval"
|
||||
| "appIdentity"
|
||||
| "generatedPageBranding"
|
||||
| "agentSkillMigration"
|
||||
| "memory"
|
||||
| "fileWriting"
|
||||
| "retryKeepPartial"
|
||||
| "uploads"
|
||||
| "tools"
|
||||
| "encoder"
|
||||
@@ -86,7 +69,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
const { defaultSection = "appearance", ...dialogProps } = props;
|
||||
const { t } = useI18n();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const live = useLiveAccount();
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SettingsSection>(defaultSection);
|
||||
|
||||
@@ -115,21 +97,15 @@ 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: "generatedPageBranding", label: "生成网页标识", icon: ImageIcon },
|
||||
{ id: "agentSkillMigration", label: "智能体与技能迁移", icon: ArrowRightLeftIcon },
|
||||
{ id: "memory", label: "问答记忆", icon: BrainIcon },
|
||||
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
|
||||
{ id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon },
|
||||
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
|
||||
{ id: "authentication", label: "登录与注册", icon: ShieldCheckIcon },
|
||||
]
|
||||
: []),
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
@@ -214,39 +190,18 @@ 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 === "authentication" && isAdmin && (
|
||||
<AuthenticationSettingsPage
|
||||
key={live.userId}
|
||||
onClose={() => props.onOpenChange?.(false)}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "appIdentity" && isAdmin && (
|
||||
<AppIdentitySettingsPage />
|
||||
)}
|
||||
{activeSection === "generatedPageBranding" && isAdmin && (
|
||||
<GeneratedPageBrandingSettingsPage />
|
||||
)}
|
||||
{activeSection === "agentSkillMigration" && isAdmin && (
|
||||
<AgentSkillMigrationSettingsPage />
|
||||
)}
|
||||
{activeSection === "memory" && isAdmin && (
|
||||
<MemoryRuntimeSettingsPage />
|
||||
)}
|
||||
{activeSection === "fileWriting" && isAdmin && (
|
||||
<WriteFileCompatibilitySettingsPage />
|
||||
)}
|
||||
{activeSection === "retryKeepPartial" && isAdmin && (
|
||||
<RetryKeepPartialSettingsPage />
|
||||
)}
|
||||
{activeSection === "uploads" && isAdmin && (
|
||||
<UploadSettingsPage />
|
||||
)}
|
||||
|
||||
@@ -11,10 +11,8 @@ import {
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
EyeOffIcon,
|
||||
FileArchive,
|
||||
FileDownIcon,
|
||||
FileUpIcon,
|
||||
EyeOffIcon,
|
||||
FileArchive,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
@@ -31,15 +29,13 @@ import {
|
||||
Upload,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -73,20 +69,7 @@ import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
analyzeSkillMigrationSkip,
|
||||
applyMigrationManifest,
|
||||
exportSkillMigrationManifest,
|
||||
getMigrationRoots,
|
||||
preflightMigrationManifest,
|
||||
} from "@/core/agent-migrations";
|
||||
import type {
|
||||
AgentSkillMigrationManifest,
|
||||
MigrationPreflight,
|
||||
MigrationResourcePreflight,
|
||||
MigrationRoots,
|
||||
} from "@/core/agent-migrations";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
|
||||
@@ -114,7 +97,6 @@ import {
|
||||
type SkillAvailabilityControl,
|
||||
} from "@/core/skill-availability";
|
||||
import {
|
||||
useAgentSkillMigrationSettings,
|
||||
usePublishSquares,
|
||||
useSkillCompressionSettings,
|
||||
useViewMode,
|
||||
@@ -206,15 +188,14 @@ const rescanErrorToastDarkOptions = {
|
||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||
} as const;
|
||||
|
||||
function SkillSettingsList({
|
||||
function SkillSettingsList({
|
||||
onClose,
|
||||
restrictWxTypes = false,
|
||||
}: {
|
||||
onClose?: () => void;
|
||||
restrictWxTypes?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
@@ -243,31 +224,9 @@ function SkillSettingsList({
|
||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||
const [fillModel, setFillModel] = useState<string | null>(null);
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
const { data: migrationSettings } = useAgentSkillMigrationSettings({
|
||||
enabled: isAdmin,
|
||||
});
|
||||
const migrationEnabled = isAdmin && (migrationSettings?.enabled ?? false);
|
||||
const [selectedMigrationNames, setSelectedMigrationNames] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [exportingMigration, setExportingMigration] = useState(false);
|
||||
const [readingMigration, setReadingMigration] = useState(false);
|
||||
const [applyingMigration, setApplyingMigration] = useState(false);
|
||||
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
|
||||
const [migrationManifest, setMigrationManifest] =
|
||||
useState<AgentSkillMigrationManifest | null>(null);
|
||||
const [migrationPreflight, setMigrationPreflight] =
|
||||
useState<MigrationPreflight | null>(null);
|
||||
const [migrationRoots, setMigrationRoots] = useState<MigrationRoots | null>(
|
||||
null,
|
||||
);
|
||||
const [useCurrentAdminForMigration, setUseCurrentAdminForMigration] =
|
||||
useState(false);
|
||||
const [skipInvalidSkills, setSkipInvalidSkills] = useState(false);
|
||||
const migrationInputRef = useRef<HTMLInputElement>(null);
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
// A 岗位 (position) no longer *restricts* the list — it grants skills into the
|
||||
@@ -412,50 +371,10 @@ function SkillSettingsList({
|
||||
setFilter("mine");
|
||||
}
|
||||
}, [filter, squareTabs, restrictWxTypes, defaultSquareTabValue, nanjingTabValue]);
|
||||
const pagedSkills = useMemo(
|
||||
const pagedSkills = useMemo(
|
||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[orderedSkills, page],
|
||||
);
|
||||
const allFilteredMigrationSelected =
|
||||
orderedSkills.length > 0 &&
|
||||
orderedSkills.every((skill) => selectedMigrationNames.has(skill.name));
|
||||
const migrationResources = useMemo(
|
||||
() =>
|
||||
migrationPreflight
|
||||
? [
|
||||
...migrationPreflight.skills,
|
||||
...migrationPreflight.support_directories,
|
||||
]
|
||||
: [],
|
||||
[migrationPreflight],
|
||||
);
|
||||
const migrationProblems = migrationResources.filter(
|
||||
(item) => item.status !== "matched",
|
||||
);
|
||||
const strictUnresolvedMigrationOwners =
|
||||
migrationPreflight?.owner_mappings.filter(
|
||||
(owner) => owner.status === "unresolved",
|
||||
) ?? [];
|
||||
const skipAnalysis =
|
||||
migrationManifest && migrationPreflight
|
||||
? analyzeSkillMigrationSkip(migrationManifest, migrationPreflight)
|
||||
: null;
|
||||
const unresolvedMigrationOwners = skipInvalidSkills
|
||||
? (skipAnalysis?.unresolvedOwners ?? [])
|
||||
: strictUnresolvedMigrationOwners;
|
||||
const migrationProblemsCanBeSkipped =
|
||||
migrationProblems.length > 0 &&
|
||||
!!skipAnalysis &&
|
||||
skipAnalysis.skippedSkillNames.length > 0 &&
|
||||
skipAnalysis.remainingSkillCount > 0 &&
|
||||
skipAnalysis.unskippableProblemCount === 0;
|
||||
const canApplyMigration =
|
||||
!!migrationManifest &&
|
||||
!!migrationPreflight &&
|
||||
(migrationProblems.length === 0 ||
|
||||
(skipInvalidSkills && migrationProblemsCanBeSkipped)) &&
|
||||
(unresolvedMigrationOwners.length === 0 ||
|
||||
useCurrentAdminForMigration);
|
||||
);
|
||||
|
||||
const handleFillNames = async () => {
|
||||
try {
|
||||
@@ -479,7 +398,7 @@ function SkillSettingsList({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
const handleRescan = async () => {
|
||||
try {
|
||||
const result = await reconcileMutation.mutateAsync();
|
||||
if (result.adopted.length > 0) {
|
||||
@@ -500,112 +419,10 @@ function SkillSettingsList({
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMigrationSelection = (name: string) => {
|
||||
setSelectedMigrationNames((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportMigration = async () => {
|
||||
if (selectedMigrationNames.size === 0) return;
|
||||
setExportingMigration(true);
|
||||
try {
|
||||
const manifest = await exportSkillMigrationManifest([
|
||||
...selectedMigrationNames,
|
||||
]);
|
||||
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `skill-sync-${manifest.bundle_id.slice(0, 8)}.json`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(`已导出 ${manifest.skills.length} 个技能的迁移清单`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setExportingMigration(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReadMigration = async (file: File) => {
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
toast.error("技能迁移清单不能超过 20 MB");
|
||||
return;
|
||||
}
|
||||
setReadingMigration(true);
|
||||
setUseCurrentAdminForMigration(false);
|
||||
setSkipInvalidSkills(false);
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
await file.text(),
|
||||
) as AgentSkillMigrationManifest;
|
||||
if (
|
||||
manifest.schema_version !== 1 ||
|
||||
!Array.isArray(manifest.agents) ||
|
||||
!Array.isArray(manifest.skills) ||
|
||||
!Array.isArray(manifest.support_directories)
|
||||
) {
|
||||
throw new Error("不是受支持的技能迁移清单");
|
||||
}
|
||||
if (manifest.skills.length === 0 || manifest.agents.length > 0) {
|
||||
throw new Error(
|
||||
"请选择从技能管理导出的清单;智能体清单请到智能体管理导入",
|
||||
);
|
||||
}
|
||||
const [preflight, roots] = await Promise.all([
|
||||
preflightMigrationManifest(manifest),
|
||||
getMigrationRoots(),
|
||||
]);
|
||||
setMigrationManifest(manifest);
|
||||
setMigrationPreflight(preflight);
|
||||
setMigrationRoots(roots);
|
||||
setMigrationDialogOpen(true);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "技能迁移清单读取失败");
|
||||
} finally {
|
||||
setReadingMigration(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyMigration = async () => {
|
||||
if (!migrationManifest || !canApplyMigration) return;
|
||||
setApplyingMigration(true);
|
||||
try {
|
||||
const result = await applyMigrationManifest(
|
||||
migrationManifest,
|
||||
useCurrentAdminForMigration,
|
||||
skipInvalidSkills,
|
||||
);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["skills"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] }),
|
||||
]);
|
||||
toast.success(
|
||||
`同步完成:导入 ${result.skills_applied} 个技能${result.skills_skipped ? `,跳过 ${result.skills_skipped} 个` : ""}`,
|
||||
);
|
||||
setMigrationDialogOpen(false);
|
||||
setMigrationManifest(null);
|
||||
setMigrationPreflight(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setApplyingMigration(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -684,46 +501,9 @@ function SkillSettingsList({
|
||||
? t.settings.skills.rescanSkillsRunning
|
||||
: t.settings.skills.rescanSkills}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
{migrationEnabled && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleExportMigration()}
|
||||
disabled={
|
||||
selectedMigrationNames.size === 0 || exportingMigration
|
||||
}
|
||||
title="导出已选择技能的状态、归属和标签清单"
|
||||
>
|
||||
{exportingMigration ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<FileDownIcon className="size-4" />
|
||||
)}
|
||||
导出技能清单
|
||||
{selectedMigrationNames.size > 0
|
||||
? `(${selectedMigrationNames.size})`
|
||||
: ""}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => migrationInputRef.current?.click()}
|
||||
disabled={readingMigration}
|
||||
title="识别已复制到运行目录的技能并导入清单信息"
|
||||
>
|
||||
{readingMigration ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<FileUpIcon className="size-4" />
|
||||
)}
|
||||
导入技能清单
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<ModelNameSelect
|
||||
{isAdmin && (
|
||||
<>
|
||||
<ModelNameSelect
|
||||
value={fillModel}
|
||||
onChange={setFillModel}
|
||||
disabled={
|
||||
@@ -806,45 +586,11 @@ function SkillSettingsList({
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{!isLoading && !error && (
|
||||
<div className="flex min-h-9 flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-sm tabular-nums">
|
||||
共 {orderedSkills.length} 个技能
|
||||
{migrationEnabled && selectedMigrationNames.size > 0
|
||||
? ` · 已选择 ${selectedMigrationNames.size} 个`
|
||||
: ""}
|
||||
</p>
|
||||
{migrationEnabled && orderedSkills.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={allFilteredMigrationSelected}
|
||||
onClick={() =>
|
||||
setSelectedMigrationNames((current) => {
|
||||
const next = new Set(current);
|
||||
orderedSkills.forEach((skill) => next.add(skill.name));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
全选当前结果
|
||||
</Button>
|
||||
{selectedMigrationNames.size > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedMigrationNames(new Set())}
|
||||
>
|
||||
清空选择
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && (
|
||||
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
|
||||
共 {orderedSkills.length} 个技能
|
||||
</div>
|
||||
)}
|
||||
{publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||||
@@ -874,33 +620,20 @@ function SkillSettingsList({
|
||||
: "flex flex-col gap-3",
|
||||
)}
|
||||
>
|
||||
{pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
{pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
availabilityControl="owner"
|
||||
layout={viewMode === "card" ? "card" : "row"}
|
||||
onPublisherClick={setPublisher}
|
||||
selected={
|
||||
migrationEnabled && selectedMigrationNames.has(skill.name)
|
||||
}
|
||||
onSelectionToggle={
|
||||
migrationEnabled
|
||||
? () => toggleMigrationSelection(skill.name)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
migrationEnabled &&
|
||||
selectedMigrationNames.has(skill.name) &&
|
||||
"border-destructive bg-destructive/5 ring-1 ring-destructive/30 dark:border-red-500 dark:bg-red-950/20",
|
||||
)}
|
||||
/>
|
||||
onPublisherClick={setPublisher}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -924,222 +657,10 @@ function SkillSettingsList({
|
||||
>
|
||||
{t.settings.skills.paginationNext}
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{migrationEnabled && (
|
||||
<input
|
||||
ref={migrationInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
aria-label="选择技能迁移清单"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) void handleReadMigration(file);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={migrationDialogOpen} onOpenChange={setMigrationDialogOpen}>
|
||||
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>识别技能目录并导入技能信息</DialogTitle>
|
||||
<DialogDescription>
|
||||
只同步技能归属、发布/上下线状态和标签,不覆盖、移动或删除任何目录文件。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{migrationRoots && (
|
||||
<div className="bg-muted/50 space-y-2 rounded-lg border p-3 text-sm">
|
||||
<p className="font-medium">目标机器运行目录</p>
|
||||
<div className="grid gap-1.5 text-xs sm:grid-cols-[6rem_minmax(0,1fr)]">
|
||||
<span className="text-muted-foreground">技能目录</span>
|
||||
<code className="break-all">{migrationRoots.skills_root}</code>
|
||||
{migrationRoots.extensions_config_path && (
|
||||
<>
|
||||
<span className="text-muted-foreground">状态配置</span>
|
||||
<code className="break-all">
|
||||
{migrationRoots.extensions_config_path}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
请先完整复制清单中的技能及支持目录,再执行导入。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{migrationManifest && migrationPreflight && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<SkillMigrationCount
|
||||
label="技能"
|
||||
value={migrationManifest.skills.length}
|
||||
/>
|
||||
<SkillMigrationCount
|
||||
label="支持目录"
|
||||
value={migrationManifest.support_directories.length}
|
||||
/>
|
||||
<SkillMigrationCount
|
||||
label="目录已匹配"
|
||||
value={
|
||||
migrationResources.filter(
|
||||
(resource) => resource.status === "matched",
|
||||
).length
|
||||
}
|
||||
tone="success"
|
||||
/>
|
||||
<SkillMigrationCount
|
||||
label="待处理问题"
|
||||
value={
|
||||
migrationProblems.length + unresolvedMigrationOwners.length
|
||||
}
|
||||
tone={
|
||||
migrationProblems.length +
|
||||
unresolvedMigrationOwners.length >
|
||||
0
|
||||
? "danger"
|
||||
: "success"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{migrationManifest.warnings.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
<p className="font-medium">源机器导出提醒</p>
|
||||
<ul className="text-muted-foreground mt-1 list-disc space-y-1 pl-5 text-xs">
|
||||
{migrationManifest.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{migrationProblems.length > 0 && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
|
||||
<p className="text-destructive text-sm font-medium">
|
||||
{skipInvalidSkills
|
||||
? "以下异常关联的技能将在导入时跳过"
|
||||
: "目录预检未通过,可修正目录或跳过异常技能"}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2 text-xs">
|
||||
{migrationProblems.slice(0, 50).map((item) => (
|
||||
<SkillMigrationProblem
|
||||
key={`${item.kind}:${item.key}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{migrationProblems.length > 50 && (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
另有 {migrationProblems.length - 50} 项未展示。
|
||||
</p>
|
||||
)}
|
||||
{migrationProblemsCanBeSkipped && skipAnalysis && (
|
||||
<label className="mt-3 flex min-h-11 cursor-pointer items-start gap-2 rounded-md border bg-background px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={skipInvalidSkills}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipInvalidSkills(checked === true)
|
||||
}
|
||||
aria-label="跳过未通过目录预检的技能"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">
|
||||
跳过异常技能,继续导入其余技能
|
||||
</span>
|
||||
<span className="text-muted-foreground mt-0.5 block text-xs">
|
||||
将跳过 {skipAnalysis.skippedSkillNames.length} 个,继续导入 {skipAnalysis.remainingSkillCount} 个;被跳过技能不会写入任何元数据。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{!migrationProblemsCanBeSkipped && (
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
当前没有可在跳过异常项后继续导入的技能,请补齐目录后重新预检。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unresolvedMigrationOwners.length > 0 && (
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-sm font-medium">
|
||||
有 {unresolvedMigrationOwners.length} 个源创建者无法按邮箱匹配
|
||||
</p>
|
||||
<div className="text-muted-foreground mt-1 space-y-0.5 text-xs">
|
||||
{unresolvedMigrationOwners.slice(0, 10).map((owner) => (
|
||||
<p key={`${owner.source_user_id}:${owner.email}`}>
|
||||
{owner.email || owner.source_user_id || "未知用户"}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<label className="mt-3 flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
checked={useCurrentAdminForMigration}
|
||||
onCheckedChange={setUseCurrentAdminForMigration}
|
||||
aria-label="将无法匹配的创建者归属当前管理员"
|
||||
/>
|
||||
将无法匹配的创建者归属当前管理员
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{migrationProblems.length === 0 &&
|
||||
unresolvedMigrationOwners.length === 0 && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" />
|
||||
<div>
|
||||
<p className="font-medium">预检通过,可以导入技能信息</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
执行时服务器还会再次校验目录指纹。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{skipInvalidSkills &&
|
||||
migrationProblemsCanBeSkipped &&
|
||||
unresolvedMigrationOwners.length === 0 &&
|
||||
skipAnalysis && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-medium">可以跳过异常技能后继续导入</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
服务器执行时会再次校验,并返回实际导入与跳过数量。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setMigrationDialogOpen(false)}
|
||||
disabled={applyingMigration}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleApplyMigration()}
|
||||
disabled={!canApplyMigration || applyingMigration}
|
||||
>
|
||||
{applyingMigration && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{skipInvalidSkills && skipAnalysis
|
||||
? `跳过 ${skipAnalysis.skippedSkillNames.length} 个并导入`
|
||||
: "导入技能信息"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<InstallSkillDialog
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<InstallSkillDialog
|
||||
open={installOpen}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
isAdmin={isAdmin}
|
||||
@@ -1157,69 +678,15 @@ function SkillSettingsList({
|
||||
skills={archivedVisible}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillMigrationCount({
|
||||
label,
|
||||
value,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: "default" | "success" | "danger";
|
||||
}) {
|
||||
const valueClass =
|
||||
tone === "success"
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: tone === "danger"
|
||||
? "text-destructive"
|
||||
: "text-foreground";
|
||||
return (
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-muted-foreground text-xs">{label}</p>
|
||||
<p className={cn("mt-1 text-xl font-semibold", valueClass)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillMigrationProblem({
|
||||
item,
|
||||
}: {
|
||||
item: MigrationResourcePreflight;
|
||||
}) {
|
||||
const statusLabels: Record<MigrationResourcePreflight["status"], string> = {
|
||||
matched: "已匹配",
|
||||
missing: "目录或关键文件缺失",
|
||||
hash_mismatch: "目录内容不一致",
|
||||
identity_mismatch: "ID/名称不一致",
|
||||
invalid_path: "目录路径不安全",
|
||||
};
|
||||
return (
|
||||
<li className="rounded-md border bg-background px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{item.key}</span>
|
||||
<span className="text-destructive">{statusLabels[item.status]}</span>
|
||||
</div>
|
||||
<code className="text-muted-foreground mt-1 block break-all">
|
||||
{item.relative_dir}
|
||||
</code>
|
||||
{item.message && (
|
||||
<p className="text-muted-foreground mt-1">{item.message}</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
availabilityControl,
|
||||
className,
|
||||
selected = false,
|
||||
onSelectionToggle,
|
||||
layout = "row",
|
||||
layout = "row",
|
||||
onPublisherClick,
|
||||
knowledgeBindings = [],
|
||||
}: {
|
||||
@@ -1228,11 +695,6 @@ export function SkillRow({
|
||||
isAdmin: boolean;
|
||||
/** Which permission boundary exposes the global online/offline control. */
|
||||
availabilityControl?: SkillAvailabilityControl;
|
||||
/** Optional visual state supplied by management surfaces (for example selection). */
|
||||
className?: string;
|
||||
/** When provided, clicking the row/card selects it instead of opening details. */
|
||||
selected?: boolean;
|
||||
onSelectionToggle?: () => void;
|
||||
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||||
layout?: "row" | "card";
|
||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||
@@ -1591,19 +1053,8 @@ export function SkillRow({
|
||||
</>
|
||||
);
|
||||
|
||||
const actionButtons = (
|
||||
<>
|
||||
{onSelectionToggle && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
title="查看技能详情"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
const actionButtons = (
|
||||
<>
|
||||
{canToggleAvailability && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1764,23 +1215,11 @@ export function SkillRow({
|
||||
className={cn(
|
||||
"flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
|
||||
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
role={onSelectionToggle ? "checkbox" : "button"}
|
||||
aria-checked={onSelectionToggle ? selected : undefined}
|
||||
tabIndex={0}
|
||||
onClick={() =>
|
||||
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true);
|
||||
}
|
||||
}}
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
{badgeGroup}
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
||||
@@ -1797,24 +1236,12 @@ export function SkillRow({
|
||||
className={cn(
|
||||
"w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
|
||||
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
|
||||
className,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
role={onSelectionToggle ? "checkbox" : "button"}
|
||||
aria-checked={onSelectionToggle ? selected : undefined}
|
||||
tabIndex={0}
|
||||
onClick={() =>
|
||||
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelectionToggle ? onSelectionToggle() : setDetailOpen(true);
|
||||
}
|
||||
}}
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>{badgeGroup}</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -21,7 +20,6 @@ export function ThreadTitle({
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const appName = useAppSystemName(t.pages.appName);
|
||||
const { isNewThread } = useThreadChat();
|
||||
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
|
||||
|
||||
@@ -34,15 +32,15 @@ export function ThreadTitle({
|
||||
_title = t.pages.newChat;
|
||||
}
|
||||
if (thread.isThreadLoading) {
|
||||
document.title = `Loading... - ${appName}`;
|
||||
document.title = `Loading... - ${t.pages.appName}`;
|
||||
} else {
|
||||
document.title = `${_title} - ${appName}`;
|
||||
document.title = `${_title} - ${t.pages.appName}`;
|
||||
}
|
||||
}, [
|
||||
appName,
|
||||
isNewThread,
|
||||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
displayTitle,
|
||||
|
||||
@@ -5,44 +5,21 @@ import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useHtmlRuntimeSettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM,
|
||||
DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM,
|
||||
getMapTileAttribution,
|
||||
getMapTileUrl,
|
||||
MESSAGE_MAP_DISPLAY_MAX_ZOOM,
|
||||
MESSAGE_MAP_DISPLAY_MIN_ZOOM,
|
||||
} from "@/core/utils/map-tile-config";
|
||||
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
|
||||
|
||||
import {
|
||||
coordinatesMatch,
|
||||
curvedLineCoordinates,
|
||||
flowArrowsForLines,
|
||||
initialMapCamera,
|
||||
messageMapRasterSource,
|
||||
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";
|
||||
|
||||
function createStyle(minNativeZoom: number, maxNativeZoom: number): StyleSpecification {
|
||||
function createStyle(): StyleSpecification {
|
||||
return {
|
||||
version: 8,
|
||||
sources: {
|
||||
"google-raster": messageMapRasterSource(
|
||||
getMapTileUrl(),
|
||||
getMapTileAttribution(),
|
||||
minNativeZoom,
|
||||
maxNativeZoom,
|
||||
),
|
||||
"google-raster": {
|
||||
type: "raster",
|
||||
tiles: [getMapTileUrl()],
|
||||
tileSize: 256,
|
||||
attribution: getMapTileAttribution(),
|
||||
},
|
||||
},
|
||||
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
|
||||
};
|
||||
@@ -56,227 +33,50 @@ function allCoordinates(spec: MapSpec): Array<[number, number]> {
|
||||
];
|
||||
}
|
||||
|
||||
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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[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}`;
|
||||
function defaultCenter(spec: MapSpec): [number, number] {
|
||||
if (spec.center) return spec.center;
|
||||
const coordinates = allCoordinates(spec);
|
||||
if (coordinates.length === 0) return [104.1954, 35.8617];
|
||||
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
|
||||
return [total[0] / coordinates.length, total[1] / coordinates.length];
|
||||
}
|
||||
|
||||
export default function MapRenderer({ spec }: { spec: MapSpec }) {
|
||||
const { data: htmlRuntime } = useHtmlRuntimeSettings();
|
||||
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 minNativeZoom =
|
||||
htmlRuntime?.message_map_tile_min_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MIN_ZOOM;
|
||||
const maxNativeZoom =
|
||||
htmlRuntime?.message_map_tile_max_zoom ?? DEFAULT_MESSAGE_MAP_TILE_MAX_ZOOM;
|
||||
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(minNativeZoom, maxNativeZoom),
|
||||
center: boot.center,
|
||||
zoom: boot.zoom,
|
||||
minZoom: MESSAGE_MAP_DISPLAY_MIN_ZOOM,
|
||||
maxZoom: MESSAGE_MAP_DISPLAY_MAX_ZOOM,
|
||||
style: createStyle(),
|
||||
center: defaultCenter(spec),
|
||||
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
|
||||
minZoom: 1,
|
||||
maxZoom: 20,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
mapRef.current = map;
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
|
||||
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);
|
||||
const markers: maplibregl.Marker[] = [];
|
||||
for (const marker of spec.markers) {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
|
||||
element.title = marker.name;
|
||||
element.setAttribute("aria-label", marker.name);
|
||||
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
|
||||
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
|
||||
);
|
||||
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
|
||||
}
|
||||
|
||||
let baseTileLoaded = false;
|
||||
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
|
||||
@@ -291,60 +91,70 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
|
||||
map.on("sourcedata", onSourceData);
|
||||
map.on("error", onError);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const ensureView = () => {
|
||||
if (!alive || applyingView) return;
|
||||
if (viewReady) {
|
||||
map.resize();
|
||||
redrawOverlay();
|
||||
return;
|
||||
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 },
|
||||
});
|
||||
}
|
||||
applyingView = true;
|
||||
const fitted = applyView();
|
||||
applyingView = false;
|
||||
if (!fitted) return;
|
||||
viewReady = true;
|
||||
map.once("idle", () => {
|
||||
if (!alive) return;
|
||||
map.resize();
|
||||
redrawOverlay();
|
||||
});
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
map.on("load", ensureView);
|
||||
map.once("idle", ensureView);
|
||||
const resizeObserver = new ResizeObserver(ensureView);
|
||||
resizeObserver.observe(container);
|
||||
requestAnimationFrame(ensureView);
|
||||
if (spec.polygons.length > 0) {
|
||||
map.addSource("visual-polygons", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: spec.polygons.map((polygon) => ({
|
||||
type: "Feature",
|
||||
properties: { name: polygon.name ?? "" },
|
||||
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
|
||||
})),
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: "visual-polygons-fill",
|
||||
type: "fill",
|
||||
source: "visual-polygons",
|
||||
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: "visual-polygons-border",
|
||||
type: "line",
|
||||
source: "visual-polygons",
|
||||
paint: { "line-color": "#2563eb", "line-width": 2 },
|
||||
});
|
||||
}
|
||||
|
||||
if (!spec.center && coordinates.length > 1) {
|
||||
const bounds = coordinates.reduce(
|
||||
(current, coordinate) => current.extend(coordinate),
|
||||
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
|
||||
);
|
||||
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
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, lines, markerColors, maxNativeZoom, minNativeZoom, reloadToken, spec]);
|
||||
}, [coordinates, reloadToken, spec]);
|
||||
|
||||
return (
|
||||
<VisualizationCard>
|
||||
@@ -355,18 +165,6 @@ 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" />
|
||||
@@ -387,46 +185,30 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{spec.markers.length > 0 || spec.lines.length > 0 ? (
|
||||
{spec.markers.length > 0 ? (
|
||||
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">查看地点列表</summary>
|
||||
{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}
|
||||
<ul className="mt-2 space-y-1">
|
||||
{spec.markers.map((marker, index) => (
|
||||
<li key={`${marker.name}-${index}`}>
|
||||
<span className="font-medium text-foreground">{marker.name}</span>
|
||||
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
|
||||
{marker.description ? <span>:{marker.description}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</VisualizationCard>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>'"]/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[character] ?? character);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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,
|
||||
messageMapRasterSource,
|
||||
overlayPadding,
|
||||
pointAndBearing,
|
||||
polylineMetrics,
|
||||
resolveMarkerColor,
|
||||
shortenProjectedPath,
|
||||
svgPathFromCoordinates,
|
||||
} from "./map-style.ts";
|
||||
|
||||
test("applies configured native zoom limits to the message-map raster source", () => {
|
||||
const source = messageMapRasterSource(
|
||||
"https://tiles.example/{z}/{x}/{y}.png",
|
||||
"",
|
||||
0,
|
||||
8,
|
||||
);
|
||||
assert.equal(source.minzoom, 0);
|
||||
assert.equal(source.maxzoom, 8);
|
||||
assert.deepEqual(source.tiles, ["https://tiles.example/{z}/{x}/{y}.png"]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
export const MAP_MARKER_PALETTE = [
|
||||
"#2563eb",
|
||||
"#dc2626",
|
||||
"#16a34a",
|
||||
"#d97706",
|
||||
"#7c3aed",
|
||||
"#0891b2",
|
||||
"#db2777",
|
||||
"#4f46e5",
|
||||
] as const;
|
||||
|
||||
export function messageMapRasterSource(
|
||||
tileUrl: string,
|
||||
attribution: string,
|
||||
minNativeZoom: number,
|
||||
maxNativeZoom: number,
|
||||
) {
|
||||
return {
|
||||
type: "raster" as const,
|
||||
tiles: [tileUrl],
|
||||
tileSize: 256,
|
||||
attribution,
|
||||
minzoom: minNativeZoom,
|
||||
maxzoom: maxNativeZoom,
|
||||
};
|
||||
}
|
||||
|
||||
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,12 +2,7 @@ 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 {
|
||||
parseMapColor,
|
||||
parseOptionalBoolean,
|
||||
parseVisualizationSpec,
|
||||
splitVisualizationContent,
|
||||
} from "./visualization-spec.ts";
|
||||
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
|
||||
|
||||
test("ordinary markdown remains one unchanged segment", () => {
|
||||
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
|
||||
@@ -235,139 +230,6 @@ 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,10 +10,6 @@ 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"),
|
||||
@@ -26,7 +22,6 @@ const mapSchema = z.object({
|
||||
name: safeText,
|
||||
coordinate: coordinateSchema,
|
||||
description: optionalText,
|
||||
color: mapColorSchema,
|
||||
}),
|
||||
)
|
||||
.max(500)
|
||||
@@ -36,9 +31,6 @@ 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)
|
||||
@@ -48,7 +40,6 @@ 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)
|
||||
@@ -104,300 +95,13 @@ 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") {
|
||||
const normalized = normalizeMapPayload(record);
|
||||
return mapHasRenderable(normalized) ? normalized : { ...record, type: "map" };
|
||||
}
|
||||
if (language === "map-json") record.type = "map";
|
||||
if (language === "mindmap-json") record.type = "mindmap";
|
||||
return record;
|
||||
}
|
||||
@@ -517,14 +221,18 @@ export type VisualizationSegment =
|
||||
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
|
||||
|
||||
function isMapJsonSource(source: string): boolean {
|
||||
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
|
||||
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),
|
||||
);
|
||||
return hasMapField && parseVisualizationSpec("map-json", source).ok;
|
||||
}
|
||||
|
||||
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
|
||||
|
||||
@@ -2,27 +2,23 @@
|
||||
|
||||
import { ChevronLeft, ChevronRight, Trophy } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { AppBrandImage } from "@/core/branding/app-brand-image";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useAppSystemName } from "@/core/system-settings/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function BrandMark({ className }: { className?: string }) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center",
|
||||
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<AppBrandImage theme={theme} className="size-full object-contain" />
|
||||
C
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,13 +50,12 @@ 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">
|
||||
{systemName}
|
||||
{t.pages.appName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -75,7 +70,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
{state === "collapsed" ? (
|
||||
<div className="group/workspace-header relative flex min-h-[3.75rem] items-center justify-center py-2">
|
||||
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
|
||||
<BrandMark className="size-10" />
|
||||
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
|
||||
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -187,6 +188,7 @@ export function WorkspaceNavChatList({
|
||||
showCollapseTrigger?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const navigate = useNavigate();
|
||||
const { sidebarItems } = useWorkspaceCompactNavItems();
|
||||
@@ -217,42 +219,38 @@ export function WorkspaceNavChatList({
|
||||
{compact ? (
|
||||
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
|
||||
<div className="flex items-center gap-1">
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
{newChatItem && (
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(
|
||||
NEW_CHAT_CLASS,
|
||||
showCollapseTrigger && "min-w-0 flex-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}
|
||||
>
|
||||
<Link
|
||||
href="/page/workspace/chats/new"
|
||||
onClick={handleNewChatClick}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
<span>{newChatItem.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
<MessageSquarePlus />
|
||||
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldAlertIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
TimerIcon,
|
||||
@@ -318,14 +317,6 @@ export function WorkspaceNavMenu() {
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/stream-truncations")}
|
||||
>
|
||||
<ShieldAlertIcon />
|
||||
模型截断记录
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
|
||||
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
|
||||
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
|
||||
|
||||
@@ -19,22 +19,6 @@ import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/p
|
||||
// 主题类型
|
||||
export type ThemeType = BuiltinThemeId | `custom-theme-${string}`;
|
||||
|
||||
export type PresentationThemeContext = {
|
||||
presentation_theme: 'light' | 'dark';
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot only the current light/dark mode for generated HTML. Product
|
||||
* accent palettes are intentionally not copied into generated pages.
|
||||
*/
|
||||
export function getPresentationThemeContext(
|
||||
baseMode: 'light' | 'dark',
|
||||
): PresentationThemeContext {
|
||||
return {
|
||||
presentation_theme: baseMode,
|
||||
};
|
||||
}
|
||||
|
||||
// 主题配置
|
||||
export interface ThemeConfig {
|
||||
type: BuiltinThemeId;
|
||||
|
||||
@@ -133,6 +133,20 @@ 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;
|
||||
@@ -280,6 +294,28 @@ 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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deleteAdminV1Fact,
|
||||
generateUserLeaderboardReport,
|
||||
getUserMemory,
|
||||
getUserAnalytics,
|
||||
getUserLeaderboard,
|
||||
refreshTodayLeaderboard,
|
||||
listUserQa,
|
||||
@@ -161,6 +162,18 @@ 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: ({
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type MigrationResourceStatus =
|
||||
| "matched"
|
||||
| "missing"
|
||||
| "hash_mismatch"
|
||||
| "identity_mismatch"
|
||||
| "invalid_path";
|
||||
|
||||
export interface MigrationOwnerReference {
|
||||
source_user_id?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface MigrationTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AgentMigrationItem {
|
||||
id: string;
|
||||
relative_dir: string;
|
||||
tree_hash: string;
|
||||
name: string;
|
||||
description: string;
|
||||
owner?: MigrationOwnerReference | null;
|
||||
published: boolean;
|
||||
is_private: boolean;
|
||||
square_id: string;
|
||||
featured_order?: number | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
skills: string[];
|
||||
tags: MigrationTag[];
|
||||
}
|
||||
|
||||
export interface SkillMigrationItem {
|
||||
name: string;
|
||||
category: "public" | "custom";
|
||||
relative_dir: string;
|
||||
tree_hash: string;
|
||||
owner?: MigrationOwnerReference | null;
|
||||
published: boolean;
|
||||
detail?: string | null;
|
||||
name_zh?: string | null;
|
||||
always_on: boolean;
|
||||
square_id: string;
|
||||
enabled: boolean;
|
||||
state: "active" | "stale" | "archived";
|
||||
pinned: boolean;
|
||||
featured: boolean;
|
||||
source: "agent" | "upload";
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
tags: MigrationTag[];
|
||||
}
|
||||
|
||||
export interface AgentSkillMigrationManifest {
|
||||
schema_version: 1;
|
||||
bundle_id: string;
|
||||
created_at: string;
|
||||
agents: AgentMigrationItem[];
|
||||
skills: SkillMigrationItem[];
|
||||
support_directories: Array<{
|
||||
relative_dir: string;
|
||||
tree_hash: string;
|
||||
required_by: string[];
|
||||
}>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface MigrationResourcePreflight {
|
||||
kind: "agent" | "skill" | "support";
|
||||
key: string;
|
||||
relative_dir: string;
|
||||
status: MigrationResourceStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MigrationPreflight {
|
||||
ready: boolean;
|
||||
agents: MigrationResourcePreflight[];
|
||||
skills: MigrationResourcePreflight[];
|
||||
support_directories: MigrationResourcePreflight[];
|
||||
owner_mappings: Array<{
|
||||
source_user_id?: string | null;
|
||||
email?: string | null;
|
||||
target_user_id?: string | null;
|
||||
status: "matched" | "unresolved";
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MigrationRoots {
|
||||
agents_root: string;
|
||||
skills_root: string;
|
||||
extensions_config_path?: string | null;
|
||||
}
|
||||
|
||||
export interface MigrationApplyResult {
|
||||
bundle_id: string;
|
||||
agents_applied: number;
|
||||
skills_applied: number;
|
||||
skills_skipped: number;
|
||||
skipped_skill_names: string[];
|
||||
tags_created: number;
|
||||
fallback_owner_count: number;
|
||||
}
|
||||
|
||||
export interface SkillMigrationSkipAnalysis {
|
||||
skippedSkillNames: string[];
|
||||
remainingSkillCount: number;
|
||||
unskippableProblemCount: number;
|
||||
unresolvedOwners: MigrationPreflight["owner_mappings"];
|
||||
}
|
||||
|
||||
export function analyzeSkillMigrationSkip(
|
||||
manifest: AgentSkillMigrationManifest,
|
||||
preflight: MigrationPreflight,
|
||||
): SkillMigrationSkipAnalysis {
|
||||
const failedSkillDirs = new Set(
|
||||
preflight.skills
|
||||
.filter((item) => item.status !== "matched")
|
||||
.map((item) => item.relative_dir),
|
||||
);
|
||||
const skipped = new Set(
|
||||
manifest.skills
|
||||
.filter((item) => failedSkillDirs.has(item.relative_dir))
|
||||
.map((item) => item.name),
|
||||
);
|
||||
const supportByDir = new Map(
|
||||
manifest.support_directories.map((item) => [item.relative_dir, item]),
|
||||
);
|
||||
let unskippableProblemCount = preflight.agents.filter(
|
||||
(item) => item.status !== "matched",
|
||||
).length;
|
||||
for (const problem of preflight.skills.filter(
|
||||
(item) => item.status !== "matched",
|
||||
)) {
|
||||
if (!manifest.skills.some((item) => item.relative_dir === problem.relative_dir)) {
|
||||
unskippableProblemCount += 1;
|
||||
}
|
||||
}
|
||||
for (const problem of preflight.support_directories.filter(
|
||||
(item) => item.status !== "matched",
|
||||
)) {
|
||||
const support = supportByDir.get(problem.relative_dir);
|
||||
const affected = (support?.required_by ?? []).filter((name) =>
|
||||
manifest.skills.some((item) => item.name === name),
|
||||
);
|
||||
if (affected.length === 0) {
|
||||
unskippableProblemCount += 1;
|
||||
} else {
|
||||
affected.forEach((name) => skipped.add(name));
|
||||
}
|
||||
}
|
||||
|
||||
const remainingSkills = manifest.skills.filter(
|
||||
(item) => !skipped.has(item.name),
|
||||
);
|
||||
const remainingOwnerKeys = new Set(
|
||||
remainingSkills
|
||||
.filter(
|
||||
(item) =>
|
||||
item.owner &&
|
||||
(item.owner.source_user_id != null || item.owner.email != null),
|
||||
)
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.owner?.source_user_id ?? ""}\u0000${item.owner?.email?.trim().toLowerCase() ?? ""}`,
|
||||
),
|
||||
);
|
||||
const unresolvedOwners = preflight.owner_mappings.filter(
|
||||
(owner) =>
|
||||
owner.status === "unresolved" &&
|
||||
remainingOwnerKeys.has(
|
||||
`${owner.source_user_id ?? ""}\u0000${owner.email?.trim().toLowerCase() ?? ""}`,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
skippedSkillNames: manifest.skills
|
||||
.filter((item) => skipped.has(item.name))
|
||||
.map((item) => item.name),
|
||||
remainingSkillCount: remainingSkills.length,
|
||||
unskippableProblemCount,
|
||||
unresolvedOwners,
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/admin/agent-skill-migrations`;
|
||||
|
||||
async function parse<T>(response: Response): Promise<T> {
|
||||
if (response.ok) return (await response.json()) as T;
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
detail?: string | { message?: string };
|
||||
};
|
||||
const detail = payload.detail;
|
||||
const message =
|
||||
typeof detail === "string"
|
||||
? detail
|
||||
: detail?.message ?? `请求失败(${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function getMigrationRoots(): Promise<MigrationRoots> {
|
||||
return parse(await apiFetch(`${baseUrl()}/roots`));
|
||||
}
|
||||
|
||||
export async function exportMigrationManifest(
|
||||
agentIds: string[],
|
||||
): Promise<AgentSkillMigrationManifest> {
|
||||
return parse(
|
||||
await apiFetch(`${baseUrl()}/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agent_ids: agentIds }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportSkillMigrationManifest(
|
||||
skillNames: string[],
|
||||
): Promise<AgentSkillMigrationManifest> {
|
||||
return parse(
|
||||
await apiFetch(`${baseUrl()}/export-skills`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ skill_names: skillNames }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function preflightMigrationManifest(
|
||||
manifest: AgentSkillMigrationManifest,
|
||||
): Promise<MigrationPreflight> {
|
||||
return parse(
|
||||
await apiFetch(`${baseUrl()}/preflight`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(manifest),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function applyMigrationManifest(
|
||||
manifest: AgentSkillMigrationManifest,
|
||||
useCurrentAdminForUnresolvedOwners: boolean,
|
||||
skipInvalidSkills = false,
|
||||
): Promise<MigrationApplyResult> {
|
||||
return parse(
|
||||
await apiFetch(`${baseUrl()}/apply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
manifest,
|
||||
use_current_admin_for_unresolved_owners:
|
||||
useCurrentAdminForUnresolvedOwners,
|
||||
skip_invalid_skills: skipInvalidSkills,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./api";
|
||||
@@ -1,41 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,463 +1,462 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
adminSyncAgentSkills,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
favoriteAgent,
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
listSidebarAgents,
|
||||
listAgentsPaginated,
|
||||
listAllAgentsAdminPaginated,
|
||||
listPrivateAgents,
|
||||
listPrivateAgentsPaginated,
|
||||
pinAgent,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentHidden,
|
||||
setAgentPrivate,
|
||||
syncAgentDirectory,
|
||||
unfavoriteAgent,
|
||||
unpinAgent,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSort,
|
||||
CreateAgentRequest,
|
||||
PaginatedAgents,
|
||||
UpdateAgentRequest,
|
||||
} from "./types";
|
||||
import { mergeConversationAgentOptions } from "./conversation-options";
|
||||
|
||||
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
|
||||
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
|
||||
type CachedAgents = Agent[] | PaginatedAgents | undefined;
|
||||
|
||||
function isPaginated(value: CachedAgents): value is PaginatedAgents {
|
||||
return !!value && !Array.isArray(value) && Array.isArray(value.agents);
|
||||
}
|
||||
|
||||
/** Apply ``map`` to every agent inside whatever listing shape is cached. */
|
||||
function mapCachedAgents(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
map: (a: Agent) => Agent,
|
||||
) {
|
||||
queryClient.setQueriesData<CachedAgents>({ queryKey: ["agents"] }, (old) => {
|
||||
if (Array.isArray(old)) return old.map(map);
|
||||
if (isPaginated(old)) return { ...old, agents: old.agents.map(map) };
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
/** Patch an updated agent into every cached agent listing immediately so
|
||||
* cards re-render before the background refetch finishes. Without this the
|
||||
* mutation just invalidates the query and the user has to wait for the
|
||||
* round-trip + render — which on a big list feels like nothing happened.
|
||||
*/
|
||||
function applyAgentToCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
next: Agent,
|
||||
) {
|
||||
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
|
||||
}
|
||||
|
||||
export function useAgents(
|
||||
params: { search?: string; tagIds?: string[]; enabled?: boolean } = {},
|
||||
) {
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", search, [...tagIds].sort()],
|
||||
queryFn: () => listAgents({ search, tagIds }),
|
||||
enabled: params.enabled ?? true,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/** Server-side paginated agent listing for one gallery tab (scope). Returns
|
||||
* the page of agents plus the total count for the "共 N 个" header. */
|
||||
export function useAgentsPaginated(params: {
|
||||
scope: "mine" | "square" | "builtin";
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
enabled?: boolean;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
/** Keep only agents filed under this publish square (发布广场). */
|
||||
square?: string;
|
||||
}) {
|
||||
const { scope, page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const owner = params.owner ?? "";
|
||||
const square = params.square ?? "";
|
||||
const { data, isLoading, isFetching, error } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
"paginated",
|
||||
scope,
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner,
|
||||
square,
|
||||
],
|
||||
queryFn: () =>
|
||||
listAgentsPaginated({
|
||||
scope,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner: owner || undefined,
|
||||
square: square || undefined,
|
||||
}),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
// keepPreviousData keeps isLoading false on subsequent searches; expose
|
||||
// isFetching so callers can show a "searching" indicator on every query.
|
||||
isFetching,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only paginated listing — every agent including unpublished agents
|
||||
* owned by other users and private-square entries, sliced server-side.
|
||||
* Caller must be ``system_role === "admin"`` or the backend will 403.
|
||||
* Sort defaults to ``time_asc`` so the console keeps its historical
|
||||
* oldest-first order.
|
||||
*/
|
||||
export function useAllAgentsAdminPaginated(params: {
|
||||
search?: string;
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
/** Catalog filter: keep only hidden (true) / only visible (false). Omit for both. */
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
const { page, pageSize, hidden } = params;
|
||||
const search = params.search ?? "";
|
||||
const sort = params.sort ?? "time_asc";
|
||||
const { data, isLoading, isFetching, error } = useQuery({
|
||||
queryKey: ["agents", "admin", "paginated", search, sort, page, pageSize, hidden ?? "any"],
|
||||
queryFn: () => listAllAgentsAdminPaginated({ search, sort, page, pageSize, hidden }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgent(
|
||||
id: string | null | undefined,
|
||||
{ enabled = true, retry }: { enabled?: boolean; retry?: boolean | number } = {},
|
||||
) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", id],
|
||||
queryFn: () => getAgent(id!),
|
||||
enabled: enabled && !!id,
|
||||
retry,
|
||||
});
|
||||
return { agent: data ?? null, isLoading, error };
|
||||
}
|
||||
|
||||
export function useCreateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (request: CreateAgentRequest) => createAgent(request),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
request,
|
||||
}: {
|
||||
id: string;
|
||||
request: UpdateAgentRequest;
|
||||
}) => updateAgent(id, request),
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteAgent(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
|
||||
* ``is_favorite`` across every cached agent listing + detail so the star
|
||||
* reacts instantly before the round-trip completes. */
|
||||
export function useToggleAgentFavorite() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
|
||||
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
|
||||
onMutate: ({ id, favorite }) => {
|
||||
mapCachedAgents(queryClient, (a) =>
|
||||
a.id === id ? { ...a, is_favorite: favorite } : a,
|
||||
);
|
||||
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
|
||||
old ? { ...old, is_favorite: favorite } : old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleAgentPin() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
|
||||
pinned ? pinAgent(id) : unpinAgent(id),
|
||||
onMutate: ({ id, pinned }) => {
|
||||
mapCachedAgents(queryClient, (a) =>
|
||||
a.id === id ? { ...a, is_pinned: pinned } : a,
|
||||
);
|
||||
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
|
||||
old ? { ...old, is_pinned: pinned } : old,
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentSelector({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "selector"],
|
||||
queryFn: () => getAgentSelector(),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSidebarAgents({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "sidebar"],
|
||||
queryFn: listSidebarAgents,
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent catalogue for project/ordinary conversations.
|
||||
*
|
||||
* It combines the admin-curated homepage list with every custom agent the
|
||||
* current user can access. This deliberately does not expose unfeatured
|
||||
* built-in workflow workers as standalone chat agents.
|
||||
*/
|
||||
export function useConversationAgentOptions() {
|
||||
const featured = useAgentSelector();
|
||||
const visible = useAgents();
|
||||
return {
|
||||
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
|
||||
isLoading: featured.isLoading || visible.isLoading,
|
||||
error: featured.error ?? visible.error,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
}
|
||||
|
||||
export function useSetAgentFeatured() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
|
||||
setAgentFeatured(id, order),
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderFeaturedAgents() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (agentIds: string[]) => reorderFeaturedAgents(agentIds),
|
||||
onSuccess: () => invalidateAgentQueries(queryClient),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the password-gated private square. ``password`` is sent on every
|
||||
* fetch — wire ``enabled`` to false until the user has typed it so we don't
|
||||
* hit the endpoint with an empty body and get a 403.
|
||||
*/
|
||||
export function usePrivateAgents(password: string | null) {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["agents", "private-square", password ?? ""],
|
||||
queryFn: () => listPrivateAgents(password!),
|
||||
enabled: !!password,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error, refetch };
|
||||
}
|
||||
|
||||
/** Paginated variant of {@link usePrivateAgents} — one page of the
|
||||
* password-gated private square plus the total count. */
|
||||
export function usePrivateAgentsPaginated(
|
||||
password: string | null,
|
||||
params: {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
enabled?: boolean;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
},
|
||||
) {
|
||||
const { page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const owner = params.owner ?? "";
|
||||
const { data, isLoading, isFetching, error, refetch } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
"private-square",
|
||||
password ?? "",
|
||||
"paginated",
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner,
|
||||
],
|
||||
queryFn: () =>
|
||||
listPrivateAgentsPaginated({
|
||||
password: password!,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner: owner || undefined,
|
||||
}),
|
||||
enabled: !!password && enabled,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSetAgentPrivate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
|
||||
setAgentPrivate(id, isPrivate),
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["agents", "private-square"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin-only: hide / restore an agent in the catalog. Hidden agents vanish
|
||||
* from every non-admin listing, so all agent queries are invalidated. */
|
||||
export function useSetAgentHidden() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) =>
|
||||
setAgentHidden(id, hidden),
|
||||
onSuccess: (data) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin gallery action: rescan `.deer-flow/agents/` into the catalog. */
|
||||
export function useSyncAgentDirectory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: syncAgentDirectory,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function useAdminSyncAgentSkills() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: adminSyncAgentSkills,
|
||||
onSuccess: (result) => {
|
||||
if (result.modified > 0) {
|
||||
invalidateAgentQueries(queryClient);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
adminSyncAgentSkills,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
favoriteAgent,
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
listSidebarAgents,
|
||||
listAgentsPaginated,
|
||||
listAllAgentsAdminPaginated,
|
||||
listPrivateAgents,
|
||||
listPrivateAgentsPaginated,
|
||||
pinAgent,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentHidden,
|
||||
setAgentPrivate,
|
||||
syncAgentDirectory,
|
||||
unfavoriteAgent,
|
||||
unpinAgent,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSort,
|
||||
CreateAgentRequest,
|
||||
PaginatedAgents,
|
||||
UpdateAgentRequest,
|
||||
} from "./types";
|
||||
import { mergeConversationAgentOptions } from "./conversation-options";
|
||||
|
||||
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
|
||||
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
|
||||
type CachedAgents = Agent[] | PaginatedAgents | undefined;
|
||||
|
||||
function isPaginated(value: CachedAgents): value is PaginatedAgents {
|
||||
return !!value && !Array.isArray(value) && Array.isArray(value.agents);
|
||||
}
|
||||
|
||||
/** Apply ``map`` to every agent inside whatever listing shape is cached. */
|
||||
function mapCachedAgents(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
map: (a: Agent) => Agent,
|
||||
) {
|
||||
queryClient.setQueriesData<CachedAgents>({ queryKey: ["agents"] }, (old) => {
|
||||
if (Array.isArray(old)) return old.map(map);
|
||||
if (isPaginated(old)) return { ...old, agents: old.agents.map(map) };
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
/** Patch an updated agent into every cached agent listing immediately so
|
||||
* cards re-render before the background refetch finishes. Without this the
|
||||
* mutation just invalidates the query and the user has to wait for the
|
||||
* round-trip + render — which on a big list feels like nothing happened.
|
||||
*/
|
||||
function applyAgentToCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
next: Agent,
|
||||
) {
|
||||
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
|
||||
}
|
||||
|
||||
export function useAgents(
|
||||
params: { search?: string; tagIds?: string[]; enabled?: boolean } = {},
|
||||
) {
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", search, [...tagIds].sort()],
|
||||
queryFn: () => listAgents({ search, tagIds }),
|
||||
enabled: params.enabled ?? true,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/** Server-side paginated agent listing for one gallery tab (scope). Returns
|
||||
* the page of agents plus the total count for the "共 N 个" header. */
|
||||
export function useAgentsPaginated(params: {
|
||||
scope: "mine" | "square" | "builtin";
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
enabled?: boolean;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
/** Keep only agents filed under this publish square (发布广场). */
|
||||
square?: string;
|
||||
}) {
|
||||
const { scope, page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const owner = params.owner ?? "";
|
||||
const square = params.square ?? "";
|
||||
const { data, isLoading, isFetching, error } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
"paginated",
|
||||
scope,
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner,
|
||||
square,
|
||||
],
|
||||
queryFn: () =>
|
||||
listAgentsPaginated({
|
||||
scope,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner: owner || undefined,
|
||||
square: square || undefined,
|
||||
}),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
// keepPreviousData keeps isLoading false on subsequent searches; expose
|
||||
// isFetching so callers can show a "searching" indicator on every query.
|
||||
isFetching,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only paginated listing — every agent including unpublished agents
|
||||
* owned by other users and private-square entries, sliced server-side.
|
||||
* Caller must be ``system_role === "admin"`` or the backend will 403.
|
||||
* Sort defaults to ``time_asc`` so the console keeps its historical
|
||||
* oldest-first order.
|
||||
*/
|
||||
export function useAllAgentsAdminPaginated(params: {
|
||||
search?: string;
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
/** Catalog filter: keep only hidden (true) / only visible (false). Omit for both. */
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
const { page, pageSize, hidden } = params;
|
||||
const search = params.search ?? "";
|
||||
const sort = params.sort ?? "time_asc";
|
||||
const { data, isLoading, isFetching, error } = useQuery({
|
||||
queryKey: ["agents", "admin", "paginated", search, sort, page, pageSize, hidden ?? "any"],
|
||||
queryFn: () => listAllAgentsAdminPaginated({ search, sort, page, pageSize, hidden }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgent(
|
||||
id: string | null | undefined,
|
||||
{ enabled = true, retry }: { enabled?: boolean; retry?: boolean | number } = {},
|
||||
) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", id],
|
||||
queryFn: () => getAgent(id!),
|
||||
enabled: enabled && !!id,
|
||||
retry,
|
||||
});
|
||||
return { agent: data ?? null, isLoading, error };
|
||||
}
|
||||
|
||||
export function useCreateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (request: CreateAgentRequest) => createAgent(request),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
request,
|
||||
}: {
|
||||
id: string;
|
||||
request: UpdateAgentRequest;
|
||||
}) => updateAgent(id, request),
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteAgent(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
|
||||
* ``is_favorite`` across every cached agent listing + detail so the star
|
||||
* reacts instantly before the round-trip completes. */
|
||||
export function useToggleAgentFavorite() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
|
||||
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
|
||||
onMutate: ({ id, favorite }) => {
|
||||
mapCachedAgents(queryClient, (a) =>
|
||||
a.id === id ? { ...a, is_favorite: favorite } : a,
|
||||
);
|
||||
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
|
||||
old ? { ...old, is_favorite: favorite } : old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleAgentPin() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
|
||||
pinned ? pinAgent(id) : unpinAgent(id),
|
||||
onMutate: ({ id, pinned }) => {
|
||||
mapCachedAgents(queryClient, (a) =>
|
||||
a.id === id ? { ...a, is_pinned: pinned } : a,
|
||||
);
|
||||
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
|
||||
old ? { ...old, is_pinned: pinned } : old,
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentSelector({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "selector"],
|
||||
queryFn: () => getAgentSelector(),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSidebarAgents({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "sidebar"],
|
||||
queryFn: listSidebarAgents,
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent catalogue for project/ordinary conversations.
|
||||
*
|
||||
* It combines the admin-curated homepage list with every custom agent the
|
||||
* current user can access. This deliberately does not expose unfeatured
|
||||
* built-in workflow workers as standalone chat agents.
|
||||
*/
|
||||
export function useConversationAgentOptions() {
|
||||
const featured = useAgentSelector();
|
||||
const visible = useAgents();
|
||||
return {
|
||||
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
|
||||
isLoading: featured.isLoading || visible.isLoading,
|
||||
error: featured.error ?? visible.error,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
}
|
||||
|
||||
export function useSetAgentFeatured() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
|
||||
setAgentFeatured(id, order),
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderFeaturedAgents() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (agentIds: string[]) => reorderFeaturedAgents(agentIds),
|
||||
onSuccess: () => invalidateAgentQueries(queryClient),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the password-gated private square. ``password`` is sent on every
|
||||
* fetch — wire ``enabled`` to false until the user has typed it so we don't
|
||||
* hit the endpoint with an empty body and get a 403.
|
||||
*/
|
||||
export function usePrivateAgents(password: string | null) {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["agents", "private-square", password ?? ""],
|
||||
queryFn: () => listPrivateAgents(password!),
|
||||
enabled: !!password,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error, refetch };
|
||||
}
|
||||
|
||||
/** Paginated variant of {@link usePrivateAgents} — one page of the
|
||||
* password-gated private square plus the total count. */
|
||||
export function usePrivateAgentsPaginated(
|
||||
password: string | null,
|
||||
params: {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
enabled?: boolean;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
},
|
||||
) {
|
||||
const { page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const owner = params.owner ?? "";
|
||||
const { data, isLoading, isFetching, error, refetch } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
"private-square",
|
||||
password ?? "",
|
||||
"paginated",
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner,
|
||||
],
|
||||
queryFn: () =>
|
||||
listPrivateAgentsPaginated({
|
||||
password: password!,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner: owner || undefined,
|
||||
}),
|
||||
enabled: !!password && enabled,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSetAgentPrivate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
|
||||
setAgentPrivate(id, isPrivate),
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["agents", "private-square"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin-only: hide / restore an agent in the catalog. Hidden agents vanish
|
||||
* from every non-admin listing, so all agent queries are invalidated. */
|
||||
export function useSetAgentHidden() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) =>
|
||||
setAgentHidden(id, hidden),
|
||||
onSuccess: (data) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin gallery action: rescan `.deer-flow/agents/` into the catalog. */
|
||||
export function useSyncAgentDirectory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: syncAgentDirectory,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminSyncAgentSkills() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: adminSyncAgentSkills,
|
||||
onSuccess: (result) => {
|
||||
if (result.modified > 0) {
|
||||
invalidateAgentQueries(queryClient);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -33,26 +33,6 @@ 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;
|
||||
@@ -73,8 +53,6 @@ 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
|
||||
@@ -93,8 +71,6 @@ 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. */
|
||||
@@ -130,16 +106,12 @@ 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 {
|
||||
@@ -151,8 +123,6 @@ 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;
|
||||
/** 入库模板名;传空字符串表示清除。 */
|
||||
@@ -160,6 +130,4 @@ export interface UpdateAgentRequest {
|
||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
/** 对话跳转;传 null 或空 url 表示清除,仍进入本系统问答。 */
|
||||
chat_link?: AgentChatLink | null;
|
||||
}
|
||||
@@ -7,12 +7,10 @@ import { shouldAttachStoredAuthorization } from "./auth-request-policy";
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
|
||||
function withAuthHeaders(input: RequestInfo | URL, headers?: HeadersInit): Headers {
|
||||
// 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;
|
||||
const next = withCsrfHeaders(headers);
|
||||
const authHeader = shouldAttachStoredAuthorization(requestUrl(input))
|
||||
? getAuthorizationHeaderValue()
|
||||
: undefined;
|
||||
if (authHeader && !next.has("Authorization")) {
|
||||
next.set("Authorization", authHeader);
|
||||
}
|
||||
@@ -51,10 +49,7 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
typeof window !== "undefined" &&
|
||||
!window.location.hash.startsWith("#/login")
|
||||
) {
|
||||
// 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";
|
||||
window.location.replace("#/login?reason=session-expired");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@ 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,
|
||||
@@ -157,25 +152,12 @@ 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, isHtmlArtifact, thread, useWriteFileStreaming]);
|
||||
|
||||
const shouldLoadArtifact =
|
||||
Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath);
|
||||
const htmlRunEpoch = !useWriteFileStreaming && isHtmlArtifact
|
||||
? (thread.isLoading ? "running" : "settled")
|
||||
: null;
|
||||
}, [filepath, thread, useWriteFileStreaming]);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: [
|
||||
@@ -185,35 +167,15 @@ 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: shouldLoadArtifact,
|
||||
enabled: Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath),
|
||||
refetchInterval: thread.isLoading ? 1000 : false,
|
||||
// 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,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
staleTime: 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,26 +17,12 @@ export async function loadArtifactContent({
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
const isSkillArchive = filepath.endsWith(".skill");
|
||||
let enhancedFilepath = filepath;
|
||||
if (isSkillArchive) {
|
||||
if (filepath.endsWith(".skill")) {
|
||||
enhancedFilepath = filepath + "/SKILL.md";
|
||||
}
|
||||
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 url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
||||
const response = await apiFetch(url);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let detail = text.trim();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { fetchWithBindingRetry } from "@/core/auth/login-transport";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
@@ -30,10 +29,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 fetchWithBindingRetry(() => apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
}));
|
||||
});
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(accountError(body, "请求失败,请稍后重试"));
|
||||
return body as T;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { fetchWithBindingRetry } from "@/core/auth/login-transport";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
@@ -15,73 +14,24 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉全部注册用户。`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());
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
|
||||
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`, {
|
||||
@@ -97,7 +47,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`, {
|
||||
@@ -145,15 +95,13 @@ export async function loginByUsername(
|
||||
password != null && password !== ""
|
||||
? `${base}?password=${encodeURIComponent(password)}`
|
||||
: base;
|
||||
// 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, {
|
||||
const response = await apiFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: new URLSearchParams({ username }),
|
||||
}));
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null);
|
||||
throw new Error(extractErrorMessage(error));
|
||||
@@ -183,15 +131,12 @@ export async function loginByToken(
|
||||
|
||||
for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) {
|
||||
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
|
||||
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;
|
||||
}
|
||||
const response = await apiFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
|
||||
return response.json() as Promise<LoginByUsernameResponse>;
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import { listAllUsers, listUsersPaged, setUserApproval, setUserLoginPassword } from "./api";
|
||||
import { listAllUsers, setUserApproval, setUserLoginPassword } from "./api";
|
||||
|
||||
export function useAllUsers(enabled: boolean, includeCounts = true) {
|
||||
export function useAllUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
// "full"/"lite" 后缀区分缓存;放行/改密后按 ["admin","users"] 前缀失效,两种都刷新。
|
||||
queryKey: ["admin", "users", includeCounts ? "full" : "lite"],
|
||||
queryFn: () => listAllUsers(includeCounts),
|
||||
queryKey: ["admin", "users"],
|
||||
queryFn: listAllUsers,
|
||||
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();
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -21,14 +21,6 @@ type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: st
|
||||
| Extract<AccountAccess, { status: "unauthorized" }>
|
||||
| { status: "checking" } | { status: "error"; message: string };
|
||||
|
||||
const INITIAL_CHECK_TIMEOUT_MS = 20_000;
|
||||
const BACKGROUND_CHECK_TIMEOUT_MS = 45_000;
|
||||
const BACKGROUND_WARNING_AFTER = 2;
|
||||
|
||||
function isTransientAuthFailure(error: unknown, aborted: boolean): boolean {
|
||||
return aborted || error instanceof TypeError;
|
||||
}
|
||||
|
||||
/** Keep protected pages unmounted until the configured authentication check succeeds. */
|
||||
export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
children: ReactNode;
|
||||
@@ -38,9 +30,6 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState<GateState>({ status: "checking" });
|
||||
const writingVerifiedAccount = useRef(false);
|
||||
const requestInFlight = useRef(false);
|
||||
const lastAllowedAuth = useRef<ReturnType<typeof getStoredAuth>>(null);
|
||||
const transientFailures = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
@@ -62,19 +51,11 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
const controller = new AbortController();
|
||||
const requestedAuth = getStoredAuth();
|
||||
let disposed = false;
|
||||
requestInFlight.current = true;
|
||||
if (isRecentlyAuthenticatedLogin(requestedAuth)) {
|
||||
setState({ status: "allowed", auth: requestedAuth });
|
||||
return () => controller.abort();
|
||||
}
|
||||
const backgroundRecheck = lastAllowedAuth.current != null
|
||||
&& JSON.stringify(lastAllowedAuth.current) === JSON.stringify(requestedAuth);
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
backgroundRecheck ? BACKGROUND_CHECK_TIMEOUT_MS : INITIAL_CHECK_TIMEOUT_MS,
|
||||
);
|
||||
// A background recheck must not unmount an active editor on every poll.
|
||||
// A changed account/token still blocks immediately until its own check ends.
|
||||
const timeout = setTimeout(() => controller.abort(), 20_000);
|
||||
// Keep the protected tree mounted while a renewed token for the same
|
||||
// account is verified. Unmounting here destroys nested iframes and resets
|
||||
// an active chat even though the user's identity did not change.
|
||||
@@ -96,8 +77,6 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
writingVerifiedAccount.current = true;
|
||||
try {
|
||||
if (result.status === "unauthorized") {
|
||||
lastAllowedAuth.current = null;
|
||||
transientFailures.current = 0;
|
||||
if (requestedAuth) clearStoredAuth();
|
||||
} else if (result.auth && JSON.stringify(result.auth) !== JSON.stringify(getStoredAuth())) {
|
||||
setStoredAuth(result.auth);
|
||||
@@ -105,30 +84,16 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
} finally {
|
||||
writingVerifiedAccount.current = false;
|
||||
}
|
||||
if (result.status === "allowed") {
|
||||
lastAllowedAuth.current = result.auth;
|
||||
transientFailures.current = 0;
|
||||
}
|
||||
setState(result);
|
||||
}).catch((error: unknown) => {
|
||||
if (disposed) return;
|
||||
const message = controller.signal.aborted ? "验证登录超时,请检查网络后重试"
|
||||
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
|
||||
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
|
||||
setState((previous) => {
|
||||
const keepAllowed = previous.status === "allowed"
|
||||
&& isSameAccountIdentity(previous.auth, requestedAuth)
|
||||
&& isSameAccountSession(getStoredAuth(), requestedAuth);
|
||||
if (!keepAllowed) return { status: "error", message };
|
||||
// After a long Q&A the browser is still downloading artifacts / holding
|
||||
// SSE, so the 15s login poll often waits in the 6-connection queue and
|
||||
// aborts. One transient miss is not a real session failure.
|
||||
if (isTransientAuthFailure(error, controller.signal.aborted)) {
|
||||
transientFailures.current += 1;
|
||||
if (transientFailures.current < BACKGROUND_WARNING_AFTER) return previous;
|
||||
}
|
||||
return { ...previous, warning: message };
|
||||
});
|
||||
setState((previous) => previous.status === "allowed"
|
||||
&& isSameAccountIdentity(previous.auth, requestedAuth)
|
||||
&& isSameAccountSession(getStoredAuth(), requestedAuth)
|
||||
? { ...previous, warning: message } : { status: "error", message });
|
||||
}).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
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, ["行为体:媒体", "传播网简介:这条传播网主要讨论暑期文旅。"]);
|
||||
});
|
||||
@@ -20,10 +20,7 @@ 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,
|
||||
type BusinessMapping,
|
||||
} from "@/strategy-components/api/business-mapping";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
|
||||
/** xdfx 业务码:深链传入的是「行动id」,需先调 action 接口解析出真正的「任务id」。 */
|
||||
const XDFX_BUSINESS_CODE = "7BF";
|
||||
@@ -40,8 +37,6 @@ export interface CopTaskDetail {
|
||||
* 1 打击 / 2 塑势 / 3 防御;缺省或无法识别时为空,发送时省略该行。
|
||||
*/
|
||||
taskType?: string;
|
||||
/** 传播网名称。xdfx 在目的或行为体为 null 时,用它请求传播网简介。 */
|
||||
cbwName?: string;
|
||||
}
|
||||
|
||||
/** cop-task-detail ``taskType`` 字典码 → 发给模型的任务类型。 */
|
||||
@@ -78,12 +73,6 @@ 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", // 三情分析
|
||||
@@ -103,33 +92,20 @@ const BUSINESS_TASK_BASE: Record<string, string> = {
|
||||
|
||||
export interface ParsedGoPath {
|
||||
businessCode: string;
|
||||
/** Base goPath, normalized to lowercase and without the optional `-A`. */
|
||||
goPath: string;
|
||||
goPath: "3qfx" | "rwfx" | "xdfx";
|
||||
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
|
||||
roundtable: boolean;
|
||||
}
|
||||
|
||||
/** Normalize any syntactically valid goPath before resolving the mapping. */
|
||||
function parseGoPathToken(goPath: string): Pick<ParsedGoPath, "goPath" | "roundtable"> | null {
|
||||
/** 解析 goPath(大小写不敏感);``-A`` 后缀表示多智能体会商。无法识别返回 null。 */
|
||||
export function parseGoPath(goPath: string): ParsedGoPath | 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, roundtable };
|
||||
return { businessCode, goPath: base as ParsedGoPath["goPath"], roundtable };
|
||||
}
|
||||
|
||||
function consumerBaseUrl(): string {
|
||||
@@ -152,7 +128,6 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
|
||||
taskContent:
|
||||
"围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。",
|
||||
taskType: "1",
|
||||
cbwName: "暑期文旅传播网",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +163,6 @@ 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 });
|
||||
@@ -254,208 +228,6 @@ 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);
|
||||
@@ -558,16 +330,13 @@ export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Prom
|
||||
*
|
||||
* 失败语义与登录页原逻辑一致:行动详情失败按原始 id 回退拉任务详情、
|
||||
* realTaskId 给 ""(调用方回退原始 id);任务详情失败为 null(调用方
|
||||
* 走 stub)。xdfx 同时预取 consumer ``task-action/actionDetail``,失败只是
|
||||
* 开场词里不带目的/行为体等行。这些 promise 均不 reject。
|
||||
* 走 stub)。两个 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 {
|
||||
@@ -577,23 +346,14 @@ 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,
|
||||
actionStructure,
|
||||
taskDetail: actionDetail.then((ad) => fetchCopTaskDetail(ad?.taskId || taskId)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
realTaskId: Promise.resolve(taskId),
|
||||
taskDetail: fetchCopTaskDetail(taskId),
|
||||
actionStructure: Promise.resolve(null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -621,8 +381,6 @@ 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 {
|
||||
@@ -635,7 +393,6 @@ 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)
|
||||
@@ -673,36 +430,10 @@ export async function resolveTaskDeeplinkTarget(
|
||||
taskId: string,
|
||||
rawId?: string,
|
||||
): Promise<TaskDeeplinkTarget | 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 parsed = parseGoPath(goPath);
|
||||
if (!parsed) return null;
|
||||
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
|
||||
if (!isCustom && !taskBase) return null;
|
||||
if (!taskBase) return null;
|
||||
|
||||
// 清掉上一次深链可能残留的任务上下文(单智能体分支会用本次任务重新写入)。
|
||||
clearTaskContext();
|
||||
@@ -716,19 +447,11 @@ export async function resolveTaskDeeplinkTarget(
|
||||
let actionId = "";
|
||||
if (parsed.businessCode === XDFX_BUSINESS_CODE && rawId) {
|
||||
actionId = rawId;
|
||||
const [, ad] = await Promise.all([
|
||||
fetchTaskActionStructure(rawId),
|
||||
readActionDetail() ? Promise.resolve(readActionDetail()) : fetchActionDetail(rawId),
|
||||
]);
|
||||
await ensureNarrativeSummary(rawId, narrativeCbwName(detail, ad));
|
||||
const ad = readActionDetail() ?? (await fetchActionDetail(rawId));
|
||||
actionName = ad?.actionName ?? "";
|
||||
}
|
||||
return {
|
||||
// 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`,
|
||||
path: `${taskBase}/roundtable`,
|
||||
state: {
|
||||
roundtablePrefill: {
|
||||
taskTitle: detail.taskName,
|
||||
@@ -743,15 +466,14 @@ export async function resolveTaskDeeplinkTarget(
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
const agentId = mapping?.agentId ?? null;
|
||||
if (!agentId) {
|
||||
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
|
||||
return null;
|
||||
@@ -767,12 +489,7 @@ export async function resolveTaskDeeplinkTarget(
|
||||
const actionId = rawId || taskId;
|
||||
let actionInfo: ActionOpeningInfo | undefined;
|
||||
if (isXdfx) {
|
||||
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));
|
||||
const ad = rawId ? (readActionDetail() ?? (await fetchActionDetail(rawId))) : null;
|
||||
actionInfo = {
|
||||
actionId,
|
||||
actionName: ad?.actionName,
|
||||
@@ -789,12 +506,5 @@ 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` };
|
||||
}
|
||||
@@ -33,22 +33,7 @@ export interface PurposeTreeNode {
|
||||
children: PurposeTreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 type TaskDeeplinkGoPath = "3qfx" | "rwfx" | "xdfx" | "b5-1";
|
||||
|
||||
/** 当前任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
|
||||
export interface TaskContext {
|
||||
@@ -391,7 +376,13 @@ 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: readTaskDeeplinkGoPath(parsed.goPath),
|
||||
goPath:
|
||||
parsed.goPath === "3qfx" ||
|
||||
parsed.goPath === "rwfx" ||
|
||||
parsed.goPath === "xdfx" ||
|
||||
parsed.goPath === "b5-1"
|
||||
? parsed.goPath
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,15 @@ const headerRuntimeKeys: Record<BrandTheme, string> = {
|
||||
dark: "VITE_BRAND_LOGO_DARK_URL",
|
||||
};
|
||||
|
||||
const welcomeRuntimeKeys: Record<BrandTheme, string> = {
|
||||
light: "VITE_BRAND_WELCOME_LIGHT_URL",
|
||||
lightblue: "VITE_BRAND_WELCOME_LIGHTBLUE_URL",
|
||||
lightgreen: "VITE_BRAND_WELCOME_LIGHTGREEN_URL",
|
||||
iceblue: "VITE_BRAND_WELCOME_ICEBLUE_URL",
|
||||
lightyellow: "VITE_BRAND_WELCOME_LIGHTYELLOW_URL",
|
||||
dark: "VITE_BRAND_WELCOME_DARK_URL",
|
||||
};
|
||||
|
||||
function runtimeConfigBaseUrl(): string | undefined {
|
||||
if (typeof document === "undefined") {
|
||||
return undefined;
|
||||
@@ -68,3 +77,11 @@ export function getHeaderBrandAsset(theme: BrandAssetTheme): string {
|
||||
export function getHeaderBrandFallback(theme: BrandAssetTheme): string {
|
||||
return headerFallbackAssets[resolveBrandTheme(theme)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome content deliberately has no bundled image fallback. An empty
|
||||
* runtime URL keeps the original CM text + gradient treatment.
|
||||
*/
|
||||
export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined {
|
||||
return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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");
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
/** 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";
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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,29 +22,3 @@ 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,15 +1,57 @@
|
||||
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";
|
||||
|
||||
test("ordinary knowledge entries always open native documents", () => {
|
||||
assert.equal(defaultEmbeddedKnowledgeTab(), "documents");
|
||||
assert.equal(defaultEmbeddedKnowledgeTab(null), "documents");
|
||||
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("explicit document, Wiki and graph deep links keep their target", () => {
|
||||
for (const tab of ["documents", "wiki", "graph"] as const) {
|
||||
assert.equal(defaultEmbeddedKnowledgeTab(tab), tab);
|
||||
}
|
||||
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,
|
||||
);
|
||||
});
|
||||