This commit is contained in:
xuhd committed 2026-06-16 17:26:19 +08:00
commit 4511c0dee7
282 files changed
+27745 -5360

No files matched your search

+2 -1
View File
@@ -78,7 +78,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
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)
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **轻应用中心 (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).
- **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/`
Submodule _skills_src/ai-skills added at cde1c8385e.
Submodule _skills_src/anthropics-skills added at 5754626092.
Submodule _skills_src/awesome-claude-skills added at 92568c1eda.
Submodule _skills_src/ce-kit added at d4973c4827.
+1
Submodule _skills_src/csm added at 3fa16a94e0.
Submodule _skills_src/superpowers added at 6fd4507659.
Submodule _skills_src/tapestry-skills added at 80e1dc56df.
@@ -0,0 +1,253 @@
# AI 写作「四阶段内置智能体化」改造方案
> 目标:把 AI 写作现有的 **素材收集专家 / 写作大纲 / 作家写作 / 编辑审核** 四个功能,
> 从「硬编码的 LangGraph 节点 + 硬编码提示词」改造为由**内置功能型智能体**驱动——
> 每个阶段对应一个可配置的内置 agent(独立人设 SOUL、可配模型、可挂技能/工具)。
本文给出现状梳理、目标设计、以及**分步可独立交付的实现计划**。
---
## 0. 一句话结论
不推倒重来。**保留现有图的编排骨架、4 个用户干预点、流式与状态机,只把每个阶段节点的「大脑」从「硬编码 prompt 直调 LLM」替换为「加载对应内置 agent 的 SOUL+配置、在子线程上跑该 agent、按结构化契约解析其输出」。** 这样前端几乎不动,复杂逻辑(严格模式、素材不足求助、逐章流式、技能检索步骤)全部保留。
---
## 1. 现状梳理(精确到文件)
AI 写作是一条 LangGraph 图:`packages/harness/deerflow/agents/ai_writing/graph.py`
```
intent_parser → researcher → (pause 素材确认) → writer_outline → (pause 大纲确认)
→ writer_draft → (pause 草稿确认 / 素材不足求助) → editor → (pause 编辑打回) → done
```
四个待改造阶段(均为**硬编码节点**,直接调 LLM、不走 agent 运行时):
| 阶段(用户语言) | 节点文件 | 提示词 | 产出(`state.py`) |
|---|---|---|---|
| 素材收集专家 | `nodes/researcher.py` | `prompts/researcher_prompts.py` | `material_package`(keywords + materials) |
| 写作大纲 | `nodes/writer_outline.py` | `prompts/writer_prompts.py` | `current_outline`(Outline JSON) |
| 作家写作 | `nodes/writer_draft.py` | `prompts/writer_prompts.py` | `current_draft`(Markdown,逐章流式) |
| 编辑审核 | `nodes/editor.py` | `prompts/editor_prompts.py` | `review_result`(评分 + 问题列表 JSON) |
关键事实:
- **LLM 调用方式**:节点通过 `nodes/_utils.py` 的 `get_model()` + `llm_json()` **直接调 LLM**,提示词写死在 `prompts/*.py`。**没有**走 agent 人设 / 技能 / 工具体系。
- **模型**:全程单一 `state.model_name`(用户在写作表单里选的主模型),各阶段无法分别配。
- **干预点 / 流式 / 严格模式 / 素材不足求助**:都在 `graph.py` 的 pause 节点和 `writer_draft` 里,逻辑较重,需原样保留。
- **前端**:`frontend-web/src/open-canvas/contexts/AIWritingContext.tsx` 是状态机(`researching/writing_outline/writing_draft/reviewing/...`),时间线 `AIWritingTimeline.tsx` 按 `progress_events`(已带 `agent_name` 字段)渲染。
---
## 2. 可复用的现成机制(不用造轮子)
代码库里**已经有**一套成熟的「内置功能型智能体」范式——圆桌的 `roundtable-intent / roundtable-recommender / roundtable-coordinator`:
1. **资源即代码**:`app/gateway/routers/_roundtable_seed_assets/<agent-id>/`
- `config.yaml`(`id` / `name` / `description`,可扩展 `model` / `skills` / `tool_groups`)
- `SOUL.md`(人设 + 职责 + 工作流 + **结构化输出契约**)
2. **种子器**:`app/gateway/routers/_roundtable_seed.py::ensure_roundtable_functional_agents`
- 启动时 + 相关路由入口自愈:目录不存在才从资源字节级落盘到 `.deer-flow/agents/<id>/` + DB(**已存在则永不覆盖**,尊重本地编辑)。
3. **运行**:在**独立 thread** 上跑该内置 agent(见 `app/gateway/routers/intent.py`,`INTENT_AGENT_ID = "roundtable-intent"`),通过 lead agent 运行时把 SOUL.md 作系统提示,流式经 LangGraph `custom`/`messages` 通道透传。
4. **结构化输出约定**:SOUL 规定模型末尾输出固定 **marker + ```json```**(如 `[INTENT_READY]\n```json{...}```),后端用正则解析(`intent.py::_INTENT_READY_PATTERN`)。
> 我们要做的,本质就是**照搬这套范式,给 AI 写作做 4 个功能型 agent**,并让对应的图节点改成「跑这个 agent + 解析其结构化输出」。
另有 agents 通用体系可复用:`agents` 表、`/api/agents` CRUD、智能体管理页(配模型/技能/工具)——4 个写作 agent 落盘后天然出现在这里,可被 admin 编辑。
---
## 3. 目标设计
### 3.1 四个内置写作智能体(建议 id / 职责)
| Agent id | 名称 | 取代节点 | 结构化输出 marker | 产出 schema |
|---|---|---|---|---|
| `ai-writing-researcher` | 素材收集专家 | `researcher` | `[MATERIALS_READY]` | `MaterialPackage` |
| `ai-writing-outliner` | 大纲规划师 | `writer_outline` | `[OUTLINE_READY]` | `Outline` |
| `ai-writing-writer` | 作家 | `writer_draft` | `[SECTION_READY]`(逐章) | 章节 Markdown |
| `ai-writing-editor` | 编辑 | `editor` | `[REVIEW_READY]` | `ReviewResult` |
每个 agent 自带:
- `config.yaml`:`id / name / description` +(扩展)`model`(该阶段专用模型,可空=回退用户主模型)、`skills`(如 researcher 挂检索技能)、`tool_groups`。
- `SOUL.md`:由现有 `prompts/*.py` 内容迁移改写,**追加结构化输出契约**(严格 marker + JSON 格式 + 反例,仿 `roundtable-intent` SOUL 的「硬规则」写法,压制弱模型乱输出)。
### 3.2 节点改造方式(核心)
每个阶段节点保留**输入/输出契约不变**(仍读写 `state` 的同一批字段),内部从:
```
# 旧:硬编码 prompt 直调
model = get_model(config, state["model_name"])
result = llm_json(model, SYSTEM_PROMPT, USER_PROMPT.format(...))
```
改为:
```
# 新:跑对应内置 agent,按 marker 解析
result = await run_writing_agent(
agent_id="ai-writing-outliner",
state=state, config=config,
user_payload=<本阶段输入>, # 意图/素材/大纲/草稿等拼成的一段输入
marker="[OUTLINE_READY]", # 结构化输出契约
)
```
新增统一助手 `nodes/_agent_runner.py::run_writing_agent(...)`:在子 thread 上跑指定内置 agent(复用 `intent.py` 的 run/stream 机制)、把流式 chunk 透传到 `custom` 通道(前端时间线不回归)、用复用的 `_utils.parse_json` 多级容错解析 marker 后的 JSON。
### 3.3 为什么选这个方案(A)而非全多智能体重写(B)
- **方案 A(推荐)**:保留图骨架,逐节点替换大脑。前端基本不动;干预点/流式/严格模式/素材求助原样保留;可**按阶段灰度 + feature flag 回退**,风险可控。
- **方案 B(不推荐)**:仿圆桌重做成多 agent 编排,需重写编排、状态、流式、4 个干预点对接,工作量数倍、回归面巨大,收益(更"纯"的多 agent)对本场景并不必要。
---
## 4. 配置与可管理性
- **落盘**:仿 roundtable 新增 `_ai_writing_seed_assets/` + 一个 `ensure_ai_writing_functional_agents()` 种子器,在 Gateway 启动与 ai_writing 会话入口自愈。
- **管理界面**:4 个 agent 落盘后即出现在智能体管理页,admin 可编辑 **SOUL(人设)/ 模型 / 挂载技能**。可选:在「文章类型配置页」旁加一个「写作智能体配置」入口,或给这 4 个打一个 `tag` 便于筛选。
- **模型优先级**:节点跑 agent 时 `该 agent config.model > 用户写作表单选的主模型 model_name`(保留现有"用户选模型"语义,新增 per-阶段覆盖能力)。
- **技能**:researcher 现有的技能检索能力(`search/skill_search.py`)改为 `ai-writing-researcher` 的**挂载技能**,admin 可增减;检索步骤事件(`SkillStep`)形态保留给前端展示。
---
## 5. 分步实现计划(每步可独立交付 / 验收)
### Phase 0 · 契约冻结
- 定 4 个 agent 的 `id / name`、每个的 **marker + JSON schema**(与 `state.py` 的 `MaterialPackage/Outline/DraftArticle/ReviewResult` 严格对齐)。
- **验收**:本文 §3.1 / §6 的契约评审通过、字段与现有 TypedDict 一一对应。
### Phase 1 · 内置 agent 种子化(后端,纯新增,不接管流程)
- 新建 `app/gateway/routers/_ai_writing_seed_assets/<4 个 agent>/{config.yaml, SOUL.md}`;SOUL 由现有 `prompts/*.py` 迁移改写 + 追加结构化输出契约。
- 仿 `_roundtable_seed.py` 写 `ensure_ai_writing_functional_agents()`,挂到启动 lifespan + ai_writing 入口。
- **验收**:启动后 4 个 agent 出现在 `.deer-flow/agents/`、DB、`/api/agents`,能在智能体管理页查看/编辑;**此阶段不改图,写作流程行为不变**。
### Phase 2 · 节点接入「跑内置 agent」(后端,逐阶段灰度)
- 新增 `nodes/_agent_runner.py::run_writing_agent(...)`(子线程跑 agent + 流式透传 + marker 解析)。
- 加 feature flag:`config.yaml → ai_writing.use_builtin_agents.{editor,outliner,researcher,writer}`(默认 false=走旧节点)。
- **接入顺序(由易到难)**:
1. **editor**(最简单:小 JSON、无检索、无逐章流式)→ flag 打开端到端验证。
2. **outliner**(输出 Outline JSON)。
3. **researcher**(保留技能检索步骤事件、素材确认暂停)。
4. **writer**(最难:保留**逐章流式** + **严格模式素材不足求助** `blocked_sections`;建议**仍由节点控制"逐章循环"**,每章调一次 `ai-writing-writer`,`section_help` 暂停逻辑不动)。
- 每接一个,旧 prompt 与旧逻辑**保留**,节点按 flag 二选一。
- **验收**:每个阶段 flag 打开后端到端走通,产出结构与旧版一致,干预点 / 流式 / 严格模式 / 求助暂停均不回归。
### Phase 3 · 每阶段 agent 配置化
- per-agent 模型覆盖生效(`agent.config.model > model_name`)。
- researcher 的检索能力改为挂载技能,admin 可增减。
- 智能体管理页可编辑这 4 个的 SOUL / 模型 / 技能;config 热加载,新会话即生效、不重启前端。
- **验收**:admin 改某阶段 agent 的模型 / SOUL / 技能后,新会话立即按新配置运行。
### Phase 4 · 前端适配(小改)
- 时间线 / 状态文案:阶段名读对应 agent 的 `name`(`progress_events.agent_name` 已存在,基本沿用,去掉写死文案)。
- 可选:写作表单 / 设置加「查看·配置写作智能体」入口,跳智能体管理页。
- **验收**:前端展示各阶段对应的智能体名;配置入口可达。
### Phase 5 · 清理收口
- 四阶段稳定后,默认打开全部 flag、移除旧 prompt 直调路径与 flag(prompts 历史留在 git)。
- 更新 `offline-backend-20260512/backend/CLAUDE.md` 架构描述。
- **验收**:回归测试通过、冗余删除、文档同步。
---
## 6. 结构化输出契约(与现有 state 对齐)
各 agent SOUL 末尾必须输出 `marker + ```json``` 块,schema 直接对齐 `state.py`:
- `ai-writing-researcher` → `[MATERIALS_READY]`:`{ "keywords": string[], "materials": Material[] }`(`Material` 见 `state.py`,含 `id/title/content/source/relevance_score/source_type/...`)。
- `ai-writing-outliner` → `[OUTLINE_READY]`:`Outline = { "title": string, "sections": OutlineSection[] }`(`OutlineSection = { section_title, key_points: KeyPoint[], material_ids }`)。
- `ai-writing-writer` → 逐章 `[SECTION_READY]`:`{ "section_title": string, "markdown": string, "needs_more_material"?: { "reason": string } }`(`needs_more_material` 触发严格模式求助 `blocked_sections`)。
- `ai-writing-editor` → `[REVIEW_READY]`:`ReviewResult = { "verdict": "pass"|"reject", "fact_score", "logic_score", "language_score", "overall_score", "pass_threshold", "revision_notes": ReviewIssue[] }`。
解析复用 `nodes/_utils.py::parse_json`(多级容错),marker 正则仿 `intent.py::_INTENT_READY_PATTERN`。
---
## 7. 风险与注意
1. **弱模型结构化输出不稳**:SOUL 写严格格式 + 反例 + 「输出 marker 后立刻闭嘴」硬规则(仿 `roundtable-intent` SOUL §0);解析层复用 `parse_json` 多级降级。
2. **writer 的逐章流式 + 严格模式求助最复杂**:建议**循环控制权留在节点**(每章调一次 agent),不要让 agent 一次吐全文,否则 `blocked_sections` / `section_help` / 逐章流式都要重做。
3. **researcher 技能检索步骤事件**(`SkillStep`)前端有专门卡片:迁移时保留这些 `progress_events` 形态。
4. **种子器 never-overwrite**:迭代 SOUL 后**老部署不会自动更新**(与圆桌同一问题)。运维更新办法:删 `.deer-flow/agents/<id>/` 重新种子,或后续给种子器加「内置功能 agent 按版本/hash 覆盖」开关。
5. **子线程隔离**:跑写作 agent 的子 thread 与主 ai_writing thread 状态隔离,注意 checkpointer 与清理(沿用 ai_writing 既有的会话清理)。
6. **离线内网**:researcher 的检索一律走技能(不联网),与现有「离线部署禁 web_search、走 skill」策略一致。
---
## 8. 涉及文件清单(落地时对照)
**后端(主要改动)**
- 新增 `app/gateway/routers/_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}`
- 新增 `app/gateway/routers/_ai_writing_seed.py`(种子器,仿 `_roundtable_seed.py`)
- 新增 `packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py`(`run_writing_agent`)
- 改 `nodes/{researcher,writer_outline,writer_draft,editor}.py`(按 flag 二选一)
- 改 `config.yaml`(新增 `ai_writing.use_builtin_agents.*` 开关;可选 per-agent 默认模型)
- 保留 `prompts/*.py`(回退用,Phase 5 删)
**前端(小改)**
- 改 `src/open-canvas/components/ai-writing/AIWritingTimeline.tsx`(阶段名读 agent name)
- 可选:写作表单/设置加「配置写作智能体」入口(跳 `/page/...` 智能体管理页)
**文档**
- 本文件
- Phase 5 收口时更新 `offline-backend-20260512/backend/CLAUDE.md`
---
## 9. 实施记录(2026-06-11,Phase 1-4 已落地)
### 已交付
- **Phase 1**:`_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}` + `_ai_writing_seed.py::ensure_ai_writing_functional_agents()`,挂启动 lifespan(`_sync_legacy_agents` 之前)+ `ai_writing.list_sessions / list_article_types` 入口自愈。
- **Phase 2**:`nodes/_agent_runner.py`(`stream_writing_agent` / `run_writing_agent` / `parse_marker_json` / `resolve_agent_model_name` / `use_builtin_agent`);四节点按 `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` 二选一(默认全 false,config 按 mtime 热加载,改完即对新会话生效)。agent 目录缺失时节点**静默回退旧 prompt 路径**,写作流程永不因 agent 被删而中断。
- **Phase 3(部分)**:per-agent 模型覆盖已生效(优先级 `agent config.model > 节点显式回退模型(keyword_model/rank_model)> 表单主模型 model_name`);SOUL/模型在智能体管理页可编辑、按 mtime 热加载。researcher「检索能力改挂载技能」未做(仍走节点内 SkillPicker 编排)。
- **Phase 4**:时间线 agent 名由后端 `progress_events.agent_name` 动态下发(flag 开启时读 agent config 的 `name`);`TimelineStep.tsx` 补「大纲规划师」配色(未命中名字回退灰色,admin 改名不报错)。
- **测试**:`tests/test_ai_writing_seed.py`(资源完整性 / 契约 marker / never-overwrite)+ `tests/test_ai_writing_agent_runner.py`(marker 多级解析 / 模型优先级 / flag / 流式端到端 / SOUL 缺失降级),共 31 例。
### 与 §6 契约的修订(以实现为准)
为保住「逐章流式打字机」「review_chunk/review_item_done 分维度卡片」「素材确认/求助暂停」等前端形态,编排控制权全部留在节点,agent 只承担**单次结构化产出**:
| Agent | 实际契约 |
|---|---|
| `ai-writing-researcher` | 两个子任务:`[KEYWORDS_READY]` + `{"keywords": []}`;`[MATERIALS_RANKED]` + `{"ranked_ids": [], "summary": ""}`。素材本体来自节点的检索编排(agent 不联网),`MaterialPackage` 仍由节点组装 |
| `ai-writing-outliner` | `[OUTLINE_READY]` + 完整 `Outline` JSON(新规划 / 修改模式共用,payload 开头标明任务模式) |
| `ai-writing-writer` | **无 JSON marker**:每章直接输出 Markdown 正文(保流式);严格模式素材不足只输出一行 `[[SECTION_BLOCKED]] 原因`(沿用原哨兵) |
| `ai-writing-editor` | 每个维度一次调用:`[REVIEW_READY]` + `{"score": int, "issues": []}`;综合分/verdict/pass_threshold 仍由节点按 config 计算(不信任 LLM 自判通过) |
另一处架构修订:**不在子 thread 上经 lead agent 运行时跑**(§3.2 原设想复用 `intent.py` 机制,但其 loopback 在 app 层,harness 图节点不可 import;且单轮结构化调用无需 thread/checkpointer/中间件成本)。`run_writing_agent` 直接「SOUL 作 system prompt + 单条输入」调模型,规避了 §7.5 的子线程隔离/清理风险。researcher 挂技能(Phase 3 剩余项)如需完整工具体系再评估接入运行时。
### 已知存量测试失败(与本次无关)
`test_ai_writing_graph.py`(mock 已删除的 `intent_parser.llm_json`)、`test_ai_writing_word_count.py`(mock 已删除的 `writer_draft.get_model`)、`test_ai_writing_draft_headings.py`(断言三级结构升级前的 `####→###` 行为)、`test_ai_writing_outline_revise.py`(断言旧 prompt 文案),改造前即失败,待另行清理。
## 10. Phase 3 收尾:检索类型两项化 + 配置技能驱动检索(2026-06-13 已实施)
本节落地了 §9 遗留的「researcher 检索改挂载技能」,并按新需求重新设计了检索类型与 PAUSE-1 交互。
### 10.1 检索类型(material_source)收敛为两项
- 写作表单「素材来源」改名「检索类型」,只剩 `general`(通用检索,默认)/ `notebook`(我的空间);旧值 `knowledge_base` / `skill` 由 researcher 节点按 `general` 兼容(老会话 resume 不受影响)。
- 前端:`AIWritingForm.tsx`(选项/标签)、`WritingFormContext.tsx`(默认值/类型/applyProposal 旧值映射)、`types/ai-writing.ts`、`message-list.tsx`(审批卡标签兼容旧值)。
### 10.2 通用检索 = 素材收集专家配置的技能编排
- `ai-writing-researcher` agent 的 `config.yaml → skills` 是权威配置(智能体管理页可增删/替换);种子默认 `[knowledge-base-search]`。
- 内置技能 **knowledge-base-search(知识库检索)**:把原 web/intranet 关键词并发检索包装成技能。researcher 对它特殊处理——不拼 description,直接对 Step1 生成的检索词做原生并发检索(行为与旧 knowledge_base 模式一致)。其它配置技能按 `意图 + description` 拼定向 query 检索。按配置顺序执行、累积达 `max_materials` 早停(`stop_early`)。
- 技能配置为空/全部失效 → 内存兜底回退知识库检索(`researcher._load_configured_skills`),检索永不瘫痪。
- 种子:`_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` → `skills/public/`(never-overwrite);旧部署的 researcher `config.yaml` 缺 `skills` 键时就地补默认(显式 `skills: []` 不动)。`_ai_writing_seed.py::ensure_ai_writing_functional_agents()` 一并处理。
### 10.3 PAUSE-1 输入框 → 匹配技能检索并**追加**
- 用户在素材确认卡输入自然语言(如「检索 A 的数据」)→ `re_search + user_query`:配置技能 >1 个时 `SkillPicker(candidates=配置技能)` 让模型按输入匹配排序(流式打字机保留),单技能直接用;用用户原话(非知识库技能附 description)检索。
- **追加语义**:保留原素材列表与顺序,新素材按 URL 去重后追加在末尾(ID 续号),跳过 LLM 重排与 max_materials 截断(仅 100 条硬上限),summary 显示「追加检索到 X 条新素材,当前共 Y 条」。原 20 条 + 新 20 条 = 40 条。
- 「技能检索」独立按钮已删除(通用检索本来就走技能编排);后端继续兼容旧 `enable_skill_search` action(按 user_query 追加处理)。无输入的「重新搜索」仍为覆盖式重检。
- 前端:`InterventionCard.tsx`(按钮/文案,hasQuery 时按钮文案「检索并追加素材」)、`MaterialsToolbar.tsx`(提示语)、`useAIWritingStream.ts`(素材卡回到 researching 一律置 skillSearchPending,让 SkillStepsCard 占位即时挂载)。
### 10.4 测试
- `tests/test_ai_writing_material_source.py` 重写(9 例):缺省/旧值→通用检索、自定义技能 description query、user_query 追加+URL 去重+多技能 picker 匹配(candidates 断言)、无输入重检覆盖、空配置回退。
- `tests/test_ai_writing_seed.py` 扩展(+5 例):技能资产存在、researcher 种子含默认 skills、技能种子 never-overwrite、缺 skills 键就地补丁、显式 `[]` 不动。
@@ -1,228 +0,0 @@
# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。
---
## 一、功能概览
| 交互 | 用户感知 | 组件 | 触发方式 |
|------|----------|------|----------|
| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` |
| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` |
| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 |
画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。
---
## 二、在 AI 写作中的挂载路径
```mermaid
flowchart TB
AIWritingPage --> CanvasResizableLayout
CanvasResizableLayout --> AIWritingDraftPanel
AIWritingDraftPanel --> DraftStreamingPreview
AIWritingDraftPanel --> TextArtifactRenderer
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
TextArtifactRenderer --> WritingBubbleMenu
TextArtifactRenderer --> WritingParagraphMenu
TextArtifactRenderer --> TiptapEditor
TextArtifactRenderer --> WritingAiMenu
```
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|------|----------|-----------------|
| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** |
| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** |
| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) |
相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
入口文件:
- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer`
- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单
---
## 三、技术栈:编辑器 vs 浮层
### 3.1 Tiptap 编辑器(有使用)
| 包 | 用途 |
|----|------|
| `@tiptap/react` | `useEditor`、`EditorContent` |
| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 |
| `@tiptap/extension-placeholder` | 空文档占位符 |
| `tiptap-markdown` | Markdown ↔ 文档互转 |
| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) |
扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。
### 3.2 浮层菜单(自研,非 Tiptap 官方插件)
| 包 | 是否用于滑词/段落菜单 |
|----|----------------------|
| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) |
| `@tiptap/react` 的 `<BubbleMenu>` | **未使用** |
两套菜单均采用:
1. 监听 Tiptap / DOM 事件更新位置与选区;
2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切;
3. `position: fixed` + `getBoundingClientRect()` 定位。
命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
---
## 四、核心文件索引
| 文件 | 职责 |
|------|------|
| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 |
| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight |
| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 |
| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable |
| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 |
| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 |
| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 |
| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 |
| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 |
| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 |
| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 |
| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 |
---
## 五、滑词:`WritingBubbleMenu`
### 5.1 行为
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。
- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。
- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。
### 5.2 实现要点
```text
editor.on("selectionUpdate")
→ 读取 editor.state.selection (from, to, empty)
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
```
关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。
点击「AI 改写」时:
1. `editor.commands.setAiHighlight(from, to)` 高亮选区;
2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。
---
## 六、段落菜单:`WritingParagraphMenu`
### 6.1 行为
- 鼠标在编辑器内移动时,检测光标下的**块级节点**;
- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮;
- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
### 6.2 实现要点
```text
editor.view.dom mousemove
→ editor.view.posAtCoords({ left, top })
→ doc.resolve(pos).before(depth) 定位块起点
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
```
点击 ✨ 时:
1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`);
2. `setAiHighlight` + `setWritingSelection`;
3. 触发与滑词相同的 `onAiRewrite()` 流程。
关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。
---
## 七、AI 改写数据流
```mermaid
sequenceDiagram
participant User
participant Menu as BubbleMenu / ParagraphMenu
participant Ctx as CanvasUIContext
participant Renderer as TextArtifactRenderer
participant API as rewrite API
User->>Menu: 选中文本或点段落 ✨
Menu->>Ctx: setWritingSelection
Menu->>Renderer: onAiRewrite()
Renderer->>Renderer: phase = menu, WritingAiMenu
User->>Renderer: 选择润色/扩写等
Renderer->>API: useWritingRewrite.rewrite
API-->>Renderer: 流式结果
Renderer->>Renderer: WritingRewritePreview
User->>Renderer: 接受改写
Renderer->>Renderer: applyReplace / applyInsert
```
- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。
- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
---
## 八、与顶部工具栏、目录栏的关系
同一 `TextArtifactRenderer` 编辑模式下还包含:
| 组件 | 作用 |
|------|------|
| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 |
| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading |
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。
---
## 九、开发与改造建议
### 9.1 若要改用官方 Bubble Menu
1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`);
2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准);
3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
### 9.2 新增浮层按钮
- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。
- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。
### 9.3 流式阶段禁止交互
不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。
---
## 十、相关文档
- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表
- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验)
---
## 十一、依赖版本(摘录)
以 [package.json](../package.json) 为准,编辑器相关主要为:
- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4`
- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现
- `tiptap-markdown`:Markdown 扩展
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。
@@ -1,274 +0,0 @@
# AI 写作 ·「用户自带大纲」手动测试用例
> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。
> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx)
---
## 一、测试目标
验证三件事:
1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。
2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。
3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。
---
## 二、前置条件
| 项 | 要求 |
|----|------|
| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 |
| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) |
| 账号 | 已登录(或无鉴权模式) |
| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 |
---
## 三、通用操作步骤
除非用例另有说明,每条用例都按以下步骤执行:
1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。
2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。
3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。
4. 文章类型 / 目标字数 / 素材来源保持默认即可。
5. 点击「开始 AI 写作」。
6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。
7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。
> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。
---
## 四、测试用例
### TC-01 Markdown 三级 + 文章标题
**输入大纲:**
```
# 新能源汽车出口为何高速增长
## 引言
### 出口数据创新高
#### 同比增幅
#### 全球排名变化
### 市场背景
## 增长动因
### 成本优势
```
**预期大纲结构:**
- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节)
- 章节1 `引言`
- 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化`
- 小节 `市场背景`(无子要点)
- 章节2 `增长动因`
- 小节 `成本优势`
**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。
---
### TC-02 中文编号三级(一、/(一)/ 1.)
**输入大纲:**
```
一、引言
(一)背景
(二)意义
二、现状分析
(一)市场数据
1. 国内市场
2. 海外市场
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场`
- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字)
**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。
---
### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1)
**输入大纲:**
```
1. 引言
1.1 研究背景
1.2 研究意义
2. 现状
2.1 行业概况
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `研究背景`、`研究意义`
- 章节2 `现状` → 小节 `行业概况`
---
### TC-04 缩进 bullet 表示层级
**输入大纲:**
```
- 引言
- 背景
- 意义
- 现状
- 数据
- 细节展开
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开`
**校验重点:** 缩进深度正确映射成层级。
---
### TC-05 只有一级目录(平铺 Markdown)
**输入大纲:**
```
## 引言
## 现状
## 对策
## 结论
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。
**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。
**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。
---
### TC-06 只有一级目录(中文编号)
**输入大纲:**
```
一、引言
二、现状
三、结论
```
**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。
---
### TC-07 纯文本平铺(无任何标记)
**输入大纲:**
```
引言
现状
对策
总结
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。
**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。
---
### TC-08 格式混乱 / 散文化(走 LLM 兜底)
**输入大纲:**
```
我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。
希望先讲背景,再讲现状,最后给出展望。
```
**预期行为:**
- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。
- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。
- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。
**校验重点:** 流程不崩、能产出可用大纲。
---
### TC-09 保真校验(标题原样保留)
**输入大纲:**
```
一、A股市场2024年回顾与展望
(一)宏观经济(GDP/CPI)影响
(二)流动性与利率
二、行业轮动主线
```
**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离:
- 章节1 `A股市场2024年回顾与展望`
- 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率`
- 章节2 `行业轮动主线`
**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。
---
### TC-10 大纲确认卡里再编辑 / 重新规划
**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。
**操作:**
1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。
2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。
**预期行为:**
- 手动改后确认:正文按改后的大纲生成。
- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。
**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。
---
### TC-11 回归:不填大纲(默认流程不受影响)
**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。
**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。
**校验重点:** 增量功能不污染老流程。
---
### TC-12 素材关联(仍走检索)
**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。
**预期行为:**
- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。
- 关联**只填素材、不改大纲标题与结构**。
**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。
---
## 五、缺陷记录模板
| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 |
|------|------------------|------|------|----------|------|
| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | |
| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | |
| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | |
| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | |
| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | |
| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | |
| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | |
| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | |
| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | |
| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | |
| TC-11 | 留空 | 走自动规划 | | ☐ | |
| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | |
---
## 六、备注
- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。
- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。
- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。
@@ -0,0 +1,154 @@
# 公开页 · iframe 嵌入外观对接说明(主题 / 背景色)
本文档面向**嵌入方(宿主系统)**,说明如何在用 `<iframe>` 嵌入 DeerFlow 公开页时,从外部控制页面的**整体明暗主题(light/dark)**与**背景色 / 文字色**,使其与宿主站点的视觉风格保持一致。
前端实现见:`src/core/embed/background.ts`(参数解析与 postMessage 监听)、`src/App.tsx`(主题接入 `forcedTheme`)。
---
## 0. 速读
| 想做什么 | 怎么做 |
|----------|--------|
| 嵌入时指定深色主题 | iframe `src` 加 `?theme=dark` |
| 嵌入时指定背景/文字色 | iframe `src` 加 `?bg=%23001529&text=%23ffffff`(`#` 需编码为 `%23`) |
| 宿主切主题时同步切换 | `postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*")` |
| 宿主动态改背景色 | `postMessage({ type: "deerflow:set-bg", bg: "#001529", text: "#fff" }, "*")` |
| 什么都不传 | 页面使用 DeerFlow 自带默认配色,互不影响 |
---
## 1. 适用页面
以下公开页(免登录、只读)均支持本文档的全部参数:
| 页面 | 路由 |
|------|------|
| 会话分享页 | `/#/share/{shareCode}` |
| 圆桌分享页 | `/#/roundtable-share/{shareCode}`、`/#/roundtable-share/{shareCode}/report` |
| 用户排行榜 | `/#/public/leaderboard` |
| 定时任务公开页 | `/#/public/scheduled-tasks/{taskId}`(含 `/runs/{runId}/fullscreen` 全屏页) |
> 注意:站点使用 **hash 路由**,路径在 `#` 之后。
> 嵌入聊天页(`/#/embed/chats/*`、`/#/embed/roundtable`)有自己的主题约定,见 `embed-iframe-chat.md`,不在本文范围内。
---
## 2. URL 查询参数(嵌入时一次性指定)
### 2.1 参数列表
| 参数 | 别名 | 作用 | 合法值 |
|------|------|------|--------|
| `theme` | — | 切整体明暗主题 | 见 2.2 取值映射表 |
| `bg` | `background` | 页面根容器背景色 | 见 2.3 颜色格式 |
| `text` | `fg` | 页面根容器文字色 | 见 2.3 颜色格式 |
三个参数相互独立,可任意组合;不传或传非法值时该项忽略、沿用默认。
### 2.2 `theme` 取值映射
| 传入值(不区分大小写) | 实际主题 |
|------|------|
| `dark`、`dark-blue`、`深蓝`、`深色` | 深色 |
| `light`、`red`、`红白`、`浅色` | 浅色 |
| 其他 / 不传 | 忽略,沿用站内默认(浅色) |
### 2.3 颜色格式(`bg` / `text`)
颜色值经过白名单校验,仅接受以下写法,其余一律丢弃(防注入):
- **hex**:`#fff`、`#ffffffcc` 等 3/4/6/8 位(`#` 可省略;URL 里 `#` 必须编码为 `%23`)
- **函数式**:`rgb()` / `rgba()` / `hsl()` / `hsla()`,括号内仅允许数字、逗号、百分号、空格、`/`
- **命名色**:纯英文字母,如 `white`、`transparent`
### 2.4 参数位置
参数放在 hash 路由之后(推荐)或 `#` 之前都能识别;两处都传时**hash 路由内的优先**:
```
https://<host>/#/public/leaderboard?theme=dark ← 推荐
https://<host>/?theme=dark#/public/leaderboard ← 也支持
```
### 2.5 示例
```html
<!-- 深色主题 -->
<iframe src="https://<host>/#/public/leaderboard?theme=dark"></iframe>
<!-- 浅色主题 + 自定义背景与文字色(# 编码为 %23) -->
<iframe src="https://<host>/#/share/Ab3xYz?theme=light&bg=%23f5f7fa&text=%23333333"></iframe>
<!-- 主题与背景叠加:整页走深色变量,根背景用宿主品牌色 -->
<iframe src="https://<host>/#/roundtable-share/Ab3xYz?theme=dark&bg=%23001529"></iframe>
```
---
## 3. postMessage(嵌入后动态切换)
适用于宿主站点支持用户切主题、需要 iframe 实时跟随的场景。向 iframe 的 `contentWindow` 发消息即可,**无需等待子页面回执**(监听器在应用挂载时注册)。
### 3.1 消息格式
```js
const frame = document.getElementById("deerflow-frame");
// 切整体明暗主题(theme 取值同 2.2)
frame.contentWindow.postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*");
// 改背景色 / 文字色(字段名与 URL 参数一致,bg/background、text/fg 均可)
frame.contentWindow.postMessage({ type: "deerflow:set-bg", bg: "#001529", text: "#fff" }, "*");
// 兼容别名:type 也可写 "deerflow:set-background"
```
说明:
- 两类消息相互独立,可只发其一;`set-bg` 里 `bg`、`text` 也可只传其一,未传的字段保持当前值。
- 非法颜色值 / 不认识的 `theme` 取值会被忽略,不会清空已有设置。
- targetOrigin 建议按需收紧(写 DeerFlow 站点的 origin 而非 `"*"`)。
### 3.2 宿主主题联动示例
```js
function syncDeerflowTheme(isDark) {
const win = document.getElementById("deerflow-frame").contentWindow;
win.postMessage({ type: "deerflow:set-theme", theme: isDark ? "dark" : "light" }, "*");
win.postMessage(
{ type: "deerflow:set-bg", bg: isDark ? "#001529" : "#ffffff" },
"*",
);
}
// iframe 首次加载用 URL 参数兜底,之后切换走 postMessage
myThemeStore.subscribe((theme) => syncDeerflowTheme(theme === "dark"));
```
---
## 4. 优先级与默认行为
同一项配置多处传入时,按以下优先级取值(高 → 低):
1. **postMessage** 动态设置的值
2. hash 路由内的 URL 参数(`#/路由?theme=...`)
3. `#` 之前的 URL 参数(`?theme=...#/路由`)
4. 都没传 → DeerFlow 默认配色(浅色主题 + 页面自带 Tailwind 配色)
其他保证:
- 外部主题只影响**当前 iframe 的渲染**,不写 localStorage——不会污染用户直接访问 DeerFlow 主站时自己选择的主题。
- `bg`/`text` 是内联样式,作用在页面根容器上,优先级高于主题变量;与 `theme` 叠加时以 `bg`/`text` 为准。
---
## 5. 常见问题排查
| 现象 | 排查 |
|------|------|
| `theme=dark` 不生效 | 确认路由属于第 1 节列出的公开页;确认参数拼在 hash 路由后(`/#/share/xx?theme=dark`),而不是写成了 `/#/share/xx#theme=dark` |
| `bg=#fff` 不生效 | URL 里 `#` 没编码会被浏览器截断,必须写 `%23fff`;或直接省略 `#` 写 `bg=fff` |
| postMessage 无反应 | 确认消息体是对象且 `type` 拼写为 `deerflow:set-theme` / `deerflow:set-bg`;确认在 iframe `load` 之后发送 |
| 颜色被忽略 | 检查是否符合 2.3 白名单格式(如 `linear-gradient(...)` 等复杂值不支持) |
| 主题切了但个别区域颜色没变 | 该区域可能使用了固定配色;把页面路由与截图反馈给 DeerFlow 维护方补充 `dark:` 样式 |
-265
View File
@@ -1,265 +0,0 @@
# 多智能体协调 API 接口文档
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
路由前缀:`/api/multi-agent`
## 概览
提供一个"总控 + 多个子智能体"的协调编排接口。
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
调用方按下面顺序使用:
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
## 1. 初始化多智能体集群
### `POST /api/multi-agent/init`
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
#### 请求体
```json
{
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
"main_agent_name": "main-controller",
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
#### 响应(200)
```json
{
"status": "success",
"main_agent_name": "main-controller",
"thread_ids": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
"us-intelligence-gathering-agent": "thread-uuid-zzz"
}
}
```
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_names` 为空 |
| `401` | 未携带 `Authorization` 头 |
| `500` | 协调智能体创建失败且不是"已存在"错误 |
---
## 2. 流式调度
### `POST /api/multi-agent/run/stream`
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
#### 请求体
```json
{
"agent_type": "leader",
"d_agent_thread_id": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
},
"agent_name": "main-controller",
"new_message": "请分析特朗普的支持率",
"skill_stop_names": [],
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
#### 响应
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
##### a. 透传上游事件
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
##### b. 最后一帧:调度状态
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
**`agent_type == "leader"`:**
```
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
```
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
**`agent_type == "special"`:**
```
data: {"status": "子智能体xxx完成yyy工作"}
```
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
#### 副作用(重要)
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
| `401` | 未携带 `Authorization` 头 |
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
---
## 3. 前端调用流程示例
```ts
// 1. 拿 token
const loginResp = await fetch("/api/v1/auth/login/username", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "yourname" }),
});
const { access_token } = await loginResp.json();
const authHeaders = {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
};
// 2. 初始化
const initResp = await fetch("/api/multi-agent/init", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_names: [
"taiwan-intelligence-gathering-agent",
"us-intelligence-gathering-agent",
],
main_agent_name: "main-controller",
model: "deepseek-chat",
}),
});
const { thread_ids } = await initResp.json();
// 3. 流式调用 leader
async function streamLeader(message: string, model = "deepseek-chat") {
const resp = await fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "leader",
d_agent_thread_id: thread_ids,
agent_name: "main-controller",
new_message: message,
skill_stop_names: [],
model,
}),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
let dispatched: [string, string][] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
try {
const payload = JSON.parse(line.slice(5).trim());
if (Array.isArray(payload.status)) {
dispatched = payload.status;
}
// 其余事件按 LangGraph SDK 协议自行渲染
} catch {
/* 非 JSON 帧,可忽略或交给现有渲染器 */
}
}
}
return dispatched;
}
// 4. 对派活清单逐个调用 special
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
return fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "special",
d_agent_thread_id: thread_ids,
agent_name: agentName,
new_message: task,
skill_stop_names: [],
model,
}),
});
// 同样按 SSE 解析
}
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
const dispatched = await streamLeader("请分析特朗普的支持率");
for (const [agentName, task] of dispatched) {
await streamSpecial(agentName, task);
}
const next = await streamLeader("根据上面收集到的信息出最终结论");
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
```
---
## 4. 注意事项
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
---
## 5. 相关接口(底层依赖)
本接口内部转发以下 Gateway 原生接口,了解细节可查:
| 接口 | 路由 | 用途 |
|---|---|---|
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
@@ -1,124 +0,0 @@
# 多智能体「后台挂起」—— 当前状态、遗留问题与下次继续指南
> 配套设计文档:`multi-agent-background-run-dev.md`(分 Phase 0-9 的总方案)。
> 本文记录**截至本次会话的实际进展 + 还没解决的遗留问题**,方便下次直接续接。
---
## 一、整体状态:跑通到哪了
「后台挂起」= Step2 把研讨交给后端**进程内驱动真模型**(leader→各席位→共识→报告),
survive 刷新/关页,对话与报告作为一个 **run 写进草稿**(和手动跑同一存取),可在历史回看。
**真网关已在本地验证可跑通**(2026-06-04 日志确认:roundtable-coordinator + 各席位 +
roundtable-report 真调 deepseek、真搜索、跑到 report run success)。但还有几个**内容/产物层面的遗留问题**(见第三节)。
### 已完成(可用)
- ✅ 后端持久化:`roundtable_jobs` 表(迁移 `20260604_01` / `20260605_01` dialogues / `20260605_02` step3)。
- ✅ 编排引擎 `packages/harness/deerflow/agents/roundtable_orchestrator/`(recommend + chain + 报告,纯逻辑可单测)。
- ✅ 控制面 `app/gateway/routers/roundtable_jobs.py`:start/get/by-draft/stream(SSE)/resume/cancel/admin-cleanup。
- ✅ 执行器 `app/gateway/roundtable_job_executor.py`:后台任务 + `set_current_user` + 生命周期 + **每轮把对话作为 `job-<id>` run 写进 `draft.step2.runs[]`(同手动存取)** + done 写 step3。
- ✅ 真网关 `app/gateway/roundtable_inprocess_gateway.py`:`SimpleNamespace(app)` + `start_run` 进程内驱动 agent;复用 `multi_agent._extract_dispatch` 解析派活;checkpointer 读最终 messages。
- ✅ 模拟网关 `roundtable_orchestrator/simulated_gateway.py`(不调真模型,前端联调用)。
- ✅ 前端:`api/roundtable-jobs.ts`、`lib/job-types.ts`、Step2「后台挂起」按钮、历史下拉 loading、`DispatchChainModal`/`DispatchChainJobModal`(流程图 + 对话面板 markdown + 加宽 + 澄清作答 + 取消 + 查看)。
- ✅ 上下文「广播」(inline):席位/总控 prompt 带上「任务背景 + 前序各席位交付 + 别提问」约束(修「席位拿不到关键信息、反问用户、总控瞎编」)。
- ✅ 后端测试:`tests/test_roundtable_jobs.py` / `test_roundtable_orchestrator.py` / `test_roundtable_job_executor.py` / `test_roundtable_jobs_api.py` 全过。
---
## 二、怎么启动 / 验证(每次开工先做)
1. **开真网关**(默认是模拟网关):已写进 `offline-backend-20260512/.env`:
```
ROUNDTABLE_JOB_GATEWAY=inprocess
```
启动日志应出现 `[roundtable-jobs] gateway = inprocess (real models)`。
2. **必须用 `make gateway`(不带 --reload)跑后端!**
`make dev` 的 `--reload` 监视 `backend/` 目录,agent 运行时往 `.deer-flow/` 写文件
(memory.json / checkpoint / 沙箱输出)会触发热重载,**把长时后台任务杀掉**。
(改进项:给 uvicorn 加 `--reload-exclude '.deer-flow/*'`,或始终用 `make gateway`。)
3. **直接查库验证**(最有效,PowerShell 控制台是 GBK,导出到文件看中文):
```bash
cd offline-backend-20260512/backend
# 查最新作业的 dialogues / step3
PYTHONPATH=. uv run python -c "import sqlite3,json; con=sqlite3.connect('.deer-flow/data/deerflow.db'); ..."
```
- 作业表:`.deer-flow/data/deerflow.db` → 表 `roundtable_jobs`(`dialogues`/`step3` 列)。
- 草稿表:`roundtable_drafts` → `step2` 列里应有 `runs[ {id:"job-<jobId>", jobId, jobStatus, step2RoundtableDialogues:[...], step3:{...}} ]`。
4. **前端验证**:后台挂起 → 等跑完 → 从历史加载该草稿 → Step2 应显示后台研讨完整对话(走现有 `normalizeStep2Runs`/`hydrateStep2Run`)。
---
## 三、遗留问题(下次重点,已按优先级排)
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)
- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads/<id>/user-data/outputs`;
`run.step3.html` 回退成报告 agent 的**纯文字**(不含 `<html>`),第3步画不出东西。
- **已做**:`build_report_prompt`(`roundtable_orchestrator/engine.py`)已对齐前端
`useStep3Report.buildReportPrompt`,明确「用 write_file 写入 /mnt/user-data/outputs/方案总览.html」;
`_read_report_html`(`roundtable_inprocess_gateway.py`)已改为扫 outputs 目录任意 `.html`。
- **仍没解决**:报告 agent(deepseek-chat)还是没调 `write_file`。
- **下次怎么查**:
1. 对比**前端那条能写出 HTML 的报告 run** 的确切调用(`useStep3Report.ts` 里 `streamMultiAgent`
调 `/api/multi-agent/run/stream` 的 body:`agent_type`/`excluded_tools`/`context` 等),
和后台 `_run_agent_turn`(`roundtable_inprocess_gateway.py`,agent_name=roundtable-report,
`excluded_tools=_REPORT_EXCLUDED`)逐项比对,找出差异(怀疑 reasoning_effort / thinking /
某个 context 字段 / recursion 步数导致它没走到 write_file)。
2. 看报告 run 的最终 messages(checkpoint)里有没有 write_file 的 tool_call(被 dangling 截断?)。
3. 报告 agent 的 SOUL:`app/gateway/routers/_roundtable_seed/roundtable-report/SOUL.md`——确认它默认行为。
### P1 —— 内容质量(本次刚加 inline 广播,待真机验证)
- **已做**:席位/总控 prompt 注入「任务背景 + 前序交付 + 别提问」。
- **待验证**:真机跑一次,看席位是否基于真实上下文产出(不再反问/凭记忆瞎编)。
- **若 inline 不够**:考虑实现**真·thread 广播**(像 `multi_agent._append_thread_message` 那样把
「用户意图 / 派活 / 各席位交付」追加进各 thread 历史)。进程内要避开 HTTP loopback 鉴权,
得用 checkpointer 直接 append human 消息(参考 `threads` 路由 POST `/state` 的内部逻辑)。这块较重,先看 inline 够不够。
### P2 —— 对话「没有步骤(steps)」
- **现象**:手动跑的气泡带工具调用步骤卡(`onPhaseChange` → `appendStepIfNew` → `Step2Dialogue.steps`);
后台对话只存了最终正文,没有 steps。
- **下次怎么做**:后台 `_run_agent_turn` 跑完后,从 checkpoint 的 messages 里抽出 tool_calls,
映射成 `Step2Dialogue.steps`(`{id,key,label,ts,args}`),在执行器 `_to_step2_dialogue` 里带上。
纯展示增强,不影响内容/报告。
### P3 —— 双跑浪费 + --reload 坑(工程健壮性)
- 进 Step2 时前端会自动跑一遍本地研讨(真模型、真线程);点后台挂起后前端那条 run 在后端还会跑完
(`stopOrchestration` 只断客户端、停不了后端 run)→ 和后台作业短暂双跑、双倍 token。
- 优化方向:进 Step2 改成「显式开始」而非自动跑;或后台挂起时真正 cancel 前端那条 run。
- `make dev` 的 `--reload` 会杀后台任务(见第二节);改进项:`--reload-exclude '.deer-flow/*'`。
---
## 四、关键文件清单(下次定位用)
**后端**
| 文件 | 作用 |
|------|------|
| `app/gateway/roundtable_inprocess_gateway.py` | 真网关:进程内驱动 leader/seat/re 编排循环 + `build_report_prompt` + inline 广播(`_seat_message`/`_deliveries_block`) |
| `.../roundtable_orchestrator/progress.py` | 进度落库 + `on_dialogue` 每轮回调 |
| `.../persistence/roundtable_jobs/` | job 表 model/sql |
| `app/gateway/deps.py` | `ROUNDTABLE_JOB_GATEWAY` 选网关 + 注入 executor(draft_store) |
**前端**
| 文件 | 作用 |
|------|------|
| `src/roundtable-planning/api/roundtable-jobs.ts` | 作业 API + SSE |
| `src/roundtable-planning/lib/job-types.ts` | 类型(RoundtableJob/JobDialogue/...) |
| `src/roundtable-planning/components/DispatchChainModal.tsx` / `DispatchChainJobModal.tsx` | 流程图弹窗 + 对话面板 |
| `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 后台挂起按钮、历史下拉、**runs[] 多运行模型**(`hydrateStep2Run`/`getStep2SnapshotWithRuns`/`normalizeStep2Runs`) |
| `src/roundtable-planning/api/drafts.ts` | `DraftStep2RunSnapshot`(带 jobId/jobStatus/step3) |
---
## 五、关键架构事实(避免重复踩坑)
1. **真网关进程内驱动**:`start_run(body, thread_id, SimpleNamespace(app=app))` + `set_current_user`,
不走 HTTP(loopback 调 `/api/multi-agent/*` 不可行:`_auth_headers` 只转发用户 token + owner check)。
2. **后台作业首启自建全新线程**(`StartParams.is_resume`),绝不复用前端本地研讨的线程(会撞 409)。
3. **存取统一**:后台作业 = `draft.step2.runs[]` 里一个带 jobId 的 run,和手动跑同一存取/hydrate 路径,
**不要再开 observe/回显分支**(之前试过,被否)。
4. **聊天记录确实存上了**(job.dialogues + draft 的 run);之前"没存上"是测了旧代码 / 看的是前端本地残缺数据。
5. 多 worker / SSE:状态进 DB、SSE 走 DB 轮询;真网关进程内无 loopback。
@@ -0,0 +1,198 @@
# 后台挂起「在当前进度继续」实现方案
> 状态:**待实现**(仅方案,未改代码)
> 关联:[[multi-agent-background-run-dev.md]]、[[multi-agent-frontend-dev.md]]、memory `project-roundtable-background`
> 目标:点「后台挂起」后,后端作业**在前端当前研讨进度上接着跑**,而不是新建空线程从头重跑。
---
## 1. 现象与根因
### 现象
在第二步研讨进行中点「后台挂起」,转入后台的作业是**从头重新开始执行**(重新初始化席位、从原始 intent 重新派活),而不是接着前端已经讨论到的进度往下走。
### 根因
后端「起作业」路由 `start_job` **从不设 `is_resume`**(默认 `False`),而执行器据此**新建一套全新线程**:
```python
# offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py (_run, 约 215 行)
init = getattr(gateway, "init_threads", None)
if init is not None and not params.is_resume:
thread_ids, coordinator_name = await init() # ← 建全新空线程
```
加上前端 `startBackgroundJob` **故意不传 `threadIds`**、用 `buildInitialIntent()` 作第一轮 leader prompt,于是后台作业 = 空线程 + 原始意图 = 从头重跑。
> 这是**原设计的有意取舍**。`startBackgroundJob` 注释写明:不复用前端线程,因为「那些线程可能有活跃 run,复用会撞 409」。本方案就是要在可控前提下打开「复用前端线程续跑」这条路。
### 后端其实已支持续跑
澄清续跑路由 `resume_job`(`roundtable_jobs.py` 约 293 行)已经在做「在原线程上接着派活」:
```python
executor.start_job(StartParams(
...,
seed_message=_RESUME_PREFIX + answer, # 续跑式提示,而非原始 intent
thread_ids=row.get("thread_ids"), # 复用作业自己的线程
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
is_resume=True, # ← 关键:跳过 init_threads,直接复用
...
))
```
执行器在 `is_resume=True` 时直接用 `params.thread_ids` + `params.coordinator_name`(不调 `init_threads`),交给 `run_orchestration` 续跑。**所以能力已存在,`start_job` 只是没走这条路。**
---
## 2. 设计目标与关键事实
- 「同进度续跑」= 后台作业复用**前端当前会话的真实线程**(总控 + 各席位),这些线程的 checkpoint 里已存着到目前为止的完整研讨上下文,续跑 = 在原线程上 post 一条「继续」消息让总控接着派活。
- 前端编排是**逐轮**的:每轮一个 `POST /api/multi-agent/run/stream`(leader 或某席位),**轮与轮之间线程空闲**。只有「某一轮在飞时」线程才被占用。
- 后端复用现成线程是**已验证路径**(`resume_job` 天天在用),唯一新增的不同点是:这次复用的是「前端会话线程」而非「上一个后台作业的线程」——但线程同属一个后端线程库,复用方式完全一致。
---
## 3. 改造点(最小集)
### 3.1 后端:让 `start_job` 支持复用线程
文件:`offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py`
1. `StartJobRequest` 增加字段:
```python
resume: bool = False # true + 携带 threadIds ⇒ 复用这些线程续跑,不新建
```
2. `start_job` 里把它透传给 `StartParams`:
```python
is_resume = bool(body.resume and body.threadIds)
executor.start_job(StartParams(
...,
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
is_resume=is_resume, # ← 新增
...
))
```
其余执行器逻辑**不动**(它在 `is_resume=True` 时已能复用 `params.thread_ids`)。
> 幂等闸门(同草稿未终态作业直接返回)保持不变;续跑挂起前前端应保证该草稿没有在跑的旧作业。
### 3.2 前端 API:补 `resume` 入参
文件:`frontend-web/src/roundtable-planning/lib/job-types.ts`
- `StartJobPayload` 增加 `resume?: boolean`。
文件:`frontend-web/src/roundtable-planning/api/roundtable-jobs.ts`
- `toStartBody` 增加 `resume: p.resume ?? false`。
### 3.3 前端:`startBackgroundJob` 改为复用线程续跑
文件:`frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(`startBackgroundJob` 约 1119 行)
当前端已有现成会话(`step2.threadIds` 且 `step2.coordinatorName` 非空,说明研讨已 init)时,改传:
```ts
const canContinue = !!(step2.threadIds && step2.coordinatorName);
await startJob({
draftId: draftId ?? undefined,
// 续跑:用「继续」式 seed,而不是 buildInitialIntent()(后者会让总控从头理解任务)
intent: buildInitialIntent(), // 仍传,作为无线程回退时的首轮 prompt
seedMessage: canContinue
? "用户已将本次研讨转入后台,请基于现有进展继续上一轮的协调与派活;若已可收口则直接综合输出结论。"
: undefined,
agents,
model: selectedModel || undefined,
orchestrationMode,
chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 关键三件套:复用前端真实线程 + 协调名 + resume 标志
threadIds: canContinue ? step2.threadIds ?? undefined : undefined,
coordinatorName: canContinue ? step2.coordinatorName ?? undefined : undefined,
resume: canContinue,
});
```
> 没有现成会话(极早期、线程未建)时 `canContinue=false`,自动回退到「新建线程 + 原 intent」的旧行为,安全。
### 3.4 前端:挂起前先停本地编排(避免交出去时线程还在飞)
文件:同上,`handleBackgroundSuspend`(约 1161 行)
当前顺序是 `startBackgroundJob()` → `resetToStep1()`(reset 里才 `stopOrchestration`)。复用线程时必须**先停再交**:
```ts
const handleBackgroundSuspend = useCallback(async () => {
if (step2.selectedAgents.length === 0) { /* toast 提示后 return */ }
// 1. 先彻底停掉前端在飞的那一轮,尽量让线程在交给后台前变空闲
step2.stopOrchestration();
step2.setIsRoundTableRunning(false);
try {
// 2. 再起后台作业(复用线程续跑)
await startBackgroundJob();
} catch (err) { /* toast 失败后 return */ }
// 3. 最后重置回第一步(后台作业继续在历史记录里跑)
resetToStep1();
triggerToast("🚀 已转入后台,将基于当前进度继续研讨…");
void drafts.refresh();
}, [...]);
```
---
## 4. 关键风险:线程占用(409)与三种处理
复用前端线程的唯一风险:**在某一轮在飞时挂起**,`stopOrchestration()` 只断了客户端 SSE,服务端那次 run 可能还在跑一小会儿;后台作业首轮 leader 向同一总控线程 post → 撞 409 / rollback。
| 方案 | 做法 | 取舍 |
|---|---|---|
| **A(推荐起步)** | 仅前端 stop + 后端 `is_resume` 复用,接受小概率 409 | 实现最简,真正同进度续跑;轮间挂起 100% 安全,仅「某轮在飞时挂起」有小概率 409 —— 走编排循环**现有 rollback/重试容错**兜底 |
| **B(最稳)** | 同 A,但后端在复用线程续跑前,**先 cancel 这些线程上可能残留的 run**(参考 takeover 里 `cancelJob` 的 `task.cancel()` 思路,落到 thread-run 级别)再 post | 几乎无 409;后端要多写「按 thread_id 取消活跃 run」的逻辑 |
| **C(不推荐)** | 不复用线程,新建空线程,但把「目前为止的研讨」塞进 seed_message 让总控续写 | 零 409,但**席位子线程上下文全丢**、不是真正同进度、leader prompt 臃肿 |
> 建议:先上 **A**,真机验证 409 命中率;若频繁再加 **B** 的后端线程级 cancel。
---
## 5. 适用范围(哪些挂起入口改续跑)
三个触发源(见 `autoSuspendRef`):
1. **手动「后台挂起」按钮**(`handleBackgroundSuspend`)——**改续跑**。能在 start 前干净 stop,最适合复用线程。
2. **路由卸载自动挂起**(切到其它页面,`fire`)——可改续跑(能走完整异步链);卸载瞬间 stop 不一定彻底,按 A 方案的容错兜底。
3. **刷新/关标签页 keepalive**(`beforeunload` 的 `beacon` → `startJobKeepalive`)——**建议保持新建线程**。关页时无法保证前端 run 已停,复用线程 409 风险最高;这里走「新建线程 + 原 intent」的安全回退即可(用户可在历史记录里点「查看」再用我们已实现的 takeover 接管回前端续跑)。
> 落地顺序建议:先只改 #1,验证稳定后再决定是否推广到 #2。
---
## 6. 与已实现「查看=接管回前端」的关系
二者是一对对称操作,复用同一套「线程同属一个后端库、可跨前后端续跑」的事实:
- **后台挂起(本方案)**:前端 → 后端。前端 stop,把前端线程交给后台 `is_resume` 续跑。
- **查看接管(已实现,见 memory)**:后端 → 前端。`cancelJob` 停后台,`hydrateFromDraft` 恢复作业真实线程,`resumeLiveTakeover()` 由前端在那些线程上续跑。
实现本方案时可直接参照 takeover 的线程复用与 `is_resume` 经验。
---
## 7. 测试
后端(`make test`):
- 新增/扩展 `tests/test_roundtable_jobs.py`:`start_job` 带 `resume=true + threadIds` ⇒ `StartParams.is_resume=True` 且**不调** `init_threads`、直接用传入线程;`resume=false` 或无 `threadIds` ⇒ 走 `init_threads` 新建(回归旧行为)。
- 回归:`tests/test_roundtable_inprocess_request.py`、`tests/test_roundtable_step3_pipeline.py`(确认续跑不破坏 Step3 双产物)。
前端(`pnpm typecheck`)+ 真机:
- 研讨进行到几轮后点「后台挂起」→ 历史记录里点「查看」→ 确认后台是**接着已有对话**往下派活,而非从第 1 轮重来。
- 轮间挂起 / 某轮在飞时挂起两种时机各验一次,观察是否出现 409(A 方案下应被 rollback 容错吸收)。
---
## 8. 落地清单(TL;DR)
- [ ] 后端 `StartJobRequest.resume` 字段 + `start_job` 透传 `is_resume`
- [ ] 前端 `StartJobPayload.resume` + `toStartBody`
- [ ] 前端 `startBackgroundJob`:有会话时传 `threadIds/coordinatorName/seedMessage/resume`
- [ ] 前端 `handleBackgroundSuspend`:调整为 **先 stop 再 start 再 reset**
- [ ] 后端测试(resume 复用 vs 新建分支)
- [ ] 真机验证「接着进度跑」+ 409 命中率
- [ ](可选)方案 B:后端复用线程前按 thread_id 取消残留 run
@@ -0,0 +1,375 @@
# 浅蓝色 UI 规范
> 本文档由设计稿截图(`1.1 全局样式-色彩`、`1.2 全局样式-色彩附录`)识别整理而成。
> 全部色值已与"色彩附录"编号表逐一核对,保证准确。
---
## 1.1 色彩体系总览
调色体系分为 **主色** 与 **辅色** 两大类。每个色相按用途分为若干阶梯:
| 阶梯 | 数量 | 用途 |
| --- | --- | --- |
| 浅色 | 3 | 用于大面积底色 |
| 中亮色 | 6 | 用于标签、图表、提示等 |
| 深色 | 4(+基准色,共 5 个深阶) | 用于小面积按钮、图标等 |
| 线性渐变色 | 1 | 用于特殊装饰性小面积底色 |
| 径向渐变色 | 1 | 用于特殊装饰性小面积底色 |
> 中性色(灰阶)略有不同:浅色用于背景 / 白色文字及分割线,中亮色用于备注文字及图标,深色用于正文,且**无渐变色**。
---
## 主色
### 主色 - 红(基准 `#CF1322`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FEFAFA` · `#FEF6F6` · `#FEEDED` |
| 中亮色 | `#FDDADC` · `#FCB5B9` · `#FA9196` · `#F86C73` · `#F74750` · `#F5222D` |
| 深色 | `#CF1322` · `#BC0D1E` · `#A8071A` · `#820014` · `#5C0011` |
| 线性渐变色 | `#F5222D` → `#A8071A` |
| 径向渐变色 | `#F86C73` → `#F5222D` |
### 主色 - 蓝(基准 `#0062D9`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#F9FCFF` · `#F4F9FF` · `#EAF4FF` |
| 中亮色 | `#D4E9FF` · `#AAD2FF` · `#80BCFF` · `#55A6FE` · `#2B8FFE` · `#0079FE` |
| 深色 | `#0062D9` · `#0056C6` · `#004AB3` · `#00368C` · `#002466` |
| 线性渐变色 | `#0079FE` → `#004AB3` |
| 径向渐变色 | `#55A6FE` → `#0079FE` |
---
## 辅色
### 辅色 - 橙(基准 `#D97B00`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FFFCF9` · `#FFFAF4` · `#FFF6EA` |
| 中亮色 | `#FFEED4` · `#FFDDAA` · `#FFCC80` · `#FFBB55` · `#FFAA2B` · `#FF9900` |
| 深色 | `#D97B00` · `#C66D00` · `#B35F00` · `#8C4600` · `#663000` |
| 线性渐变色 | `#FF9900` → `#B35F00` |
| 径向渐变色 | `#FFBB55` → `#FF9900` |
### 辅色 - 绿(基准 `#1EB07F`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FAFEFD` · `#F6FDFB` · `#EEFCF7` |
| 中亮色 | `#DCF8EE` · `#BAF1DD` · `#97EBCD` · `#74E4BC` · `#52DDAB` · `#2FD69A` |
| 深色 | `#1EB07F` · `#189D72` · `#118A65` · `#07634B` · `#043D30` |
| 线性渐变色 | `#2FD69A` → `#118A65` |
| 径向渐变色 | `#74E4BC` → `#2FD69A` |
### 辅色 - 青蓝(基准 `#2F9ACC`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FBFDFE` · `#F7FCFE` · `#EFFAFE` |
| 中亮色 | `#E0F5FD` · `#C1EBFB` · `#A2E1F9` · `#83D7F7` · `#64CDF5` · `#45C3F3` |
| 深色 | `#2F9ACC` · `#2788B9` · `#1E76A6` · `#115580` · `#0B3959` |
| 线性渐变色 | `#45C3F3` → `#1E76A6` |
| 径向渐变色 | `#83D7F7` → `#45C3F3` |
### 中性色 - 灰(基准 `#222222`)
| 用途 | 色值 |
| --- | --- |
| 浅色(背景、白色文字及分割线) | `#FFFFFF` · `#FAFAFA` · `#F5F5F5` |
| 中亮色(备注文字及图标) | `#E8E8E8` · `#DBDBDB` · `#C1C1C1` · `#A6A6A6` · `#7F7F7F` · `#575757` |
| 深色(正文) | `#3D3D3D` · `#222222` |
---
## 1.2 色彩附录(编号对照表)
编号规则:`ZS` = 主色(主色调),`FS` = 辅色 / 中性色。`-01~-03` 为浅色,`-04~-09` 为中亮色,`-10~-13` 为深色,`-14` 为线性渐变色,`-15` 为径向渐变色;不带后缀的为该色相基准深色。
### 主色 ZS
| 编号 | 红 (ZS-001) | 编号 | 蓝 (ZS-002) |
| --- | --- | --- | --- |
| ZS-001 | `#CF1322` | ZS-002 | `#0062D9` |
| ZS-001-01 | `#FEFAFA` | ZS-002-01 | `#F9FCFF` |
| ZS-001-02 | `#FEF6F6` | ZS-002-02 | `#F4F9FF` |
| ZS-001-03 | `#FEEDED` | ZS-002-03 | `#EAF4FF` |
| ZS-001-04 | `#FDDADC` | ZS-002-04 | `#D4E9FF` |
| ZS-001-05 | `#FCB5B9` | ZS-002-05 | `#AAD2FF` |
| ZS-001-06 | `#FA9196` | ZS-002-06 | `#80BCFF` |
| ZS-001-07 | `#F86C73` | ZS-002-07 | `#55A6FE` |
| ZS-001-08 | `#F74750` | ZS-002-08 | `#2B8FFE` |
| ZS-001-09 | `#F5222D` | ZS-002-09 | `#0079FE` |
| ZS-001-10 | `#BC0D1E` | ZS-002-10 | `#0056C6` |
| ZS-001-11 | `#A8071A` | ZS-002-11 | `#004AB3` |
| ZS-001-12 | `#820014` | ZS-002-12 | `#00368C` |
| ZS-001-13 | `#5C0011` | ZS-002-13 | `#002466` |
| ZS-001-14 | `#F5222D` → `#A8071A` | ZS-002-14 | `#0079FE` → `#004AB3` |
| ZS-001-15 | `#F86C73` → `#F5222D` | ZS-002-15 | `#55A6FE` → `#0079FE` |
### 辅色 / 中性色 FS
| 编号 | 橙 (FS-001) | 绿 (FS-002) | 青蓝 (FS-003) | 中性灰 (FS-004) |
| --- | --- | --- | --- | --- |
| FS-00n | `#D97B00` | `#1EB07F` | `#2F9ACC` | `#222222` |
| -01 | `#FFFCF9` | `#FAFEFD` | `#FBFDFE` | `#FFFFFF` |
| -02 | `#FFFAF4` | `#F6FDFB` | `#F7FCFE` | `#FAFAFA` |
| -03 | `#FFF6EA` | `#EEFCF7` | `#EFFAFE` | `#F5F5F5` |
| -04 | `#FFEED4` | `#DCF8EE` | `#E0F5FD` | `#E8E8E8` |
| -05 | `#FFDDAA` | `#BAF1DD` | `#C1EBFB` | `#DBDBDB` |
| -06 | `#FFCC80` | `#97EBCD` | `#A2E1F9` | `#C1C1C1` |
| -07 | `#FFBB55` | `#74E4BC` | `#83D7F7` | `#A6A6A6` |
| -08 | `#FFAA2B` | `#52DDAB` | `#64CDF5` | `#7F7F7F` |
| -09 | `#FF9900` | `#2FD69A` | `#45C3F3` | `#575757` |
| -10 | `#C66D00` | `#189D72` | `#2788B9` | `#3D3D3D` |
| -11 | `#B35F00` | `#118A65` | `#1E76A6` | — |
| -12 | `#8C4600` | `#07634B` | `#115580` | — |
| -13 | `#663000` | `#043D30` | `#0B3959` | — |
| -14 | `#FF9900` → `#B35F00` | `#2FD69A` → `#118A65` | `#45C3F3` → `#1E76A6` | — |
| -15 | `#FFBB55` → `#FF9900` | `#74E4BC` → `#2FD69A` | `#83D7F7` → `#45C3F3` | — |
> 注:中性灰(FS-004)无渐变阶,最深至 `#222222`(基准)/`#3D3D3D`(FS-004-10),用于正文文字。
---
# 组件规范
> 以下为各组件的视觉规范与实现方式。颜色统一引用上文色值,建议在项目中先抽取为 CSS 变量 / Tailwind token,组件只引用语义化变量。
## 语义化 Token 建议
把色彩体系映射成语义变量,组件层只用语义名,便于换肤与维护:
```css
:root {
/* 品牌 / 主色(蓝) */
--color-primary: #0062D9; /* 主按钮、实底选中:ZS-002 深色基准 */
--color-primary-hover: #0079FE; /* 悬浮态:ZS-002-09 */
--color-primary-active: #0056C6; /* 按下态:ZS-002-10 */
--color-primary-bg: #EAF4FF; /* 浅底选中底色:ZS-002-03 */
--color-primary-border: #0079FE; /* 描边选中边框 */
/* 中性 */
--color-text: #3D3D3D; /* 正文:FS-004-10 */
--color-text-secondary: #A6A6A6; /* 次要 / 占位符:FS-004-07 */
--color-border: #DBDBDB; /* 默认边框:FS-004-05 */
--color-bg-disabled: #F5F5F5; /* 失效底色:FS-004-03 */
--color-fill-disabled: #A6A6A6; /* 主按钮失效底色:FS-004-07 */
--color-surface: #FFFFFF; /* 卡片 / 控件底 */
}
```
> 项目使用 Tailwind v4 + shadcn,可在 `@theme` 中声明这些变量后用 `bg-primary` / `text-primary` 等使用;下文示例同时给出等价的 Tailwind 写法。
---
## 2.1 基础 - 按钮(Button)
### 按钮尺寸
| 规格 | 高度 | 圆角 | 用途 |
| --- | --- | --- | --- |
| 小 | **28px** | 4px | 空间小的板块内部,如表格中、卡片标题等 |
| 正常 | **32px** | 4px | 常用按钮(默认尺寸) |
水平内边距约 12–16px,字号 14px,图标与文字间距 4–8px。
### 常用按钮类型
| 类型 | 视觉 | 用途 |
| --- | --- | --- |
| 主按钮 | 实底 `--color-primary`,白字 | 一个操作区域**只能有一个**主按钮 |
| 主按钮·失效 | 实底灰 `--color-fill-disabled`,白字 | 主按钮禁用态 |
| 次要按钮 | 白底 + `--color-primary` 边框 + 蓝字 | 重要度低于主按钮,高于默认按钮 |
| 默认按钮 | 白底 + `--color-border` 边框 + `--color-text` | 默认动作(取消等) |
| 默认·失效 | `--color-bg-disabled` 底 + 浅边框 + `--color-text-secondary` | 次要/默认按钮禁用态 |
| 文本按钮 | 无边框无底,蓝字(可加下划线) | 表格操作、文件下载等 |
| 图标按钮 | 仅图标,可选 描边/实底/浅底 × 方形/圆形 | 增强辨识度或节省空间 |
实现(Tailwind 示意):
```tsx
// 主按钮
<button className="h-8 px-4 rounded text-sm text-white bg-[#0062D9]
hover:bg-[#0079FE] active:bg-[#0056C6]
disabled:bg-[#A6A6A6] disabled:cursor-not-allowed">确定</button>
// 次要按钮(次按钮)
<button className="h-8 px-4 rounded text-sm text-[#0062D9] bg-white
border border-[#0062D9] hover:bg-[#EAF4FF]">点击</button>
// 默认按钮
<button className="h-8 px-4 rounded text-sm text-[#3D3D3D] bg-white
border border-[#DBDBDB] hover:border-[#0079FE] hover:text-[#0079FE]
disabled:bg-[#F5F5F5] disabled:text-[#A6A6A6] disabled:border-[#DBDBDB]">取消</button>
// 文本按钮
<button className="text-sm text-[#0062D9] hover:underline">文本按钮样式</button>
// 图标按钮(实底圆形)
<button className="size-7 grid place-items-center rounded-full text-white bg-[#0062D9]
hover:bg-[#0079FE]"><TrashIcon className="size-4" /></button>
```
图标按钮三种底 × 两种形状:
- **描边**:白底 + `--color-border` 边框,图标用 `--color-text`
- **实底**:`--color-primary` 底 + 白图标
- **浅底**:`--color-primary-bg`(`#EAF4FF`)底 + 蓝图标
- 形状:方形 `rounded`(4px)/ 圆形 `rounded-full`
### 按钮组合
| 组合 | 说明 |
| --- | --- |
| 文字 + 下拉按钮 | 按钮右侧带 `⌄` 箭头;有 默认 / 主按钮 两种变体 |
| 图标 + 文字按钮 | `+ 正常`,前置图标;有 描边 / 实底 两种变体 |
| 文本按钮 + 图标 | 蓝色文本链按钮:`更多 »` `展开 ⌄` `下一页 »|` `收起 ⌃` `|⌜ 上一页` |
| 主按钮 + 默认按钮 | `确定`(主) `取消`(默认) `返回`(默认) 并排,间距 8–12px |
### 其他状态
- **悬浮状态(hover)**:主按钮悬浮时底色变亮(`--color-primary-hover` `#0079FE`),用于 Banner / 登录 / 视觉冲击强的场景。
- **加载中(loading)**:按钮内前置旋转图标 `⟳ 加载中`,期间 `disabled` 并降低交互;用于异步操作等待反馈。
```tsx
<button className="h-8 px-4 rounded text-sm text-white bg-[#0062D9] inline-flex items-center gap-1.5" disabled>
<Spinner className="size-4 animate-spin" /> 加载中
</button>
```
---
## 5.9 输入 - 单选框(选择按钮 / Segmented Radio)
一组互斥选项,外观为按钮组。两种排布 × 三种选中样式。
### 排布方式
| 排布 | 说明 |
| --- | --- |
| 拼接式 | 选项首尾相连,仅最外侧两角圆角,相邻共用 1px 边框 |
| 分离式 | 选项之间留间距(约 8px),每个独立 4px 圆角,各自带完整边框 |
### 选中(已选)样式 × 三种
| 样式 | 已选 | 未选 |
| --- | --- | --- |
| 描边式 | 白底 + 蓝边框 `--color-primary` + 蓝字 | 白底 + `--color-border` 边框 + `--color-text` |
| 浅底式 | `--color-primary-bg`(`#EAF4FF`) 底 + 蓝字 + 蓝边框 | 同上 |
| 实底式 | `--color-primary` 实底 + 白字 | 同上 |
- **带统计**:选项文字后追加计数,如 `已选(230)` / `未选(230)`,计数用次要色 `--color-text-secondary`。
实现(拼接式 + 描边选中):
```tsx
<div className="inline-flex rounded overflow-hidden border border-[#DBDBDB] divide-x divide-[#DBDBDB]">
{options.map((o) => (
<button key={o.value}
className={cn(
"h-8 px-4 text-sm bg-white",
value === o.value
? "text-[#0062D9] ring-1 ring-inset ring-[#0079FE] z-10" // 描边选中
: "text-[#3D3D3D] hover:text-[#0079FE]"
)}>
{o.label}{o.count != null && <span className="text-[#A6A6A6]">({o.count})</span>}
</button>
))}
</div>
```
> 浅底式把选中态换成 `bg-[#EAF4FF] text-[#0062D9]`;实底式换成 `bg-[#0062D9] text-white`。
> 分离式去掉 `divide-x` / `overflow-hidden`,外层用 `gap-2`,每个按钮单独 `rounded border`。
### 按钮式页签(Tab as Button)
图标 + 文字的页签型按钮:
- **激活** 两种:描边(白底 + 蓝边框 + 蓝字)/ 实底(`--color-primary` 底 + 白字)
- **正常**:白底 + `--color-border` 边框 + `--color-text`,前置图标
- 结构与拼接式选择按钮一致,区别在每项前置一个功能图标。
---
## 5.11 输入 - 选择器(Select / Dropdown)
单项输入选择,下拉单选。
### 状态
| 状态 | 触发框 |
| --- | --- |
| 占位 | `请选择` 占位文字用 `--color-text-secondary`,灰边框 `--color-border`,右侧 `⌄` |
| 展开/聚焦 | 边框变 `--color-primary`,箭头翻转为 `⌃` |
| 已选 | 显示选中项文字(正文色),保持聚焦时蓝边框 |
### 下拉面板
- 白底、圆角 4px、投影(如 `shadow-lg`),最大高度后出现滚动条
- 选项默认 `--color-text`;**hover / 选中** 项:`--color-primary-bg`(`#EAF4FF`) 底 + `--color-primary` 文字
- **分组**:分组标题用小号灰字 `--color-text-secondary`,组之间用 1px 分割线 `--color-border`
实现示意:
```tsx
// 触发框
<button className="h-8 w-full px-3 inline-flex items-center justify-between rounded
border border-[#DBDBDB] bg-white text-sm
data-[state=open]:border-[#0062D9]">
<span className={selected ? "text-[#3D3D3D]" : "text-[#A6A6A6]"}>{selected ?? "请选择"}</span>
<ChevronDown className="size-4 text-[#A6A6A6] transition-transform data-[state=open]:rotate-180" />
</button>
// 下拉项
<li className={cn(
"h-9 px-3 flex items-center text-sm cursor-pointer rounded-sm",
active ? "bg-[#EAF4FF] text-[#0062D9]" : "text-[#3D3D3D] hover:bg-[#EAF4FF]"
)}>{label}</li>
// 分组标题
<div className="px-3 py-1.5 text-xs text-[#A6A6A6]">分组标题1</div>
```
> 推荐直接基于 Radix `Select`(项目已有 shadcn 封装)改这些类名落地。
---
## 5.14 输入 - 开关(Switch)
两种形态:
### 1. 滑块开关(多用于表单、表格中)
- **开**:胶囊底 `--color-primary`,白色圆形滑块在右,左侧白字「开」
- **关**:胶囊底灰 `--color-text-secondary`/`#C1C1C1`,滑块在左,右侧灰字「关」
- 切换时滑块带左右过渡动画
```tsx
<button role="switch" aria-checked={on}
className={cn(
"relative h-6 w-12 rounded-full transition-colors text-[11px] text-white",
on ? "bg-[#0062D9]" : "bg-[#C1C1C1]"
)}>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white transition-all",
on ? "left-[26px]" : "left-0.5")} />
<span className={cn("absolute top-1/2 -translate-y-1/2", on ? "left-2" : "right-2 text-[#7F7F7F]")}>
{on ? "开" : "关"}
</span>
</button>
```
### 2. 单选框作为开关(分段式)
- `开 | 关` 两段式:激活段 `--color-primary` 实底白字,未激活段白底灰字 + `--color-border` 边框
- 即 5.9 拼接式「实底选择按钮」的两选项特例
```tsx
<div className="inline-flex rounded overflow-hidden border border-[#DBDBDB]">
<button className={cn("h-7 px-4 text-sm", on ? "bg-[#0062D9] text-white" : "bg-white text-[#3D3D3D]")}>开</button>
<button className={cn("h-7 px-4 text-sm border-l border-[#DBDBDB]", !on ? "bg-[#0062D9] text-white" : "bg-white text-[#3D3D3D]")}>关</button>
</div>
```
+161
View File
@@ -0,0 +1,161 @@
# 需求文档 · 问答中心左侧栏改造(0616)
> 整理自 2026-06-16 需求草稿。本批需求统一围绕「问答中心」菜单下 5 个页面入口的**左侧侧边栏**进行改造,目标是让每个业务页面的左栏从「通用导航」变为「贴合该业务场景的快捷入口 + 历史记录」。
>
> 菜单结构参考:`frontend-web/src/core/page-layout/sidebar-menu.ts` 中 `qa-center`(问答中心)分组,含 5 个子项,正好对应下文 5 条需求。
## 0. 背景与共性约定
| 需求 | 菜单项 | id | 现有路由 | 对应页面/组件 |
|------|--------|----|----------|--------------|
| 1 | 通用问答 | `general-qa` | `/page/workspace/chats/new` | Workspace Chat + 左栏(`recent-chat-list.tsx` 等) |
| 2 | 多智能体会商 | `multi-agent-roundtable` | `/page/strategy/qa/multi-agent-roundtable` | `roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 3 | zzJC支持 | `qa-zzjc` | `/page/strategy/agents/6bf/chats/new` | `pages/AgentChatPage.tsx` |
| 4 | zzXD应用 | `qa-zzxd` | `/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new` | `pages/AgentChatPage.tsx` |
| 5 | 课题研究 | `qa-research` | `/page/canvas/ai-writing` | `open-canvas/pages/AIWritingPage.tsx` |
**共性约定(适用于全部 5 条,除非单条另有说明):**
- 「更多」一律为可点击入口;点击后跳转到对应的完整列表/管理页(具体目标见各条「待确认」)。
- 列表为空时的兜底:参考各条「现状」中的级联/空态规则;无内容时该区块整体隐藏或显示占位文案(待 UI 设计确认,默认隐藏,与现有 `recent-chat-list.tsx` 一致)。
- 左栏宽度、折叠交互沿用现有侧边栏体系(`SidebarGroup` 系列组件)。
- 所有「智能体」相关数据复用 `core/agents`(`Agent` 类型已含 `is_pinned` / `is_favorite` / `featured_order`,scope 支持 `mine` / `square` / `builtin`)。
---
## 1. 通用问答 — 左侧栏改造
**页面**:`/page/workspace/chats/new`
### 1.1 现状
当前左栏主要展示「最近会话」列表(`recent-chat-list.tsx`,已过滤 `scheduler` / `bootstrap` / `roundtable` 类型线程,置顶会话优先)。
### 1.2 目标布局(自上而下)
左栏从上到下依次为三块:**新建回答按钮 → 常用智能体 → 聊天记录**。
#### ① 顶部:新建回答
- 左栏最顶部放置「**新建回答**」按钮(主操作)。
- 点击 = 开启一次全新的通用问答会话(等价于跳转 `/page/workspace/chats/new`)。
#### ② 常用智能体(最多 3 个 + 更多)
- 展示「常用智能体」卡片/列表项,**最多 3 个**。
- 数据来源采用**级联兜底**逻辑(取到即用,不足再降级):
1. 若用户有**置顶智能体**(`is_pinned = true`)→ 展示置顶的;
2. 否则展示**用户自己的智能体**(`scope = "mine"`);
3. 仍没有 → 展示**默认广场智能体**(`scope = "square"` / `builtin`,即广场默认推荐)。
- 三者按上述优先级取,最终最多取 3 个。
- 下方显示「**更多**」入口。
- 点击某个常用智能体 → 进入该智能体的对话(待确认:是否为该智能体新建会话)。
#### ③ 聊天记录(最多 10 条 + 更多)
- 展示用户的问答聊天记录,**最多 10 条**(沿用现有 `useThreads` + 过滤规则)。
- 下方显示「**更多**」入口。
- 点击「更多」→ **跳转到问答记录页面**(完整聊天记录列表页)。
### 1.3
- 「常用智能体」点击后「打开该智能体主页」
- 「常用智能体」的「更多」跳转目标页 智能体管理 `/page/strategy/agents`。
- 「聊天记录」的「问答记录页面」具体路由http://localhost:5173/#/page/workspace/chats。
- 级联兜底是(置顶组够 3 个就完全不展示我的/广场)如果不够「补足到 3 个
---
## 2. 多智能体会商 — 左侧栏重做
**页面**:`/page/strategy/qa/multi-agent-roundtable`(`RoundtablePlanningPage`)
### 2.1 现状
圆桌会商页已有业务链选择、分析草稿等能力(`roundtable-planning/api/chains.ts`、`hooks/useRoundtableDrafts.ts`)。需求要求**左栏整体重做**。
### 2.2 目标布局(自上而下)
#### ① 顶部:业务链(公共,前 3 个)
- 顶部展示**业务链**,仅取**公共**链条(`listChains("public")`,即所有已发布 `is_public = true` 的链条)。
- 仅展示**前 3 个**。
- 点击某条业务链 → 以该业务链启动一次会商(沿用现有"会商自动串联圆桌"流程,见记忆 `business-chain-mapping`)。
#### ② 下方:分析记录
- 业务链下方展示「**分析记录**」列表。
- 数据来源:圆桌分析草稿/历史(`useRoundtableDrafts` / `listDrafts`)。
- 点击某条分析记录 → 打开对应的历史会商分析。
### 2.3 待确认
- 「业务链」是否需要「更多」入口跳转到业务链条配置页(`/page/strategy/business-chains`)?需求未提及,默认仅展示前 3 个、无更多。 需要更多入口
- 「分析记录」是否需要分页/「更多」?需求未提及,默认展示全部或合理上限 分析记录默认全部吧。
- 公共业务链「前 3 个」的排序口径(创建时间倒序?已有 `listChains` 默认 newest first) 按照现在默认的显示来吧,然后要支持用户单选,如果用户选择后,点击进入多智能体研讨按钮时,直接按照选择的业务链进去执行,如果用户取消勾选,或没有勾选就还是打开弹窗。
---
## 3. zzJC支持 — 左侧栏改造(按类型聚合智能体)
**页面**:`/page/strategy/agents/6bf/chats/new`(当前为单一智能体 `6bf` 的对话页)
### 3.1 现状
当前 zzJC支持直接进入某个固定智能体(id=`6bf`)的对话页(`AgentChatPage`)。
### 3.2 目标布局
- **左栏按「类型」分组**,展示该类型下的智能体列表("类型"待确认,推测为智能体 `tags` / 分类)。
- 智能体分组**下方**展示**聊天记录**。
- 左栏**宽度与通用问答(需求 1)保持一致**("左侧是问答宽")。
### 3.3 Admin 设置入口
- 在 **admin 账号权限下**,页面**右侧顶部显示「设置」图标按钮**(齿轮图标)。
- 点击设置按钮 → 打开设置面板,可对该模块进行配置。
- 非 admin 账号不显示设置按钮(沿用 `sidebar-menu.ts` 中 `adminOnly` 同款角色判断)。
### 3.4 待确认
- 「类型」的确切含义:是智能体的 `tags`,还是 zzJC 业务自定义的一组分类?
- 现 zzJC 入口是单个智能体 `6bf`;改为「按类型展示多个智能体」后,**进入该菜单时默认落地页是什么**(智能体列表?还是仍默认进 `6bf` 对话)?是否需要新建一个 zzJC 聚合页。
- Admin「设置」具体可配置项:配置「该模块纳入哪些类型/哪些智能体」?还是其他(如默认智能体、欢迎语)?需明确设置项清单。
- 「聊天记录」过滤范围:仅 zzJC 相关会话,还是全部会话?
---
## 4. zzXD应用 — 左侧栏改造
**页面**:`/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new`
### 4.1 需求
**与需求 3(zzJC支持)完全一致**:
- 左栏按类型展示该类型下的智能体 + 下方聊天记录;
- 左栏宽度同通用问答;
- admin 账号下右侧顶部显示设置图标按钮,可设置。
### 4.2 待确认
- 同需求 3 全部待确认项。
- zzXD 与 zzJC 是否共用同一套「按类型 + 设置」组件(仅数据源/默认智能体不同),还是各自独立配置?建议**抽象为同一可复用组件**,通过参数区分。
---
## 5. 课题研究 — 左侧栏改造(研究模板 + 笔记本)
**页面**:`/page/canvas/ai-writing`(`AIWritingPage`)
### 5.1 目标布局(自上而下)
#### ① 上半部分:研究模板
- 左栏上半部分展示「**研究模板**」。
- 研究模板 = **原来的「文章类型」**(`open-canvas/hooks/useArticleTypes.ts` 的 `articleTypes`,即 AI 写作表单里的「文章类型」选择项)。
- 点击某个研究模板 → 以该模板/文章类型发起课题研究(沿用现有 `articleType` 设置逻辑)。
#### ② 下半部分:笔记本
- 左栏下半部分展示「**笔记本**」。
- 笔记本内容 = **「简洁模式」左侧菜单下方展示的笔记本内容**(`useStudioNotebooks`,即 `workspace-nav-studio.tsx` 中渲染的 studio notebooks 列表)。
- 点击某个笔记本 → 打开该笔记本(沿用现有 `/page/workspace/studio/notebooks/{id}` 行为,或作为课题研究的素材来源 `materialSource = 'notebook'`,待确认)。
> 备注:草稿原文写作「简介模式」,结合代码应为「**简洁模式**」(`appearance-settings-page.tsx` 中的全局界面模式:全量 / 简洁;简洁模式下笔记本在左栏菜单展示)。
### 5.2 待确认
- 「研究模板」点击行为:仅预填表单 `articleType`,还是直接开始生成?
- 「笔记本」点击行为:跳转笔记本详情页,还是将笔记本设为当前课题研究的素材来源(`materialSource='notebook'`)?
- 上/下两部分是否各自可折叠、是否需要「更多」入口与数量上限?需求未提及。
---
## 6. 整体待确认与建议
1. **复用性**:需求 3 / 4(zzJC / zzXD)布局完全相同,建议实现为同一套「按类型分组 + 聊天记录 + admin 设置」可复用组件,通过配置区分模块。
2. **左栏宽度统一**:需求 1 / 3 / 4 都要求"问答宽",建议统一左栏宽度常量,避免各页面各写一套。
3. **「更多」目标页**:需求 1 涉及「智能体更多」「聊天记录更多→问答记录页」两个跳转目标需先确定路由是否已存在。
4. **Admin 设置项清单**:需求 3 / 4 的设置面板具体能配置什么,需要产品给出字段清单后才能落地。
5. **入口语义变更**:需求 3 / 4 把原「单一智能体对话」入口改为「按类型聚合」,菜单 `path` 与默认落地页可能需要调整,需同步确认。
+41 -11
View File
@@ -10,6 +10,12 @@ import { detectLocale } from "@/core/i18n/locale";
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import {
resolveEmbedRoundtableTheme,
resolveEmbedTheme,
} from "@/core/embed/params";
import { usePublicEmbedTheme } from "@/core/embed/background";
import { readWujieProps } from "@/core/embed/wujie";
import { HashRouter, useLocation } from "react-router-dom";
@@ -24,25 +30,47 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = resolveForcedThemeForPath(location.pathname);
const publicEmbedTheme = usePublicEmbedTheme();
const forcedTheme = useMemo(() => {
const routeTheme = resolveForcedThemeForPath(location.pathname);
if (routeTheme) return routeTheme;
if (location.pathname === "/embed/roundtable") {
const wp = readWujieProps();
const raw =
(typeof wp.theme === "string" && wp.theme.trim()) ||
new URLSearchParams(location.search).get("theme")?.trim() ||
"dark";
return resolveEmbedRoundtableTheme(raw);
}
if (location.pathname.startsWith("/embed/chats")) {
const raw = new URLSearchParams(location.search).get("theme")?.trim();
return raw ? resolveEmbedTheme(raw) : undefined;
}
if (PUBLIC_EMBED_PREFIXES.some((p) => location.pathname.startsWith(p))) {
return publicEmbedTheme;
}
return undefined;
}, [location.pathname, location.search, publicEmbedTheme]);
return (
<NextThemesProvider
attribute="class"
storageKey="strategy-theme"
defaultTheme="light"
themes={["light", "dark", "lightblue"]}
enableSystem={false}
disableTransitionOnChange
forcedTheme={forcedTheme}
>
{/* QueryClientProvider must wrap ThemeProvider because the custom
ThemeProvider reads the admin-configured default via useQuery. */}
<QueryClientProvider>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
@@ -67,20 +95,20 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only user leaderboard publish page — no authentication. */}
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
{/* Public read-only roundtable share — landing + full result report. */}
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage />}
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage report />}
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
{/* Public read-only scheduled-task runs viewer — no authentication. */}
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
@@ -89,7 +117,6 @@ export function App() {
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
element={
@@ -98,8 +125,11 @@ export function App() {
</ChatRuntime>
}
/>
{/* Embedded roundtable planning — body only, no sidebars; username login (not wujie ticket) */}
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080" viewBox="0 0 1920 1080"><image width="1920" height="1080" xlink:href="data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMwaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA5LjEtYzAwMSA3OS4xNDYyODk5Nzc3LCAyMDIzLzA2LzI1LTIzOjU3OjE0ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgMjUuMCAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OUVERkMwNjI0Q0RCMTFGMDg5MTZCMkY2RDg1RkVEQkUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OUVERkMwNjM0Q0RCMTFGMDg5MTZCMkY2RDg1RkVEQkUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5RURGQzA2MDRDREIxMUYwODkxNkIyRjZEODVGRURCRSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5RURGQzA2MTRDREIxMUYwODkxNkIyRjZEODVGRURCRSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pv/uAA5BZG9iZQBkwAAAAAH/2wCEAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwODxAPDgwTExQUExMcGxsbHB8fHx8fHx8fHx8BBwcHDQwNGBAQGBoVERUaHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fH//AABEIBaAKAAMBEQACEQEDEQH/xACVAAEBAQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgBAQEBAQEBAAAAAAAAAAAAAAABAgMEBRAAAQMDAwIEBAYCAgICAAENAQARITFBAlFhEnEDgZGhIrHBMhPw0eFCUgTxBWIjcjMUBoKSokOywtIkFeJTFmMHEQEBAQEAAwEBAAIDAQEAAAAAARECITEDEkFhBFFxEyIy/9oADAMBAAIRAxEAPwD/ADJ3O73PuZjnk3I1J1XqcWfu91/ry8ygfd7n88urlA+73W+vJ+pQT7nd/nlG5QX7vdE88tpKCDu9z+WXmUFPd7tOeUblA+73f5nzKB97ufyy8ygfe7teeXmVA+73J9+TdSgfe7n88m6lBPu92nLLzKov3e4R9eXVygn3e5/PLzKB93uH9+XmUD7vd/nl5lA+73a88o3KB93u/wA8vMoq/d7rtzy0qUD7vdvnkPEoh93ufzy8yig7vdrzPmUQ+73K88vNA+93P5ZeZRT7vcB+vLzKiH3e4w9+XmUU+73P55HxKIfd7tTkfMoH3e6/15eZQPu93+eXmUD7vdec8vMoH3e7/LLq5QT7nd/nl5lFX73dpyy8ygHu93+eW0lUPu90/vPmUQPd7v8APLzKB93u/wAz5lA+73a88vMop97uv9eXmUD7vd/nl5lED3e6z88tpKB93ufzy8ygfd7v88vMoL93ut9WUblBPudz+eXmUF+53f55eaB93ufzy6uUA93uv9eXmVQ+73P55eZQPu93+ZPiUAd3uP8AXl5lA+73f55eZQPvd1/rPmURfu93+eXmUE+73f55eZVF+73P55P1KB93u/yy8ygfd7rPzPmUA97u/wAz5lUPu9yvM+ZQPu9xvryHiUAd3ufzPiSgfd7leeXVygv3e7/M9HKCfd7v8svMoL93u/zy8yqIO73f5HzKC/e7rPzy8ygfd7ls8vMoh97ufzy2koH3O7/M+ZQPu9z+eXmUU+73P55dHKB93uU55eZQPu9wfvy8ygn3e7/PJupQX7vd/nk3UoH3e7/PLzKQfs/9f/tv6eX+qyJ7gxyxxIyxL1ZvVeueXyPp8+p2/HZ97uHPIjPJiYkrzd5vh9bn0n3e7/PLq5WVT7vd/nl5lA+73H+vLzKgfd7l88vMoH3e5/PLzKAO73bZZeZQD3O5bPI+JRT7vc/nl5lBPu9x/ry8yoL93uT7svMoIe73K88vMoH3e7/PLzKC/d7n88m1coH3e40Z5N1KIfd7n88vMoA7vc/nl5lFPu9yvPLzKgn3e5/PLzKoDu93+eXmUD7vdf68vMoH3e7/ADy8ygfd7v8AM+ZUD7vd/nk/UoIe73R+/J+pQX7vcpzy8ygfd7leeXmUD7vd/nkPEoB73c/nl5lA+73f55eZQPu92+eXmVA+73f55eZVD7vctmfMoJ93umeWXmUD7vdb68vMqB93u/zPmVRfu93+eQ8Sgfc7tOeXmUD7vc/mT4lA+73P55eZQT7nc/nl5lBfu93+Z8ygfd7p/efMoH3e7/LLzKgfd7lOeXmVQ+73f55eZQPu9z+eXmUD7vd/mfMoH3e7/PLzKB93ut9eT9Sgfd7v88vMoH3e431k+JQPu9z+eXmUF+73f55eZVD7nc/nl5lBPud3+eXmVBfu9z+R8yqh93u/zy8ygfd7t88vMoq/d7jkc8tpKCHu9z+eXmUD7vcb68vMoH3e6f35eZQPu9z+eXmUD7nddueRGjlA+53R+8t1KB93uu/PLzKC/e7o/fl5lUT7vdj35dHKAO53W+vLzKCnu9xvqy8ygv3e9/PLzKIfd7rfXk3UoH3O649x8ygfd7pP15eZQPu9yPefMwgfd7kjlkPEop93u/yy8yqh93uH9+XmUD7vcMc8urlAHd7n88vMpA+73f5ZeZVD7vdvlk53KAe73f5ZeZUD7nd/nk3UoJ93ufyy8ygfd7l88vMoOv8AV/uZdnv493PlmMX9pyIdwyJZsTu/2Mu538+5iTjjkTkMXJZMWMfe7teeU7lAHd7v88n6lA+73KjPLzKo32v7f9jtl+33csTsSkqWad3+3/Y7uXLudzLI6klW3STGD3e5/M+ZRU+73Qfry8yoB7vcvnl5lA+73THPLaSgfc7388o3QB3e4/15eZQPu9z+eXRygfd7n88vMoi/d7v8z5lFT7nc/nlvJQUd3uV5ZdHKIn3e40Z5byUF+73R+/LzKB97ufyy8ygn3O5/PLzKB93u/wAzTUoL9zufzPmUD7vc/mW6lA+73G+sv1KB93uU55RuVRR3u7fPKdygfd7grnltJQD3O4R9eXRygfd7k+8nxKofc7p/fltJQPu91/rPmYQD3e7/ADy8yiH3e6G9+T9Sgfd7n88vMqi/c7p/efMoJ9zu/wA8vMoL93uP9WXgSgv3e5fPLzKofc7v/wDcy8ymCDu9z+eT9Sgfc7v88tpKgp7ncf68urlBPu9w/uy8ygfd7n8st5KB93ufyy8ygfd7n88p3Kgp7vdf68n6lUPud3+eXVyiB7ndH78vMoJ93un9+QazlBfu9yvLLzKAO73p958ygfc7v88t5KYan3e7QZ5P1KC/d7ls8t5KCfd7k+8kdSgfd7jfXkfEqB93vWzy8SVRfvd1m5mdygn3O6I55eZUD73ceMy3UoH3e68ZZOdyqL9zu3yy2koIO73XnM+ZUFHe7xLcsnO5VD7vdEc8n6qAO53ZbPI+JTDUHe7gB9+XmUgDud2+WWgkpgfdz4znl5lBB3e7Tkd5KBj3O4x9xi7lSQqjvd3+eQ8Sgfd7teWTauUDn3WnPKNymGh7vcrzyO7mqofc7onmZ3Kgfd7wD8y4oHKB9zumueXmUU+73T+7JjdyiB7ndY+8ltygfc7rPyym7lFT7ndb68n6lTBfu9z+eR8SqiHu9wBxnlpUqKDud0//AKQ+ZQPu9wge7LzKoDu9z+eTdSoH3e7/ADy8ygfd7teR8ygn3e67c8iTSSin3u431FxUOVBD3e63/sy1qUF+73D+8v1KBj3e7Puy8yqH3O67cj1coJ93u2zLdSgp7ubfXl5lBPu93+eRrcqCnud2pyPmUEHd7g/fl5lA+73ZHPLq5hBfu9z+eQbcqCc+88ZF+pQU9zuGeeT9UE+93Kc8h0KKo73c/nl5lED3e63/ALMtalUPu93+eXRyoJ93u/zIOjlFX7vcYe8+ZQPu94zzy6uUEPc7gH15TuWUA93u/wAyB1KCfc7j/WX6lBfu93+WQaklAPd7tszO5QPu9y+WR8Sgg7vd/nl4koL93uuRzMblA+73Sfryncqh93uH9+U7lA+53P55eZQT7ndvnkT1KB93u055dXKgfd7v8yAayVQ+73Kcy2rlBfu9yTzy8ygn3e659+T3YlQPu912OeT6uUwPudz+WXmgfd7rxnk3UoIO93aDPLq5QPu9Line truncated

After

Width:  |  Height:  |  Size: 431 KiB

@@ -0,0 +1 @@
<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,iVBORw0KGgoAAAANSUhEUgAAAhgAAACWCAYAAACcofGRAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7tnVmSJLmSXTMiMiKzqt7A12S3cAcU4Sa4Cn7zj39cA4WbpEjvgMIm+/ENNWRmDNSrgMLV4BgUk7l5pHtJlnvYAIPBBj129QJ29+H2ubXAshZ4u1tW9F4F//cP++/D/7zANvdqz5Xb+Y8f3lYWnyz7f1xgm9N38m7/dpu+D7cCj9gC+988j9gKtzpNaoErBYo9IOIGDZPOsUnF7AEjVwsfN+CYdJZ998XcAOO7PwVGGuCKgGIVROwJDv/7pmzw2fpPO6oGq0DkquDjBhwjd8nved0bYHzPR79r368AKmbCxCqAuMFC19nXvdIqKJkJIFcBHTfY6D4Hv8MVb4DxHR70tl0+MFDMAomZEHEDh7bT62hLzwSRWfBxaPC4AcfRTuEj1ecGGEc6GoeqyzsHixlQcYOJQ52xyyozAzpmwMYNNJYd4lvBa1rgBhhr2vVKSz0gVIyqFCMg8R4A4j8c3Lfxzzv6KVZdlSMAMgoeh4SOm6qx6lS7tnJvgHFtR2xqfQ8EFL0gMQIQ0pZHBImjg8HU83BCYUcElRHwkCbpBZBDgccNOCac4VdZxA0wrvKwjVb6AGDRAxQjMHFpiNgbGP58cOUiPoX/tLOScWkgGYGPHug4BHDcQGP0zn1t698A49qOWFd9rxAoemHiEiAxGx6uDQ66zskFK82GlEtASC94tELHDTgWnIC3IuMWuAHGuz4nvhOw2BsqZgHFDST2ufpmgcfewNEDGzfQ2Oecum3F1AI3wDA107UtdEGwaE19tCgVe4JED0TcgOHaLpR0fXuAZE/4aAGPqwKOWwrlfVxAp724Aca7OKIXAoprhYkeeMB5cg0A8e8vdC7Muo7+1xUEmR4AQfushJB3CR1XcC7MOu/faTk3wLjqA3uBYNICFVZ1YqUy0QoTl4aIaweES19PlwaUVvg4AnS0qBwX8W7cQOPSl1Xv9m+A0dtyF1tvZ6i4FqBoAYm9IWI2NPz17vu8bv/wNvetn3vDSAt8rAAPq8pxaOC4wcbFQk/Hhr/PG1VHQx1jlR3h4hrA4ohQMQsmvleI6L3QZsHHXtBxSdi4etC4QUbvZbL3ejfA2LvFm7d3xVAxM/VhhYmV6sQIPNyAofnMX7rCCJCshBAreMxUOCzAcVhV4wYbS6+TwcJvgDHYgGtW3wkqrCrF3l4KC0ysAIlegLhWePh5p/PspysNAr0QsgJALOAxCzoswIEbnxU6dvNtXOl5tiaIHKLUG2Ac4jBIJXa44VugYk+guARMtIDEpeFhLwg41HXQUJlLw0sLhMwGj72gYyZw7AIbN9BouIKWLnoDjKXN21L4FcHFjNTH3mBxdKg4Akj8wyTz6L9ONmO2XEay7CXA41KwcSTQsKgaN8joOaOvcp0bYFz0sC2GCotagf2vKRZ7AMWslIcVJPZSJmaCwywAuOg5P2HjMwFmLxCxwscslaMGHTPSKTVlwwIbOB2WA8dN0Zhw1XUVcQOMrmYbXekAYLEaKmoKxQygsMDESpDohYfVoPDL4vNr9PTPrf/j4kDQCyYrIcQCHjOgYzVwzICNG2isurIuVu4NMHZr+sU3/ZpaUQMKtMOIUlECilGYuARI9MDDKDhcKxjsdg0ZNzQKKj0gMhtC9gCPEnSMKBw12MBhrKkbN9gwnuzHXuwGGMuPz0KwGIWKIwLFnjDRChE9AHGDhuVX2NAGemCkFUBmwcdq6LgUcFwUNharZkMn5/WvfAOMpcfwHcLFKqWiBhazUh0tUNEKFO8RJr5UjJ+fDmDonH0Nt0JHC3DsBRsjaZUVoFFTNW6QMfssPkR5N8CYfhjeGVRcAij2hIkWiDgCQNQC/vTz+WAFHgFoWgDEAh97QMeRgOMGGwe7qNZV5wYYU9r2BhXFZlypTlgUCStE7A0Qe8HC7xeenyPXz992kqf3hhIrgKyGj1pKpQc6ZqsbN9gYuYIOv+4NMIYO0aIb94i3otdXMVOpKAHFiDoxCyZWgsQMaDgqEAxdKzusPANYVsKIBTwuCR2twDEbNnCKlIDjYmmUnUB4h0tk703cAKOrxQ8GFrOhoqfXRw4qeoGiBhMWVWIFSPQCxA0auq60ZSv1wsgKAJkBHr1plpzK0QobOFA54OjtkZKDjRtoLLssZhd8A4zmFr0AXJS6mPbARU6taAWLFUpFCSwuARU9QHGDiear6hAr9EDHbOAYhY3ZoIED0wobM0GjV9FY1s31pma0XKs3wDC31gKw6E2FHBEqepSKEZiYqU60QsTRAOLLh/d5HX/68Ga+PPdYsBVAZsJHDTxKqZUe6JilbOwBGxdRNG6gYbnk3ueNybLn5mV2BouZasUMpWJm6qMXKGbARAtEXAog3isomK+1SQteCkxaAGQGfJSg44jAMQs2DqVq3ECjdNXeACPbOpPBYi+1YhVUzFQoaqmOEaCwgsReELEIGv7006QXk02K6auL+fPPi8bb2AtGrPAxAh69KscshaMllbIaNnZXNW6gkboH3ADjrFV2BItZakUKKmb4KVqhIqdQlIBiNUysAolBcDgSILwO7ksOLu73Ct4GuhkGlFX7YgGPVdCRUzlagSOVTmmBjZxBtMUcehhV4wYa+nK8Acbm5vQdwkUqBbIaLEagAserpFIcECj2golVoGCI31MX2QtMhqDjEsAxAho4QjmF4yigkVI1bpAx9drau7AbYHCLHwAsWoybo4rFKFTspVTU0h2zYaLzSX4UIN4LGOx986ptbxRUugFkNnzUVI4e8Ojxb7QoG6OqxirYKKVOpvc8uakZN8CYCRcln0UqHTIKFbjDWlMhK6BiZupjT5hoAIkReLiBQw0BjjF/BESaIGQmeMyGjlbg2As2Zng1UumT3UDj+4aM7xQwdlIsch6LUbDYAypaVIqelMfqNEcDRHxoMEz+ScXEG0AcAxBW10IDyJ9bNtZiTJ0BHyXomKVyjKZTRpSNvVWNm6LRcrYnl/0OAWMSXLSqFWh+K1iMpEBGlIoUVORUilaoWAkUVphoAIk/WsscvgQ7C/j2Tse+yDXH48HGxIjq+ZcWQLCCR0uZqXabCRwt/g2runEp2OgZIXQabHxfisZ3BhjfGVy0mDWtcHEUsLACgBEqDgkU3xtEdLLWhwPChxk49oCN1aCRUjWskIFjHoOGtQfKiKJxg4zeq61pve8IMCbAxZ6qxeo0iBUocDq1QEVOqRgxZFpgwggS2J2LwcTBgOHthwnXhOF2c/frwZ7aLgQkZuhAm1rAY0ThyEFHSypltqoxU9Gw9j65GGgc7JowXMc9i3wHgDHpJpqDixHzZm8qpDcNYoWKSwLFRJjYFSR2gIe9gKDnRrLnOrsAy44QYgaPldCxCjh61Y1e2JitauTMoLeUiemSf8eAMQEsWhWL1R6LGCwsKZDZUDFboagBRUWZWA4RC8DhBgqme9P0hZaAyWIQqcJHDTp6VY5R4EipG7Ngw5JC2Qs0cJZOgY33qWi8Q8CYABY4aWYrFrPUiqNARU/KowQThhTHUpgYBIkbNEzngYsWOAwjC8GjCh21FEsPdKSAw5pOuVbYaOneOgUycODeF2i8M8C4wQXlbrfHNNULxJoCmaVWHBUsBqDiBhQXjf+7b3wIOBbBxu6gsYeqYTGH9phCexWNG2QMXWvvCDAmwMVs1QKHJlYuLOZNnQqZpViMQMVMteISKY9OkPgeIOLt69rurndPx+5eOnT39Ct3w8cC8KhCRy6lcmlVY0b6pCd1MmIGXerPeB9KxjsBjEG4mA0WPemQUX/FbKWiBSoGFIppaY/vACJWw8CMYLtHGdcELZeGj27gwIFsgY6VKZQVqsZVKBrXDxlXDhiDYJHzWvT2DGkFi1GoQP1jsLAoFTNSHzmo2EOhaICJI6sQN2DYA0coq31gFaUJQCYoHt3AMQobDCxvb9UjHvs1YmVjNmysBo0p3ozrBY0rBoxBuEipFr1De7emQXrAwhm33PGaBRWLVYpp6oTclWpg8Ue34NvXwXOjehcsL3AEcHh7vruqa/vuoyH4DB6X2uqXBJG7JxVE/lKp6QTQ0FvIQkeph8oocIzAhgUysIOtXo0YNmrpk5YxNIZB4zoh46puQqeLYjCAWOHC0u10NVzUTJu9ioUVLjqUimGwqIGEujteWqG4BExcGzzUArt1/iUg5JLQgXbZU+Eoqhuj3o3eFMqoorEaMnCQrEbQ7xAyrhAwBuDC6rU4Oli8N6gwAsXeMLESHr5XSLDCxOzlVsLJ3hBiho4BpePwsNGaOrkEaCwxgV6XknFFgDEAFrhbzVItVioWo2pFyluxQKnYQ6HYEyZmgsTFwOHz4PUxO6KPlvfbZW6kM0FkT/AwQccAcOBwJqHjEsrGiKqxGjRuasbmyr8SwBi4eV4CLFo9FiWw6FErLFDRmProhooDqRMzQGI5QLw3UBgFjdb1F4PJDADZAzxMwIG27YSOZt+GxbMRp1FafRorVY0Z/oyUojGUNrkMhLdcklcAGO8YLi6lWKTgItP749rBYhQqlgHFDSRa7lPjyy4Cj1HgWA0bhwKNHsjAka+Bxl6Kxg0ymq/DgwPGheFiVTpkBCx60yAHgIpVaY9RiMBVMxUkbvDQfCM6xAoLine truncated

After

Width:  |  Height:  |  Size: 33 KiB

+1
View File
@@ -0,0 +1 @@
<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"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}.c{clip-path:url(#c);}.d{fill:url(#d);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593"/></linearGradient><linearGradient id="b" x1="0.473" x2="0.473" y2="0.95" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593" stop-opacity="0"/></linearGradient><clipPath id="c"><rect class="a" width="1920" height="64" transform="translate(-2 5)"/></clipPath><pattern id="d" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 1917 104"><image width="1917" height="104" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB30AAABoCAYAAAD1oOqOAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7svQnUbMdRJli3qv717U+bpSdZsi1LtiUbtVdsQ7Nz2LpZvNADPdimWYfumW4Yz4Dpw+FwGs2hB4YZppsxDeOF5TCNMYYeNwM+mIPBm7zbki3JkmxL1tPytLz1X6vq3onIyMiMjBt5q/7//ZL+/ylL+l/VzZtL5BdLZkbkzVt986eaqlc+BYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgILAnEahK0HdP8q0QXRAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQEHAIl6FsEoSBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgI7GEEStB3DzOvkF4QKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBErQt8hAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBDYwwiUoO8eZl4hvSBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIlKBvkYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgILCHEShB3z3MvEL6U4pA9ZS2bjfe7EKaCkkFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgsDsRKH7u3cmXQlVBYFsIlKDvtmArhQoCDoHdMiCWYG8RyIJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBDYDgLFz70d1EqZgsAuRKAEfXchUwpJewqB3TAglqDvnhKZQmxBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQENhVCBQ/965iRyGmILA9BErQd3u4lVJ7G4HdMIDtVgRLAHm3cqbQVRAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQEIgLFz52XhuLnLprytESgBH2flmx/2ne6DIZlMHzaK0EBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQENjTCBQ/d/Fz72kBLsTvPAIY9O3vfLWlxoJAQaAgUBAoCBQECgK7GoGy43NXs6cQVxAoCBQECgIFgYJAQaAgUBDYNgIlCLJt6ErBgkBBoCBQECgIFAT2MgIl6LuXuVdoLwgUBAoCBYGCQEFgJxEogeCdRLPUVRAoCBQECgIFgYJAQaAgUBB44hEoAd4nHuPSQkGgIFAQKAgUBAoCewSBEvTdI4wqZO4qBGZdUGDwYNa8XR3U9XRdl4DFrhKVQkxBoCBwgSGwE3a92OkLTChKdwoCBYGCQEGgIFAQKAgUBEwELH/IrPPpWfMV6AsCBYGCQEFg+whsx2/9RNtnWX/xn2yft6Xk0xiBEvR9GjO/dH1LCGxnENxSA09A5jIwPgGglioLAgWBgsAOIFDs8w6AWKooCBQECgIFgYJAQaAgUBDY1QjsRT/Krga0EFcQKAgUBHYIgb1on4sfZYeYX6q58BGobvqPHxwcfeWrm8c+9KGq8m/3xevH4bqH13UE4eirp6djWS6BdbgyH4FvrBO+sb5QD2bkNwpjO/C7gW9ndbht/91A4kWvinXo+4FKyB/akvzz9Ye+wT2ss8LGNE3cPt7zveFyeHmR7xNXL+8xhlgn16/pcX0U2CZ4+EoZN3fpaeffrl5t5sSbmRu4F+5LHI26knb4PnfM4IHDiz8ZXubkJ9Cde4u0qi+Li5fNRNZQtuDDbVtyJ+UZ70N2V+bsbbdWJAjIbw8sXuvfPu3AjS9ssIz7/vxt1YEbbgzcCOlYJ3wwj2sD8rlryIt5HI8EliGfpMXTw+0wfQ0UPOjrcZVIOgV7Qn98Q9w2lpE0n/G0jk+fcbQGmfC6H3TP6zPmcfYBP17fXJrXb66DSXnswx+qpO5i/TpN1hXsBbQRdBQzeHpMO6RlSuhMS+c9t1rb0ry8a52Q5YO98vQktGTSWN81z009Ufoq87AdYRvZ0l0PuLQ3rCPBvmtcgGdsL6S+ef1wNTq+Mv4GzhYGQQzVGMLpWsaS/Hwhxp4ue8/20cIqjF+SDm0TNd+kDsnxyac78eGxA/VFjG0sx5Imrk6Of0GHrDHW65EcnzQ+SZtCByXpgW8STzk+qnGF9VaO/S3eajngOoTtTngrxm83Jmk+qPqkjPM4G8ZLySdh7wOWyAs/R5G6zbLM8xGNvewjt896E7CQdHJfUXf8WJ7kF0zQY5g1prnsYkA/+qqvQ/Kb43/27urYD7ymOf7ud5sLsWOviffwN1Zz/5++uyJzLyYIrEdaF0WbjBd+X/na1/m63uXma/i58vU+7U8gzX8w7X5xbaVznlxertuqR8ryLG1JOmXZvfq7mUx6zXjcw+8av/FvNMLfVTMaw/cIrq1vyof5XTlRz3axYP5vt/xeLCdlUsqx0zND7qUsS3mV2HXJ+Sw6YtExTTem3Z+FN5b+W7qby3e+eafRqPs4zV7NypNZbNA02vR9i9YuWcvJm8XXLvy77PY0mnJlu2TW6rfuS64PjMesdE0bk7bKo5K/ILATuq/nP9a1bEfL/Sz6pccariP4omQDuKjQn2RNl96Xc0xdjO+5bz9XxTyPf/iDsUZoz5p/u3zsH/S+BL0eb/kJ9fpfEJRdK4n1icsu1/jKhyF9Gi4v+z/wt/AVJmtRkU+ue1prCuFP5XvW2kquP+R6xeGlfFzsi0j8i3KuL2jTflbpJ2NeJPV5bALvlA8g8YlABdx3XA8hVs7fI/BNZEfQmPjrpL9HrE0Tf+YUs8S+JfbrYvYgV9baU9aX8zdwHuU7QR+W5T/WfhS5Dmdfe/AhC/mUdOZ4mkuXGGm/EPLEtaf0jX13vMZl/5TVhpbZRC6kH8HLnKkLjGPOJ235psT6WvoFuX7te0l8zJKfks+WDRC2QdsJ9lU6H5qQdeZliFfgPY6d6PaU3OqYQLA3yucubUTwH2qfi9BN5gvSPHfwYDDm7of0FXt62JeNl+G3dExDo4lvmvvBdUm/uUiTvmbnn5Y+aPSFC591yOtp4Cacz5zzyXb9bzleBL+6b8c5NRWtmHLwxhfWLO/a9rCtcLYeMksZb/l35Jjp/UFYXvuLEhsv5LHlX9W+OIyFQX0XSd+ib7MVI5N6JfWP7a+/nx2TfBmuJrEfYsx1/RM+frzW/iwZ8+L6ZBxQxhOt8YbLI/uSeB9UJmNvLb1H8ZY4aFuOtPrYpPaLmv59gx8hTsjxTBUjlfMcppXHCEmvNT6YsQDsD/tr1Rwp6LmP23LfJI8sPyzed0HfhBFRVZLAayuQyIJiOMWTwIgSKFd9LkAjGOUUGgVfBjCR8XryZdWv29BOT1YeVXdCG5SRAadE0TJttiYXVsBD4mUFIgwFZrwCDWiRkuirZBr8tgRW0txFF1dlDOStwItBPwVECIng5Jfk6XotupR8uH5jZcgQvgeC0cD13KE4sCkUOi+9Z7w3OnNajA693twBqM8PXhhcJf+5xxvb5DSrdj3Q6Dz+vquu3w7WNnWTstUKRCt6zE4ag3sY8HNyg33bCoAi7/gsBIvl4sqyCZZcs55aQQnUfeS7L8f2wHXfTwRlEFXqp95UoQO1VgDRrYWRL9yeH/hde06a1SfXR5EuaTbxsXQtg0UreNllR1intJ3VfRCL2rAJxehXa7PGlMl0blGsF56JnWI1k0Io7H/CA22/jEWEmV/3LTcOSfy0LZXyoHmgy0mZl/oh5V7TnrOHurxVt2rfXOxKGVAT1xZ/JC1WOSmrmb63+KD7Z2FtjRE+n9bF7CJvlrFX9smYJEqdNbHU+DFfc7hJW6Z1ka9bu7liobWHHu400Sk2Pqulq76NUFlOn6VBZPoyFCS22Jie6A0vVvdnyZODraQXBJ4MBLr21z0Z7W+nDUuvcrq21XRNzzQ7gPl3Ws/PtLine truncated

After

Width:  |  Height:  |  Size: 98 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="30" viewBox="0 0 32 30"><defs><style>.a{fill:rgba(255,255,255,0.05);}.b{fill:url(#a);}</style><linearGradient id="a" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#a2e1f9"/><stop offset="1" stop-color="#45c3f3"/></linearGradient></defs><g transform="translate(1.411 -9.285)"><rect class="a" width="32" height="30" rx="4" transform="translate(-1.411 9.285)"/><path class="b" d="M4618.588,4844.6a1.257,1.257,0,1,1,1.258,1.256A1.258,1.258,0,0,1,4618.588,4844.6Zm4.609.838v-1.676h12.569v1.676Zm-4.609-5.867a1.257,1.257,0,1,1,1.258,1.257A1.257,1.257,0,0,1,4618.588,4839.569Zm4.609.838v-1.676h12.569v1.676Zm-4.609-5.865a1.257,1.257,0,1,1,1.258,1.257A1.257,1.257,0,0,1,4618.588,4834.542Zm4.609.838V4833.7h12.569v1.676Z" transform="translate(-4613 -4815)"/></g></svg>

After

Width:  |  Height:  |  Size: 892 B

@@ -245,9 +245,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
// first paint is already roughly laid out instead of an exploding
// cloud that drifts out of the viewport.
if (props.layoutType === "force") {
const deadline = performance.now() + 120;
const budget = simNodes.length > 350 ? 280 : 120;
const deadline = performance.now() + budget;
let guard = 0;
while (sim.alpha > 0.25 && guard < 300 && performance.now() < deadline) {
while (sim.alpha > 0.25 && guard < 600 && performance.now() < deadline) {
sim.tick();
guard += 1;
}
@@ -295,9 +296,15 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
// Notify React of zoom changes only when k actually moved — a state
// update per animation frame re-renders the whole page while settling.
const lastNotifiedZoomRef = useRef(0);
const setTransform = useCallback((t: Transform) => {
transformRef.current = t;
propsRef.current.onZoomChange(t.k);
if (Math.abs(t.k - lastNotifiedZoomRef.current) > 0.004) {
lastNotifiedZoomRef.current = t.k;
propsRef.current.onZoomChange(t.k);
}
needsDrawRef.current = true;
}, []);
@@ -326,32 +333,39 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
[setTransform],
);
const computeFitTransform = useCallback((): Transform | null => {
const sim = simRef.current;
const { w, h } = sizeRef.current;
if (!sim || !sim.nodes.length || !w || !h) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x - n.radius);
minY = Math.min(minY, n.y - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
maxY = Math.max(maxY, n.y + n.radius);
}
// A single bad coordinate must not poison the viewport transform.
if (!Number.isFinite(minX + minY + maxX + maxY)) return null;
const pad = 70;
const bw = Math.max(maxX - minX, 1);
const bh = Math.max(maxY - minY, 1);
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
return { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
}, []);
const fitView = useCallback(
(animated = true) => {
const sim = simRef.current;
const { w, h } = sizeRef.current;
if (!sim || !sim.nodes.length || !w || !h) return;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x - n.radius);
minY = Math.min(minY, n.y - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
maxY = Math.max(maxY, n.y + n.radius);
}
const pad = 70;
const bw = Math.max(maxX - minX, 1);
const bh = Math.max(maxY - minY, 1);
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const target = { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
const target = computeFitTransform();
if (!target) return;
if (animated) animateTo(target);
else setTransform(target);
},
[animateTo, setTransform],
[animateTo, computeFitTransform, setTransform],
);
const focusNode = useCallback(
@@ -527,11 +541,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
ctx.globalAlpha = alpha;
// Glow for hubs and the active node.
if (
alpha > 0.5 &&
(isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold)
) {
// Glow for hubs and the active node. Canvas shadows are expensive,
// so on large graphs only the focused nodes keep theirs.
const glowEligible =
sim.nodes.length <= 450
? isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold
: isActive || isSelected || isExploreRoot;
if (alpha > 0.5 && glowEligible) {
ctx.shadowColor = fillColor;
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
} else {
@@ -692,6 +708,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
};
if (progress >= 1) {
animRef.current = null;
lastNotifiedZoomRef.current = transformRef.current.k;
propsRef.current.onZoomChange(transformRef.current.k);
}
active = true;
@@ -699,10 +716,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
if (sim && layoutRef.current === "force" && sim.tick()) {
active = true;
// Until the user pans/zooms/drags, keep the settling layout fully in
// view every frame so nodes never drift out of the viewport.
// Until the user pans/zooms/drags, gently track the settling layout
// so it stays in view without the viewport snapping every frame.
if (!interactedRef.current && sizeRef.current.w) {
fitView(false);
const target = computeFitTransform();
if (target) {
const t = transformRef.current;
const ease = t.k === 0 ? 1 : 0.16;
setTransform({
x: t.x + (target.x - t.x) * ease,
y: t.y + (target.y - t.y) * ease,
k: t.k + (target.k - t.k) * ease,
});
}
autoFitRef.current = true;
} else if (!autoFitRef.current && sim.alpha < 0.3) {
autoFitRef.current = true;
@@ -721,7 +747,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [draw, fitView]);
}, [computeFitTransform, draw, fitView, setTransform]);
// --------------------------------------------------------------- resize
@@ -801,7 +827,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.button !== 0) return;
interactedRef.current = true;
const { sx, sy } = localPoint(e);
const { x: wx, y: wy } = screenToWorld(sx, sy);
const node = hitTest(wx, wy);
@@ -828,7 +853,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
if (drag && drag.pointerId === e.pointerId) {
const dx = sx - drag.startX;
const dy = sy - drag.startY;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
if (Math.abs(dx) + Math.abs(dy) > 3) {
drag.moved = true;
// Only a real pan/drag takes over the camera; a plain click keeps
// the auto-follow so the settling graph can't drift out of view.
interactedRef.current = true;
}
if (drag.node) {
const world = screenToWorld(sx, sy);
drag.node.fx = world.x;
@@ -239,15 +239,41 @@ export function applyCircularLayout(nodes: SimNode[]): void {
export class ForceSimulation {
alpha = 1;
readonly alphaMin = 0.004;
private readonly alphaDecay = 0.0228;
private readonly alphaDecay: number;
private readonly velocityDecay = 0.38;
private readonly chargeStrength = -420;
private readonly chargeCutoff2 = 600 * 600;
private readonly chargeStrength: number;
private readonly chargeCutoff: number;
private readonly chargeCutoff2: number;
/** Hard cap on per-tick movement so dense graphs can't oscillate or fly off. */
private readonly maxSpeed = 30;
constructor(
public nodes: SimNode[],
public edges: SimEdge[],
) {}
) {
// Scale forces down and cool faster as the graph grows — with the
// small-graph constants a few hundred nodes keep shoving each other
// around for minutes and the layout never settles.
const n = nodes.length;
if (n > 600) {
this.chargeStrength = -160;
this.chargeCutoff = 260;
this.alphaDecay = 0.05;
} else if (n > 300) {
this.chargeStrength = -240;
this.chargeCutoff = 340;
this.alphaDecay = 0.038;
} else if (n > 150) {
this.chargeStrength = -320;
this.chargeCutoff = 440;
this.alphaDecay = 0.03;
} else {
this.chargeStrength = -420;
this.chargeCutoff = 600;
this.alphaDecay = 0.0228;
}
this.chargeCutoff2 = this.chargeCutoff * this.chargeCutoff;
}
reheat(alpha = 0.9): void {
this.alpha = Math.max(this.alpha, alpha);
@@ -276,31 +302,8 @@ export class ForceSimulation {
s.vy += dy * 0.5;
}
// Many-body repulsion with a distance cutoff.
for (let i = 0; i < nodes.length; i++) {
const ni = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const nj = nodes[j];
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
let d2 = dx * dx + dy * dy;
if (d2 > this.chargeCutoff2) continue;
if (d2 < 1) {
// Deterministic nudge for coincident nodes.
dx = ((i % 3) - 1) * 0.5 || 0.5;
dy = ((j % 3) - 1) * 0.5 || -0.5;
d2 = dx * dx + dy * dy;
}
const d = Math.sqrt(d2);
const f = (this.chargeStrength * a) / d2;
const fx = (dx / d) * f;
const fy = (dy / d) * f;
ni.vx += fx;
ni.vy += fy;
nj.vx -= fx;
nj.vy -= fy;
}
}
// Repulsion + collision over nearby pairs only (uniform grid).
this.applyPairForces(a);
// Gentle pull toward the origin keeps disconnected clusters on screen.
for (const n of nodes) {
@@ -308,33 +311,9 @@ export class ForceSimulation {
n.vy += (0 - n.y) * 0.05 * a;
}
// Positional collision resolution (prevents node overlap).
for (let i = 0; i < nodes.length; i++) {
const ni = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const nj = nodes[j];
const minDist = ni.radius + nj.radius + 8;
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
const d2 = dx * dx + dy * dy;
if (d2 >= minDist * minDist || d2 === 0) continue;
const d = Math.sqrt(d2);
const push = ((minDist - d) / d) * 0.5;
dx *= push;
dy *= push;
if (ni.fx == null) {
ni.x -= dx;
ni.y -= dy;
}
if (nj.fx == null) {
nj.x += dx;
nj.y += dy;
}
}
}
// Integrate velocities.
for (const n of nodes) {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
if (n.fx != null && n.fy != null) {
n.x = n.fx;
n.y = n.fy;
@@ -343,10 +322,113 @@ export class ForceSimulation {
} else {
n.vx *= 1 - this.velocityDecay;
n.vy *= 1 - this.velocityDecay;
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
if (speed > this.maxSpeed) {
const s = this.maxSpeed / speed;
n.vx *= s;
n.vy *= s;
}
n.x += n.vx;
n.y += n.vy;
}
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) {
// A NaN/Infinity coordinate would blank the whole canvas (the
// bounding box and fit transform become NaN); park the node instead.
const r = 40 + 18 * Math.sqrt(i + 1);
n.x = r * Math.cos(i * GOLDEN_ANGLE);
n.y = r * Math.sin(i * GOLDEN_ANGLE);
n.vx = 0;
n.vy = 0;
}
}
return true;
}
/**
* Many-body repulsion + collision resolution, restricted to pairs within
* one grid cell (cell size = charge cutoff). Two nodes farther apart than
* the cutoff never interact, so binning by cell keeps the scan close to
* O(n) for spread-out layouts instead of O(n²) over all pairs.
*/
private applyPairForces(a: number): void {
const cellSize = this.chargeCutoff;
const grid = new Map<string, { cx: number; cy: number; items: SimNode[] }>();
for (const n of this.nodes) {
const cx = Math.floor(n.x / cellSize);
const cy = Math.floor(n.y / cellSize);
const key = `${cx},${cy}`;
let bucket = grid.get(key);
if (!bucket) grid.set(key, (bucket = { cx, cy, items: [] }));
bucket.items.push(n);
}
// Half of the 8-neighborhood so every cell pair is visited exactly once.
const offsets = [
[0, 0],
[1, 0],
[0, 1],
[1, 1],
[1, -1],
] as const;
for (const bucket of grid.values()) {
for (const [ox, oy] of offsets) {
const other =
ox === 0 && oy === 0
? bucket
: grid.get(`${bucket.cx + ox},${bucket.cy + oy}`);
if (!other) continue;
const left = bucket.items;
const right = other.items;
if (other === bucket) {
for (let i = 0; i < left.length; i++) {
for (let j = i + 1; j < left.length; j++) {
this.pairForce(left[i], left[j], i, j, a);
}
}
} else {
for (let i = 0; i < left.length; i++) {
for (let j = 0; j < right.length; j++) {
this.pairForce(left[i], right[j], i, j, a);
}
}
}
}
}
}
private pairForce(ni: SimNode, nj: SimNode, i: number, j: number, a: number): void {
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
let d2 = dx * dx + dy * dy;
if (d2 > this.chargeCutoff2) return;
if (d2 < 1) {
// Deterministic nudge for coincident nodes.
dx = ((i % 3) - 1) * 0.5 || 0.5;
dy = ((j % 3) - 1) * 0.5 || -0.5;
d2 = dx * dx + dy * dy;
}
const d = Math.sqrt(d2);
const f = (this.chargeStrength * a) / d2;
const fx = (dx / d) * f;
const fy = (dy / d) * f;
ni.vx += fx;
ni.vy += fy;
nj.vx -= fx;
nj.vy -= fy;
// Positional collision resolution (prevents node overlap).
const minDist = ni.radius + nj.radius + 8;
if (d < minDist) {
const push = ((minDist - d) / d) * 0.5;
const px = dx * push;
const py = dy * push;
if (ni.fx == null) {
ni.x -= px;
ni.y -= py;
}
if (nj.fx == null) {
nj.x += px;
nj.y += py;
}
}
}
}
+427 -5
View File
@@ -43,8 +43,8 @@
width: 100%;
height: 75px;
margin-top: 13px;
font-family: Noto Sans S Chinese;
font-weight: 500;
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-weight: 700;
color: #fff;
font-size: 20px;
display: flex;
@@ -111,6 +111,42 @@
word-break: break-all;
}
.sidebar-contnet--top-submenu .sidebar-contents {
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-contnet--top-submenu .item-nav {
height: auto;
min-height: 75px;
padding: 10px 2px;
font-size: 19px;
line-height: 1.35;
}
.sidebar-contnet--top-submenu .item-nav__label {
width: 3.1em;
max-width: 100%;
padding-left: 0;
letter-spacing: 0;
line-height: 1.35;
text-align: center;
word-break: normal;
overflow-wrap: normal;
white-space: normal;
}
.sidebar-contnet--top-submenu .item-nav__label--four-char {
width: 2.2em;
}
.sidebar-contnet--top-submenu .item-nav:hover::after,
.sidebar-contnet--top-submenu .item-nav-active::after,
.sidebar-contnet--top-submenu .item-nav-hovered::after {
top: auto;
bottom: 0;
}
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
@@ -194,10 +230,10 @@
border: 0;
background: transparent;
padding: 3px 0;
font-family: Noto Sans S Chinese;
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-size: 18px;
line-height: 16px;
font-weight: 500;
font-weight: 700;
letter-spacing: 0;
color: rgba(255, 255, 255, 0.8);
text-align: left;
@@ -320,4 +356,390 @@
max-width: 64px;
opacity: 1;
transform: translateX(0);
}
}
.sidebar-contnet--top-submenu .item-nav:hover::before,
.sidebar-contnet--top-submenu .item-nav-active::before,
.sidebar-contnet--top-submenu .item-nav-hovered::before {
display: none;
}
.top-submenu-bar {
position: relative;
z-index: 35;
height: 64px;
min-width: 0;
flex: 1 1 auto;
align-self: stretch;
margin-left: 34px;
background: transparent;
border: 0;
box-shadow: none;
overflow-x: auto;
overflow-y: hidden;
}
/* 左右分组:不撑满、不裁切选中高光 */
.top-submenu-bar--group {
flex: 0 0 auto;
margin-left: 0;
overflow: visible;
}
.top-submenu-list {
min-width: max-content;
height: 64px;
display: flex;
align-items: stretch;
gap: 0;
padding: 0;
}
.top-submenu-item {
position: relative;
min-width: 122px;
height: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0 18px;
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-size: 18px;
line-height: 1;
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
overflow: visible;
transition: color 0.18s ease, text-shadow 0.18s ease;
}
.top-submenu-item:hover {
color: #9fe3ff;
text-shadow: 0 0 10px rgba(91, 210, 255, 0.4);
}
/* 选中态:设计稿提供的 nav-active.svg 发光高光 + 青色文字 */
.top-submenu-item--active {
color: #64cdf5;
text-shadow: 0 0 12px rgba(100, 205, 245, 0.5);
background-image: url("../assets/img/nav-active.svg");
background-repeat: no-repeat;
background-position: center calc(50% + 7px);
background-size: 152px 42px;
}
.third-submenu-bar {
position: relative;
z-index: 34;
height: 42px;
margin-left: 118px;
flex-shrink: 0;
background:
linear-gradient(180deg, rgba(4, 34, 78, 0.96) 0%, rgba(2, 24, 58, 0.96) 100%),
radial-gradient(ellipse at top left, rgba(45, 149, 255, 0.18) 0%, rgba(45, 149, 255, 0) 42%);
border-top: 1px solid rgba(79, 154, 255, 0.18);
border-bottom: 1px solid rgba(43, 143, 254, 0.26);
box-shadow: inset 0 1px 0 rgba(115, 194, 255, 0.08), inset 0 -10px 18px rgba(0, 26, 70, 0.22);
overflow-x: auto;
overflow-y: hidden;
}
.third-submenu-list {
min-width: max-content;
height: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 0 24px;
}
.third-submenu-item {
position: relative;
height: 28px;
min-width: 86px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(90, 169, 255, 0.2);
border-radius: 2px;
padding: 0 16px;
background: rgba(2, 28, 65, 0.36);
color: rgba(221, 242, 255, 0.78);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1;
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.third-submenu-item:hover,
.third-submenu-item--active {
color: #ffffff;
border-color: rgba(82, 205, 255, 0.55);
background: linear-gradient(180deg, rgba(14, 84, 165, 0.54) 0%, rgba(4, 45, 104, 0.58) 100%);
box-shadow: inset 0 0 12px rgba(0, 185, 255, 0.14), 0 0 10px rgba(35, 167, 255, 0.14);
text-shadow: 0 0 8px rgba(87, 215, 255, 0.34);
}
.third-submenu-item--active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: -1px;
height: 2px;
background: linear-gradient(90deg, rgba(62, 178, 255, 0) 0%, #4ee6ff 50%, rgba(62, 178, 255, 0) 100%);
box-shadow: 0 0 10px rgba(78, 230, 255, 0.72);
}
/* ══════════════════════════════════════════════════════════════════════
浅色主题导航(红/蓝两套,颜色经 --brand-* 变量自动切换)
逻辑同深色:一级导航在顶栏、二级导航在侧边栏、三级导航在顶栏下方横条。
但一级导航不做深色那种围绕基座的特殊处理,正常横排即可。
══════════════════════════════════════════════════════════════════════ */
/* ── 顶栏一级导航(白字落在 red/blue-header banner 上)──────────────────── */
.light-topnav {
display: flex;
align-items: center;
height: 64px;
min-width: 0;
gap: 6px;
/* 整体往右移(用百分比,随容器宽度自适应;约等于宽屏下的 200px 量级) */
margin-left: 10%;
overflow-x: auto;
overflow-y: hidden;
}
.light-topnav-item {
position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: 60px;
padding: 0 18px;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.85);
/* 复用全局字体栈(与「多智能体会商」等页面一致):Source Han Sans SC 在 Windows 上
常按字重分家族,放栈首会导致 font-weight 选不到粗体;用 --font-sans(Noto Sans SC
领先)才能正常加粗。 */
font-family: var(--font-sans);
font-size: 16px;
/* 普通项用 500(雅黑兜底下渲染为常规字面),选中项 700 才能看出粗体对比 */
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: color 0.18s ease;
}
.light-topnav-item:hover {
color: #ffffff;
}
/* 选中态:不改背景,文字加粗变亮 + 贴近导航栏底部一条粗白线 */
.light-topnav-item--active,
.light-topnav-item--active:hover {
color: #ffffff;
font-weight: 700;
}
.light-topnav-item--active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: 0;
height: 4px;
border-radius: 3px 3px 0 0;
background: #ffffff;
}
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
.light-sidebar {
position: fixed;
left: 0;
top: 4rem;
z-index: 40;
width: 96px;
height: calc(100vh - 4rem);
display: flex;
flex-direction: row;
background: #ffffff;
border-right: 1px solid var(--border);
box-sizing: border-box;
overflow: hidden;
/* 展开三级列时整体变宽的过渡动画 */
transition: width 0.3s ease;
}
/* 展开三级列:96(二级) + 140(三级) = 236px */
.light-sidebar--expanded {
width: 236px;
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
.light-sidebar__primary {
flex: 0 0 96px;
width: 96px;
height: 100%;
display: flex;
flex-direction: column;
/* 横向 padding 置 0:让选中态浅红背景能铺满与列宽一致 */
padding: 10px 0;
box-sizing: border-box;
}
/* 三级列:固定 140px,随侧边栏展开淡入 */
.light-sidebar__secondary {
flex: 0 0 140px;
width: 140px;
height: 100%;
border-left: 1px solid var(--border);
/* 横向 padding 置 0:让三级选中背景能铺满与列宽一致(文字内缩由 subitem 自身 padding 保证) */
padding: 12px 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
background: #ffffff;
opacity: 0;
transition: opacity 0.2s ease;
}
.light-sidebar--expanded .light-sidebar__secondary {
opacity: 1;
}
.light-sidebar__list {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
overflow-x: hidden;
}
.light-sidebar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px 4px;
border: 0;
border-radius: 0;
background: transparent;
color: #222222;
/* 复用全局字体栈,确保加粗生效(详见 .light-topnav-item 注释) */
font-family: var(--font-sans);
font-size: 16px;
font-weight: 500;
line-height: 1.3;
text-align: center;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.light-sidebar-item__icon {
flex-shrink: 0;
/* 图标统一用主题色(红主题红、蓝主题蓝) */
color: var(--brand-900);
transition: color 0.18s ease, transform 0.18s ease;
}
.light-sidebar-item__label {
display: block;
width: 100%;
word-break: break-word;
}
.light-sidebar-item:hover {
background: var(--brand-200);
}
.light-sidebar-item:hover .light-sidebar-item__icon {
color: var(--brand-900);
transform: scale(1.05);
}
.light-sidebar-item--active {
background: var(--brand-200);
color: var(--brand-900);
font-weight: 600;
/* 选中态:去圆角去边框,浅红底铺满父元素宽度,右侧一条粗主题色边(用 inset 阴影避免挤压内容) */
box-shadow: inset -4px 0 0 0 var(--brand-900);
}
.light-sidebar-item--active .light-sidebar-item__icon {
color: var(--brand-900);
}
/* 侧边栏底部:设置入口(浅色模式不支持收起,故无收起按钮) */
.light-sidebar__footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
/* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */
.light-sidebar-subnav {
display: flex;
flex-direction: column;
gap: 2px;
}
.light-sidebar-subitem {
position: relative;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px;
border: 0;
background: transparent;
color: #222222;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 500;
line-height: 1.3;
text-align: left;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.light-sidebar-subitem__icon {
flex-shrink: 0;
/* 图标用主题色(红主题红、蓝主题蓝) */
color: var(--brand-900);
}
.light-sidebar-subitem__label {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.light-sidebar-subitem:hover {
background: var(--brand-200);
}
.light-sidebar-subitem--active {
background: var(--brand-200);
color: var(--brand-900);
font-weight: 700;
box-shadow: inset -4px 0 0 0 var(--brand-900);
}
@@ -1,14 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { PanelLeftOpen, PanelLeftClose } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import {
defaultExpandedIds,
filterMenuItemsByRole,
sidebarMenuItems,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import './page-sdebar.css'
type PageSidebarDarkProps = {
@@ -19,10 +19,8 @@ type PageSidebarDarkProps = {
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useMemo(
() => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin),
[isAdmin],
);
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const inWujieEnv = isWujieEnv();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
const navigate = useNavigate();
@@ -56,7 +54,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
};
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
@@ -126,6 +124,13 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
};
const handleSecondItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
if (path) {
navigate(path);
}
};
const handleMenuItemClick = (item: MenuItem) => {
if (item.children?.length) {
toggleExpanded(item.id);
@@ -137,6 +142,35 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
};
const activeTopItem =
visibleMenuItems.find((item) => item.id === activeTopId) ??
visibleMenuItems.find(hasActiveChild) ??
visibleMenuItems[0];
const sidebarMenuItemsToRender = inWujieEnv
? visibleMenuItems
: activeTopItem?.children ?? [];
const isSidebarItemActive = (item: MenuItem) =>
inWujieEnv
? item.id === activeTopId
: item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item);
const handleSidebarItemClick = (item: MenuItem) => {
if (inWujieEnv) {
handleTopItemClick(item);
return;
}
handleSecondItemClick(item);
};
const getSidebarLabelClassName = (label: string) => {
if (!inWujieEnv && Array.from(label.trim()).length === 4) {
return "item-nav__label item-nav__label--four-char";
}
return "item-nav__label";
};
const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
{items.map((item) => {
@@ -163,6 +197,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
})}
</div>
);
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
if (collapsed) {
return (
@@ -180,16 +215,16 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
return (
<aside className="sidebar-contnet">
<aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
<div className="sidebar-contents">
{visibleMenuItems.map((item) => (
{sidebarMenuItemsToRender.map((item) => (
<div
key={item.id}
className="item-nav-group"
onMouseEnter={() => handleNavHoverEnter(item.id)}
onMouseLeave={scheduleNavPanelClose}
onMouseEnter={inWujieEnv ? () => handleNavHoverEnter(item.id) : undefined}
onMouseLeave={inWujieEnv ? scheduleNavPanelClose : undefined}
>
{item.children?.length ? (
{inWujieEnv && item.children?.length ? (
<div
className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`}
>
@@ -199,12 +234,12 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
<div className="item-nav-wrap">
<button
type="button"
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
onClick={() => handleTopItemClick(item)}
className={`item-nav ${isSidebarItemActive(item) ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
onClick={() => handleSidebarItemClick(item)}
>
<span className="item-nav__label">{item.label}</span>
<span className={getSidebarLabelClassName(item.label)}>{item.label}</span>
</button>
{item.children?.length && hoveredTopId === item.id ? (
{inWujieEnv && item.children?.length && hoveredTopId === item.id ? (
<span className="item-nav-hover-bridge" aria-hidden="true" />
) : null}
</div>
@@ -0,0 +1,303 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
Folder,
FolderOpen,
Settings,
User,
Wrench,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
defaultExpandedIds,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
type PageSidebarProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
export function PageSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
const iconButtonClass = isDark
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = isDark
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
: "ml-4 space-y-1 border-l border-red-100 pl-4";
const labelClass = (compact: boolean, small = false) =>
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
const menuButtonClass = (isActive: boolean, depth = 0) =>
`group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3"
} ${
isActive
? isDark
? "bg-blue-500/15 text-cyan-300 font-semibold"
: "bg-red-50 text-red-600 font-semibold"
: isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
}`;
const footerButtonClass = menuButtonClass(false, 0);
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const renderLeaf = (item: MenuItem, depth = 0) => {
const Icon = item.icon;
const isActive = !!item.path && pathname === item.path;
return (
<button
key={item.id}
type="button"
title={collapsed && depth === 0 ? item.label : undefined}
onClick={() => item.path && navigate(item.path)}
className={menuButtonClass(isActive, depth)}
>
{isActive && (
<span
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
isDark ? "bg-cyan-300" : "bg-red-600"
}`}
/>
)}
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
{(!collapsed || depth > 0) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
</button>
);
};
const renderBranch = (item: MenuItem, depth = 0) => {
const expanded = expandedIds.has(item.id);
const isTopLevel = depth === 0;
const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon;
return (
<div key={item.id} className="space-y-1">
<button
type="button"
onClick={() => {
if (collapsed && isTopLevel) {
onToggleCollapsed();
return;
}
toggleExpanded(item.id);
}}
title={collapsed && isTopLevel ? item.label : undefined}
className={menuButtonClass(false, depth)}
>
<Icon
className={`${isTopLevel ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
expanded && depth > 0 ? "scale-105" : ""
}`}
/>
{(!collapsed || !isTopLevel) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
{(!collapsed || !isTopLevel) && (
<ChevronRight
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
/>
)}
</button>
{(!collapsed || !isTopLevel) && item.children && (
<div
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
}`}
>
{item.children.map((child) =>
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
)}
</div>
)}
</div>
);
};
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
} ${collapsed ? "w-16" : "w-64"}`}
>
<button
type="button"
onClick={onToggleCollapsed}
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
isDark
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
}`}
>
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
</button>
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{visibleMenuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
</nav>
<div
className={`mt-auto shrink-0 theme-transition ${
isDark ? "border-t border-blue-800/30" : "border-t border-red-100"
} ${collapsed ? "p-2" : "p-4"}`}
>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={collapsed ? t.workspace.settingsAndMore : undefined}
className={footerButtonClass}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
<ChevronsUpDown className="h-4 w-4 flex-shrink-0" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={collapsed ? "start" : "end"}
side={collapsed ? "right" : "top"}
sideOffset={4}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span
className={`ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
isDark ? "bg-cyan-500" : "bg-red-500"
}`}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className={`${footerButtonClass} pointer-events-none`}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
)}
</button>
)}
</div>
<div
className={`pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full blur-3xl theme-transition ${
isDark ? "bg-blue-500/10" : "bg-red-500/5"
}`}
/>
<div
className={`pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full blur-3xl theme-transition ${
isDark ? "bg-cyan-400/5" : "bg-red-400/5"
}`}
/>
</aside>
</>
);
}
+214 -11
View File
@@ -22,14 +22,17 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
defaultExpandedIds,
filterMenuItemsByRole,
sidebarMenuItems,
getActiveThirdLevelItems,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import "@/components/page-sdebar.css";
type PageSidebarProps = {
collapsed: boolean;
@@ -38,14 +41,210 @@ type PageSidebarProps = {
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
export function PageSidebar({
const hasActiveChild = (item: MenuItem, pathname: string): boolean => {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
};
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) {
return null;
}
for (const child of items) {
if (child.path) {
return child.path;
}
const nested = findFirstRoutePath(child.children);
if (nested) {
return nested;
}
}
return null;
};
/**
* 浅色侧边栏入口。
*
* - 普通桌面环境:渲染「二级导航图标卡片」(一级在顶栏、二级在侧边栏,参考 导航-1.png)。
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
* (保证嵌入模式下所有层级仍可达,零回归)。
*/
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
if (isWujieEnv()) {
return (
<LegacyAccordionSidebar
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
/>
);
}
return <SecondaryNavSidebar />;
}
/**
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
*/
function SecondaryNavSidebar() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
// 当前激活的一级菜单(与顶栏一级导航联动);其 children 即侧边栏要渲染的二级导航。
const activeTopItem =
visibleMenuItems.find((item) => hasActiveChild(item, pathname)) ??
visibleMenuItems[0];
const secondaryItems = activeTopItem?.children ?? [];
const isItemActive = (item: MenuItem) =>
item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item, pathname);
const handleItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
if (path) {
navigate(path);
}
};
// 当前激活二级项的三级子项;非空时侧边栏在右侧展开「三级列」并整体变宽(见 css 过渡)。
const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
const expanded = thirdItems.length > 0;
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
{/* 一级列:二级图标卡片 + 底部设置 */}
<div className="light-sidebar__primary">
<nav className="light-sidebar__list" aria-label="二级菜单">
{secondaryItems.map((item) => {
const Icon = item.icon;
const active = isItemActive(item);
return (
<button
key={item.id}
type="button"
title={item.label}
onClick={() => handleItemClick(item)}
className={`light-sidebar-item ${active ? "light-sidebar-item--active" : ""}`}
>
<Icon className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">{item.label}</span>
</button>
);
})}
</nav>
<div className="light-sidebar__footer">
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={t.workspace.settingsAndMore}
className="light-sidebar-item"
>
<Settings className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">设置</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="right"
sideOffset={8}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--brand-900)] px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{/* 二级列:三级导航(激活二级项有三级子项时展开,整体动画见 css width transition) */}
<div className="light-sidebar__secondary" aria-hidden={!expanded}>
<nav className="light-sidebar-subnav" aria-label="三级菜单">
{thirdItems.map((child) => {
const ChildIcon = child.icon;
const childActive = child.path
? pathname === child.path || pathname.startsWith(child.path + "/")
: hasActiveChild(child, pathname);
return (
<button
key={child.id}
type="button"
title={child.label}
onClick={() => handleItemClick(child)}
className={`light-sidebar-subitem ${childActive ? "light-sidebar-subitem--active" : ""}`}
>
<ChildIcon className="light-sidebar-subitem__icon h-4 w-4" />
<span className="light-sidebar-subitem__label">{child.label}</span>
</button>
);
})}
</nav>
</div>
</aside>
</>
);
}
/**
* 旧版浅色整树手风琴侧边栏。仅用于 wujie 嵌入环境(顶栏被宿主隐藏时保证所有层级可达)。
*/
function LegacyAccordionSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(sidebarMenuItems, !!isAdmin);
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
@@ -67,9 +266,6 @@ export function PageSidebar({
setSettingsOpen(true);
};
const iconButtonClass = isDark
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = isDark
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
: "ml-4 space-y-1 border-l border-red-100 pl-4";
@@ -78,8 +274,8 @@ export function PageSidebar({
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
const menuButtonClass = (isActive: boolean, depth = 0) =>
`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
`group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3"
} ${
isActive
? isDark
@@ -115,7 +311,14 @@ export function PageSidebar({
onClick={() => item.path && navigate(item.path)}
className={menuButtonClass(isActive, depth)}
>
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
{isActive && (
<span
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
isDark ? "bg-cyan-300" : "bg-red-600"
}`}
/>
)}
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
{(!collapsed || depth > 0) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
@@ -145,7 +348,7 @@ export function PageSidebar({
className={menuButtonClass(false, depth)}
>
<Icon
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
className={`${isTopLevel ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
expanded && depth > 0 ? "scale-105" : ""
}`}
/>
@@ -0,0 +1,186 @@
import {
ChevronLeft,
ChevronRight,
FileText,
Hash,
Loader2,
Search,
Sparkles,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useKnowledgeNotes } from "@/core/knowledge/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { SOURCE_TYPE_LABELS, formatDateTime } from "./wikiUtils";
/** What was clicked: a tag (exact filter) or an entity (relation-graph match). */
export type RelatedNotesFilter =
| { kind: "tag"; value: string }
| { kind: "entity"; value: string };
const PAGE_SIZE = 20;
function useDebounced<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(id);
}, [value, delay]);
return debounced;
}
/**
* Click a tag / entity chip → list every other note that shares it.
* Server-side keyword search + pagination so huge graphs stay usable:
* tags use the backend's exact `tag` filter, entities the exact `entity`
* filter (note→entity relations), both combinable with `keyword`.
*/
export function RelatedNotesDialog({
filter,
currentNoteId,
onOpenChange,
onSelect,
}: {
filter: RelatedNotesFilter | null;
currentNoteId?: string;
onOpenChange: (open: boolean) => void;
onSelect: (noteId: string) => void;
}) {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search.trim());
const [page, setPage] = useState(0);
// New chip or new search term → back to the first page.
useEffect(() => {
setPage(0);
}, [filter?.kind, filter?.value, debouncedSearch]);
// Reset the search box whenever a different chip is opened.
useEffect(() => {
setSearch("");
}, [filter?.kind, filter?.value]);
const params = {
...(filter?.kind === "tag" ? { tag: filter.value } : {}),
...(filter?.kind === "entity" ? { entity: filter.value } : {}),
keyword: debouncedSearch || undefined,
limit: filter ? PAGE_SIZE : 0,
offset: page * PAGE_SIZE,
};
const { data, isLoading, isFetching } = useKnowledgeNotes(params);
const items = (data?.items ?? []).filter(
(n: KnowledgeNote) => n.id !== currentNoteId,
);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<Dialog open={!!filter} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{filter?.kind === "entity" ? (
<Sparkles className="size-4 text-violet-500" />
) : (
<Hash className="size-4 text-rose-500" />
)}
<span className="truncate">{filter?.value}</span>
</DialogTitle>
<DialogDescription>
{filter?.kind === "entity"
? "提到该实体的关联知识"
: "使用该标签的关联知识"}
{total > 0 ? ` · 共 ${total} 条` : ""}
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="在关联知识中搜索标题/内容..."
className="h-9 pl-8"
/>
</div>
<ScrollArea className="max-h-[50vh]">
{isLoading || (isFetching && items.length === 0) ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
{debouncedSearch ? "没有匹配的关联知识。" : "暂无其他关联知识。"}
</div>
) : (
<ul className="flex flex-col gap-1 pr-2">
{items.map((note) => (
<li key={note.id}>
<button
type="button"
onClick={() => onSelect(note.id)}
className="flex w-full flex-col gap-0.5 rounded-md border border-transparent px-3 py-2 text-left transition-colors hover:border-slate-200 hover:bg-slate-50 dark:hover:border-slate-700 dark:hover:bg-slate-800/60"
>
<span className="flex items-center gap-2 text-sm font-medium text-slate-800 dark:text-slate-100">
<FileText className="size-3.5 shrink-0 text-slate-400" />
<span className="truncate">{note.title}</span>
</span>
{note.summary ? (
<span className="line-clamp-2 pl-[1.375rem] text-xs text-slate-500 dark:text-slate-400">
{note.summary}
</span>
) : null}
<span className="text-muted-foreground pl-[1.375rem] text-[11px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
{note.updated_at
? ` · ${formatDateTime(note.updated_at).slice(0, 10)}`
: ""}
</span>
</button>
</li>
))}
</ul>
)}
</ScrollArea>
{pageCount > 1 ? (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-muted-foreground text-xs">
第 {page + 1} / {pageCount} 页
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page === 0 || isFetching}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft className="size-4" /> 上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= pageCount - 1 || isFetching}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
下一页 <ChevronRight className="size-4" />
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -25,6 +25,10 @@ import { DocumentToolbar } from "./DocumentToolbar";
import { FloatingToc } from "./FloatingToc";
import { MarkdownViewer } from "./MarkdownViewer";
import { RelatedDocs } from "./RelatedDocs";
import {
RelatedNotesDialog,
type RelatedNotesFilter,
} from "./RelatedNotesDialog";
import {
SOURCE_TYPE_LABELS,
STATUS_LABELS,
@@ -91,6 +95,10 @@ export function WikiContent({
const [draftContent, setDraftContent] = useState(note.content_md);
const [draftStatus, setDraftStatus] = useState<string>(note.status);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
// Tag / entity chip clicked → show notes that share it.
const [relatedFilter, setRelatedFilter] = useState<RelatedNotesFilter | null>(
null,
);
// Pull the backend-injected `## Related` wikilinks out of the body so they
// render as a structured module; editing still works on the full source.
@@ -270,12 +278,15 @@ export function WikiContent({
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null}
{note.tags.map((tag) => (
<span
<button
key={tag}
className="inline-flex h-6 items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
type="button"
title={`查看「${tag}」的关联知识`}
onClick={() => setRelatedFilter({ kind: "tag", value: tag })}
className="inline-flex h-6 cursor-pointer items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 transition-colors hover:bg-rose-100 hover:text-rose-600 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20"
>
#{tag}
</span>
</button>
))}
</div>
@@ -287,11 +298,15 @@ export function WikiContent({
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
<button
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
type="button"
title={`查看与「${ent.name}」(${meta.label})相关的知识`}
onClick={() =>
setRelatedFilter({ kind: "entity", value: ent.name })
}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
"inline-flex cursor-pointer items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium transition-opacity hover:opacity-80",
meta.className,
)}
>
@@ -300,7 +315,7 @@ export function WikiContent({
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
</button>
);
})}
</div>
@@ -373,6 +388,18 @@ export function WikiContent({
</div>
</div>
</div>
<RelatedNotesDialog
filter={relatedFilter}
currentNoteId={note.id}
onOpenChange={(open) => {
if (!open) setRelatedFilter(null);
}}
onSelect={(id) => {
setRelatedFilter(null);
navigate(`/page/workspace/knowledge/${id}`);
}}
/>
</div>
);
}
@@ -1,5 +1,4 @@
import {
BookOpen,
Copy,
Menu,
Network,
@@ -41,7 +40,7 @@ function HeaderButton({
);
}
/** Light 56px wiki header: logo + product name on the left, action entries on the right. */
/** Light 56px wiki header: plain page title on the left, action entries on the right. */
export function WikiHeader({
onToggleSidebar,
onImportFile,
@@ -68,14 +67,8 @@ export function WikiHeader({
>
<Menu className="size-5" />
</button>
<div className="flex size-9 items-center justify-center rounded-[9px] bg-red-600 text-white">
<BookOpen className="size-5" />
</div>
<div className="leading-none">
<div className="text-base font-extrabold tracking-tight text-slate-900 dark:text-slate-100">
问答梳理
</div>
<div className="mt-1 text-xs text-emerald-500">LLM Wiki · 问答梳理中心</div>
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
问答梳理
</div>
</div>
@@ -3,6 +3,7 @@
import {
BotIcon,
CopyIcon,
EyeOffIcon,
GlobeIcon,
LockIcon,
MessageSquareIcon,
@@ -529,7 +530,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
来自他人
</Badge>
)}
{isOwned && agent.published && !agent.is_private && (
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
@@ -539,6 +540,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
已发布
</Badge>
)}
{(isOwned || isAdmin) && !isBuiltin && !agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-500 dark:text-zinc-400 text-xs"
title="该智能体尚未发布,仅所有者可见"
>
<EyeOffIcon className="size-3" />
未发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
@@ -15,11 +15,12 @@ import {
SparklesIcon,
SquarePenIcon,
Trash2Icon,
UploadCloudIcon,
UserRoundIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -42,6 +43,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
@@ -57,33 +59,12 @@ import {
type UpdateAgentRequest,
} from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { isAgentSkillCandidate } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Turn the admin-authored SOUL.md markdown into a clean, readable profile blurb
* for the narrow panel. The view mode is a profile card, not a document view —
* so we strip markdown syntax (headings, emphasis, inline code, list bullets,
* blockquotes) rather than rendering it, keeping the line structure intact.
*/
function cleanProfileText(raw: string): string {
return raw
.split("\n")
.map((line) =>
line
.replace(/^\s{0,3}#{1,6}\s*/, "")
.replace(/^\s{0,3}>\s?/, "")
.replace(/^(\s*)[-*+]\s+/, "$1• ")
.replace(/(\*\*|__)(.*?)\1/g, "$2")
.replace(/(\*|_)(.*?)\1/g, "$2")
.replace(/`([^`]+)`/g, "$1"),
)
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Local edit-mode draft — committed in one `updateAgent` call on 保存. */
interface AgentDraft {
@@ -94,6 +75,7 @@ interface AgentDraft {
skills: string[];
published: boolean;
featured: boolean;
templateName: string;
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -105,6 +87,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
skills: agent.skills ?? [],
published: agent.published,
featured: agent.featured_order != null,
templateName: agent.template_name ?? "",
};
}
@@ -176,18 +159,22 @@ export function AgentDetailPanel({
const setFeatured = useSetAgentFeatured();
const { data: publishSquares } = usePublishSquares();
const { skills, isLoading: isSkillsLoading } = useSkills();
// Same pool the edit dialog offers: only globally enabled skills are
// whitelist candidates.
const enabledPool = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Permission model mirrors agent-card: admins manage the full catalog,
// owners manage their own agents, everyone else is read-only.
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」,
// 其他用户未发布的自定义技能不可选(管理员例外)。
const enabledPool = useMemo(
() =>
skills.filter((skill) =>
isAgentSkillCandidate(skill, currentUserId, isAdmin),
),
[skills, currentUserId, isAdmin],
);
const isOwned =
!!agent &&
!agent.builtin &&
@@ -199,14 +186,30 @@ export function AgentDetailPanel({
const isSaving = updateAgent.isPending || setFeatured.isPending;
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
// 信息面板给预览让位;新文件落地时同样收起。编辑中不打断用户,用户随时
// 可手动重新展开。
const artifactsCtx = useArtifactsOptional();
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
const artifactPanelVisible = !!artifactsCtx?.open && artifactCount > 0;
const prevArtifactPanelVisible = useRef(false);
const prevArtifactCount = useRef(0);
useEffect(() => {
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
const grew =
artifactPanelVisible && artifactCount > prevArtifactCount.current;
prevArtifactPanelVisible.current = artifactPanelVisible;
prevArtifactCount.current = artifactCount;
if ((opened || grew) && !editing) {
setCollapsed(true);
}
}, [artifactPanelVisible, artifactCount, editing]);
const displayName = desensitize(agent?.name ?? agentName);
const questions = useMemo(
() => normalizeAgentQuestions(agent?.common_questions),
[agent?.common_questions],
);
const soulPreview = agent?.soul?.trim()
? cleanProfileText(desensitize(agent.soul))
: undefined;
function beginEdit() {
if (!agent || !canEdit) return;
@@ -280,6 +283,8 @@ export function AgentDetailPanel({
if (draft.description !== (agent.description ?? ""))
request.description = draft.description;
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
if (draft.templateName.trim() !== (agent.template_name ?? ""))
request.template_name = draft.templateName.trim();
if (nextPublished !== agent.published) request.published = nextPublished;
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills;
@@ -422,14 +427,7 @@ export function AgentDetailPanel({
</div>
) : (
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
className="hidden"
onClick={beginEdit}
aria-hidden="true"
tabIndex={-1}
>
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
@@ -528,9 +526,31 @@ export function AgentDetailPanel({
)}
</div>
{/* 入库模板名 — view mode shows a hint row when configured. */}
{!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" />
入库模板:{desensitize(agent.template_name)}
</div>
)}
{/* Publish / featured toggles — only meaningful in edit mode. */}
{editing && draft && (
<div className="mt-3 space-y-2">
<div className="bg-background/60 space-y-1 rounded-lg border px-3 py-2">
<p className="text-sm font-medium">入库模板名</p>
<Input
value={draft.templateName}
onChange={(e) =>
setDraft({ ...draft, templateName: e.currentTarget.value })
}
placeholder="例如:临时策略库(留空表示不支持入库)"
className="h-8 text-sm"
/>
<p className="text-muted-foreground text-xs">
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
</p>
</div>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">公开发布</p>
@@ -564,41 +584,31 @@ export function AgentDetailPanel({
)}
</section>
{/* ── SOUL / 人设 ────────────────────────────────────────────────── */}
{(editing || soulPreview) && (
<Section
icon={<SparklesIcon className="size-3.5" />}
title="人设(SOUL)"
>
{editing && draft ? (
<Textarea
value={draft.soul}
onChange={(e) =>
setDraft({ ...draft, soul: e.currentTarget.value })
}
placeholder="输入 SOUL.md 内容"
className="max-h-[45vh] min-h-48 resize-y font-mono text-xs"
/>
) : (
// Viewport-relative cap: the SOUL preview expands with the screen
// (taller than the old fixed 240px box) but never swallows the
// whole panel — long content still scrolls inside.
<p className="text-muted-foreground max-h-[40vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{soulPreview}
</p>
)}
</Section>
)}
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(editing || questions.length > 0) && (
{(editing || questions.length > 0 || canEdit) && (
<Section
icon={<MessageCircleQuestionIcon className="size-3.5" />}
title="常用问题"
extra={
<span className="text-muted-foreground text-xs tabular-nums">
{(editing && draft ? draft.questions : questions).length} 个
</span>
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs tabular-nums">
{(editing && draft ? draft.questions : questions).length} 个
</span>
{!editing && canEdit && (
<Tooltip content="管理常用问题(新增 / 删除 / 排序)">
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={beginEdit}
aria-label="管理常用问题"
>
<SquarePenIcon className="size-3.5" />
</Button>
</Tooltip>
)}
</div>
}
>
{editing && draft ? (
@@ -607,6 +617,15 @@ export function AgentDetailPanel({
onChange={(next) => setDraft({ ...draft, questions: next })}
disabled={isSaving}
/>
) : questions.length === 0 ? (
<button
type="button"
onClick={beginEdit}
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
>
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
添加常用问题
</button>
) : (
<div className="flex flex-col gap-2">
{questions.map((question, index) => {
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
@@ -29,6 +30,8 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<string>("mine");
const [search, setSearch] = useState("");
@@ -58,15 +61,21 @@ export function AgentGallery() {
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab.
useEffect(() => {
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
if (activeTab === "builtin" && !isAdmin) {
setActiveTab("mine");
return;
}
if (
activeTab !== "mine" &&
activeTab !== "private" &&
activeTab !== "builtin" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === activeTab)
) {
setActiveTab("mine");
}
}, [activeTab, squareTabs]);
}, [activeTab, squareTabs, isAdmin]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -80,9 +89,16 @@ export function AgentGallery() {
const [passwordInput, setPasswordInput] = useState("");
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
const isSquareTab = activeTab !== "mine" && activeTab !== "private";
const mainScope = activeTab === "mine" ? "mine" : "square";
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
// "内置" to "builtin" (admin-only, user_id IS NULL).
const isSquareTab =
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
const mainScope =
activeTab === "mine"
? "mine"
: activeTab === "builtin"
? "builtin"
: "square";
const mainPage = pages[activeTab] ?? 1;
const {
agents: mainAgents,
@@ -112,7 +128,7 @@ export function AgentGallery() {
search,
tagIds,
sort,
page: pages.private,
page: pages.private ?? 1,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
owner: publisher?.id,
@@ -190,16 +206,21 @@ export function AgentGallery() {
list: Agent[];
total: number;
isLoading: boolean;
isFetching: boolean;
tab: string;
emptyDescription: string;
showNewButton?: boolean;
}) => {
const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts;
const { list, total, isLoading, isFetching, tab, emptyDescription, showNewButton } =
opts;
// keepPreviousData keeps the old page visible while a new page loads, so
// only show the spinner on the very first load (no rows yet).
if (isLoading && list.length === 0) {
// this full-height spinner only covers the very first load (no rows yet),
// or a filter change while the previous result was already empty (where
// isLoading stays false but there is nothing to keep on screen).
if ((isLoading || isFetching) && list.length === 0) {
return (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
);
@@ -208,8 +229,29 @@ export function AgentGallery() {
return renderEmpty(emptyDescription, showNewButton);
}
return (
<div className="flex flex-col gap-4">
{renderGrid(list)}
<div className="relative flex flex-col gap-4">
{/* While a server-side search/tag/page query is in flight the stale
keepPreviousData rows stay mounted — dim them and float a spinner
on top so a slow backend query never looks like the filter was
ignored, and block clicks on results that are about to change. */}
<div
className={
isFetching
? "pointer-events-none opacity-40 transition-opacity duration-200"
: "transition-opacity duration-200"
}
aria-busy={isFetching}
>
{renderGrid(list)}
</div>
{isFetching && (
<div className="absolute inset-0 z-10 flex items-start justify-center pt-24">
<div className="bg-background/90 text-muted-foreground flex items-center gap-2 rounded-full border px-4 py-2 text-sm shadow-sm">
<Loader2 className="size-4 animate-spin" />
正在查询…
</div>
</div>
)}
<Pagination
page={pages[tab] ?? 1}
pageSize={PAGE_SIZE}
@@ -315,6 +357,7 @@ export function AgentGallery() {
list: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
isFetching: isPrivateFetching,
tab: "private",
emptyDescription: "私密广场暂时还没有智能体。",
});
@@ -366,6 +409,12 @@ export function AgentGallery() {
私密广场
</TabsTrigger>
)}
{isAdmin && (
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
<BotIcon className="mr-1 h-3.5 w-3.5" />
内置智能体
</TabsTrigger>
)}
</TabsList>
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm whitespace-nowrap">
@@ -444,6 +493,7 @@ export function AgentGallery() {
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: sq.id,
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
})}
@@ -455,12 +505,26 @@ export function AgentGallery() {
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: "mine",
emptyDescription: t.agents.emptyDescription,
showNewButton: true,
})}
</TabsContent>
{isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: "builtin",
emptyDescription: "暂时没有内置智能体。",
})}
</TabsContent>
)}
{privateFeatureAvailable && (
<TabsContent
value="private"
@@ -0,0 +1,78 @@
"use client";
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useState } from "react";
import type { AgentQuestion } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** Chips shown before the rest folds behind the 更多问题 toggle. */
const VISIBLE_COUNT = 4;
/**
* 常用问题 prompt chips on the agent chat landing screen — light-gray
* borderless pills stacked left-aligned between the welcome hero and the
* input box. Clicking a chip sends the question's full prompt through the
* page's existing submit flow. Lists longer than {@link VISIBLE_COUNT} fold
* behind a 更多问题 toggle; an empty list renders nothing.
*/
export function AgentQuestionChips({
questions,
onAsk,
disabled,
className,
}: {
questions: AgentQuestion[];
onAsk: (prompt: string) => void;
disabled?: boolean;
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
if (questions.length === 0) return null;
const visible = expanded ? questions : questions.slice(0, VISIBLE_COUNT);
const foldedCount = questions.length - VISIBLE_COUNT;
return (
<div className={cn("flex flex-col items-start gap-2 px-1", className)}>
{visible.map((question, index) => (
<button
key={index}
type="button"
disabled={disabled}
onClick={() => onAsk(question.prompt)}
title={desensitize(question.prompt)}
className={cn(
"group bg-muted text-foreground/90 flex max-w-full items-center gap-2 rounded-full px-4 py-2.5 text-left text-sm leading-5 transition",
"hover:bg-accent active:scale-[0.98]",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="min-w-0 truncate">{desensitize(question.title)}</span>
<ArrowRightIcon className="text-muted-foreground size-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
</button>
))}
{foldedCount > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded-full px-4 py-1.5 text-xs transition-colors"
>
{expanded ? (
<>
收起
<ChevronUpIcon className="size-3.5" />
</>
) : (
<>
更多问题({foldedCount})
<ChevronDownIcon className="size-3.5" />
</>
)}
</button>
)}
</div>
);
}
@@ -12,10 +12,20 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { AgentQuestion } from "@/core/agents";
/** Soft cap on the chip label length — beyond this the landing chip truncates. */
const TITLE_SOFT_LIMIT = 80;
/** The text a question row would actually persist as (panel save falls back
* title↔prompt), used for duplicate detection. */
function effectivePrompt(q: AgentQuestion): string {
return (q.prompt.trim() || q.title.trim()).toLowerCase();
}
/**
* Editable list of an agent's 常用问题 (recommended questions). Each row has a
* short `title` (the chip label) and the full `prompt` sent on click. Supports
* add / remove / inline edit / reorder (move up & down). Pure controlled
* add / remove / inline edit / reorder (move up & down), with non-blocking
* hints for over-long titles and duplicate questions. Pure controlled
* component — persistence stays with the caller (edit dialog or detail panel).
*/
export function AgentQuestionEditor({
@@ -52,7 +62,13 @@ export function AgentQuestionEditor({
尚未配置常用问题,点击下方按钮添加。
</p>
)}
{value.map((item, index) => (
{value.map((item, index) => {
const titleLength = item.title.trim().length;
const prompt = effectivePrompt(item);
const duplicateOf = prompt
? value.findIndex((other, i) => i < index && effectivePrompt(other) === prompt)
: -1;
return (
<div key={index} className="bg-background/60 space-y-1.5 rounded-lg border p-2.5">
<div className="flex items-center gap-1.5">
<Input
@@ -103,8 +119,19 @@ export function AgentQuestionEditor({
onChange={(e) => update(index, { prompt: e.currentTarget.value })}
className="min-h-16 resize-none text-sm"
/>
{titleLength > TITLE_SOFT_LIMIT && (
<p className="text-destructive text-xs">
标题建议不超过 {TITLE_SOFT_LIMIT} 字(当前 {titleLength} 字),过长会在聊天区被截断。
</p>
)}
{duplicateOf >= 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400">
与第 {duplicateOf + 1} 条问题内容重复。
</p>
)}
</div>
))}
);
})}
<Button
type="button"
variant="outline"
@@ -29,6 +29,8 @@ interface AgentSelectorProps {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
disabled?: boolean;
/** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */
compact?: boolean;
}
/**
@@ -44,6 +46,7 @@ export function AgentSelector({
selectedAgentId,
onAgentChange,
disabled,
compact = false,
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
@@ -76,12 +79,17 @@ export function AgentSelector({
disabled={disabled}
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
compact
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
: selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<BotIcon className="size-3" />
<BotIcon
className={cn("size-3", compact && "text-[var(--primary)]")}
/>
<span className="max-w-[140px] truncate text-xs font-normal">
{triggerLabel}
</span>
@@ -5,14 +5,16 @@ import {
Code2Icon,
CopyIcon,
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
@@ -35,10 +37,11 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import {
useArtifactContent,
@@ -86,6 +89,13 @@ function extractTextFromChildren(children: ReactNode): string {
return "";
}
/** 取 md 标题用于入库:优先文中第一个标题行,否则退回文件名(去扩展名)。 */
function extractMarkdownTitle(content: string, filepath: string): string {
const heading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/m.exec(content);
if (heading?.[1]) return heading[1].trim();
return getFileName(filepath).replace(/\.(md|markdown)$/i, "");
}
function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) {
return null;
@@ -111,12 +121,19 @@ export function ArtifactFileDetail({
filepath: filepathFromProps,
threadId,
onClose,
allowVirtualMarkdownEdit = false,
}: {
className?: string;
filepath: string;
threadId: string;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
/**
* 圆桌沙箱(Step2/Step3)用:流式结束后,把 `write-file:` 虚拟 md 预览切回真实
* artifacts API(可加载 + PATCH 保存),从而支持用户编辑(对齐主聊天页 md 可编辑)。
* 默认 false —— 主聊天页等其它调用方行为不变。
*/
allowVirtualMarkdownEdit?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
@@ -131,10 +148,6 @@ export function ArtifactFileDetail({
}
return filepathFromProps;
}, [filepathFromProps, isWriteFileVirtual]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath);
}, [filepath, isWriteFileVirtual]);
const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill");
}, [filepath]);
@@ -143,6 +156,24 @@ export function ArtifactFileDetail({
const isSpreadsheetFile = useMemo(() => {
return /\.xlsx$/i.test(filepath);
}, [filepath]);
// 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实
// artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅
// allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。
const editVirtualMarkdown = useMemo(() => {
return (
allowVirtualMarkdownEdit &&
isWriteFileVirtual &&
!isImageFile(filepath) &&
!isMock &&
!thread.isLoading &&
checkCodeFile(filepath).language === "markdown"
);
}, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language;
@@ -163,7 +194,10 @@ export function ArtifactFileDetail({
}, [language]);
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
// 不会被旧的内存 stub 值覆盖回退。
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
enabled: isCodeFile && !useWriteFileStreaming,
});
@@ -439,6 +473,50 @@ export function ArtifactFileDetail({
updateArtifact,
]);
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
const { agent: routeAgent } = useAgent(routeAgentId ?? null);
const ingestTemplateName = routeAgent?.template_name?.trim() ?? "";
const canPublishToKnowledge =
!!routeAgentId &&
!!ingestTemplateName &&
language === "markdown" &&
!isSkillFile &&
!isMock;
const [isPublishingToKnowledge, setIsPublishingToKnowledge] = useState(false);
const handlePublishToKnowledge = useCallback(async () => {
if (!routeAgentId || isPublishingToKnowledge) {
return;
}
const source = (displayContent ?? "").trim();
if (!source) {
toast.error("Markdown 内容为空,无法发布入库");
return;
}
setIsPublishingToKnowledge(true);
try {
const result = await publishArtifactToKnowledge({
agent_id: routeAgentId,
title: extractMarkdownTitle(source, filepath),
content: source,
});
if (result.success) {
toast.success(`已发布入库:${result.node_name}`);
} else {
const last = result.attempts[result.attempts.length - 1];
toast.error(
`发布入库失败:${last?.error ?? last?.response_text ?? "接口返回失败"}`,
);
}
} catch (error) {
console.error("Failed to publish artifact to knowledge base:", error);
toast.error(error instanceof Error ? error.message : "发布入库失败");
} finally {
setIsPublishingToKnowledge(false);
}
}, [displayContent, filepath, isPublishingToKnowledge, routeAgentId]);
const [isExportingWord, setIsExportingWord] = useState(false);
const handleDownloadAsWord = useCallback(async () => {
const source = (displayContent ?? "").trim();
@@ -459,30 +537,20 @@ export function ArtifactFileDetail({
}
}, [displayContent, filepath]);
const handleOpenWriteFileInNewTab = useCallback(() => {
/** HTML 产物在新窗口打开(结果绘制汇总/大屏多页都需要全屏看)。
* blob: URL 带 charset=utf-8(与预览/下载一致,防中文乱码);URL 延迟回收,
* 给新窗口足够的加载时间。 */
const handleOpenInNewWindow = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法打开");
toast.error("文件内容为空,暂时无法在新窗口打开");
return;
}
if (language === "mermaid") {
void openMermaidSvgInNewTab(source).catch((error) => {
console.error("Failed to open mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Failed to open artifact");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const blob = new Blob([source], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [displayContent, language]);
}, [displayContent]);
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
@@ -516,7 +584,7 @@ export function ArtifactFileDetail({
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
@@ -546,7 +614,7 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
</div>
<div className="flex items-center justify-center">
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
{isSupportPreview && (
<div className="flex items-center gap-2">
<ToggleGroup
@@ -589,8 +657,17 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
{canPublishToKnowledge && (
<ArtifactAction
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
label="发布入库"
tooltip={`发布到知识库(模板:${ingestTemplateName})`}
disabled={isPublishingToKnowledge || !displayContent}
onClick={() => void handlePublishToKnowledge()}
/>
)}
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
@@ -614,45 +691,6 @@ export function ArtifactFileDetail({
/>
</Tooltip>
)}
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab();
return;
}
if (language === "mermaid") {
try {
await openMermaidSvgInNewTab(displayContent ?? "");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
return;
}
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
}}
/>
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
@@ -679,6 +717,15 @@ export function ArtifactFileDetail({
onClick={handleDownloadAsWord}
/>
)}
{language === "html" && (
<ArtifactAction
icon={ExternalLinkIcon}
label="新窗口打开"
tooltip="在新窗口全屏打开 HTML"
disabled={!displayContent?.trim()}
onClick={handleOpenInNewWindow}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
@@ -0,0 +1,49 @@
"use client";
import { cn } from "@/lib/utils";
/**
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
* 仅展示,无交互。配色走主题 token(--workspace-brand / --primary),红/蓝/深蓝主题自适应。
* 非紧凑模式仍用 <Welcome/>,本组件不参与。
*/
export function CompactWelcome({ className }: { className?: string }) {
return (
<div
className={cn(
"mx-auto flex w-full flex-col items-center justify-center text-center select-none",
className,
)}
>
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
<div
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
style={{
backgroundColor:
"color-mix(in srgb, var(--workspace-brand) 5%, transparent)",
border:
"1px solid color-mix(in srgb, var(--workspace-brand) 12%, transparent)",
}}
>
<div
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
style={{
backgroundImage:
"linear-gradient(to top right, var(--workspace-brand), color-mix(in srgb, var(--workspace-brand) 55%, white))",
}}
>
<span className="text-2xl font-black tracking-wide text-white italic">
CM
</span>
</div>
</div>
<h1 className="flex items-center justify-center gap-2 text-2xl font-bold tracking-tight text-foreground/90">
<span className="text-2xl">👋</span> 欢迎使用CM助手
</h1>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
</p>
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import { Bot, ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
/** 常用智能体最多展示个数(需求 1.2 ②)。 */
const MAX_FREQUENT = 3;
/** 「更多」跳转目标:智能体管理页(需求 1.3)。 */
const MORE_HREF = "/page/strategy/agents";
/** 打开某个智能体主页(其对话落地页,thread_id=new = 新会话)。 */
function agentHomeHref(agentId: string): string {
return `/page/strategy/agents/${agentId}/chats/new`;
}
/**
* 「常用智能体」侧栏区块(通用问答左栏,全量模式)。
*
* 级联兜底取数(需求 1.2 ② / 1.3)——按优先级补足到最多 3 个、去重:
* 1. 置顶智能体(``is_pinned``,可来自「我的」或「广场」);
* 2. 不足 3 个 → 用「我的」智能体(``scope="mine"``)补足;
* 3. 仍不足 → 用「广场」默认推荐(``scope="square"``)补足。
*
* 取不到任何智能体时整块隐藏(与 ``RecentChatList`` 的空态约定一致)。
*/
export function FrequentAgentsList() {
const pathname = usePathname();
const { agents: mine } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 20,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
});
const frequent = useMemo<Agent[]>(() => {
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
seen.add(agent.id);
out.push(agent);
};
// 1. 置顶组优先(够 3 个则下面我的/广场都不再补)。
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
// 2. 我的智能体补足。
mine.forEach(add);
// 3. 广场默认推荐补足。
square.forEach(add);
return out;
}, [mine, square]);
if (frequent.length === 0) {
return null;
}
return (
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
常用智能体
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{frequent.map((agent) => {
const href = agentHomeHref(agent.id);
const isActive = pathname === href;
return (
<SidebarMenuItem key={agent.id}>
<SidebarMenuButton
isActive={isActive}
asChild
className={
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
}
>
<Link
className="flex w-full items-center gap-2 whitespace-nowrap"
href={href}
>
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{desensitize(agent.name)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
>
<Link
className="flex w-full items-center justify-between gap-1"
href={MORE_HREF}
>
<span>更多</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,44 +1,10 @@
"use client";
import { Database } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useThreadSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { Tooltip } from "./tooltip";
/**
* Per-conversation toggle for knowledge-base RAG injection (phase 2).
*
* Writes ``context.knowledge_rag_enabled`` into the thread settings, which the
* run forwards as ``runtime.context`` so ``KnowledgeRagMiddleware`` injects
* recalled knowledge before each model call for this conversation only.
*/
export function KnowledgeRagToggle({ threadId }: { threadId: string }) {
const [settings, setSettings] = useThreadSettings(threadId);
const enabled = settings.context.knowledge_rag_enabled === true;
const toggle = () => {
setSettings("context", {
...settings.context,
knowledge_rag_enabled: !enabled,
});
};
return (
<Tooltip content={enabled ? "知识库召回:已开启" : "知识库召回:已关闭"}>
<Button
variant="ghost"
onClick={toggle}
className={cn(
"text-muted-foreground hover:text-foreground",
enabled && "text-primary hover:text-primary",
)}
>
<Database />
知识库
</Button>
</Tooltip>
);
export function KnowledgeRagToggle({
className: _className,
}: {
className?: string;
}) {
void _className;
return null;
}
@@ -129,6 +129,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
export function MessageListItem({
className,
fitRowToContent = false,
anchorId,
message,
isLoading,
threadId,
@@ -140,6 +141,11 @@ export function MessageListItem({
}: {
className?: string;
fitRowToContent?: boolean;
/**
* Stable DOM id so the question navigator can scrollIntoView to this
* message. `scroll-mt` keeps the target clear of the overlay header.
*/
anchorId?: string;
message: Message;
isLoading?: boolean;
threadId: string;
@@ -156,10 +162,12 @@ export function MessageListItem({
'';
return (
<AIElementMessage
id={anchorId}
className={cn(
'group/conversation-message relative',
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
isHuman && fitRowToContent && 'ml-auto',
anchorId && 'scroll-mt-20',
className,
)}
from={isHuman ? 'user' : 'assistant'}
@@ -1,7 +1,8 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import {
Conversation,
@@ -35,6 +36,7 @@ import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
@@ -51,6 +53,38 @@ import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
function ScrollToBottomOnUserMessage({
threadId,
messages,
}: {
threadId: string;
messages: Message[];
}) {
const { scrollToBottom } = useStickToBottomContext();
const prevHumanCountRef = useRef<number | null>(null);
useEffect(() => {
prevHumanCountRef.current = null;
}, [threadId]);
useEffect(() => {
const humanCount = messages.reduce(
(count, message) => count + (message.type === "human" ? 1 : 0),
0,
);
const prev = prevHumanCountRef.current;
prevHumanCountRef.current = humanCount;
if (prev !== null && humanCount > prev) {
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToBottom());
});
}
}, [messages, scrollToBottom]);
return null;
}
export function MessageList({
className,
contentClassName,
@@ -265,6 +299,7 @@ export function MessageList({
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
@@ -297,6 +332,11 @@ export function MessageList({
<MessageListItem
key={`${group.id}/${msg.id}`}
fitRowToContent={fitMessageRowsToContent}
anchorId={
msg.type === "human"
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
@@ -750,6 +790,9 @@ const WRITING_MODE_LABELS: Record<string, string> = {
loose: "可结合通用知识",
};
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
general: "通用检索",
notebook: "我的空间",
// 旧值(历史会话回显兼容)
knowledge_base: "知识库",
skill: "技能检索",
};
@@ -23,7 +23,7 @@ function classifySpeed(tps: number): { color: string; label: string } {
return { color: "#ef4444", label: "缓慢" };
}
function formatDuration(ms: number): string {
export function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const mins = Math.floor(ms / 60_000);
@@ -31,7 +31,7 @@ function formatDuration(ms: number): string {
return `${mins}m ${secs}s`;
}
function SpeedPill({ metric }: { metric: MessageMetric }) {
export function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
@@ -0,0 +1,372 @@
"use client";
import type { Message } from "@langchain/langgraph-sdk";
import { ChevronsRightIcon, ListIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
extractContentFromMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
import { cn } from "@/lib/utils";
import { Tooltip } from "./tooltip";
/** Width of the expanded floating panel, in px. */
const PANEL_WIDTH = 300;
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
const PANEL_GUTTER_MARGIN = 24;
/** Max characters kept in a question summary card. */
const SUMMARY_MAX_LENGTH = 80;
/** Questions needed before the navigator shows up at all. */
const MIN_QUESTIONS = 2;
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
/**
* Stable DOM anchor id for a message. Prefers the server-side message id and
* falls back to the render index so messages without ids still get a target
* (requirement: `message-${index}` style fallback).
*/
export function getQuestionAnchorId(
message: Pick<Message, "id">,
index: number,
): string {
return `message-${message.id ?? index}`;
}
/**
* Turn the raw markdown of a user question into a short plain-text summary:
* strips upload/artifact/page-context envelopes and common Markdown syntax,
* collapses whitespace, and truncates with an ellipsis.
*/
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
if (text.length > SUMMARY_MAX_LENGTH) {
return `${text.slice(0, SUMMARY_MAX_LENGTH)}…`;
}
return text;
}
type QuestionItem = {
anchorId: string;
summary: string;
};
/** Walk up from a message anchor to the element that actually scrolls. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
while (node) {
const overflowY = window.getComputedStyle(node).overflowY;
if (
(overflowY === "auto" || overflowY === "scroll") &&
node.scrollHeight > node.clientHeight
) {
return node;
}
node = node.parentElement;
}
return null;
}
function readCollapsedPreference(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(COLLAPSE_STORAGE_KEY) === "1";
} catch {
return false;
}
}
/**
* ChatGPT-style question index floating at the right edge of the conversation
* column. Reads questions straight from the live `messages` array (no global
* state copy), so it stays in sync with streaming, history switches and new
* turns for free.
*
* Layout contract: render inside a `relative` ancestor that spans the chat
* content area, and pass the inset via `className` (e.g. `top-16 bottom-48
* left-0 right-0`). The component measures its own width: when the side gutter
* next to the centered message column is wide enough it floats an expandable
* panel there, vertically centered in the available chat area; on narrower
* viewports it degrades to a small pill button that opens a right-side drawer,
* so it never covers the chat body or input box.
*/
export function QuestionNavigator({
messages,
className,
contentMaxWidth = 1024,
}: {
messages: Message[];
className?: string;
/** Max width (px) of the centered message column, used to size the gutter. */
contentMaxWidth?: number;
}) {
const rootRef = useRef<HTMLDivElement | null>(null);
const [panelFits, setPanelFits] = useState(false);
const [collapsed, setCollapsed] = useState(readCollapsedPreference);
const [sheetOpen, setSheetOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const questions = useMemo(() => {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human") return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (!summary) return;
items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}, [messages]);
const visible = questions.length >= MIN_QUESTIONS;
// Decide between floating panel and drawer mode from the actual gutter width
// next to the centered message column — not from the browser viewport — so
// opening the artifact preview or the sidebar reflows the mode correctly.
useEffect(() => {
if (!visible) return;
const root = rootRef.current;
if (!root) return;
const update = () => {
const gutter = (root.clientWidth - contentMaxWidth) / 2;
setPanelFits(gutter >= PANEL_WIDTH + PANEL_GUTTER_MARGIN);
};
update();
const observer = new ResizeObserver(update);
observer.observe(root);
return () => observer.disconnect();
}, [visible, contentMaxWidth]);
// Highlight the question nearest the top of the chat viewport. A plain
// rAF-throttled scroll listener over a handful of anchors is cheaper and
// more predictable here than juggling IntersectionObserver thresholds.
useEffect(() => {
if (!visible) {
setActiveId(null);
return;
}
const firstAnchor = document.getElementById(questions[0]!.anchorId);
const scroller = findScrollParent(firstAnchor);
if (!scroller) return;
let raf = 0;
const update = () => {
raf = 0;
const viewportTop = scroller.getBoundingClientRect().top;
let current = questions[0]!.anchorId;
for (const question of questions) {
const el = document.getElementById(question.anchorId);
if (!el) continue;
if (el.getBoundingClientRect().top - viewportTop <= 140) {
current = question.anchorId;
} else {
break;
}
}
setActiveId(current);
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(update);
};
update();
scroller.addEventListener("scroll", onScroll, { passive: true });
return () => {
scroller.removeEventListener("scroll", onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, [visible, questions]);
const scrollToQuestion = useCallback((anchorId: string) => {
document
.getElementById(anchorId)
?.scrollIntoView({ behavior: "smooth", block: "start" });
setActiveId(anchorId);
}, []);
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
try {
window.localStorage.setItem(COLLAPSE_STORAGE_KEY, next ? "1" : "0");
} catch {
// localStorage unavailable (private mode) — keep in-memory state only.
}
return next;
});
}, []);
if (!visible) return null;
const pillButton = (label: string, onClick: () => void) => (
<Tooltip content={label}>
<Button
type="button"
variant="outline"
size="sm"
className="bg-background/80 pointer-events-auto h-8 gap-1.5 rounded-full px-3 shadow-md backdrop-blur-md"
onClick={onClick}
>
<ListIcon className="size-3.5" />
<span className="text-xs">问题列表</span>
<span className="text-muted-foreground text-xs tabular-nums">
{questions.length}
</span>
</Button>
</Tooltip>
);
return (
<div
ref={rootRef}
className={cn("pointer-events-none absolute z-20", className)}
>
<div className="absolute inset-y-0 right-3 flex items-center justify-end">
{panelFits ? (
collapsed ? (
pillButton("展开问题导航", toggleCollapsed)
) : (
<div
className="border-border/60 bg-background/85 pointer-events-auto flex max-h-full flex-col overflow-hidden rounded-xl border shadow-lg backdrop-blur-md"
style={{ width: PANEL_WIDTH }}
>
<div className="border-border/50 flex shrink-0 items-center gap-2 border-b px-3 py-2">
<Tooltip content="收起问题导航">
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-6"
onClick={toggleCollapsed}
>
<ChevronsRightIcon className="size-3.5" />
</Button>
</Tooltip>
<span className="text-xs font-medium">本轮问题</span>
<span className="text-muted-foreground ml-auto text-xs tabular-nums">
{questions.length}
</span>
</div>
<QuestionCardList
questions={questions}
activeId={activeId}
onSelect={scrollToQuestion}
className="min-h-0 flex-1"
/>
</div>
)
) : (
<>
{pillButton("打开问题导航", () => setSheetOpen(true))}
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
side="right"
className="flex w-[320px] flex-col gap-0 p-0 sm:max-w-[320px]"
>
<SheetHeader className="border-border/50 border-b px-4 py-3">
<SheetTitle className="text-sm">
本轮问题({questions.length})
</SheetTitle>
</SheetHeader>
<QuestionCardList
questions={questions}
activeId={activeId}
onSelect={(anchorId) => {
setSheetOpen(false);
scrollToQuestion(anchorId);
}}
className="min-h-0 flex-1"
/>
</SheetContent>
</Sheet>
</>
)}
</div>
</div>
);
}
function QuestionCardList({
questions,
activeId,
onSelect,
className,
}: {
questions: QuestionItem[];
activeId: string | null;
onSelect: (anchorId: string) => void;
className?: string;
}) {
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
// Keep the active card visible as the chat scrolls. `block: "nearest"` only
// nudges this inner list — the card is already inside the on-screen panel,
// so no outer container moves.
useEffect(() => {
if (!activeId) return;
itemRefs.current.get(activeId)?.scrollIntoView({ block: "nearest" });
}, [activeId]);
return (
<nav
aria-label="问题导航"
className={cn("flex flex-col gap-2 overflow-y-auto p-2", className)}
>
{questions.map((question, index) => {
const isActive = question.anchorId === activeId;
return (
<button
key={question.anchorId}
ref={(el) => {
if (el) itemRefs.current.set(question.anchorId, el);
else itemRefs.current.delete(question.anchorId);
}}
type="button"
title={question.summary}
onClick={() => onSelect(question.anchorId)}
className={cn(
"w-full cursor-pointer rounded-lg border px-3 py-2 text-left text-[13px] leading-5 shadow-xs transition-colors",
isActive
? "border-primary/40 border-l-primary bg-primary/10 text-foreground border-l-2 font-medium"
: "border-border/50 bg-background/60 text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<span className="flex items-start gap-1.5">
<span
className={cn(
"shrink-0 text-[11px] leading-5 tabular-nums",
isActive ? "text-primary" : "text-muted-foreground/60",
)}
>
{index + 1}.
</span>
<span className="line-clamp-2 break-words">
{question.summary}
</span>
</span>
</button>
);
})}
</nav>
);
}
@@ -2,9 +2,11 @@
import {
ChevronDown,
ChevronRight,
Download,
FileJson,
FileText,
MessageSquare,
MoreHorizontal,
Pencil,
Pin,
@@ -71,18 +73,32 @@ import {
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
export function RecentChatList() {
export function RecentChatList({
compact = false,
limit,
moreHref,
}: {
compact?: boolean;
/** 最多展示的会话条数;省略则不限制(需求 1.3:通用问答最多 10 条)。 */
limit?: number;
/** 「更多」入口跳转目标;省略则不展示「更多」行。 */
moreHref?: string;
}) {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(
const [internalCollapsed, setInternalCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
// 紧凑模式下整列由 WorkspaceSidebar 统一收起,列表本身始终展开、不显示标题折叠钮。
const collapsed = compact ? false : internalCollapsed;
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
if (compact) return;
const next = !internalCollapsed;
setInternalCollapsed(next);
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
};
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
@@ -185,21 +201,35 @@ export function RecentChatList() {
if (visibleThreads.length === 0) {
return null;
}
const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
className={cn(
"text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none flex items-center justify-between transition-colors",
!compact && "cursor-pointer hover:text-muted-foreground",
)}
onClick={toggleCollapsed}
>
<span>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
<span className="flex items-center gap-2">
<span>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
</span>
{compact && (
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
{visibleThreads.length}
</span>
)}
</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
{!compact && (
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
)}
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
@@ -208,7 +238,7 @@ export function RecentChatList() {
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => {
{shownThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
@@ -223,13 +253,26 @@ export function RecentChatList() {
className={
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none" +
(compact
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
: "")
}
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{compact && (
<MessageSquare
className={
"size-3.5 shrink-0 " +
(isActive
? "text-[var(--primary)]"
: "text-muted-foreground/70")
}
/>
)}
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
@@ -338,6 +381,22 @@ export function RecentChatList() {
</SidebarMenuItem>
);
})}
{moreHref && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
>
<Link
className="flex w-full items-center justify-between gap-1"
href={moreHref}
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
@@ -1,7 +1,8 @@
"use client";
import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react";
import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
@@ -25,6 +26,7 @@ import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
useLayoutMode,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks";
@@ -42,9 +44,10 @@ const languageOptions: { value: Locale; label: string }[] = [
{ value: "zh-CN", label: zhCN.locale.localName },
];
export function AppearanceSettingsPage() {
export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const isAdmin = getStoredAuth()?.system_role === "admin";
@@ -55,17 +58,32 @@ export function AppearanceSettingsPage() {
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const handleSetGlobalDefault = async (value: "light" | "dark") => {
const handleSetGlobalDefault = async (
value: "light" | "dark" | "lightblue",
) => {
if (value === globalDefault) return;
try {
await updateAppearance.mutateAsync({ default_theme: value });
const label =
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
toast.success(`已将系统默认主题设置为${label}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const handleSetDefaultLayoutMode = async (value: "full" | "compact") => {
if (value === globalLayoutMode) return;
try {
await updateAppearance.mutateAsync({ default_layout_mode: value });
toast.success(
value === "light"
? "已将系统默认主题设置为浅色"
: "已将系统默认主题设置为深色",
`已将系统默认界面模式设置为${value === "full" ? "全量模式" : "简洁模式"}`,
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
@@ -107,12 +125,20 @@ export function AppearanceSettingsPage() {
description: t.settings.appearance.darkDescription,
icon: MoonIcon,
},
{
id: "lightblue",
label: t.settings.appearance.lightblue,
description: t.settings.appearance.lightblueDescription,
icon: DropletIcon,
},
],
[
t.settings.appearance.dark,
t.settings.appearance.darkDescription,
t.settings.appearance.light,
t.settings.appearance.lightDescription,
t.settings.appearance.lightblue,
t.settings.appearance.lightblueDescription,
],
);
@@ -130,13 +156,86 @@ export function AppearanceSettingsPage() {
label={option.label}
description={option.description}
active={currentTheme === option.id}
mode={option.id as "light" | "dark"}
mode={option.id as "light" | "dark" | "lightblue"}
onSelect={(value) => setTheme(value)}
/>
))}
</div>
</SettingsSection>
<Separator />
<SettingsSection
title="界面模式"
description="全量模式展示侧边栏与顶栏导航;简洁模式隐藏侧边栏与顶栏,获得更大的工作区。"
>
<div className="flex gap-2">
<Button
type="button"
variant={effectiveLayoutMode !== "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setLayoutMode("full");
onClose?.();
navigate("/page/workspace/chats/new");
}}
>
<PanelLeftIcon className="mr-1.5 size-4" />
全量模式
</Button>
<Button
type="button"
variant={effectiveLayoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setLayoutMode("compact");
onClose?.();
navigate("/page/workspace/chats/new");
}}
>
<LayoutIcon className="mr-1.5 size-4" />
简洁模式
</Button>
</div>
</SettingsSection>
{isAdmin && (
<>
<Separator />
<SettingsSection
title="系统默认界面模式(管理员)"
description="给所有还没有手动切换过界面模式的用户的默认;已经自己切换过的用户保留其个人选择不受影响。"
>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={globalLayoutMode === "full" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultLayoutMode("full")}
disabled={updateAppearance.isPending}
>
<PanelLeftIcon className="mr-1.5 size-4" />
全量模式
</Button>
<Button
type="button"
variant={globalLayoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultLayoutMode("compact")}
disabled={updateAppearance.isPending}
>
<LayoutIcon className="mr-1.5 size-4" />
简洁模式
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalLayoutMode === "full" ? "全量模式" : "简洁模式"}
</p>
</div>
</SettingsSection>
</>
)}
{isAdmin && (
<>
<Separator />
@@ -165,9 +264,23 @@ export function AppearanceSettingsPage() {
<MoonIcon className="mr-1.5 size-4" />
深色
</Button>
<Button
type="button"
variant={globalDefault === "lightblue" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("lightblue")}
disabled={updateAppearance.isPending}
>
<DropletIcon className="mr-1.5 size-4" />
浅蓝
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalDefault === "light" ? "浅色" : "深色"}
{globalDefault === "light"
? "浅色"
: globalDefault === "dark"
? "深色"
: "浅蓝"}
</p>
</div>
</SettingsSection>
@@ -370,8 +483,8 @@ function ThemePreviewCard({
label: string;
description: string;
active: boolean;
mode: "light" | "dark";
onSelect: (mode: "light" | "dark") => void;
mode: "light" | "dark" | "lightblue";
onSelect: (mode: "light" | "dark" | "lightblue") => void;
}) {
const previewMode = mode;
return (
@@ -401,14 +514,20 @@ function ThemePreviewCard({
"relative overflow-hidden rounded-md border text-xs transition-colors",
previewMode === "dark"
? "border-neutral-800 bg-neutral-900 text-neutral-200"
: "border-slate-200 bg-white text-slate-900",
: previewMode === "lightblue"
? "border-blue-200 bg-white text-slate-900"
: "border-slate-200 bg-white text-slate-900",
)}
>
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">
<div
className={cn(
"h-2 w-2 rounded-full",
previewMode === "dark" ? "bg-emerald-400" : "bg-emerald-500",
previewMode === "dark"
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: "bg-emerald-500",
)}
/>
<div className="h-2 w-10 rounded-full bg-current/20" />
@@ -121,7 +121,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
@@ -10,12 +10,14 @@ import {
ChevronRightIcon,
ClockIcon,
CopyIcon,
EyeOffIcon,
FileArchive,
GlobeIcon,
InfoIcon,
Loader2,
LockIcon,
PinIcon,
PlusIcon,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
@@ -436,6 +438,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<Upload className="size-4" />
{t.settings.skills.installSkill}
</Button>
<Button
size="sm"
onClick={() => {
onClose?.();
router.push("/page/workspace/chats/new?mode=skill");
}}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
title="在对话中描述需求,由 AI 帮你创建一个新技能"
>
<PlusIcon className="size-4" />
{t.settings.skills.createSkill}
</Button>
</div>
</header>
<TagSearchBar
@@ -736,7 +750,7 @@ export function SkillRow({
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
{(isOwned || isAdmin) && !isBuiltin && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
@@ -746,6 +760,16 @@ export function SkillRow({
{t.settings.skills.badgePublished}
</Badge>
)}
{(isOwned || isAdmin) && !isBuiltin && !skill.published && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-500 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeUnpublishedTitle}
>
<EyeOffIcon className="size-3" />
{t.settings.skills.badgeUnpublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
@@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, ChevronRight, Trophy } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -86,6 +86,20 @@ export function WorkspaceHeader({ className }: { className?: string }) {
) : (
titleBlock
)}
{/* 用户排行榜入口:紧挨左上角的应用名,新标签打开免登录的公开排行榜页。 */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
window.open("#/public/leaderboard", "_blank", "noopener")
}
title="打开用户排行榜公开页"
className="text-muted-foreground hover:text-sidebar-foreground h-8 shrink-0 gap-1 px-1.5 text-xs font-medium"
>
<Trophy className="size-4" />
<span>排行榜</span>
</Button>
<SidebarCollapseButton />
</div>
)}
@@ -14,8 +14,9 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
@@ -32,10 +33,19 @@ const NEW_CHAT_CLASS =
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25";
// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标
const NEW_CHAT_COMPACT_CLASS =
"h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " +
"border border-[color-mix(in_srgb,var(--primary)_18%,transparent)] " +
"hover:bg-[color-mix(in_srgb,var(--primary)_4%,transparent)] hover:text-[var(--primary)] hover:border-[color-mix(in_srgb,var(--primary)_32%,transparent)] " +
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
export function WorkspaceNavChatList({
showCollapseTrigger = false,
compact = false,
}: {
showCollapseTrigger?: boolean;
compact?: boolean;
}) {
const { t } = useI18n();
const pathname = usePathname();
@@ -59,21 +69,46 @@ export function WorkspaceNavChatList({
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarMenu className="gap-1">
<SidebarMenuItem>
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
{compact ? (
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
) : (
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
>
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
)}
</SidebarMenuItem>
{!showCollapseTrigger && (
<>
@@ -161,6 +196,7 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<KnowledgeRagToggle className={NAV_ITEM_CLASS} />
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/knowledge")}
@@ -165,7 +165,7 @@ export function WorkspaceNavMenu() {
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin && (
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
@@ -174,6 +174,15 @@ export function WorkspaceNavMenu() {
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
@@ -10,7 +10,9 @@ import {
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { FrequentAgentsList } from "./frequent-agents-list";
import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
@@ -25,13 +27,58 @@ export function WorkspaceSidebar({
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
if (compactChrome) {
return (
<div
aria-hidden={!isSidebarOpen}
className={cn(
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
)}
>
<Sidebar
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-(--sidebar-width) border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger compact />
{isGeneralQa && <FrequentAgentsList />}
<RecentChatList
compact
limit={isGeneralQa ? 10 : undefined}
moreHref={isGeneralQa ? "/page/workspace/chats" : undefined}
/>
</SidebarContent>
<SidebarRail />
</Sidebar>
</div>
);
}
return (
<>
<Sidebar
variant="sidebar"
collapsible="icon"
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
// 非紧凑沿用 icon。
collapsible={compactChrome ? "none" : "icon"}
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
@@ -45,10 +92,15 @@ export function WorkspaceSidebar({
</SidebarHeader>
)}
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />}
{isSidebarOpen && <RecentRoundtableList />}
{isSidebarOpen && <RecentWritingList />}
<WorkspaceNavChatList
showCollapseTrigger={compactChrome}
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
{isSidebarOpen && !compactChrome && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
+52 -10
View File
@@ -10,7 +10,7 @@ import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
// 主题类型
export type ThemeType = 'dark' | 'light';
export type ThemeType = 'dark' | 'light' | 'lightblue';
// 主题配置
export interface ThemeConfig {
@@ -82,6 +82,28 @@ const themes: Record<ThemeType, ThemeConfig> = {
overlay: 'black/30',
},
},
lightblue: {
type: 'lightblue',
name: '浅蓝主题',
colors: {
background: 'white',
backgroundSecondary: 'blue-50',
text: 'gray-800',
textSecondary: 'gray-500',
primary: 'blue-600',
primaryHover: 'blue-500',
border: 'blue-200',
accent: 'blue-700',
success: 'emerald-600',
warning: 'amber-500',
error: 'red-600',
card: 'white',
cardHover: 'blue-50',
input: 'white',
inputBorder: 'blue-200',
overlay: 'black/30',
},
},
};
const STORAGE_KEY = 'strategy-theme';
@@ -97,7 +119,9 @@ function readPersistedTheme(): ThemeType {
return 'light';
}
const saved = localStorage.getItem(STORAGE_KEY);
return saved === 'dark' ? 'dark' : 'light';
if (saved === 'dark') return 'dark';
if (saved === 'lightblue') return 'lightblue';
return 'light';
}
function hasExplicitChoice(): boolean {
@@ -128,7 +152,10 @@ interface ThemeProviderProps {
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
// forcedTheme:嵌入路由 / landing 由 AppProviders 强制的主题。强制期间
// 它决定渲染,且本组件不得写 localStorage(嵌入子应用与主站同域共享
// 存储,写入会覆盖用户在主站的选择)。
const { resolvedTheme, forcedTheme, setTheme: setNextTheme } = useNextTheme();
const [mounted, setMounted] = useState(false);
// Admin-configured global default. If the user hasn't explicitly chosen
// a theme yet, this value drives the rendered theme — and re-syncs on
@@ -144,11 +171,16 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
// 「用户已选过」,admin 默认就再也注入不进来了。
useEffect(() => {
if (!mounted) return;
if (!mounted || forcedTheme) return;
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
if (current && current !== 'light' && current !== 'dark') {
if (
current &&
current !== 'light' &&
current !== 'dark' &&
current !== 'lightblue'
) {
// Garbage value — wipe it so the boot-sync below can take over.
localStorage.removeItem(STORAGE_KEY);
}
@@ -162,26 +194,29 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
if (legacy) {
localStorage.removeItem('theme');
}
}, [mounted, setNextTheme]);
}, [mounted, forcedTheme, setNextTheme]);
// Boot-time sync: when the user has never picked a theme themselves,
// apply whatever the admin configured. Re-runs whenever the admin
// setting changes (so a logged-in user sees the new default after
// refreshing without having to clear cookies).
useEffect(() => {
if (!mounted) return;
if (!mounted || forcedTheme) return;
if (hasExplicitChoice()) return;
const adminDefault = appearance?.default_theme;
if (!adminDefault) return;
if (resolvedTheme !== adminDefault) {
setNextTheme(adminDefault);
}
}, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]);
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
const effectiveTheme = forcedTheme ?? resolvedTheme;
const theme: ThemeType = mounted
? resolvedTheme === 'dark'
? effectiveTheme === 'dark'
? 'dark'
: 'light'
: effectiveTheme === 'lightblue'
? 'lightblue'
: 'light'
: readPersistedTheme();
useEffect(() => {
@@ -192,11 +227,18 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
if (theme === 'dark') {
root.setAttribute('theme-mode', 'dark');
root.classList.add('dark');
root.removeAttribute('data-theme');
return;
}
root.removeAttribute('theme-mode');
root.classList.remove('dark');
if (theme === 'lightblue') {
root.setAttribute('data-theme', 'lightblue');
} else {
root.removeAttribute('data-theme');
}
}, [theme]);
const toggleTheme = () => {
+41 -2
View File
@@ -25,8 +25,9 @@ export class AgentNameCheckError extends Error {
export interface ListAgentsParams {
search?: string;
tagIds?: string[];
/** Pagination scope. Omit for the legacy combined-visibility list. */
scope?: "mine" | "square";
/** Pagination scope. Omit for the legacy combined-visibility list.
* "builtin" = 内置智能体 (user_id IS NULL), admin only — backend 403s non-admins. */
scope?: "mine" | "square" | "builtin";
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
page?: number;
pageSize?: number;
@@ -335,6 +336,44 @@ export async function adminSyncAgentSkills(): Promise<AgentSkillsSyncResult> {
return res.json() as Promise<AgentSkillsSyncResult>;
}
export interface KnowledgeIngestAttempt {
title_field: string;
status_code: number | null;
response_text: string;
success: boolean;
error: string | null;
}
export interface KnowledgeIngestPublishResult {
success: boolean;
template_name: string;
node_name: string;
batch_number: string;
attempts: KnowledgeIngestAttempt[];
}
/** 把一份 md 产物(标题 + 全文)发布到外部知识入库接口。后端会先按
* 「名称」字段映射发送,失败后用同一 nodeName 换「标题」字段重试一次。 */
export async function publishArtifactToKnowledge(request: {
agent_id: string;
title: string;
content: string;
}): Promise<KnowledgeIngestPublishResult> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/knowledge-ingest/publish`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `发布入库失败: ${res.statusText}`);
}
return res.json() as Promise<KnowledgeIngestPublishResult>;
}
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
+1 -1
View File
@@ -79,7 +79,7 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
/** 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";
scope: "mine" | "square" | "builtin";
search?: string;
tagIds?: string[];
sort?: AgentSort;
+6
View File
@@ -55,6 +55,9 @@ export interface Agent {
* New agents store `{title, prompt}`; legacy agents may still return `string[]`.
* Read via {@link normalizeAgentQuestions}. */
common_questions?: (string | AgentQuestion)[] | null;
/** 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
* Markdown 产物右上角出现"发布入库"按钮。空/缺省 = 不支持入库。 */
template_name?: string | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -87,6 +90,7 @@ export interface CreateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
}
export interface UpdateAgentRequest {
@@ -100,4 +104,6 @@ export interface UpdateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
}
+12 -2
View File
@@ -133,8 +133,18 @@ export function useUpdateArtifactContent() {
return useMutation({
mutationFn: updateArtifactContent,
onSuccess: (content, variables) => {
queryClient.setQueryData(
["artifact", variables.filepath, variables.threadId, variables.isMock],
// useArtifactContent 的查询键带第 5 个元素 publicShareCode,这里用前缀
// 过滤(setQueriesData)匹配,避免精确键缺少 publicShareCode 写到幽灵缓存、
// 导致保存后内容被旧缓存重置回原值。
queryClient.setQueriesData(
{
queryKey: [
"artifact",
variables.filepath,
variables.threadId,
variables.isMock,
],
},
{
content,
url: undefined,
+31
View File
@@ -44,6 +44,37 @@ export function clearStoredAuth() {
window.localStorage.removeItem(AUTH_STORAGE_KEY);
}
/**
* 把登录用户名写入 localStorage.userInfo.userName(顶栏用户区据此显示)。
* 仅在尚未设置时写入,避免覆盖外部登录(yUserId)从 consumer 拿到的用户名。
*/
export function setStoredUserName(name: string | null | undefined) {
if (!isBrowser()) {
return;
}
const trimmed = name?.trim();
if (!trimmed) {
return;
}
let info: Record<string, unknown> = {};
try {
const raw = window.localStorage.getItem("userInfo");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
info = parsed as Record<string, unknown>;
}
}
} catch {
info = {};
}
if (typeof info.userName === "string" && info.userName.trim()) {
return;
}
info.userName = trimmed;
window.localStorage.setItem("userInfo", JSON.stringify(info));
}
export function getAccessToken(): string | undefined {
const auth = getStoredAuth();
const token = auth?.access_token?.trim();
+36 -7
View File
@@ -31,15 +31,44 @@ export async function loginByYUserId(
{ yuserId: yUserId },
)) as UserDetailResponse;
const result = res.data;
if (!result?.id) {
throw new Error("无法根据 yUserId 获取用户信息。");
}
localStorage.setItem(EXTERNAL_USER_ID_STORAGE_KEY, result.id);
localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(result));
// consumer 返回 { accessToken, gateDepartName, typeName, userName }。
// 一并存入路由参数 yUserId,使「应用管理」里 source=yUserId 的路由参数能取到(与
// source=userName/gateDepartName/typeName 直接取接口值统一走 userInfo[source])。
// 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的
// access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。
const result = res.data ?? {};
localStorage.setItem(
EXTERNAL_USER_INFO_STORAGE_KEY,
JSON.stringify({ ...result, yUserId }),
);
}
export function persistToolsToken(token: string): void {
localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token);
}
/**
* 把内网登录路由参数 access_token 合并写入 userInfo.accessToken(覆盖接口返回的同名值)。
* 据此拼参的有:XK/PG 中任务 iframe(XK 用 accessToken、PG 用 access_token,二者均读
* external-links.getAccessToken() = userInfo.accessToken);以及「应用管理」里
* source=accessToken 的轻应用路由参数(resolveRouteParamValue 取 userInfo.accessToken)。
* 用合并而非覆盖整对象,避免抹掉 loginByYUserId 从 consumer 拿到的用户信息。
*/
export function persistAccessToken(token: string): void {
const trimmed = token?.trim();
if (!trimmed) return;
let info: Record<string, unknown> = {};
try {
const raw = localStorage.getItem(EXTERNAL_USER_INFO_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
info = parsed as Record<string, unknown>;
}
}
} catch {
info = {};
}
info.accessToken = trimmed;
localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
/**
* 公开页(被第三方 iframe 嵌套)的可配置外观:背景色 + 整体明暗主题。
*
* 这些 `/public/*`、`/share/*`、`/roundtable-share/*` 页面会被别的系统用
* `<iframe>` 嵌进去。嵌套方希望外观跟自己的站点保持一致,所以我们把
* 「背景色 / 前景文字色 / light·dark 主题」抽成可外部传入的参数,两种传法都支持:
*
* 1. URL 查询参数(嵌套时一次性指定,最常用):
* `<iframe src=".../#/public/leaderboard?theme=dark&bg=%23ffffff&text=%23111111">`
* —— `theme` 切整体明暗(`dark`/`light`,兼容 `dark-blue`/`red` 等别名),
* `bg`/`background` 设背景,`text`/`fg` 设文字色。
* 2. `postMessage`(嵌套后动态切换,比如宿主切主题时同步):
* `iframe.contentWindow.postMessage({ type: "deerflow:set-bg", bg: "#fff", text: "#111" }, "*")`
* `iframe.contentWindow.postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*")`
*
* 不传时沿用页面自带的默认配色(Tailwind 类)与站内主题,互不影响。
* 主题经 `usePublicEmbedTheme` 在 App 层接成 next-themes 的 `forcedTheme`,
* 只影响当前渲染、不写 localStorage(原因见 App.tsx 内注释)。
*/
export type PublicBackground = {
backgroundColor?: string;
color?: string;
};
const HEX_RE = /^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
// 允许的非 hex 颜色:rgb()/rgba()/hsl()/hsla() 与简单命名色。限定安全字符集,
// 既能覆盖常见写法,又能挡掉任何想塞进内联样式的奇怪内容。
const SAFE_COLOR_RE = /^[a-zA-Z]+$|^(?:rgb|rgba|hsl|hsla)\(\s*[0-9.,%\s/]+\)$/;
/** 把外部传入的颜色串规整成可用的 CSS 颜色;非法/为空时返回 undefined。 */
export function normalizeCssColor(raw: string | null | undefined): string | undefined {
if (!raw) return undefined;
const value = raw.trim();
if (!value) return undefined;
if (HEX_RE.test(value)) return value.startsWith("#") ? value : `#${value}`;
if (SAFE_COLOR_RE.test(value)) return value;
return undefined;
}
function readFromSearch(search: string): PublicBackground {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const backgroundColor = normalizeCssColor(params.get("bg") ?? params.get("background"));
const color = normalizeCssColor(params.get("text") ?? params.get("fg"));
const result: PublicBackground = {};
if (backgroundColor) result.backgroundColor = backgroundColor;
if (color) result.color = color;
return result;
}
/**
* 读取公开页背景配置,可直接 `style={...}` 展开到页面根容器。
*
* 合并两处来源:HashRouter 路由内的查询串(`?bg=` 跟在 `#/路由` 后面)以及
* `window.location.search`(`?bg=` 在 `#` 前面);路由内的更贴近当前页,优先级更高。
* 同时监听 `postMessage` 以支持嵌套后动态改色。
*/
/**
* 把外部传入的主题标识规整成 light/dark;不认识或为空返回 undefined(沿用站内主题)。
* 兼容嵌入聊天页已有的取值习惯:`dark-blue`/深蓝 → 深色,`red`/红白 → 浅色。
*/
export function normalizePublicTheme(
raw: string | null | undefined,
): "dark" | "light" | undefined {
const value = raw?.trim().toLowerCase();
if (!value) return undefined;
if (value === "dark" || value === "dark-blue" || value === "深蓝" || value === "深色") {
return "dark";
}
if (value === "light" || value === "red" || value === "红白" || value === "浅色") {
return "light";
}
return undefined;
}
function readThemeFromSearch(search: string): "dark" | "light" | undefined {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
return normalizePublicTheme(params.get("theme"));
}
/**
* 读取公开页的外部主题(`?theme=` 或 `deerflow:set-theme` postMessage)。
*
* 与 `usePublicBackground` 同样合并两处查询串(hash 路由内优先),
* postMessage 的动态值优先级最高。返回 undefined 表示外部未指定。
*/
export function usePublicEmbedTheme(): "dark" | "light" | undefined {
const location = useLocation();
const fromUrl = useMemo(() => {
const outer = readThemeFromSearch(
typeof window !== "undefined" ? window.location.search : "",
);
const inner = readThemeFromSearch(location.search);
return inner ?? outer;
}, [location.search]);
const [override, setOverride] = useState<"dark" | "light" | undefined>(undefined);
useEffect(() => {
function onMessage(event: MessageEvent) {
const data = event.data as { type?: string; theme?: string } | null;
if (!data || typeof data !== "object") return;
if (data.type !== "deerflow:set-theme") return;
const theme = normalizePublicTheme(data.theme);
if (theme) setOverride(theme);
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
return override ?? fromUrl;
}
export function usePublicBackground(): PublicBackground {
const location = useLocation();
const fromUrl = useMemo<PublicBackground>(() => {
const outer = readFromSearch(typeof window !== "undefined" ? window.location.search : "");
const inner = readFromSearch(location.search);
return { ...outer, ...inner };
}, [location.search]);
const [override, setOverride] = useState<PublicBackground>({});
useEffect(() => {
function onMessage(event: MessageEvent) {
const data = event.data as
| { type?: string; bg?: string; background?: string; text?: string; fg?: string }
| null;
if (!data || typeof data !== "object") return;
if (data.type !== "deerflow:set-bg" && data.type !== "deerflow:set-background") return;
setOverride((prev) => {
const next: PublicBackground = { ...prev };
const bg = normalizeCssColor(data.bg ?? data.background);
const text = normalizeCssColor(data.text ?? data.fg);
if (bg) next.backgroundColor = bg;
if (text) next.color = text;
return next;
});
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
return useMemo(() => ({ ...fromUrl, ...override }), [fromUrl, override]);
}
+11
View File
@@ -44,3 +44,14 @@ export function resolveEmbedChatParams(search: string): EmbedChatParams {
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
return resolveThemeFromQuery(themeParam || null);
}
/**
* 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**:
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
*/
export function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
const v = raw.trim().toLowerCase();
if (v === "light" || v === "red" || v === "红白") return "light";
return "dark";
}
@@ -448,9 +448,11 @@ export const enUS: Translations = {
system: "System",
light: "Light",
dark: "Dark",
lightblue: "Light Blue",
systemDescription: "Match the operating system preference automatically.",
lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.",
languageTitle: "Language",
languageDescription: "Switch between languages.",
},
@@ -482,6 +484,8 @@ export const enUS: Translations = {
badgeFromOthersTitle: "Owned by user {user_id}",
badgePublished: "Published",
badgePublishedTitle: "Visible to all users",
badgeUnpublished: "Unpublished",
badgeUnpublishedTitle: "Not published yet — only visible to its owner",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
badgeArchived: "Archived",
@@ -383,9 +383,11 @@ export interface Translations {
system: string;
light: string;
dark: string;
lightblue: string;
systemDescription: string;
lightDescription: string;
darkDescription: string;
lightblueDescription: string;
languageTitle: string;
languageDescription: string;
};
@@ -415,6 +417,8 @@ export interface Translations {
badgeFromOthersTitle: string;
badgePublished: string;
badgePublishedTitle: string;
badgeUnpublished: string;
badgeUnpublishedTitle: string;
badgeStale: string;
badgeStaleTitle: string;
badgeArchived: string;
@@ -429,9 +429,11 @@ export const zhCN: Translations = {
system: "系统",
light: "浅色",
dark: "深色",
lightblue: "浅蓝",
systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
languageTitle: "语言",
languageDescription: "在不同语言之间切换。",
},
@@ -462,6 +464,8 @@ export const zhCN: Translations = {
badgeFromOthersTitle: "来自用户 {user_id}",
badgePublished: "已发布",
badgePublishedTitle: "该技能已对所有用户可见",
badgeUnpublished: "未发布",
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
+1
View File
@@ -32,6 +32,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
const qs = new URLSearchParams();
if (params.keyword) qs.set("keyword", params.keyword);
if (params.tag) qs.set("tag", params.tag);
if (params.entity) qs.set("entity", params.entity);
if (params.source_type) qs.set("source_type", params.source_type);
if (params.status) qs.set("status", params.status);
qs.set("limit", String(params.limit ?? 20));
+2
View File
@@ -54,6 +54,8 @@ export interface KnowledgeListResponse {
export interface KnowledgeListParams {
keyword?: string;
tag?: string;
/** Exact entity-name filter (matched via the knowledge relation graph). */
entity?: string;
source_type?: string;
status?: string;
limit?: number;
+345 -325
View File
@@ -1,325 +1,345 @@
/**
* 全局侧边栏菜单数据与权限过滤逻辑。
*
* 浅色 ``PageSidebar`` 和深色 ``PageSidebarDark`` 共用这份数据 —— 菜单结构与
* adminOnly 权限策略对两种皮肤完全一致;UI 渲染(icon 显示/折叠样式/hover flyout)
* 在各自组件里实现。
*
* 修改菜单只需改这一处,两个 sidebar 自动同步。
*/
import type { ElementType } from "react";
import {
BookOpen,
Bot,
Brain,
CalendarClock,
CircleHelp,
Database,
FileBox,
FileText,
Folder,
Gauge,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
List,
ListChecks,
ListTodo,
MessageSquareCode,
Network,
NotebookPen,
Plus,
Settings,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
export type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "multi-agent-roundtable",
label: "多智能体会商",
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
label: "zzjc支持",
icon: Bot,
path: "/page/strategy/agents/6bf/chats/new",
},
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
// {
// id: "light-app-center",
// label: "轻应用",
// icon: Grid3X3,
// children: [
// {
// id: "light-app-note-management",
// label: "笔记管理",
// icon: NotebookPen,
// children: [
// {
// id: "light-app-new-note",
// label: "新建笔记",
// icon: Plus,
// path: "/page/strategy/light-app/new-note",
// },
// {
// id: "light-app-note-list",
// label: "笔记列表",
// icon: List,
// path: "/page/strategy/light-app/notes",
// },
// {
// id: "light-app-note-qa",
// label: "笔记问答",
// icon: MessageSquareCode,
// path: "/page/strategy/light-app/note-qa",
// },
// ],
// },
// { id: "light-app-square", label: "应用广场", icon: Layout },
// { id: "light-app-records", label: "运行记录", icon: History },
// ],
// },
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
{
id: "xk-center-task",
label: "XK中任务",
icon: Folder,
children: [
{
id: "xk-task-jc",
label: "任务JC",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-jc",
},
{
id: "xk-task-dispatch",
label: "任务调度",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-dispatch",
},
],
},
{
id: "coh-center-task",
label: "COH中任务",
icon: Folder,
children: [
{
id: "coh-task-3qfx",
label: "3QFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment",
},
{
id: "coh-task-fx",
label: "任务FX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx",
},
{
id: "coh-task-xdfx",
label: "XDFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action",
},
],
},
{
id: "pg-center-task",
label: "PG中任务",
icon: Folder,
children: [
{
id: "pg-task-pg",
label: "任务PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-task-pg",
},
{
id: "pg-task-xdpg",
label: "XDPG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-action-pg",
},
{
id: "pg-task-info-pg",
label: "信息PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-info-pg",
},
],
},
{
id: "ty-center-task",
label: "TY中任务",
icon: Folder,
children: [
{
id: "ty-task-xdty",
label: "XDTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-mechanism",
},
{
id: "ty-task-plan",
label: "方案TY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-plan",
},
{
id: "ty-task-xxty",
label: "XXTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-zsty",
},
],
},
],
},
{
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
children: [
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
label: "配置管理",
icon: Network,
path: "/page/strategy/business-chain-mapping",
},
{
id: "mgmt-business-chains",
label: "业务链条配置",
icon: Layers,
path: "/page/strategy/business-chains",
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "定时任务",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
},
],
},
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
{
id: "resource-network-xwt",
label: "网络xwt资源管理",
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
{
id: "resource-knowledge-native",
label: "问答梳理",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base",
},
{
id: "resource-evaluation-index",
label: "作战资源管理",
icon: FileBox,
path: "/page/strategy/resource-management/resource-evaluation-index",
},
],
},
];
/** 默认展开的菜单 id 集合(浅色版可折叠交互的初始状态) */
export const defaultExpandedIds: ReadonlySet<string> = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
"comprehensive-management",
]);
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) =>
item.children
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
: item,
);
}
/**
* 全局侧边栏菜单数据与权限过滤逻辑。
*
* 浅色 ``PageSidebar`` 和深色 ``PageSidebarDark`` 共用这份数据 —— 菜单结构与
* adminOnly 权限策略对两种皮肤完全一致;UI 渲染(icon 显示/折叠样式/hover flyout)
* 在各自组件里实现。
*
* 修改菜单只需改这一处,两个 sidebar 自动同步。
*/
import type { ElementType } from "react";
import {
Activity,
BookOpen,
Bot,
Brain,
CalendarClock,
CircleHelp,
ClipboardList,
Database,
FileBox,
FileText,
Gauge,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
List,
ListChecks,
ListTodo,
Network,
Radar,
Settings,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
export type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "multi-agent-roundtable",
label: "多智能体会商",
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
label: "zzJC支持",
icon: Bot,
path: "/page/strategy/agents/6bf/chats/new",
},
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
{
id: "xk-center-task",
label: "XK中任务",
icon: ClipboardList,
children: [
// {
// id: "xk-task-jc",
// label: "任务JC",
// icon: ListTodo,
// path: "/page/strategy/task-management/xk-task-jc",
// },
// {
// id: "xk-task-dispatch",
// label: "任务调度",
// icon: ListTodo,
// path: "/page/strategy/task-management/xk-task-dispatch",
// },
],
},
{
id: "coh-center-task",
label: "COH中任务",
icon: Activity,
children: [
{
id: "coh-task-3qfx",
label: "3QFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment",
},
{
id: "coh-task-fx",
label: "任务FX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx",
},
{
id: "coh-task-xdfx",
label: "XDFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action",
},
],
},
{
id: "pg-center-task",
label: "PG中任务",
icon: Gauge,
children: [
// {
// id: "pg-task-pg",
// label: "任务PG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-task-pg",
// },
// {
// id: "pg-task-xdpg",
// label: "XDPG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-action-pg",
// },
// {
// id: "pg-task-info-pg",
// label: "信息PG",
// icon: ListTodo,
// path: "/page/strategy/task-management/effect-info-pg",
// },
],
},
{
id: "ty-center-task",
label: "TY中任务",
icon: Radar,
children: [
// {
// id: "ty-task-xdty",
// label: "XDTY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-mechanism",
// },
// {
// id: "ty-task-plan",
// label: "方案TY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-plan",
// },
// {
// id: "ty-task-xxty",
// label: "XXTY任务",
// icon: ListTodo,
// path: "/page/strategy/task-management/mnty-task-zsty",
// },
],
},
],
},
{
id: "light-app-center",
label: "应用中心",
icon: Grid3X3,
children: [
{
id: "light-app-management",
label: "应用管理",
icon: Layout,
path: "/page/strategy/light-app/management",
adminOnly: true,
},
{
id: "light-app-apps",
label: "应用列表",
icon: List,
path: "/page/strategy/light-app/apps",
},
],
},
{
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
children: [
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
userPath: "/page/strategy/user-leaderboard",
},
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
label: "配置管理",
icon: Network,
path: "/page/strategy/business-chain-mapping",
},
{
id: "mgmt-business-chains",
label: "业务链条配置",
icon: Layers,
path: "/page/strategy/business-chains",
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "定时任务",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
},
],
},
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
// {
// id: "resource-network-xwt",
// label: "网络xwt资源管理",
// icon: Network,
// path: "/page/strategy/resource-management/resource-network-actor",
// },
{
id: "resource-knowledge-native",
label: "问答梳理",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base",
},
// {
// id: "resource-evaluation-index",
// label: "作战资源管理",
// icon: FileBox,
// path: "/page/strategy/resource-management/resource-evaluation-index",
// },
],
},
];
/** 默认展开的菜单 id 集合(浅色版可折叠交互的初始状态) */
export const defaultExpandedIds: ReadonlySet<string> = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
"comprehensive-management",
]);
/** 该菜单项自身或任意后代的 path 是否匹配当前路由(支持子路径,如 /knowledge/:id) */
export function menuItemContainsPath(item: MenuItem, pathname: string): boolean {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false;
}
/**
* 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。
* 浅色侧边栏据此在二级右侧展开三级列;深色顶栏据此渲染三级横条。
* 全量模式:三级仅一项时不返回(无选择必要,侧边栏保持窄栏 / 不渲染横条)。
*/
export function getActiveThirdLevelItems(
items: MenuItem[],
pathname: string,
): MenuItem[] {
const activeTop =
items.find((item) => menuItemContainsPath(item, pathname)) ?? items[0];
const secondary = activeTop?.children ?? [];
const activeSecond = secondary.find(
(item) => (item.children?.length ?? 0) > 0 && menuItemContainsPath(item, pathname),
);
const third = activeSecond?.children ?? [];
return third.length <= 1 ? [] : third;
}
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项;``userPath`` 替换 ``path``。 */
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => {
const children = item.children
? filterMenuItemsByRole(item.children, isAdmin)
: undefined;
const base = children ? { ...item, children } : item;
if (!isAdmin && base.userPath) {
const { userPath: _userPath, ...rest } = base;
return { ...rest, path: base.userPath };
}
return base;
});
}
@@ -0,0 +1,77 @@
/**
* 侧边栏菜单合并 hook —— 把已注册的「iframe 类型」轻应用动态注入静态菜单。
*
* 静态结构来自 sidebar-menu.ts;每个启用的 iframe 应用按其 `mountLocation`(父菜单 id)
* 作为新子项注入对应分组(XK中任务 / PG中任务 / TY中任务 / 综合管理),点击进入
* iframe 宿主页 `/page/strategy/light-app/view/:appId`。
*
* 浅色 PageSidebar 与深色 PageSidebarDark 共用此 hook,保证两套皮肤同步。
*/
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { Layout } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import {
filterMenuItemsByRole,
getActiveThirdLevelItems,
sidebarMenuItems,
type MenuItem,
} from "./sidebar-menu";
function lightAppLeaf(app: LightApp): MenuItem {
return {
id: `light-app-${app.appId}`,
label: app.name,
icon: Layout,
path: `/page/strategy/light-app/view/${app.appId}`,
};
}
/** 在菜单树(不可变克隆)中,把 iframe 应用按 mountLocation 注入对应父节点末尾。 */
function injectLightApps(items: MenuItem[], apps: LightApp[]): MenuItem[] {
if (apps.length === 0) return items;
const byMount = new Map<string, LightApp[]>();
for (const app of apps) {
if (!app.mountLocation) continue;
const list = byMount.get(app.mountLocation) ?? [];
list.push(app);
byMount.set(app.mountLocation, list);
}
if (byMount.size === 0) return items;
const walk = (nodes: MenuItem[]): MenuItem[] =>
nodes.map((node) => {
const children = node.children ? walk(node.children) : undefined;
const extra = byMount.get(node.id);
if (!extra || extra.length === 0) {
return children ? { ...node, children } : node;
}
return { ...node, children: [...(children ?? []), ...extra.map(lightAppLeaf)] };
});
return walk(items);
}
/** 返回合并了 iframe 轻应用、并按 admin 角色过滤后的菜单。 */
export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
return useMemo(
() => filterMenuItemsByRole(injectLightApps(sidebarMenuItems, iframeApps), isAdmin),
[iframeApps, isAdmin],
);
}
/**
* 当前路由所在「激活二级项」的三级子项(浅色侧边栏右侧列用)。
* 供 PageRoutes(主内容左内边距随展开自适应)与浅色侧边栏共用同一份判定逻辑。
*/
export function useActiveThirdLevel(): MenuItem[] {
const isAdmin = getStoredAuth()?.system_role === "admin";
const items = useSidebarMenuItems(!!isAdmin);
const { pathname } = useLocation();
return useMemo(() => getActiveThirdLevelItems(items, pathname), [items, pathname]);
}
@@ -37,11 +37,17 @@ export interface PublicDraftShareSnapshot {
consensus_text: string;
/** 各席位最终交付. */
seat_deliveries: PublicDraftSeatDelivery[];
/** Self-contained Step 3 result report HTML (empty when no report exists). */
/** Self-contained Step 3 result report HTML — 经典 HTML 看板 (empty when none). */
html: string;
summary: string | null;
generated_at: string | null;
has_report: boolean;
/** 方案总结报告 (Markdown,roundtable-summary 产出). */
summary_report_md: string;
has_summary_report: boolean;
/** 大屏多页 (模式二) 结构化数据 (report-json 字符串);前端按主题渲染成自包含 HTML。 */
dashboard_json: string | null;
has_dashboard: boolean;
created_at: string | null;
}
+5
View File
@@ -1,4 +1,9 @@
export { useLocalSettings, useThreadSettings } from "./hooks";
export {
getKnowledgeRagEnabled,
setKnowledgeRagEnabled,
useKnowledgeRagEnabled,
} from "./knowledge-rag";
export {
getLastAgentId,
isThreadRagModeLocked,
@@ -0,0 +1,48 @@
import { useCallback, useSyncExternalStore } from "react";
/**
* Session-wide switch for knowledge-base RAG injection, toggled from the
* workspace sidebar. Deliberately kept in memory only (NOT localStorage):
* the product decision is "turn it on for this session and every chat sent
* while it is on defaults to enabled" — a page reload resets it to off.
*/
let knowledgeRagEnabled = false;
const listeners = new Set<() => void>();
function emitChange() {
for (const listener of listeners) {
listener();
}
}
export function subscribeKnowledgeRag(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getKnowledgeRagEnabled(): boolean {
return knowledgeRagEnabled;
}
export function setKnowledgeRagEnabled(value: boolean) {
if (knowledgeRagEnabled === value) {
return;
}
knowledgeRagEnabled = value;
emitChange();
}
export function useKnowledgeRagEnabled(): [boolean, (value: boolean) => void] {
const enabled = useSyncExternalStore(
subscribeKnowledgeRag,
getKnowledgeRagEnabled,
() => false,
);
const setEnabled = useCallback((value: boolean) => {
setKnowledgeRagEnabled(value);
}, []);
return [enabled, setEnabled];
}
+16 -4
View File
@@ -62,6 +62,11 @@ export interface LocalSettings {
*/
skillViewMode?: "card" | "list";
agentViewMode?: "card" | "list";
/**
* Layout mode: "full" shows the page sidebar + header; "compact" hides them
* for a cleaner work area. Defaults to "full" (undefined treated as "full").
*/
layoutMode?: "full" | "compact";
};
context: Omit<
AgentThreadContext,
@@ -71,6 +76,9 @@ export interface LocalSettings {
| "subagent_enabled"
| "model_name"
| "reasoning_effort"
// Session-only sidebar switch (core/settings/knowledge-rag.ts) — must not
// round-trip through localStorage.
| "knowledge_rag_enabled"
> & {
model_name?: string | undefined;
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
@@ -79,12 +87,16 @@ export interface LocalSettings {
}
function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
const context = {
...DEFAULT_LOCAL_SETTINGS.context,
...settings?.context,
};
// Drop the value persisted by the retired per-thread header toggle; the
// knowledge-RAG switch now lives in the sidebar and is session-only.
delete (context as Record<string, unknown>).knowledge_rag_enabled;
return {
...DEFAULT_LOCAL_SETTINGS,
context: {
...DEFAULT_LOCAL_SETTINGS.context,
...settings?.context,
},
context,
notification: {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
+1
View File
@@ -1,2 +1,3 @@
export * from "./api";
export * from "./type";
export * from "./utils";
+22
View File
@@ -0,0 +1,22 @@
import type { Skill } from "./type";
/**
* 智能体技能白名单的候选池过滤。
*
* 编辑智能体时,普通用户只能从「内置技能 + 自己的技能 + 已发布到广场的
* 技能」里挑选,其他用户未发布的自定义技能不可选;管理员仍可使用全部
* 已启用技能。
*/
export function isAgentSkillCandidate(
skill: Skill,
currentUserId: string | null,
isAdmin = false,
): boolean {
if (!skill.enabled) return false;
if (isAdmin) return true;
if ((skill.state ?? "active") !== "active") return false;
const builtin = skill.builtin ?? !skill.owner_user_id;
const ownedByMe =
!!skill.owner_user_id && skill.owner_user_id === currentUserId;
return builtin || ownedByMe || !!skill.published;
}
@@ -27,6 +27,50 @@ export function getNetworkActorIframeUrl(): string {
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}
/** 读取并解析 localStorage 中的 userInfo(登录信息)。 */
function readUserInfo(): Record<string, unknown> {
try {
const raw = localStorage.getItem("userInfo");
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** 当前登录态 accessToken(无则空串)。 */
export function getAccessToken(): string {
const value = readUserInfo().accessToken;
return value == null ? "" : String(value);
}
/**
* XK 中任务 iframe 地址。
*
* 形如 `{base}{gopath}?accessToken={token}`,其中 gopath 含 hash 路由
* (如 `#/pageIndie/pageHome/pageSwrw`)—— 每次都从 base 重建,切换 gopath 不累加。
*/
export function getXkIframeUrl(gopath: string): string {
const base = readEnv("VITE_AIPI_XKLINKTO_URL");
if (!base) return "";
return `${base}${gopath}?accessToken=${getAccessToken()}`;
}
/** PG 中任务 iframe 地址:`{base}?access_token={token}&goPath={gopath}`。 */
export function getPgIframeUrl(gopath: string): string {
const base = readEnv("VITE_AIPI_PGLINKTO_URL");
if (!base) return "";
return `${base}?access_token=${getAccessToken()}&goPath=${gopath}`;
}
/** TY 中任务 iframe 地址:`{base}/fromB/toPage?defaultPage={defaultPage}`。 */
export function getTyIframeUrl(defaultPage: string): string {
const base = trimTrailingSlash(readEnv("VITE_AIPI_B2ACTOR_URL"));
if (!base) return "";
return `${base}/fromB/toPage?defaultPage=${defaultPage}`;
}
/** 知识库 iframe 地址换取 API */
export function getKnowledgeIframeApiUrl(): string {
return readEnv("VITE_AIPI_KNOWLEDGE_IFRAME_API_URL");
+4 -1
View File
@@ -135,19 +135,22 @@ export async function updatePublishSquares(
return (await response.json()) as PublishSquaresSettings;
}
export type ThemeMode = "light" | "dark";
export type ThemeMode = "light" | "dark" | "lightblue";
export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact";
export interface AppearanceSettings {
default_theme: ThemeMode;
default_skill_view: ViewMode;
default_agent_view: ViewMode;
default_layout_mode: LayoutMode;
}
export interface AppearanceUpdate {
default_theme?: ThemeMode;
default_skill_view?: ViewMode;
default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode;
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -21,6 +21,7 @@ import {
type PrivateSquareUpdate,
type PromptPrefixUpdate,
type PublishSquaresUpdate,
type LayoutMode,
type ViewMode,
} from "./api";
@@ -143,6 +144,27 @@ export function useViewMode(
return [mode, setMode];
}
/**
* Effective interface layout mode (full chrome vs compact). A user's own choice
* (persisted in local settings) wins; otherwise the admin-configured system
* default applies; otherwise `undefined` (callers fall back to the session-level
* `sidebarEnabled` flag set by portal / yUserId login). `setMode` persists the
* user's override locally.
*/
export function useLayoutMode(): [
LayoutMode | undefined,
(mode: LayoutMode) => void,
] {
const [settings, setSettings] = useLocalSettings();
const { data: appearance } = useAppearanceSettings();
const userOverride = settings.display.layoutMode;
const adminDefault = appearance?.default_layout_mode;
const mode = userOverride ?? adminDefault;
const setMode = (next: LayoutMode) =>
setSettings("display", { layoutMode: next });
return [mode, setMode];
}
export function useCitationDisplaySettings() {
return useQuery({
queryKey: CITATION_DISPLAY_KEY,
+88 -62
View File
@@ -2,6 +2,7 @@ import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
@@ -152,7 +153,10 @@ function getStreamErrorMessage(error: unknown): string {
*/
function isThreadNotAccessibleError(message: string): boolean {
const lowered = message.toLowerCase();
if (/thread\s+[\w-]{8,}\s+not\s+found/i.test(message)) {
// Backend now returns "not found" (no row + no checkpoint) and
// "not accessible" (row owned by another user / stale login session) as
// distinct details — both mean the current user can't use this thread.
if (/thread\s+[\w-]{8,}\s+not\s+(found|accessible)/i.test(message)) {
return true;
}
// Fallback: a bare 404/403 from the threads endpoint when the SDK has
@@ -163,6 +167,21 @@ function isThreadNotAccessibleError(message: string): boolean {
);
}
/**
* Detect a "Run <id> not found" 404 from the LangGraph SDK.
*
* The SDK persists the in-flight run id under ``lg:stream:{threadId}`` in
* sessionStorage and, on mount, auto-``joinStream``s it to resume a dropped
* SSE. After a backend restart the in-memory run registry is gone, so the join
* 404s — and the SDK only clears that key on reconnect *success*, never on
* failure. So every switch back into the conversation re-fires the same 404.
* We detect it here to clear the stale key ourselves and stay silent (the
* conversation renders from history; there is nothing live to reconnect to).
*/
function isRunNotFoundError(message: string): boolean {
return /run\s+[\w-]{8,}\s+not\s+found/i.test(message);
}
function buildCurrentUserLabel(): string {
const auth = getStoredAuth();
if (!auth) {
@@ -280,25 +299,19 @@ export function useThreadStream({
);
for (const update of updates) {
if (update && "title" in update && update.title) {
void queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
return oldData?.map((t) => {
if (t.thread_id === threadIdRef.current) {
return {
const nextTitle = update.title;
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
t.thread_id === threadIdRef.current
? {
...t,
values: {
...t.values,
title: update.title,
title: nextTitle,
},
};
}
return t;
});
},
}
: t,
),
);
}
}
@@ -335,6 +348,19 @@ export function useThreadStream({
onError(error) {
setOptimisticMessages([]);
const message = getStreamErrorMessage(error);
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
// conversation stops re-firing the same 404, and render from history
// silently — there is no live run to rejoin.
if (isRunNotFoundError(message)) {
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (staleThreadId && runMetadataStorageRef.current) {
runMetadataStorageRef.current.removeItem(
`lg:stream:${staleThreadId}`,
);
}
return;
}
// A "thread not found" 404 almost always means the URL points at a
// thread from a previous login session — either this tab outlived a
// logout/re-login or the thread genuinely belongs to a different user.
@@ -784,6 +810,31 @@ export interface ThreadsPageResult {
hasMore: boolean;
}
/**
* Apply ``transform`` to the thread array inside every cached
* ``["threads", "search"]`` entry. The sidebar queries cache a plain
* ``AgentThread[]`` while the paged chats list (`useThreadsPage`) caches a
* ``ThreadsPageResult`` object under the same key prefix, so fuzzy
* ``setQueriesData`` updaters must handle both shapes.
*/
function updateCachedThreadLists(
queryClient: QueryClient,
transform: (threads: AgentThread[]) => AgentThread[],
) {
queryClient.setQueriesData<AgentThread[] | ThreadsPageResult | undefined>(
{ queryKey: ["threads", "search"], exact: false },
(oldData) => {
if (Array.isArray(oldData)) {
return transform(oldData);
}
if (oldData && Array.isArray(oldData.threads)) {
return { ...oldData, threads: transform(oldData.threads) };
}
return oldData;
},
);
}
/**
* Server-side paginated conversation list for the chats page. Unlike
* `useThreads` (which loads a bounded recent window for the sidebar), this
@@ -904,17 +955,8 @@ export function useDeleteThread() {
}
},
onSuccess(_, { threadId }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
if (oldData == null) {
return oldData;
}
return oldData.filter((t) => t.thread_id !== threadId);
},
updateCachedThreadLists(queryClient, (threads) =>
threads.filter((t) => t.thread_id !== threadId),
);
},
onSettled() {
@@ -1000,27 +1042,18 @@ export function useUpdateThreadMetadata() {
}
},
onSuccess(_, { threadId, metadata }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
if (oldData == null) {
return oldData;
}
return oldData.map((t) =>
t.thread_id === threadId
? {
...t,
metadata: {
...(t.metadata as Record<string, unknown> | null),
...metadata,
},
}
: t,
);
},
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
t.thread_id === threadId
? {
...t,
metadata: {
...(t.metadata as Record<string, unknown> | null),
...metadata,
},
}
: t,
),
);
},
});
@@ -1062,25 +1095,18 @@ export function useRenameThread() {
});
},
onSuccess(_, { threadId, title }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread>) => {
return oldData.map((t) => {
if (t.thread_id === threadId) {
return {
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
t.thread_id === threadId
? {
...t,
values: {
...t.values,
title,
},
};
}
return t;
});
},
}
: t,
),
);
},
});
-1
View File
@@ -5,7 +5,6 @@
*/
export const DESENSITIZE_MAP: Record<string, string> = {
态势: "TS",
你好: "nh",
滨海作战体系: "第一张网",
滨海作战部队: "第一张网",
'Taiwan Air Force Flight Units': "第二张网",
@@ -39,16 +39,15 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
type MaterialSource = 'knowledge_base' | 'skill' | 'notebook';
type MaterialSource = 'general' | 'notebook';
const MAX_INTENT_LENGTH = 500;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222] dark:text-[#AACEFF]';
const FIELD_TRIGGER_CLASS =
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
@@ -279,14 +278,14 @@ export function AIWritingForm({
</div>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>素材来源</label>
<label className={FIELD_LABEL_CLASS}>检索类型</label>
<Select
value={materialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择素材来源" />
<SelectValue placeholder="选择检索类型" />
</SelectTrigger>
<SelectContent>
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { ChevronsDown, PanelRightClose, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -15,6 +15,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions';
@@ -83,6 +84,7 @@ export function AIWritingPanel({
const [searchParams, setSearchParams] = useSearchParams();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
const writingForm = useWritingFormOptional();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
@@ -252,7 +254,7 @@ export function AIWritingPanel({
const showNewButton = isHistoryMode || state.status !== 'idle';
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
<div className="flex flex-col h-full overflow-hidden bg-background dark:bg-transparent">
{!isHistoryMode && (
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
)}
@@ -342,6 +344,18 @@ export function AIWritingPanel({
onRename={rename}
onRefresh={refresh}
/>
{/* 收起写作配置:仅在 idle 配置阶段、表单展开时出现(与 AIWritingPage 的整块收起联动) */}
{writingForm && isIdle && !isHistoryMode && writingForm.formExpanded && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => writingForm.setFormExpanded(false)}
title="收起写作配置"
>
<PanelRightClose className="size-3.5" />
</Button>
)}
</div>
</div>
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
Trash2, BookOpen, Sparkles,
Trash2, BookOpen,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -140,19 +140,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
async function handleReSearch() {
setSubmitting(true);
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
async function handleEnableSkillSearch() {
setSubmitting(true);
await onSubmit({ action: 'enable_skill_search', userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
const hasQuery = userQuery.trim().length > 0;
// 当前素材是否来自技能检索:根据素材 sourceType 推断(覆盖式替换后全是 skill)
const isSkillResult = materials.length > 0 && materials.every((m) => m.sourceType === 'skill');
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
if (materials.length === 0) {
@@ -163,7 +157,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。
素材收集专家本次没有检索到相关数据。你可以在输入框描述想检索的内容(自动匹配已配置的技能),或在无素材的情况下直接继续写作。
</p>
</div>
</div>
@@ -189,16 +183,9 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
? <><Search className="size-3.5" />按输入检索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })} disabled={disabled || submitting}
@@ -217,8 +204,8 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,
或在下方描述想补充检索的内容——系统会匹配素材收集专家配置的技能检索,并把新素材追加进列表。
</p>
</div>
</div>
@@ -251,21 +238,12 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
{!isSkillResult && (
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
)}
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
? <><Search className="size-3.5" />检索并追加素材</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
@@ -65,7 +65,7 @@ export function MaterialsToolbar({
{/* 单输入框 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
补充检索(可选)— 写下你想检索的内容,然后点"重新搜索"或"技能检索"
补充检索(可选)— 描述想检索的内容,自动匹配已配置的技能,新素材追加到列表
</label>
<textarea
ref={textareaRef}
@@ -73,7 +73,7 @@ export function MaterialsToolbar({
value={userQuery}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? '输入文字后点击“重新搜索”或“技能检索”按钮…'}
placeholder={placeholder ?? '例如:检索新能源汽车销量数据。按 Enter 检索并追加…'}
disabled={disabled}
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
/>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search } from 'lucide-react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search, AlertTriangle, Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SkillSearchStep } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition';
@@ -16,6 +16,8 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
select_skills: Sparkles,
invoke_skill: Zap,
skill_result: CheckCircle2,
skill_error: AlertTriangle,
fallback: Shuffle,
stop_early: CircleStop,
};
@@ -25,6 +27,8 @@ const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
select_skills: '筛选相关技能',
invoke_skill: '调用技能',
skill_result: '检索结果',
skill_error: '技能调用失败',
fallback: '改用其他技能',
stop_early: '提前停止',
};
@@ -71,6 +75,9 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
const label = STEP_LABEL[step.type] ?? step.type;
const isStopEarly = step.type === 'stop_early';
const isResult = step.type === 'skill_result';
// 容错:技能调用失败标红,回退其它技能标琥珀,提示用户"出错了但已自动改用其他技能"
const isError = step.type === 'skill_error';
const isFallback = step.type === 'fallback';
const isActive = idx === activeIdx;
return (
<li key={idx} className="flex items-start gap-2 text-[11px]">
@@ -79,11 +86,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
isActive
? 'bg-violet-500 text-white'
: isStopEarly
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
: isError
? 'bg-rose-100 dark:bg-rose-900/60 text-rose-600 dark:text-rose-300'
: isStopEarly || isFallback
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
)}
>
{isActive ? <Loader2 className="size-2.5 animate-spin" /> : idx + 1}
@@ -92,11 +101,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
<div
className={cn(
'flex items-center gap-1 font-medium',
isStopEarly
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
isError
? 'text-rose-700 dark:text-rose-300'
: isStopEarly || isFallback
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
)}
>
<Icon className="size-3 shrink-0" />
@@ -22,6 +22,8 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
const AGENT_COLOR: Record<string, string> = {
素材收集专家: 'text-blue-500',
作家: 'text-purple-500',
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
大纲规划师: 'text-violet-500',
编辑: 'text-orange-500',
系统: 'text-muted-foreground',
};
@@ -114,7 +114,7 @@ export function WritingSetupChat() {
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background">
<div className="flex size-full flex-col bg-background dark:bg-transparent">
{/* 空状态隐藏对话头部,直接展示欢迎屏(与原型图一致);有消息后再显示头部 */}
{!showWelcome && (
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
@@ -139,7 +139,7 @@ export function WritingSetupChat() {
)}
</div>
<div className="bg-background shrink-0 border-t px-4 py-4">
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
{/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex items-center justify-between gap-3">
@@ -257,7 +257,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
if (!stream) return;
try {
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
// 三个合法值:knowledge_base / skill / notebook,后端 state.py 接受任意 str。
// 两个合法值:general(通用检索,按素材收集专家配置的技能编排)/
// notebook(我的空间);旧值 knowledge_base / skill 后端按 general 兼容。
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
await stream.submit(
@@ -19,6 +19,9 @@ import type { SetupWritingApprovalPayload } from '@/components/workspace/message
export interface WritingFormValue {
intent: string;
setIntent: (v: string) => void;
/** 整个写作配置表单主体是否展开;初始收起,用户点表头或问答侧「应用到表单」时展开。 */
formExpanded: boolean;
setFormExpanded: (v: boolean) => void;
showOutline: boolean;
setShowOutline: (v: boolean) => void;
outlineText: string;
@@ -31,8 +34,8 @@ export interface WritingFormValue {
setAudience: (v: string) => void;
strictMode: boolean;
setStrictMode: (v: boolean) => void;
materialSource: 'knowledge_base' | 'skill' | 'notebook';
setMaterialSource: (v: 'knowledge_base' | 'skill' | 'notebook') => void;
materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void;
modelName: string;
setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
@@ -53,18 +56,21 @@ export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext);
}
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) {
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'general' | 'notebook' }) {
const [intent, setIntent] = useState('');
const [showOutline, setShowOutline] = useState(false);
const [formExpanded, setFormExpanded] = useState(false);
const [showOutline, setShowOutline] = useState(true);
const [outlineText, setOutlineText] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>(initialMaterialSource ?? 'knowledge_base');
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
// 问答侧识别出意图并点「应用到表单」时,自动展开表单让用户看到回填结果。
setFormExpanded(true);
if (payload.user_intent?.trim()) setIntent(payload.user_intent.trim());
if (payload.article_type?.trim()) setArticleType(payload.article_type.trim());
if (typeof payload.word_count_target === 'number' && payload.word_count_target > 0) {
@@ -73,8 +79,9 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
if (payload.writing_mode === 'strict') setStrictMode(true);
else if (payload.writing_mode === 'loose') setStrictMode(false);
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base') {
setMaterialSource(payload.material_source);
// 旧值 skill / knowledge_base 已合并为通用检索 general
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base' || payload.material_source === 'general') {
setMaterialSource('general');
}
if (payload.outline?.trim()) {
setOutlineText(payload.outline);
@@ -85,6 +92,7 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
const value = useMemo<WritingFormValue>(
() => ({
intent, setIntent,
formExpanded, setFormExpanded,
showOutline, setShowOutline,
outlineText, setOutlineText,
articleType, setArticleType,
@@ -95,7 +103,7 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
modelName, setModelName,
applyProposal,
}),
[intent, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal],
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal],
);
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
@@ -103,8 +103,12 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
case 'intervention_submitted': {
const ns = action.nextStatus;
// 点击瞬间标记技能检索 pending(动机 = 用户的 action 类型),让 SkillStepsCard 占位卡立即挂载
const enablingSkillSearch = action.record.action === 'enable_skill_search';
// 点击瞬间标记技能检索 pending,让 SkillStepsCard 占位卡立即挂载。
// 通用检索现在统一走技能编排:素材确认卡上任何回到 researching 的动作
// (重新搜索 / 按输入追加检索)都会产生 skill_search_step 流。
const enablingSkillSearch =
action.record.action === 'enable_skill_search' ||
(ns === 'researching' && action.record.pausePoint === 'material_confirm');
return {
...prev,
currentPause: null,
@@ -82,7 +82,9 @@
* ──────────────────────────────────────────────────────────────────────────
*/
import { useRef } from 'react';
import { PanelRightOpen } from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { GroupImperativeHandle } from 'react-resizable-panels';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
@@ -93,7 +95,7 @@ import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPane
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
import { WritingFormProvider } from '../contexts/WritingFormContext';
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
import {
ResizableHandle,
ResizablePanel,
@@ -103,14 +105,9 @@ import {
// Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft';
// 初始问答阶段(idle)专用的两栏 ID:左=问答助手,右=写作配置表单
const SETUP_CHAT_ID = 'aw-setup-chat';
const SETUP_FORM_ID = 'aw-setup-form';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
// idle 阶段镜像原比例:问答框在左占大头(65),配置表单在右占小头(35)
const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
/**
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
@@ -139,38 +136,45 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
const layoutRef = useRef<GroupImperativeHandle>(null);
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
const { formExpanded, setFormExpanded } = useWritingForm();
// 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿)
// 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿)。
//
// 用稳定的 flex 结构(而非按 formExpanded 在两套容器间切换):左侧问答区永远是第一个
// 子节点、位置不变,避免收起/展开时 WritingSetupChat 被重新挂载(它在内部 useMemo 生成
// 独立 threadId,一旦重挂会换新线程、丢失对话)。右侧整块「写作配置表单」可收起为一条
// 竖向展开栏;问答侧点「应用到表单」(applyProposal) 会自动把 formExpanded 置 true 展开。
if (state.status === 'idle' && !isHistoryMode) {
return (
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={SETUP_LAYOUT}
className="h-full"
>
{/* 左侧:写作助手 Q&A */}
<ResizablePanel
id={SETUP_CHAT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<div className="relative flex h-full">
{/* 左侧:写作助手 Q&A —— 恒为第一个子节点,位置稳定不重挂 */}
<div className="min-h-0 flex-1 overflow-hidden">
<WritingSetupChat />
</ResizablePanel>
</div>
<ResizableHandle withHandle />
{/* 右侧:写作配置选项表单 */}
<ResizablePanel
id={SETUP_FORM_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
{/* 右侧:写作配置表单整块。容器持久存在(表单始终挂载、保留状态),宽度随 formExpanded
平滑过渡——收起时宽度归零、整块滑出,右侧完全让给问答区。 */}
<div
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width,min-width] duration-300 ease-in-out',
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0',
)}
>
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 收起态:右上角浮动展开图标按钮(与展开态头部的收起按钮位置呼应) */}
{!formExpanded && (
<button
type="button"
onClick={() => setFormExpanded(true)}
title="展开写作配置"
className="absolute right-3 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightOpen className="size-4" />
</button>
)}
</div>
);
}
@@ -225,7 +229,7 @@ export default function AIWritingPage({
<AIWritingProvider>
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div>
@@ -20,6 +20,8 @@ export interface AIMaterial {
// select_skills:LLM 按相关度排序的候选
// invoke_skill:开始调用某个技能去检索
// skill_result:某个技能检索返回了 N 条素材
// skill_error:某个技能调用失败(超时/报错),将改用其他技能(容错)
// fallback:配置技能全部失败,回退到其他技能继续搜集(容错)
// stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep {
type:
@@ -28,6 +30,8 @@ export interface SkillSearchStep {
| "select_skills"
| "invoke_skill"
| "skill_result"
| "skill_error"
| "fallback"
| "stop_early";
message: string;
skillNames?: string[];
@@ -220,7 +224,7 @@ export interface UserInterventionPayload {
approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(技能名/来源/检索意图都行)。re_search / enable_skill_search 共用。 */
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string;
editedOutline?: AIOutline;
outlineFeedback?: string;
@@ -246,8 +250,8 @@ export interface AIWritingRequest {
keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: "strict" | "loose";
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的空间 */
materialSource: "knowledge_base" | "skill" | "notebook";
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
materialSource: "general" | "notebook";
/** 笔记来源(可选):空间 search_space_id */
noteSearchSpaceId?: number;
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
+18 -27
View File
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
@@ -15,6 +16,7 @@ import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { QuestionNavigator } from "@/components/workspace/question-navigator";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
@@ -28,7 +30,6 @@ import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const routeBase = useRouteBase();
@@ -179,11 +180,12 @@ export default function AgentChatPage() {
separate flex sibling OUTSIDE ChatBox, so the artifact preview
always opens between the chat and the detail panel — the detail
panel stays rightmost. */}
<div className="flex size-full min-h-0 overflow-hidden">
<div className="flex size-full min-h-0 overflow-hidden workspace-chat-bg">
<div className="min-h-0 min-w-0 flex-1">
<ChatBox threadId={threadId}>
{/* Main conversation column */}
<main className="flex size-full min-h-0 min-w-0 flex-col">
{/* Main conversation column. `relative` hosts the floating
question navigator overlay. */}
<main className="relative flex size-full min-h-0 min-w-0 flex-col">
{/* Header in normal flow, fixed height. */}
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
@@ -235,29 +237,12 @@ export default function AgentChatPage() {
className="mb-7"
/>
<div className="w-full max-w-(--container-width-md)">
{commonQuestions.length > 0 && (
<div className="mb-2 flex max-h-20 flex-wrap items-start justify-start gap-2 overflow-y-auto px-1">
{commonQuestions.map((question, index) => (
<button
key={index}
type="button"
disabled={isDemoMode}
onClick={() =>
handleSubmit({ text: question.prompt, files: [] })
}
title={desensitize(question.prompt)}
className={cn(
"border-border/70 bg-background/85 hover:bg-accent hover:border-border max-w-full rounded-full border px-3 py-1.5 text-left text-sm leading-5 shadow-sm transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="block max-w-[220px] truncate">
{desensitize(question.title)}
</span>
</button>
))}
</div>
)}
<AgentQuestionChips
className="mb-3"
questions={commonQuestions}
disabled={isDemoMode}
onAsk={(text) => handleSubmit({ text, files: [] })}
/>
<div className="min-w-0 flex-1">
<InputBox
className="w-full"
@@ -288,6 +273,12 @@ export default function AgentChatPage() {
/* Conversation: messages scroll, input
docked at the bottom of the main column only. */
<div className="flex min-h-0 flex-1 flex-col">
{/* Question index pinned to this conversation column; the
bottom inset keeps it clear of the docked input box. */}
<QuestionNavigator
messages={thread.messages}
className="top-14 right-0 bottom-40 left-0"
/>
<MessageList
className="min-h-0 flex-1"
threadId={threadId}
@@ -1,14 +1,23 @@
/**
* 配置管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
* 配置管理页 —— 把固定业务码绑定到「智能体」+「业务链」。
*
* 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉:
* - 智能体来自 `/api/agents`
* - 业务链来自 `/api/roundtable-chains?scope=public`(只列公共链,保证全局共享时各账号都能解析)
* 智能体来自 `/api/agents`,业务链来自 `/api/roundtable-chains?scope=public`。
* 保存调 `PUT /api/business-mapping/{code}`。映射全局共享、无用户隔离。
*/
import { useEffect, useMemo, useState } from "react";
import { Loading, Select } from "tdesign-react";
import { Link2, Pencil, Save, Users, X } from "lucide-react";
import { motion } from "motion/react";
import { Select } from "tdesign-react";
import {
AlertCircle,
HelpCircle,
Info,
Link2,
Pencil,
Save,
Search,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
@@ -21,11 +30,7 @@ import {
updateBusinessMapping,
type BusinessMapping,
} from "@/strategy-components/api/business-mapping";
interface DraftSelection {
agentId: string | null;
chainId: string | null;
}
import { cn } from "@/lib/utils";
export default function BusinessChainMappingPage() {
const { theme } = useTheme();
@@ -36,8 +41,13 @@ export default function BusinessChainMappingPage() {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftSelection>({ agentId: null, chainId: null });
const [draft, setDraft] = useState<{ agentId: string | null; chainId: string | null }>({
agentId: null,
chainId: null,
});
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "configured" | "pending">("all");
useEffect(() => {
let cancelled = false;
@@ -58,9 +68,7 @@ export default function BusinessChainMappingPage() {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, []);
const agentOptions = useMemo(
@@ -72,6 +80,24 @@ export default function BusinessChainMappingPage() {
[chains],
);
const stats = useMemo(() => {
const total = rows.length;
const boundAgents = rows.filter((r) => r.agentId !== null).length;
return { total, boundAgents };
}, [rows]);
const filteredRows = useMemo(() => {
return rows.filter((r) => {
const q = searchQuery.toLowerCase();
const matchesSearch =
r.label.toLowerCase().includes(q) || r.businessCode.toLowerCase().includes(q);
const isConfigured = r.agentId !== null && r.chainId !== null;
if (statusFilter === "configured") return matchesSearch && isConfigured;
if (statusFilter === "pending") return matchesSearch && !isConfigured;
return matchesSearch;
});
}, [rows, searchQuery, statusFilter]);
const beginEdit = (row: BusinessMapping) => {
setEditingCode(row.businessCode);
setDraft({ agentId: row.agentId, chainId: row.chainId });
@@ -86,10 +112,10 @@ export default function BusinessChainMappingPage() {
setSaving(true);
try {
const agentName = draft.agentId
? agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null
? (agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null)
: null;
const chainTitle = draft.chainId
? chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null
? (chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null)
: null;
const updated = await updateBusinessMapping(row.businessCode, {
agentId: draft.agentId,
@@ -111,162 +137,416 @@ export default function BusinessChainMappingPage() {
}
};
/* ── 样式 ── */
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
? "h-full overflow-y-auto text-slate-100 workspace-chat-bg"
: "h-full overflow-y-auto bg-white text-[#222]";
// 卡片:无边框,纯阴影
const cardClass = isDark
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
? "bg-[rgba(12,29,54,0.88)] shadow-[0_8px_32px_rgba(3,10,22,0.28)] backdrop-blur-sm"
: "bg-white shadow-[0_8px_32px_rgba(0,0,0,0.07)]";
// 行分隔:极淡,不带色
const rowDivider = isDark ? "border-white/[0.04]" : "border-black/[0.04]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const codeBadge = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
const subText = isDark ? "text-slate-400" : "text-slate-500";
const filterTabs: { id: "all" | "configured" | "pending"; label: string }[] = [
{ id: "all", label: "全部业务" },
{ id: "configured", label: "已绑定" },
{ id: "pending", label: "待配置" },
];
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
配置管理
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。
</p>
</div>
<div className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
<div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8">
<Loading loading={loading} showOverlay={false}>
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
<table className="w-full border-collapse text-sm">
<thead>
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
<th className="px-6 py-4 font-semibold">业务</th>
<th className="px-6 py-4 font-semibold">智能体</th>
<th className="px-6 py-4 font-semibold">业务链</th>
<th className="px-6 py-4 text-right font-semibold">操作</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && !loading ? (
<tr>
<td colSpan={4} className={`px-6 py-12 text-center ${mutedText}`}>
暂无业务数据
</td>
</tr>
) : (
rows.map((row) => {
const editing = editingCode === row.businessCode;
return (
<tr key={row.businessCode} className={`border-t ${rowBorder}`}>
{/* 业务(只读) */}
<td className="px-6 py-4 align-middle">
<div className="flex items-center gap-2">
<span className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{row.label}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-mono ${codeBadge}`}>
{row.businessCode}
</span>
</div>
</td>
{/* ── 页头 banner ── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className={cn("rounded-[5px] p-6 lg:p-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-6 relative overflow-hidden", cardClass)}
>
<div
className="absolute right-0 top-0 w-72 h-32 pointer-events-none"
style={{
background: isDark
? "radial-gradient(ellipse at top right, rgba(77,141,255,0.07), transparent)"
: "radial-gradient(ellipse at top right, rgba(245,34,45,0.04), transparent)",
}}
/>
{/* 智能体 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[200px]"
value={draft.agentId ?? undefined}
options={agentOptions}
filterable
clearable
placeholder="搜索并选择智能体"
onChange={(v) => setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Users className={`h-3.5 w-3.5 ${mutedText}`} />
{row.agentName || row.agentId ? (
<span>{row.agentName || row.agentId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 业务链 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[220px]"
value={draft.chainId ?? undefined}
options={chainOptions}
filterable
clearable
placeholder="搜索并选择业务链(公共)"
onChange={(v) => setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Link2 className={`h-3.5 w-3.5 ${mutedText}`} />
{row.chainTitle || row.chainId ? (
<span>{row.chainTitle || row.chainId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 操作 */}
<td className="px-6 py-4 text-right align-middle">
{editing ? (
<div className="inline-flex items-center gap-2">
<button
type="button"
disabled={saving}
onClick={() => handleSave(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(34,197,94,0.18)] text-[#86EFAC]" : "bg-[#EEFCF7] text-[#0F9D6C]"
}`}
>
<Save className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
disabled={saving}
onClick={cancelEdit}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(110,136,176,0.18)] text-slate-300" : "bg-[#F5F5F5] text-[#575757]"
}`}
>
<X className="h-3.5 w-3.5" />
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => beginEdit(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium ${
isDark ? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]" : "bg-[#FFF1F0] text-[#CF1322]"
}`}
>
<Pencil className="h-3.5 w-3.5" />
编辑
</button>
)}
</td>
</tr>
);
})
<div className="space-y-3 relative z-10">
<div className="flex flex-wrap items-center gap-2.5">
<h2 className={cn("text-xl font-bold tracking-tight", isDark ? "text-slate-50" : "text-slate-900")}>
配置管理
</h2>
<span
className={cn(
"text-[10px] uppercase font-mono tracking-wider font-bold px-2.5 py-1 rounded-[5px] flex items-center gap-1.5",
isDark
? "bg-[rgba(77,141,255,0.12)] text-[#9CCBFF]"
: "bg-red-50/80 text-red-500",
)}
</tbody>
</table>
>
<span className={cn("w-1.5 h-1.5 rounded-full animate-pulse", isDark ? "bg-[#55A6FE]" : "bg-red-500")} />
智能会商神经中枢
</span>
<span
className={cn(
"text-[10px] font-mono tracking-wider px-2 py-0.5 rounded-[5px]",
isDark ? "text-slate-500 bg-white/5" : "text-slate-400 bg-slate-50",
)}
>
v1.2.5
</span>
</div>
<p className={cn("text-xs max-w-2xl leading-relaxed", subText)}>
业务流转与交叉验证的高级控制中心。在此注册高频业务模块并绑定专职执行智能体(Agent)及公共流转决策链(Chain),决策信息将基于该拓扑自动触发多智能体自诊断及会商组织。
</p>
<div className={cn("flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-[11px] font-medium", mutedText)}>
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
会商引擎: 正常运行中
</span>
<span className="opacity-30">•</span>
<span>
全局配置项:{" "}
<strong className={isDark ? "text-slate-200 font-semibold" : "text-slate-700 font-semibold"}>
{stats.total} 项注册
</strong>
</span>
<span className="opacity-30">•</span>
<span>
指定专家节点:{" "}
<strong className={isDark ? "text-[#9CCBFF] font-semibold" : "text-red-500 font-semibold"}>
{stats.boundAgents} / {stats.total}
</strong>
</span>
</div>
</div>
</Loading>
</motion.div>
{/* ── 表格区 ── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
className={cn("rounded-[5px] overflow-hidden", cardClass)}
>
{/* 搜索 + 筛选 */}
<div className={cn("px-6 py-5 border-b flex flex-col sm:flex-row sm:items-center justify-between gap-4", rowDivider)}>
<div className="relative w-full max-w-sm">
<Search className={cn("absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4", mutedText)} />
<input
type="text"
placeholder="搜索业务名称、ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"w-full pl-10 pr-4 py-2.5 rounded-[5px] text-xs transition-all placeholder:text-slate-400 focus:outline-none",
isDark
? "text-slate-200 bg-white/5 focus:bg-white/8 focus:ring-1 focus:ring-[#4D8DFF]/30"
: "text-slate-700 bg-slate-50/60 hover:bg-slate-50 focus:bg-white focus:ring-1 focus:ring-red-400/30",
)}
/>
</div>
<div className={cn("flex items-center gap-1 p-1 rounded-[5px] self-end sm:self-auto shrink-0", isDark ? "bg-white/5" : "bg-slate-100/70")}>
{filterTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setStatusFilter(tab.id)}
className={cn(
"px-3.5 py-1.5 rounded-[5px] text-xs font-semibold transition-all cursor-pointer",
statusFilter === tab.id
? isDark
? "bg-[rgba(77,141,255,0.18)] text-[#9CCBFF] shadow-sm"
: "bg-white text-gray-900 shadow-sm"
: isDark
? "text-slate-500 hover:text-slate-300"
: "text-gray-400 hover:text-gray-700",
)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 表格 */}
<div className="overflow-x-auto">
{loading ? (
<div className={cn("py-16 text-center text-sm", mutedText)}>加载中…</div>
) : (
<table className="w-full table-fixed text-left border-collapse">
<thead>
<tr className={cn("border-b", rowDivider, isDark ? "bg-white/[0.02]" : "bg-black/[0.02]")}>
<th className={cn("pl-8 pr-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[20%]", mutedText)}>业务</th>
<th className={cn("px-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[26%]", mutedText)}>智能体</th>
<th className={cn("px-4 py-3.5 text-xs font-semibold uppercase tracking-wider w-[26%]", mutedText)}>业务链</th>
<th className={cn("pl-4 pr-8 py-3.5 text-xs font-semibold uppercase tracking-wider w-[28%] text-right", mutedText)}>操作</th>
</tr>
</thead>
<tbody>
{filteredRows.length > 0 ? (
filteredRows.map((row) => {
const editing = editingCode === row.businessCode;
return (
<tr
key={row.businessCode}
className={cn(
"border-b last:border-0 transition-colors duration-150",
rowDivider,
!editing && (isDark ? "hover:bg-white/[0.025]" : "hover:bg-black/[0.015]"),
editing && (isDark ? "bg-[rgba(77,141,255,0.04)]" : "bg-red-50/30"),
)}
>
{/* 业务 */}
<td className="pl-8 pr-4 py-5 align-middle">
<div className="flex items-center gap-2.5">
<span className={cn("font-semibold tracking-tight text-sm", isDark ? "text-slate-100" : "text-gray-800")}>
{row.label}
</span>
<span
className={cn(
"text-[10px] font-mono font-bold px-2 py-0.5 rounded-[5px] leading-none",
isDark
? "bg-[rgba(77,141,255,0.12)] text-[#9CCBFF]"
: "bg-red-50 text-red-400",
)}
>
{row.businessCode}
</span>
</div>
</td>
{/* 智能体 */}
<td className="px-4 py-4 align-middle">
{editing ? (
<Select
className="min-w-[200px]"
value={draft.agentId ?? undefined}
options={agentOptions}
filterable
clearable
placeholder="搜索并选择智能体"
onChange={(v) =>
setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))
}
/>
) : (
<div className="flex items-center gap-2.5">
<div
className={cn(
"p-1.5 rounded-[5px] transition-colors",
row.agentId
? isDark
? "bg-[rgba(77,141,255,0.1)] text-[#9CCBFF]"
: "bg-red-50 text-red-400"
: isDark
? "bg-white/5 text-slate-600"
: "bg-slate-50 text-gray-300",
)}
>
<Users className="w-3.5 h-3.5 shrink-0" />
</div>
{row.agentName || row.agentId ? (
<div className="space-y-0.5">
<span className={cn("text-xs font-semibold", isDark ? "text-slate-200" : "text-gray-800")}>
{row.agentName || row.agentId}
</span>
{row.agentId && agents.find((a) => a.id === row.agentId)?.description && (
<span className={cn("block text-[10px]", mutedText)}>
{(agents.find((a) => a.id === row.agentId)?.description ?? "").slice(0, 40)}
{(agents.find((a) => a.id === row.agentId)?.description?.length ?? 0) > 40 ? "…" : ""}
</span>
)}
</div>
) : (
<span className={cn("text-xs", mutedText)}>未配置</span>
)}
</div>
)}
</td>
{/* 业务链 */}
<td className="px-4 py-4 align-middle">
{editing ? (
<Select
className="min-w-[220px]"
value={draft.chainId ?? undefined}
options={chainOptions}
filterable
clearable
placeholder="搜索并选择业务链(公共)"
onChange={(v) =>
setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))
}
/>
) : (
<div className="flex items-center gap-2.5">
<div
className={cn(
"p-1.5 rounded-[5px] transition-colors",
row.chainId
? isDark
? "bg-[rgba(34,197,94,0.08)] text-green-400"
: "bg-blue-50 text-blue-400"
: isDark
? "bg-white/5 text-slate-600"
: "bg-slate-50 text-gray-300",
)}
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
</div>
{row.chainTitle || row.chainId ? (
<div className="space-y-0.5">
<span className={cn("text-xs font-semibold", isDark ? "text-slate-200" : "text-gray-800")}>
{row.chainTitle || row.chainId}
</span>
{row.chainId && (
<span className={cn("flex items-center gap-1 text-[10px] leading-none", mutedText)}>
<span
className={cn(
"px-1 rounded-[5px] text-[9px] font-medium",
isDark
? "bg-[rgba(34,197,94,0.08)] text-green-400"
: "bg-green-50 text-green-600",
)}
>
公共
</span>
节点数: {chains.find((c) => c.id === row.chainId)?.seats?.length ?? "—"}
</span>
)}
</div>
) : (
<span className={cn("text-xs", mutedText)}>未配置</span>
)}
</div>
)}
</td>
{/* 操作 */}
<td className="pl-4 pr-8 py-4 text-right align-middle whitespace-nowrap">
{editing ? (
<div className="inline-flex items-center gap-2 justify-end">
<button
type="button"
disabled={saving}
onClick={() => handleSave(row)}
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all active:scale-95 disabled:opacity-50",
isDark
? "bg-[rgba(34,197,94,0.12)] text-green-400 hover:bg-[rgba(34,197,94,0.2)]"
: "bg-green-50 text-green-600 hover:bg-green-100",
)}
>
<Save className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
disabled={saving}
onClick={cancelEdit}
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all active:scale-95 disabled:opacity-50",
isDark
? "bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-200"
: "bg-slate-50 text-slate-500 hover:bg-slate-100",
)}
>
<X className="h-3.5 w-3.5" />
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => beginEdit(row)}
title="编辑配置"
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-95",
isDark
? "text-[#9CCBFF] bg-[rgba(77,141,255,0.12)] hover:bg-[rgba(77,141,255,0.18)]"
: "text-red-500 bg-red-50 hover:bg-red-100",
)}
>
<Pencil className="h-3.5 w-3.5" />
<span>编辑</span>
</button>
)}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="py-14 text-center">
<div className="flex flex-col items-center justify-center gap-2">
<AlertCircle className={cn("w-7 h-7", isDark ? "text-slate-700" : "text-gray-200")} />
<p className={cn("text-xs font-medium", mutedText)}>未找到符合筛选条件的业务配置</p>
<button
onClick={() => { setSearchQuery(""); setStatusFilter("all"); }}
className={cn("text-[11px] font-semibold mt-1 hover:underline", isDark ? "text-[#9CCBFF]" : "text-red-500")}
>
重置筛选条件
</button>
</div>
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* 表格尾栏 */}
<div className={cn("px-6 py-4 border-t flex items-center justify-between text-xs font-medium", rowDivider, isDark ? "bg-white/[0.02]" : "bg-black/[0.015]", mutedText)}>
<span className="flex items-center gap-1.5">
<Info className="w-3.5 h-3.5" />
会商程序会在对应业务的业务链生效时开启多体交叉核验证。
</span>
<span className="font-mono text-[10px]">
显示 {filteredRows.length} / {rows.length} 条
</span>
</div>
</motion.div>
{/* ── 使用说明 ── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
className={cn(
"rounded-[5px] p-5 flex gap-4 text-xs leading-relaxed",
isDark
? "bg-[rgba(245,158,11,0.05)] text-amber-300/70"
: "bg-amber-50/50 text-amber-800",
)}
>
<div className={cn("w-8 h-8 rounded-[5px] shrink-0 flex items-center justify-center", isDark ? "bg-[rgba(245,158,11,0.08)] text-amber-400" : "bg-amber-100/60 text-amber-500")}>
<HelpCircle className="w-4 h-4" />
</div>
<div>
<h4 className={cn("font-bold mb-0.5", isDark ? "text-amber-300" : "text-amber-900")}>如何配置业务流转?</h4>
<p className={isDark ? "text-amber-400/60" : "text-amber-700/80"}>
1. 将鼠标悬停在任一业务行右侧,点击出现的{" "}
<span className={cn("font-semibold", isDark ? "text-[#9CCBFF]" : "text-red-500")}>「编辑」</span>。
<br />
2. 在该行内直接搜索并选择 <strong>执行智能体</strong> 与 <strong>业务链</strong>,点击「保存」即时生效。
<br />
3. 若无需绑定,可搜索框清空后保存,置为「未配置」状态。
</p>
</div>
</motion.div>
</div>
</div>
);
+104 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -16,7 +16,6 @@ import {
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
import { KnowledgeRagToggle } from "@/components/workspace/knowledge-rag-toggle";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
@@ -24,10 +23,17 @@ import {
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { QuestionNavigator } from "@/components/workspace/question-navigator";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { PanelLeftOpen } from "lucide-react";
import { useAgentSelector } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
@@ -35,9 +41,11 @@ import { useNotification } from "@/core/notification/hooks";
import {
getLastAgentId,
saveLastAgentId,
useKnowledgeRagEnabled,
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
@@ -84,12 +92,29 @@ export default function ChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
useSidebar();
const { t } = useI18n();
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
const [effectiveLayoutMode] = useLayoutMode();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
// Sidebar session toggle: while on, every message in every chat carries
// knowledge_rag_enabled so the backend injects knowledge-base recall.
// In-memory only — explicitly overrides any value persisted by the old
// per-thread header toggle so stale localStorage can't re-enable it.
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const streamContext = useMemo(
() => ({
...settings.context,
knowledge_rag_enabled: knowledgeRagEnabled,
}),
[settings.context, knowledgeRagEnabled],
);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
@@ -136,7 +161,7 @@ export default function ChatPage() {
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
context: streamContext,
isMock,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
@@ -389,11 +414,32 @@ export default function ChatPage() {
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
const layoutMode = effectiveLayoutMode;
const chromeByPreference =
layoutMode === "full"
? true
: layoutMode === "compact"
? false
: readSidebarEnabled();
const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<div
className={cn(
"relative flex size-full min-h-0 justify-between",
// 紧凑模式(有顶栏+左侧页面侧栏)下,深色背景换成设计稿深蓝主题底图
compactChrome && "workspace-chat-bg",
)}
>
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
@@ -406,11 +452,24 @@ export default function ChatPage() {
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
{compactChrome && !historySidebarOpen && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={toggleHistorySidebar}
title="打开历史记录"
aria-label="打开历史记录"
className="text-muted-foreground hover:text-foreground"
>
<PanelLeftOpen className="size-4" />
</Button>
)}
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<KnowledgeRagToggle threadId={threadId} />
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} />
<ArtifactTrigger />
@@ -441,11 +500,25 @@ export default function ChatPage() {
/>
)}
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
// 外层居中容器不吃指针事件:空白区域(尤其落地态铺满 inset-0 时盖住的
// 右上角「展开历史」按钮)让点击穿透,仅真正的内容块(下方 pointer-events-auto)
// 接收交互。否则该透明层会吞掉顶栏按钮的 hover/光标/点击。
"pointer-events-none absolute z-30 flex justify-center px-4",
// 紧凑落地态:整块垂直+水平居中于对话区
compactLanding
? "inset-0 items-center"
: "right-0 bottom-0 left-0",
)}
>
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
"pointer-events-auto relative w-full",
// 非紧凑落地态沿用上移居中;紧凑态由外层 items-center 居中,无需位移
isLandingState &&
!compactLanding &&
"-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-md)"
: "max-w-(--container-width-lg)",
@@ -462,17 +535,21 @@ export default function ChatPage() {
/>
</div>
</div>
{isLandingState && mounted && (
<Welcome
mode={settings.context.mode}
className="mb-6 -translate-y-4"
/>
)}
{isLandingState && mounted &&
(compactLanding ? (
<CompactWelcome className="mb-6 -translate-y-4" />
) : (
<Welcome
mode={settings.context.mode}
className="mb-6 -translate-y-4"
/>
))}
{mounted ? (
<div className="w-full -translate-y-4">
<InputBox
className="w-full"
isNewThread={isLandingState}
compact={compactLanding}
threadId={threadId}
autoFocus={isLandingState}
status={
@@ -521,6 +598,19 @@ export default function ChatPage() {
</div>
</div>
</main>
{/*
ChatGPT-style question index. Anchored to the chat column (this
relative wrapper), not the browser edge; insets keep it below the
overlay header (h-12) and above the docked input box, and it is
pointer-events-none outside its own cards so it never blocks the
header buttons or input.
*/}
{!isLandingState && (
<QuestionNavigator
messages={thread.messages}
className="top-16 right-0 bottom-48 left-0"
/>
)}
</div>
</ChatBox>
</ThreadContext.Provider>
+3 -12
View File
@@ -4,13 +4,9 @@ import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import {
resolveEmbedChatParams,
resolveEmbedTheme,
} from "@/core/embed/params";
import { resolveEmbedChatParams } from "@/core/embed/params";
import { registerEmbedSession } from "@/core/embed/status-api";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
@@ -29,7 +25,8 @@ const EMBED_ROUTE_BASE = "/embed/chats";
export default function EmbedChatPage() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const { setTheme } = useTheme();
// 主题不在这里处理 —— ?theme= 由 AppProviders 通过 forcedTheme 强制
// (不落 localStorage),避免污染主站用户自己选的主题。
const embedParams = resolveEmbedChatParams(searchParams.toString());
const [authReady, setAuthReady] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
@@ -51,12 +48,6 @@ export default function EmbedChatPage() {
const [settings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
if (embedParams.theme) {
setTheme(resolveEmbedTheme(embedParams.theme));
}
}, [embedParams.theme, setTheme]);
useEffect(() => {
let cancelled = false;
async function runLogin() {
+3 -23
View File
@@ -3,7 +3,6 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTheme } from "@/contexts/ThemeContext";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import { readWujieProps, isWujieEnv } from "@/core/embed/wujie";
@@ -13,17 +12,6 @@ import "@/roundtable-planning/styles/roundtable-planning.css";
const DEFAULT_USERNAME = "guest";
/**
* 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**:
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
*/
function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
const v = raw.trim().toLowerCase();
if (v === "light" || v === "red" || v === "红白") return "light";
return "dark";
}
/**
* 圆桌规划嵌入页(独立入口,**与现有 wujie ticket 登录无关**)。
*
@@ -37,9 +25,10 @@ function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
*/
export default function EmbedRoundtablePage() {
const searchParams = useSearchParams();
const { setTheme } = useTheme();
// username / password / theme:wujie props 优先,URL query 兜底。
// username / password:wujie props 优先,URL query 兜底。
// 主题不在这里处理 —— 嵌入路由的主题由 AppProviders 通过 forcedTheme
// 强制(不落 localStorage),避免污染主站用户自己选的主题。
const wp = readWujieProps();
const username =
(typeof wp.username === "string" && wp.username.trim()) ||
@@ -53,19 +42,10 @@ export default function EmbedRoundtablePage() {
(typeof wp.password === "string" && wp.password) ||
searchParams.get("password") ||
undefined;
// 主题色:宿主传入优先;默认「深蓝」= dark 主题(其主色为蓝,oklch hue 259)。
const themeParam =
(typeof wp.theme === "string" && wp.theme.trim()) ||
searchParams.get("theme")?.trim() ||
"dark";
const [authReady, setAuthReady] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
useEffect(() => {
setTheme(resolveEmbedRoundtableTheme(themeParam));
}, [themeParam, setTheme]);
useEffect(() => {
if (!isWujieEnv()) return;
document.documentElement.classList.add("wujie-embed");
+152 -28
View File
@@ -1,10 +1,18 @@
"use client";
import {
BookMarkedIcon,
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { useArtifacts } from "@/components/workspace/artifacts";
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
import {
ChatBox,
useSpecificChatMode,
@@ -12,18 +20,23 @@ import {
} from "@/components/workspace/chats";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { TodoList } from "@/components/workspace/todo-list";
import { Button } from "@/components/ui/button";
import { useAgentSelector } from "@/core/agents";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useRouteBase } from "@/core/route-base";
import { useThreadSettings } from "@/core/settings";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import {
getLastAgentId,
isThreadRagModeLocked,
saveLastAgentId,
useLocalSettings,
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
@@ -81,16 +94,16 @@ export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings] = useThreadSettings(threadId);
const [settings, setSettings] = useThreadSettings(threadId);
const [localSettings, setLocalSettings] = useLocalSettings();
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector();
const pendingRequestIdRef = useRef<string | null>(null);
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
@@ -124,11 +137,19 @@ export default function IframeChatPage() {
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedAgentId(null);
setSelectedAgentId(getLastAgentId());
}
}, [isNewThread, threadId]);
useEffect(() => {
if (selectorLoading || !selectedAgentId) {
return;
}
if (!selectorAgents.some((agent) => agent.id === selectedAgentId)) {
setSelectedAgentId(null);
}
}, [selectedAgentId, selectorAgents, selectorLoading]);
const { showNotification } = useNotification();
const [thread, sendMessage, isUploading] = useThreadStream({
@@ -207,20 +228,12 @@ export default function IframeChatPage() {
return;
}
const shouldApplyPrefix =
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
const extraContext: Record<string, unknown> = {};
if (shouldApplyPrefix) {
extraContext.prompt_prefix_enabled = true;
}
if (settings.context.writing_mode && writingArtifactPath) {
extraContext.writing_artifact_path = writingArtifactPath;
}
@@ -238,8 +251,6 @@ export default function IframeChatPage() {
[
hasStartedConversation,
isNewThread,
promptPrefixOn,
promptPrefixSettings,
router,
selectedAgentId,
sendMessage,
@@ -257,6 +268,45 @@ export default function IframeChatPage() {
[sendMessage, threadId],
);
const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId);
saveLastAgentId(agentId);
}, []);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked;
const proModeEnabled = settings.context.mode === "pro";
const toggleWritingMode = useCallback(() => {
setSettings("context", {
writing_mode: !settings.context.writing_mode,
});
}, [setSettings, settings.context.writing_mode]);
const toggleMemoryInjection = useCallback(() => {
setSettings("context", {
memory_injection_enabled:
!(settings.context.memory_injection_enabled ?? true),
});
}, [setSettings, settings.context.memory_injection_enabled]);
const toggleRagMode = useCallback(() => {
if (ragModeLocked) {
return;
}
setLocalSettings("display", {
ragModeEnabled: !ragModeEnabled,
});
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const toggleProMode = useCallback(() => {
setSettings("context", {
mode: proModeEnabled ? "flash" : "pro",
reasoning_effort: proModeEnabled ? "minimal" : "medium",
});
}, [proModeEnabled, setSettings]);
const handleInterrupt = useCallback(
async (message: BrainyChatInterruptMessage) => {
const targetRequestId = message.payload?.requestId?.trim();
@@ -318,11 +368,47 @@ export default function IframeChatPage() {
};
}, [handleInterrupt, handleSubmit]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
const bottomBarHeightClass = "pb-28";
const modeButtons = [
{
key: "writing",
label: "写作",
icon: FileTextIcon,
active: !!settings.context.writing_mode,
onClick: toggleWritingMode,
activeClassName:
"border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100",
},
{
key: "memory",
label: "记忆",
icon: BrainCircuitIcon,
active: settings.context.memory_injection_enabled ?? true,
onClick: toggleMemoryInjection,
activeClassName:
"border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100",
},
{
key: "references",
label: "参考文献",
icon: BookMarkedIcon,
active: ragModeEnabled,
onClick: toggleRagMode,
disabled: ragModeLocked,
activeClassName:
"border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100",
},
{
key: "pro",
label: "专业问答",
icon: GraduationCapIcon,
active: proModeEnabled,
onClick: toggleProMode,
activeClassName:
"border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100",
},
] as const;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
@@ -350,24 +436,30 @@ export default function IframeChatPage() {
</header>
*/}
<main className="flex min-h-0 max-w-full grow flex-col overflow-hidden">
<div className="flex size-full min-w-0">
<div className={cn("flex size-full min-w-0", !isLandingState && bottomBarHeightClass)}>
{!isLandingState && (
<MessageList
className="size-full"
contentClassName="mx-0 max-w-full px-3 sm:px-4"
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
paddingBottom={96}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
)}
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"absolute inset-x-0 z-30 flex justify-center px-4",
isLandingState
? "top-0 bottom-24 items-center"
: "bottom-24",
)}
>
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-md)"
: "max-w-full",
@@ -387,6 +479,13 @@ export default function IframeChatPage() {
{isLandingState && mounted && (
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
<div className="text-2xl font-medium">你好</div>
<div className="mt-4">
<AgentSelector
selectedAgentId={selectedAgentId}
onAgentChange={handleAgentChange}
disabled={isUploading}
/>
</div>
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
@@ -396,6 +495,31 @@ export default function IframeChatPage() {
)}
</div>
</div>
<div className="absolute right-0 bottom-0 left-0 z-40 border-t bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-2 px-4 py-4">
{modeButtons.map((button) => {
const Icon = button.icon;
return (
<Button
key={button.key}
type="button"
variant="outline"
disabled={button.disabled || isUploading}
onClick={button.onClick}
className={cn(
"gap-1.5 rounded-full border border-transparent bg-muted/40 text-muted-foreground shadow-none hover:bg-muted hover:text-foreground",
button.active && button.activeClassName,
button.disabled && "cursor-not-allowed opacity-80",
)}
>
{button.active && <CheckIcon className="size-3.5" />}
<Icon className="size-3.5" />
<span className="text-xs font-normal">{button.label}</span>
</Button>
);
})}
</div>
</div>
</main>
</div>
</ChatBox>
@@ -468,6 +468,8 @@ export default function KnowledgeBasePage() {
const [status, setStatus] = useState("all");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const treeInitializedRef = useRef(false);
// Auto-open a default note once on entry so the reading pane is never blank.
const defaultNoteShownRef = useRef(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
@@ -495,6 +497,15 @@ export default function KnowledgeBasePage() {
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
// On first entry without an explicit note, open the most recent one so the
// reading pane shows real content instead of the empty placeholder.
useEffect(() => {
if (defaultNoteShownRef.current || noteId || isLoading) return;
if (notes.length === 0) return;
defaultNoteShownRef.current = true;
navigate(`/page/workspace/knowledge/${notes[0].id}`, { replace: true });
}, [noteId, isLoading, notes, navigate]);
// First load: expand the root-level folders so the tree reads as a directory.
useEffect(() => {
if (treeInitializedRef.current || tree.length === 0) return;
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff. Show more