diff --git a/CLAUDE.md b/CLAUDE.md index c054b1d8..7dba5283 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` diff --git a/_skills_src/ai-skills b/_skills_src/ai-skills new file mode 160000 index 00000000..cde1c838 --- /dev/null +++ b/_skills_src/ai-skills @@ -0,0 +1 @@ +Subproject commit cde1c8385ead64df501c9b214e63d7ba9b283ff9 diff --git a/_skills_src/anthropics-skills b/_skills_src/anthropics-skills new file mode 160000 index 00000000..57546260 --- /dev/null +++ b/_skills_src/anthropics-skills @@ -0,0 +1 @@ +Subproject commit 57546260929473d4e0d1c1bb75297be2fdfa1949 diff --git a/_skills_src/awesome-claude-skills b/_skills_src/awesome-claude-skills new file mode 160000 index 00000000..92568c1e --- /dev/null +++ b/_skills_src/awesome-claude-skills @@ -0,0 +1 @@ +Subproject commit 92568c1edaff1bde5371154f036d959346c145a8 diff --git a/_skills_src/ce-kit b/_skills_src/ce-kit new file mode 160000 index 00000000..d4973c48 --- /dev/null +++ b/_skills_src/ce-kit @@ -0,0 +1 @@ +Subproject commit d4973c48270c66f4a4751131ec84b9dbb03669dc diff --git a/_skills_src/csm b/_skills_src/csm new file mode 160000 index 00000000..3fa16a94 --- /dev/null +++ b/_skills_src/csm @@ -0,0 +1 @@ +Subproject commit 3fa16a94e0aba3509e4d4b318ca175d3191b5b7d diff --git a/_skills_src/superpowers b/_skills_src/superpowers new file mode 160000 index 00000000..6fd45076 --- /dev/null +++ b/_skills_src/superpowers @@ -0,0 +1 @@ +Subproject commit 6fd4507659784c351abbd2bc264c7162cfd386dc diff --git a/_skills_src/tapestry-skills b/_skills_src/tapestry-skills new file mode 160000 index 00000000..80e1dc56 --- /dev/null +++ b/_skills_src/tapestry-skills @@ -0,0 +1 @@ +Subproject commit 80e1dc56df74d1cb849ad649c7ead9756e7929bb diff --git a/frontend-web/docs/ai-writing-内置智能体改造.md b/frontend-web/docs/ai-writing-内置智能体改造.md new file mode 100644 index 00000000..96016c25 --- /dev/null +++ b/frontend-web/docs/ai-writing-内置智能体改造.md @@ -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//` + - `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//` + 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//` 重新种子,或后续给种子器加「内置功能 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 键就地补丁、显式 `[]` 不动。 diff --git a/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md b/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md deleted file mode 100644 index ea9c4d50..00000000 --- a/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md +++ /dev/null @@ -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
(流式只读 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` 的 `` | **未使用** | - -两套菜单均采用: - -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`)。 diff --git a/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md b/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md deleted file mode 100644 index da73fbeb..00000000 --- a/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md +++ /dev/null @@ -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`(自带大纲分支 + 素材关联)。 diff --git a/frontend-web/docs/embed-public-pages-theme.md b/frontend-web/docs/embed-public-pages-theme.md new file mode 100644 index 00000000..6a4c8c69 --- /dev/null +++ b/frontend-web/docs/embed-public-pages-theme.md @@ -0,0 +1,154 @@ +# 公开页 · 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:` 样式 | diff --git a/frontend-web/docs/multi-agent-api.md b/frontend-web/docs/multi-agent-api.md deleted file mode 100644 index a493801e..00000000 --- a/frontend-web/docs/multi-agent-api.md +++ /dev/null @@ -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 的实际执行 | diff --git a/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md b/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md deleted file mode 100644 index ba2eefde..00000000 --- a/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md +++ /dev/null @@ -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-` 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, jobStatus, step2RoundtableDialogues:[...], step3:{...}} ]`。 - -4. **前端验证**:后台挂起 → 等跑完 → 从历史加载该草稿 → Step2 应显示后台研讨完整对话(走现有 `normalizeStep2Runs`/`hydrateStep2Run`)。 - ---- - -## 三、遗留问题(下次重点,已按优先级排) - -### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物) -- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads//user-data/outputs`; - `run.step3.html` 回退成报告 agent 的**纯文字**(不含 ``),第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。 diff --git a/frontend-web/docs/multi-agent-后台挂起续跑实现方案.md b/frontend-web/docs/multi-agent-后台挂起续跑实现方案.md new file mode 100644 index 00000000..74b3811d --- /dev/null +++ b/frontend-web/docs/multi-agent-后台挂起续跑实现方案.md @@ -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 diff --git a/frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md b/frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md new file mode 100644 index 00000000..e652e565 --- /dev/null +++ b/frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md @@ -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 +// 主按钮 + + +// 次要按钮(次按钮) + + +// 默认按钮 + + +// 文本按钮 + + +// 图标按钮(实底圆形) + +``` + +图标按钮三种底 × 两种形状: +- **描边**:白底 + `--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 + +``` + +--- + +## 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 +
+ {options.map((o) => ( + + ))} +
+``` + +> 浅底式把选中态换成 `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 +// 触发框 + + +// 下拉项 +
  • {label}
  • + +// 分组标题 +
    分组标题1
    +``` + +> 推荐直接基于 Radix `Select`(项目已有 shadcn 封装)改这些类名落地。 + +--- + +## 5.14 输入 - 开关(Switch) + +两种形态: + +### 1. 滑块开关(多用于表单、表格中) + +- **开**:胶囊底 `--color-primary`,白色圆形滑块在右,左侧白字「开」 +- **关**:胶囊底灰 `--color-text-secondary`/`#C1C1C1`,滑块在左,右侧灰字「关」 +- 切换时滑块带左右过渡动画 + +```tsx + +``` + +### 2. 单选框作为开关(分段式) + +- `开 | 关` 两段式:激活段 `--color-primary` 实底白字,未激活段白底灰字 + `--color-border` 边框 +- 即 5.9 拼接式「实底选择按钮」的两选项特例 + +```tsx +
    + + +
    +``` diff --git a/frontend-web/docs/xvqiu-0616.md b/frontend-web/docs/xvqiu-0616.md new file mode 100644 index 00000000..ed3544d1 --- /dev/null +++ b/frontend-web/docs/xvqiu-0616.md @@ -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` 与默认落地页可能需要调整,需同步确认。 diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index e2c8695c..0c191a09 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -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 ( - {/* QueryClientProvider must wrap ThemeProvider because the custom - ThemeProvider reads the admin-configured default via useQuery. */} @@ -67,20 +95,20 @@ export function App() { } /> } /> } /> - {/* Public read-only conversation viewer — no authentication. */} } /> - {/* Public read-only user leaderboard publish page — no authentication. */} } /> - {/* Public read-only roundtable share — landing + full result report. */} } + element={} /> } + element={} + /> + } /> - {/* Public read-only scheduled-task runs viewer — no authentication. */} } @@ -89,7 +117,6 @@ export function App() { path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen" element={} /> - {/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */} } /> - {/* Embedded roundtable planning — body only, no sidebars; username login (not wujie ticket) */} } /> + } + /> } /> } /> diff --git a/frontend-web/src/assets/img/layout-bg-theme-dark-blue.svg b/frontend-web/src/assets/img/layout-bg-theme-dark-blue.svg new file mode 100644 index 00000000..dabbcbe3 --- /dev/null +++ b/frontend-web/src/assets/img/layout-bg-theme-dark-blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/img/nav-active.svg b/frontend-web/src/assets/img/nav-active.svg new file mode 100644 index 00000000..fe2b1952 --- /dev/null +++ b/frontend-web/src/assets/img/nav-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/img/top-bg.svg b/frontend-web/src/assets/img/top-bg.svg new file mode 100644 index 00000000..54563425 --- /dev/null +++ b/frontend-web/src/assets/img/top-bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/img/菜单按钮.svg b/frontend-web/src/assets/img/菜单按钮.svg new file mode 100644 index 00000000..a7062876 --- /dev/null +++ b/frontend-web/src/assets/img/菜单按钮.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx index 8b3b3bc7..84987919 100644 --- a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx +++ b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx @@ -245,9 +245,10 @@ export const GraphCanvas = forwardRef( // 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( 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( [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( 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( }; 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( 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( }; rafRef.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(rafRef.current); - }, [draw, fitView]); + }, [computeFitTransform, draw, fitView, setTransform]); // --------------------------------------------------------------- resize @@ -801,7 +827,6 @@ export const GraphCanvas = forwardRef( const handlePointerDown = (e: React.PointerEvent) => { 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( 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; diff --git a/frontend-web/src/components/knowledge-graph/graphUtils.ts b/frontend-web/src/components/knowledge-graph/graphUtils.ts index b5331630..35e9cab4 100644 --- a/frontend-web/src/components/knowledge-graph/graphUtils.ts +++ b/frontend-web/src/components/knowledge-graph/graphUtils.ts @@ -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(); + 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; + } + } + } } diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 205eb9a1..e6eb7677 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -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); -} \ No newline at end of file +} + +.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); +} diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index 5d54ec6a..af274aff 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -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>(() => 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) => (
    {items.map((item) => { @@ -163,6 +197,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar })}
    ); + // collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口 if (collapsed) { return ( @@ -180,16 +215,16 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar } return ( -