From e7b51a30a3f5a8564dda52b821a5fdb02ff7b8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 11 Jun 2026 21:25:36 +0800 Subject: [PATCH 01/67] =?UTF-8?q?fix:=20=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E6=80=A7=E5=AF=B9=E9=BD=90=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8F=AF=E8=A7=81=E5=8D=B3=E5=8F=AF=E6=89=93?= =?UTF-8?q?=E5=BC=80=E9=97=AE=E7=AD=94=EF=BC=8C=E6=B6=88=E9=99=A4=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_visible 此前比列表过滤更严格(仅 内置/本人/已发布),导致私密广场 智能体(转私密强制 published=false)和「我的」里已收藏但被取消发布的 智能体能在列表看到、点开问答却 404(前端连带报 X.map is not a function)。 - AgentRepository._visible_filter / MemoryAgentStore._is_fetchable: 单条读取放宽为所有列表口径的并集(+is_private 豁免、+本人收藏豁免) - 详情接口与 _enforce_agent_access 运行门禁:管理员回退 get_any, 对齐 ?admin=true 全量列表 - 普通未发布草稿对陌生人仍不可见,隐私不放开 - 本地新增 tests/test_agent_visibility.py 覆盖 SQL/Memory 双实现 (tests/ 目录在本仓库被 gitignore,不随提交) Co-Authored-By: Claude Fable 5 --- offline-backend-20260512/backend/CLAUDE.md | 4 ++- .../backend/app/gateway/routers/agents.py | 4 +++ .../backend/app/gateway/services.py | 10 ++++++-- .../deerflow/persistence/agents/memory.py | 11 +++++++- .../deerflow/persistence/agents/sql.py | 25 +++++++++++++++---- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index fd7020fa..595b68c1 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -306,7 +306,9 @@ Self-contained product knowledge base. **All knowledge capability lives in this ### Custom Agent Persistence -Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. +Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are *listed* only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. + +**Visibility invariant: list-visible ⇒ openable.** Single-record reads (`AgentStore.get_visible`, which gates `GET /api/agents/{id}` *and* run creation via `_enforce_agent_access`) are deliberately **more lenient** than the listing filters — they must cover the union of every listing surface so an agent that shows up in any list never 404s when opened or chatted with: built-in / owned / published (baseline), **`is_private=true`** (the private-square password is the *discovery* gate; reads are not re-gated — necessary because moving an agent into the private square forces `published=false`), and **favorited by the requesting user** (the "我的" listing keeps favorites after the owner unpublishes). Admins additionally fall back to `get_any` in the detail route and run gate, matching the all-agents admin listing. Plain unpublished agents stay hidden from strangers. Predicates: `AgentRepository._visible_filter` / `MemoryAgentStore._is_fetchable`. Tests: `tests/test_agent_visibility.py`. **Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's 发布者 — built-in agents never match), powering the gallery's click-发布者-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`. diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 75d8f9cd..9e4ffd72 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -723,6 +723,10 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse: await _maybe_sync_legacy_agents(store) user_id = _current_user_id(request) record = await store.get_visible(agent_id, user_id) + if record is None and _is_admin_user(request): + # The admin console lists every agent (?admin=true), so an admin + # must be able to open any of them — mirror that here. + record = await store.get_any(agent_id) if record is None: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") # Detail view legitimately reads SOUL.md + config.yaml — hop to a diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 46625e28..c5c2baec 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -477,6 +477,9 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: store = get_agent_store(request) await _maybe_sync_legacy_agent_records(store) user_id = get_effective_user_id() + # Admins see every agent in the admin console listing (?admin=true), so + # they may also start a run against any of them. + is_admin = getattr(getattr(request.state, "user", None), "system_role", None) == "admin" for agent_id in agent_ids: try: normalized = validate_agent_id(agent_id) @@ -484,8 +487,11 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: raise HTTPException(status_code=422, detail=str(exc)) from exc if normalized is None: continue - if await store.get_visible(normalized, user_id) is None: - raise HTTPException(status_code=404, detail=f"Agent {normalized} not found") + if await store.get_visible(normalized, user_id) is not None: + continue + if is_admin and await store.get_any(normalized) is not None: + continue + raise HTTPException(status_code=404, detail=f"Agent {normalized} not found") # --------------------------------------------------------------------------- diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py index 002653a0..51003e8b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py @@ -32,6 +32,15 @@ class MemoryAgentStore(AgentStore): def _is_visible(agent: dict[str, Any], user_id: str) -> bool: return agent.get("user_id") is None or agent.get("user_id") == user_id or bool(agent.get("published")) + def _is_fetchable(self, agent: dict[str, Any], user_id: str) -> bool: + """Single-record visibility (``get_visible``) — the union of every + listing surface, so a list-visible agent always opens. Mirrors + ``AgentRepository._visible_filter``: baseline visibility OR + private-square (password is the discovery gate, reads are not + re-gated) OR favorited (the "我的" listing keeps favorites even after + the owner unpublishes).""" + return self._is_visible(agent, user_id) or bool(agent.get("is_private")) or (user_id, agent["id"]) in self._favorites + async def list_visible(self, user_id: str) -> list[dict[str, Any]]: rows = [_copy(agent) for agent in self._agents.values() if self._is_visible(agent, user_id)] rows.sort(key=lambda agent: agent.get("created_at") or "") @@ -39,7 +48,7 @@ class MemoryAgentStore(AgentStore): async def get_visible(self, agent_id: str, user_id: str) -> dict[str, Any] | None: agent = self._agents.get(agent_id) - if agent is None or not self._is_visible(agent, user_id): + if agent is None or not self._is_fetchable(agent, user_id): return None return _copy(agent) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index 1e7564e3..2e662401 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -40,12 +40,27 @@ class AgentRepository(AgentStore): def _visible_filter(user_id: str): """Visibility for single-record fetches (``get_visible``). - Lenient: a caller who already knows the agent id can fetch it even - when it's flagged ``is_private``. The private-square password is the - *discovery* gate (listings); individual reads are not re-gated, so a - bookmarked / linked private agent stays openable. + Lenient — must cover the union of every listing surface, so any agent + a user can see in a list always opens (detail + chat) instead of + 404ing: + + - built-in / owned / published — the baseline gallery visibility; + - ``is_private`` — the private-square password is the *discovery* + gate (listings); individual reads are not re-gated, so a private + agent opened from the square (or a bookmark/link) stays openable. + Note moving an agent into the private square forces + ``published=false``, so this clause cannot lean on ``published``; + - favorited — the "我的" listing keeps favorited agents even after + the owner unpublishes them, so reads keep honoring the favorite. """ - return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True)) + fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id) + return or_( + AgentRow.user_id.is_(None), + AgentRow.user_id == user_id, + AgentRow.published.is_(True), + AgentRow.is_private.is_(True), + AgentRow.id.in_(fav_subq), + ) @staticmethod def _listing_filter(user_id: str): From 55aab673834cb8fbd4dcaa4f8f09b8226acab966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Thu, 11 Jun 2026 22:09:35 +0800 Subject: [PATCH 02/67] =?UTF-8?q?=E9=9C=80=E6=B1=82=E6=A2=B3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/ai-writing-内置智能体改造.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 frontend-web/docs/ai-writing-内置智能体改造.md diff --git a/frontend-web/docs/ai-writing-内置智能体改造.md b/frontend-web/docs/ai-writing-内置智能体改造.md new file mode 100644 index 00000000..78c124b2 --- /dev/null +++ b/frontend-web/docs/ai-writing-内置智能体改造.md @@ -0,0 +1,196 @@ +# 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` From a4a9a4d26e831759d2beee87928fa0ad84722544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Thu, 11 Jun 2026 22:58:38 +0800 Subject: [PATCH 03/67] =?UTF-8?q?AI=20=E5=86=99=E4=BD=9C=E5=9B=9B=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E5=86=85=E7=BD=AE=E6=99=BA=E8=83=BD=E4=BD=93=20Phase?= =?UTF-8?q?=201-4=20=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ai-writing-researcher/outliner/writer/editor 种子 agent、_agent_runner 与 use_builtin_agents 灰度开关;四节点可按 flag 切换 SOUL 模式;时间线补大纲规划师配色并更新改造文档。 Co-authored-by: Cursor --- .../docs/ai-writing-内置智能体改造.md | 29 ++ .../components/ai-writing/TimelineStep.tsx | 2 + frontend-web/vite.config.ts | 2 +- offline-backend-20260512/backend/CLAUDE.md | 9 + .../backend/app/gateway/app.py | 14 + .../app/gateway/routers/_ai_writing_seed.py | 117 ++++++++ .../ai-writing-editor/SOUL.md | 42 +++ .../ai-writing-editor/config.yaml | 3 + .../ai-writing-outliner/SOUL.md | 80 ++++++ .../ai-writing-outliner/config.yaml | 3 + .../ai-writing-researcher/SOUL.md | 66 +++++ .../ai-writing-researcher/config.yaml | 3 + .../ai-writing-writer/SOUL.md | 49 ++++ .../ai-writing-writer/config.yaml | 3 + .../backend/app/gateway/routers/ai_writing.py | 9 + offline-backend-20260512/backend/config.yaml | 9 + .../agents/ai_writing/nodes/_agent_runner.py | 269 ++++++++++++++++++ .../agents/ai_writing/nodes/editor.py | 88 ++++-- .../agents/ai_writing/nodes/researcher.py | 133 +++++++-- .../agents/ai_writing/nodes/writer_draft.py | 160 +++++++---- .../agents/ai_writing/nodes/writer_outline.py | 148 +++++++--- 21 files changed, 1104 insertions(+), 134 deletions(-) create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/config.yaml create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/SOUL.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/config.yaml create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py diff --git a/frontend-web/docs/ai-writing-内置智能体改造.md b/frontend-web/docs/ai-writing-内置智能体改造.md index 78c124b2..2cb9dc0b 100644 --- a/frontend-web/docs/ai-writing-内置智能体改造.md +++ b/frontend-web/docs/ai-writing-内置智能体改造.md @@ -194,3 +194,32 @@ result = await run_writing_agent( **文档** - 本文件 - 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 文案),改造前即失败,待另行清理。 diff --git a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx index 4a92a95b..b1289ca6 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx @@ -22,6 +22,8 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus { const AGENT_COLOR: Record = { 素材收集专家: 'text-blue-500', 作家: 'text-purple-500', + // 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名 + 大纲规划师: 'text-violet-500', 编辑: 'text-orange-500', 系统: 'text-muted-foreground', }; diff --git a/frontend-web/vite.config.ts b/frontend-web/vite.config.ts index 7b340f52..5065ca33 100644 --- a/frontend-web/vite.config.ts +++ b/frontend-web/vite.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ "base":"./", server: { // 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。 - port: 5174, + port: 5173, // 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。 cors: true, }, diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index c49d7af1..37611144 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -254,6 +254,15 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway. +### AI Writing Built-in Functional Agents (四阶段内置智能体) + +The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four stage "brains" — 素材收集专家 / 大纲规划师 / 作家 / 编辑 — that can each run in one of two modes, switched per-stage by `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` (default **false**, mtime hot-reloaded, new sessions pick up changes immediately): + +- **Legacy (flag off)**: hard-coded prompts in `prompts/*.py`, direct LLM call (unchanged behavior). +- **Builtin-agent (flag on)**: the node loads the corresponding functional agent from `.deer-flow/agents/ai-writing-/` — `SOUL.md` becomes the system prompt and `config.yaml`'s `model` overrides the stage model (priority: `agent.model > node explicit fallback (keyword_model/rank_model) > state.model_name`). Admins edit SOUL/model from the agent management page. Runner: `nodes/_agent_runner.py` (`run_writing_agent` / `stream_writing_agent` / `parse_marker_json`). Output contracts (marker + ```json``` blocks, intent.py-style): researcher `[KEYWORDS_READY]` / `[MATERIALS_RANKED]`, outliner `[OUTLINE_READY]`, editor (per-dimension) `[REVIEW_READY]`; writer emits raw section Markdown (keeps per-section streaming) with the legacy `[[SECTION_BLOCKED]]` sentinel in strict mode. **Orchestration stays in the nodes** (search fan-out, per-section loop, 3-dimension review loop, all pause points) — the agent only does single structured generations, and a missing/empty SOUL silently falls back to the legacy path (`WritingAgentUnavailable`). + +Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets//`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`. + ### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`) The admin 用户排行榜 is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 78bcde73..2a999974 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -933,6 +933,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: "Roundtable functional agent seeding failed at startup (non-fatal)" ) + # AI 写作功能型 agent(researcher / outliner / writer / editor)缺失自动补建。 + # 同圆桌:必须在 _sync_legacy_agents 之前,补建目录才能在同一次启动被 + # upsert 进 agents 表,admin 启动后即可在智能体管理页编辑四个写作 agent。 + try: + from app.gateway.routers._ai_writing_seed import ( + ensure_ai_writing_functional_agents, + ) + + ensure_ai_writing_functional_agents() + except Exception: + logger.exception( + "AI-writing functional agent seeding failed at startup (non-fatal)" + ) + # 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。 try: business_mapping_store = getattr(app.state, "business_mapping_store", None) diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed.py new file mode 100644 index 00000000..f7694ae1 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed.py @@ -0,0 +1,117 @@ +"""AI 写作功能型 agent 的「调用前自检 + 缺失自动补建」工具。 + +适用场景 +-------- +``ai-writing-researcher`` / ``ai-writing-outliner`` / ``ai-writing-writer`` / +``ai-writing-editor`` 是 AI 写作四阶段(素材收集 → 大纲 → 写作 → 审核)的 +**功能型**内置 agent。当 ``config.yaml`` 的 ``ai_writing.use_builtin_agents.*`` +开关打开时,对应的图节点会从 ``.deer-flow/agents//`` 加载该 agent 的 +SOUL.md(人设+结构化输出契约)与 config.yaml(可配 per-阶段模型)。 + +仓库根 ``.gitignore`` 把整个 ``.deer-flow/`` 目录排除,新机器 ``git clone`` +之后这些目录**不会存在**。本模块在两个地方被调用: + +1. **启动 lifespan**(``app/gateway/app.py``):服务起来就把缺失目录补齐, + 紧接着的 ``_sync_legacy_agents`` 会把它们 upsert 进 DB,新机器一启动 + ``/api/agents`` 列表里就有这四个 agent,admin 可直接在智能体管理页编辑 + SOUL / 模型。 +2. **AI 写作路由入口**(``ai_writing.list_sessions`` / ``list_article_types``, + 即用户打开写作页必经的两个 GET):防御性兜底——若管理员在启动后手删了 + 目录,下一次打开写作页也能自愈。 + +与 ``_roundtable_seed.py`` 完全同构:资源文件 +(``_ai_writing_seed_assets//``)是发布默认值的唯一可信源,seeder 只在 +目录缺失时字节级复制,**绝不覆盖**已存在目录(尊重运维/admin 现场手改)。 +线上调优 SOUL 后请把改动写回对应资源 .md,否则新机器补建出来的还是旧版。 + +注意:graph 节点侧(harness 层)对 agent 目录缺失有独立兜底——加载失败时 +自动回退到旧的硬编码 prompt 路径,所以 seeder 失败不会打断写作流程。 +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable +from pathlib import Path + +from deerflow.config.paths import get_paths + +logger = logging.getLogger(__name__) + + +RESEARCHER_AGENT_ID = "ai-writing-researcher" +OUTLINER_AGENT_ID = "ai-writing-outliner" +WRITER_AGENT_ID = "ai-writing-writer" +EDITOR_AGENT_ID = "ai-writing-editor" + +# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id。 +_ASSETS_DIR = Path(__file__).parent / "_ai_writing_seed_assets" + +# 四个功能型内置 agent,任何部署都应存在;顺序无关紧要(逐个独立补建)。 +_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = ( + RESEARCHER_AGENT_ID, + OUTLINER_AGENT_ID, + WRITER_AGENT_ID, + EDITOR_AGENT_ID, +) + +# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。 +_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md") + + +def _ensure_one(agent_id: str) -> bool: + """Copy the verified SOUL.md + config.yaml for one functional agent into + ``.deer-flow/agents//`` if that directory is missing. + + Returns True if the files were just created, False if the directory already + existed (we never overwrite — respect any local edits) or the seed assets + are missing. + """ + agent_dir = get_paths().agent_dir(agent_id) + if agent_dir.exists(): + return False + + src_dir = _ASSETS_DIR / agent_id + if not src_dir.is_dir(): + # Assets should always ship with the code; if they don't, surface a loud + # log instead of silently creating an empty agent dir. + logger.error( + "Missing seed assets for ai-writing agent '%s' (expected at %s)", + agent_id, + src_dir, + ) + return False + + agent_dir.mkdir(parents=True, exist_ok=True) + for fname in _SEED_FILES: + src = src_dir / fname + if src.exists(): + # 字节级复制,保留验证版文件原样(换行/编码),与资源文件 hash 对齐。 + (agent_dir / fname).write_bytes(src.read_bytes()) + return True + + +def ensure_ai_writing_functional_agents() -> list[str]: + """Seed the four ai-writing functional agents if their on-disk directories + are missing. Idempotent — fast no-op when all exist. + + Returns the list of agent ids that were just created (empty when nothing + needed seeding). Errors are logged but never raised — a partial failure + must not block the request; the graph node falls back to the legacy + hard-coded prompt path when the agent really cannot be loaded. + """ + created: list[str] = [] + for agent_id in _FUNCTIONAL_AGENT_IDS: + try: + if _ensure_one(agent_id): + created.append(agent_id) + except Exception: + logger.exception("Failed to seed ai-writing functional agent '%s'", agent_id) + if created: + logger.info("Seeded missing ai-writing functional agents: %s", ", ".join(created)) + return created + + +def required_agent_ids() -> Iterable[str]: + """Stable ids of agents this seeder owns — exposed for tests/diagnostics.""" + return tuple(_FUNCTIONAL_AGENT_IDS) diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/SOUL.md new file mode 100644 index 00000000..88a67541 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/SOUL.md @@ -0,0 +1,42 @@ +# 编辑 + +你是 AI 写作流水线的「编辑」,一名资深主编,标准极高,要求严格。你在一家权威媒体担任主编十五年,见过各种质量的文章。 + +系统每次派给你**一个审查维度**(输入开头会写明:事实准确性 / 逻辑结构 / 语言文字),你只从该维度审查文章,给出该维度的评分(0-100 整数)和具体问题列表。你不能改写文章,只做当前指定维度的审查。综合评分与是否通过由系统汇总判定,不归你管。 + +--- + +## 0. 硬规则(最高优先级) + +1. **只审当前维度**:输入指定哪个维度就只审哪个维度,不要跨维度挑问题。 +2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。 +3. **JSON 必须严格合法**:半角双引号 `"`,不要尾随逗号、不要注释。`score` 是 0-100 的整数;每个问题的 `severity` 必须是 `"major"`(必须修改)或 `"minor"`(建议修改);该维度没有问题时 `issues` 填空数组 `[]`。 +4. **意见要具体可执行**:`location` 指明位置(如"第二段"),`issue` 说清问题,`suggestion` 给出可操作的修改建议。 + +--- + +## 1. 结束协议:`[REVIEW_READY]` + +审查完成后,**严格**按以下结构输出,前后不得有其它内容(`[REVIEW_READY]` 单独成行): + +``` +[REVIEW_READY] +```json +{ + "score": 82, + "issues": [ + {"severity": "major", "location": "第二段", "issue": "引用的增长数据没有素材支撑", "suggestion": "补充素材 m002 中的具体数据,并注明来源"}, + {"severity": "minor", "location": "结尾段", "issue": "结论略显仓促", "suggestion": "增加一句对未来行业趋势的判断"} + ] +} +``` +``` + +--- + +## 2. 反例(请认真避开) + +❌ 先输出"这篇文章整体质量不错,下面是我的审查意见:"再给 JSON —— 直接输出 marker + JSON。 +❌ marker 后追加总结陈词 —— ```json``` 闭合后什么都不要写。 +❌ `severity` 写成 `"high"` / `"严重"` —— 只允许 `"major"` / `"minor"`。 +❌ 审「语言文字」维度时挑"数据缺乏素材支撑"的事实性问题 —— 只审当前维度。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/config.yaml new file mode 100644 index 00000000..a9c87995 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/config.yaml @@ -0,0 +1,3 @@ +description: AI 写作流程的编辑,从事实/逻辑/语言三个维度逐项审查文章质量,给出评分与具体修改意见 +id: ai-writing-editor +name: 编辑 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/SOUL.md new file mode 100644 index 00000000..fc7b2033 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/SOUL.md @@ -0,0 +1,80 @@ +# 大纲规划师 + +你是 AI 写作流水线的「大纲规划师」。你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。 + +你只做一件事:**根据素材和写作意图,产出/调整文章的三级大纲**(章节 → 小节 → 子要点)。你不写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。 + +系统派给你的任务有两种模式(输入开头会写明): + +- **新规划**:根据写作意图 + 素材,从零规划 3-5 个章节的大纲 +- **修改模式**:输入里带「当前大纲」JSON + 用户修改要求 → 在当前大纲基础上调整 + +--- + +## 0. 硬规则(最高优先级) + +1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止 `` 标签及其中内容;禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲")——无论素材是否充分,都必须直接产出大纲。 +2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。 +3. **JSON 必须严格合法**:半角双引号 `"`,不要尾随逗号、不要注释;`material_ids` 中的 ID 必须来自给定素材,没有合适素材时填 `[]`。 +4. **修改模式只改用户明确提到的部分**:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变);章节/小节数量完全由用户要求决定,要加就加、要减就减。 + +--- + +## 1. 三级层级语义(决定文章结构) + +- Level 1 = `section_title`(章节):渲染为 `##` 二级标题,统领一组内容。高度概括的短语,6-15 字。每篇 3-5 个章节(修改模式数量听用户的)。 +- Level 2 = `point_title`(小节):渲染为 `###` 三级标题,下面会有一段 2-3 句的概述正文。名词性短语,15 字以内,不带句末标点。每章节 2-4 个小节。 +- Level 3 = `sub_points`(子要点):渲染为 `####` 四级标题,**每个子要点都会展开成一段 3-5 句的独立正文**——不是 bullet 列表,所以要规划成「能独立成段展开」的小主题。名词性短语,≤15 字,不带句末标点。每小节 2-4 条;信息确实少到无法拆分时可留 `[]`。 + +兼容历史大纲:若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,输出时补成 `{"point_title": "<原 string>", "sub_points": []}`。 + +--- + +## 2. 结束协议:`[OUTLINE_READY]` + +完成规划后,**严格**按以下结构输出,前后不得有其它内容(`[OUTLINE_READY]` 单独成行): + +``` +[OUTLINE_READY] +```json +{ + "title": "新能源汽车出口为何高速增长", + "sections": [ + { + "section_title": "引言:出口数据创新高", + "key_points": [ + {"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]}, + {"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]} + ], + "material_ids": ["m001"] + }, + { + "section_title": "增长背后的三大动因", + "key_points": [ + {"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]}, + {"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]} + ], + "material_ids": ["m002", "m003"] + }, + { + "section_title": "结论与展望", + "key_points": [ + {"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]}, + {"point_title": "行业趋势判断", "sub_points": []} + ], + "material_ids": ["m004"] + } + ] +} +``` +``` + +--- + +## 3. 反例(请认真避开) + +❌ 先输出"好的,我来规划大纲:"再给 JSON —— 直接输出 marker + JSON。 +❌ marker 后追加"以上大纲覆盖了……如需调整请告诉我" —— ```json``` 闭合后什么都不要写。 +❌ 修改模式下重写了用户没提到的章节标题。 +❌ `material_ids` 里出现素材清单中不存在的编号。 +❌ 把子要点写成完整句子(如"我们应该分析出口数据的变化趋势。")——应为名词性短语。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/config.yaml new file mode 100644 index 00000000..8ea975b5 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/config.yaml @@ -0,0 +1,3 @@ +description: AI 写作流程的大纲规划师,根据写作意图与素材规划文章三级大纲(章节→小节→子要点),并按用户反馈调整已有大纲 +id: ai-writing-outliner +name: 大纲规划师 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/SOUL.md new file mode 100644 index 00000000..dbc637c7 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/SOUL.md @@ -0,0 +1,66 @@ +# 素材收集专家 + +你是 AI 写作流水线的「素材收集专家」。你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。 + +实际的网络/技能检索由系统执行,**你不联网**。你只承担两类结构化子任务,系统每次只派给你其中一类(输入开头会写明「任务:XXX」): + +- **任务 A · 提取检索词**:从写作意图中提炼指定数量的搜索关键词 +- **任务 B · 素材排序**:从已检索到的素材列表中,选出与写作意图最相关的素材 ID 并按相关度排序 + +你不写文章、不评价内容好坏、不输出任何与当前任务无关的内容。 + +--- + +## 0. 硬规则(最高优先级) + +1. **严禁思考与表态**:这是简单的结构化抽取任务。禁止输出任何思考、分析、推理或规划过程,禁止 `` 标签及其中内容,禁止"让我想想""首先"之类过渡语。收到输入后立即输出结果。 +2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。 +3. **JSON 必须严格合法**:引号一律半角双引号 `"`(不要中文引号),不要尾随逗号,不要注释,后端用程序解析,任何变形都会导致降级。 +4. **不要照抄占位符**:示例里的 `"..."`、`关键词1` 等都是占位示意,必须输出依据真实输入提炼的内容。 + +--- + +## 1. 任务 A · 提取检索词 + +输入会给出:写作意图、文章类型、目标读者、要求的关键词数量 N。 + +要求: +- 关键词必须具体、可搜索,避免过于宽泛的词。 +- 数量必须正好 N 个,不多不少。 +- 关键词依据写作意图提炼,贴合文章类型与目标读者。 + +输出格式(严格遵守,`[KEYWORDS_READY]` 单独成行): + +``` +[KEYWORDS_READY] +```json +{"keywords": ["新能源汽车出口", "汽车出口数据", "新能源汽车海外市场"]} +``` +``` + +## 2. 任务 B · 素材排序 + +输入会给出:写作意图、素材列表(每条含 ID 和摘要)。 + +要求: +- 选出与写作意图最相关的素材 ID(最多 8 条),按相关度从高到低排序。 +- `ranked_ids` 中的 ID **必须来自素材列表**,绝不编造 ID。 +- `summary` 一句话概括检索结果(共搜索到 X 条,精选 Y 条)。 + +输出格式(严格遵守,`[MATERIALS_RANKED]` 单独成行): + +``` +[MATERIALS_RANKED] +```json +{"ranked_ids": ["m003", "m001", "m005"], "summary": "共搜索到 8 条素材,精选 3 条最相关的"} +``` +``` + +--- + +## 3. 反例(请认真避开) + +❌ 输出思考过程:"让我分析一下这个写作意图……" —— 直接输出 marker + JSON。 +❌ marker 后追加说明:"以上关键词覆盖了……" —— ```json``` 闭合后什么都不要写。 +❌ 编造素材 ID:`ranked_ids` 里出现列表中不存在的 `m099`。 +❌ 照抄占位符:输出 `{"keywords": ["关键词1", "关键词2"]}`。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/config.yaml new file mode 100644 index 00000000..7fec6fdb --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/config.yaml @@ -0,0 +1,3 @@ +description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、并对检索回来的素材按相关度排序筛选 +id: ai-writing-researcher +name: 素材收集专家 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/SOUL.md new file mode 100644 index 00000000..0bd9d4eb --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/SOUL.md @@ -0,0 +1,49 @@ +# 作家 + +你是 AI 写作流水线的「作家」。你有十年内容创作经验,文字流畅,善于用事实说话。 + +系统按大纲**逐章**派活给你:每次输入一个章节(含文章整体结构、当前章节的小节/子要点、相关素材、写作模式),你写出**这一个章节**的正文。你不能修改大纲,不能写其他章节,只写当前这一段。 + +- 优先采用素材中的事实与数据;素材不足时按输入里的「写作模式」处理(宽松=可结合通用知识,严格=只准用素材)。 +- 不要脱离主题杜撰具体的数字、人名、机构名与日期。 + +--- + +## 0. 硬规则(最高优先级) + +1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止"让我想想""首先""我需要""根据写作要求"之类过渡语,禁止 `` 标签及其中内容。 +2. **绝不拒绝、绝不元话语**:禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材"——这类话绝不允许出现在文章里。无论素材是否充分,都按「写作模式」的规则直接产出。 +3. **直接输出 Markdown 正文**:不要 JSON、不要解释、不要"以下是正文"之类开场白。收到输入后立即开始写正文(含 `###` 小标题)。 +4. **严格模式的唯一例外**:输入标明「写作模式:严格依据素材」且素材确实不足以支撑本章节时,**只输出一行**:`[[SECTION_BLOCKED]] 一句话说明缺什么素材`(例:`[[SECTION_BLOCKED]] 缺少关于XX的具体数据与案例`),此外不输出任何内容。只要素材能支撑哪怕部分要点,就正常写作,不要输出该标记。 + +--- + +## 1. 结构要求(三级结构,必须严格遵守) + +「章节要点」用层级 bullet 列表给出: + +- 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题 +- 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题 + +**每个小节、每个子要点都要有自己的正文段,分两层展开**: + +1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。 +2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。 + +补充规则: + +- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。 +- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。 +- 标题层级严格对应:文章 `#` / 章节 `##` 由系统注入,**你不要输出这两级**;你只输出 `###` 小节与 `####` 子要点。 +- 不要输出 `#####` 或更深。 +- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。 +- 充分展开每一层,写满输入要求的篇幅。 + +--- + +## 2. 反例(请认真避开) + +❌ 开头写"好的,下面是「XX」章节的内容:" —— 直接从 `###` 或正文开始。 +❌ 输出 `## 章节标题` —— 章节标题由系统注入,你最高只到 `###`。 +❌ 严格模式素材不足时硬凑无关内容、或写"素材与意图不匹配" —— 只输出一行 `[[SECTION_BLOCKED]] 原因`。 +❌ 宽松模式下输出"我无法基于现有素材完成" —— 宽松模式永远正常写作,可用通用知识补充。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/config.yaml new file mode 100644 index 00000000..cc49e1bb --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/config.yaml @@ -0,0 +1,3 @@ +description: AI 写作流程的作家,根据大纲与素材逐章撰写文章正文(三级结构 Markdown),严格模式下素材不足时声明阻塞而不杜撰 +id: ai-writing-writer +name: 作家 diff --git a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py index 70fe1105..83475e64 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py +++ b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py @@ -110,6 +110,11 @@ class SessionResponse(BaseModel): async def list_sessions(request: Request): """列出当前用户的 AI 写作历史会话。""" from app.gateway.deps import get_ai_writing_session_store, get_current_user + from app.gateway.routers._ai_writing_seed import ensure_ai_writing_functional_agents + + # 调用前自检:打开写作页必经此接口。若管理员在启动后手删了内置写作 agent + # 目录,这里自愈补建,保证 use_builtin_agents 开启时图节点能加载到 SOUL。 + ensure_ai_writing_functional_agents() repo = get_ai_writing_session_store(request) if repo is None: @@ -190,6 +195,10 @@ async def save_transcript(session_id: str, body: SaveTranscriptRequest, request: async def list_article_types(request: Request): """列出所有文章类型,按 sort_order 升序排列。""" from app.gateway.deps import get_article_type_store + from app.gateway.routers._ai_writing_seed import ensure_ai_writing_functional_agents + + # 防御性兜底,与 list_sessions 同理(写作表单加载文章类型时也会经过这里)。 + ensure_ai_writing_functional_agents() store = get_article_type_store(request) if store is None: diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 0f1baf03..07a7469d 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -378,6 +378,15 @@ ai_writing: keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型 rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型 max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20 + # 四阶段「内置功能型智能体」开关(按阶段灰度)。true = 该阶段节点改为加载 + # .deer-flow/agents/ai-writing-/ 的 SOUL.md + config(admin 可在智能体 + # 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。 + # 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。 + use_builtin_agents: + researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务) + outliner: false # 大纲规划师(大纲生成 / 按反馈调整) + writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变) + editor: false # 编辑(事实/逻辑/语言三维度审查) log_system: enabled: true diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py new file mode 100644 index 00000000..204f6631 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py @@ -0,0 +1,269 @@ +"""AI 写作「内置功能型智能体」运行助手。 + +四个写作阶段(素材收集/大纲/写作/审核)的图节点在 ``config.yaml → +ai_writing.use_builtin_agents.`` 打开时,不再使用 ``prompts/*.py`` 的 +硬编码提示词直调 LLM,而是改为: + +1. 从 ``.deer-flow/agents//`` 加载该内置 agent 的 **SOUL.md** + (人设 + 工作流 + 结构化输出契约)作为 system prompt; +2. 模型按 ``agent config.yaml 的 model > 节点显式回退模型 > state.model_name`` + 的优先级解析(admin 可在智能体管理页给某阶段单独配模型); +3. 流式调用该模型,chunk 通过调用方传入的回调透传到 LangGraph ``custom`` + 通道(前端时间线打字机不回归); +4. 按 SOUL 规定的 marker(如 ``[OUTLINE_READY]``)+ ```json``` 块解析结构化 + 输出,解析复用 ``_utils.parse_json`` 多级容错。 + +设计取舍(对应 ai-writing-内置智能体改造.md §3.2 / §7): + +- **编排控制权留在节点**:writer 的逐章循环、researcher 的检索编排、editor 的 + 三维度循环都不交给 agent——agent 只承担「单次结构化产出」,这样干预点/逐章 + 流式/严格模式求助逻辑全部原样保留。 +- **不经过 lead agent 运行时**:本模块位于 harness 层,不能 import ``app.*`` + (intent.py 的 loopback 机制在 app 层),也无需为单轮结构化调用付出 + thread/checkpointer/中间件链的成本。SOUL + per-agent 模型已覆盖本场景的 + 可配置性诉求;researcher 的「挂载技能」(Phase 3)再考虑接入完整运行时。 +- **agent 目录缺失时抛 ``WritingAgentUnavailable``**:由节点捕获后回退旧的 + 硬编码 prompt 路径,种子器(app 层 ``_ai_writing_seed.py``)失败不阻断写作。 +""" +from __future__ import annotations + +import logging +import re +from collections.abc import Callable + +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.runnables import RunnableConfig + +from ._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta + +logger = logging.getLogger(__name__) + +# ── 内置写作 agent 的稳定 id(与 app 层种子器 _ai_writing_seed.py 对齐)────────── +RESEARCHER_AGENT_ID = "ai-writing-researcher" +OUTLINER_AGENT_ID = "ai-writing-outliner" +WRITER_AGENT_ID = "ai-writing-writer" +EDITOR_AGENT_ID = "ai-writing-editor" + +# ── SOUL 规定的结构化输出 marker(契约见各 agent 的 SOUL.md)───────────────────── +KEYWORDS_READY_MARKER = "[KEYWORDS_READY]" +MATERIALS_RANKED_MARKER = "[MATERIALS_RANKED]" +OUTLINE_READY_MARKER = "[OUTLINE_READY]" +REVIEW_READY_MARKER = "[REVIEW_READY]" + +# flag 名(config.yaml ai_writing.use_builtin_agents.)→ agent id +STAGE_AGENT_IDS = { + "researcher": RESEARCHER_AGENT_ID, + "outliner": OUTLINER_AGENT_ID, + "writer": WRITER_AGENT_ID, + "editor": EDITOR_AGENT_ID, +} + + +class WritingAgentUnavailable(RuntimeError): + """内置写作 agent 的 SOUL.md 缺失/为空,无法按 agent 模式运行。 + + 节点捕获后应回退到旧的硬编码 prompt 路径(等价于 flag 关闭),不让写作 + 流程因 agent 目录被删而中断。 + """ + + +def use_builtin_agent(stage: str) -> bool: + """读 ``config.yaml → ai_writing.use_builtin_agents.`` 开关。 + + 缺失/非法一律视为 False(默认走旧节点逻辑)。config 带 mtime 热加载, + 改完 config.yaml 新会话即生效。 + """ + from ._utils import get_ai_writing_config + + flags = get_ai_writing_config().get("use_builtin_agents") + if not isinstance(flags, dict): + return False + return bool(flags.get(stage, False)) + + +def load_writing_agent(agent_id: str): + """加载内置写作 agent 的 (config, soul);SOUL 缺失抛 WritingAgentUnavailable。""" + from deerflow.config.agents_config import load_agent_config, load_agent_soul + + soul = load_agent_soul(agent_id) + if not soul: + raise WritingAgentUnavailable( + f"内置写作智能体 '{agent_id}' 的 SOUL.md 缺失或为空" + "(目录可能被删除,重启 Gateway 或重新打开写作页可自动补建)" + ) + try: + agent_cfg = load_agent_config(agent_id) + except ValueError: + # config.yaml 解析失败不致命:SOUL 仍可用,模型回退节点/表单选择。 + logger.warning("load_writing_agent: config.yaml of '%s' is invalid, ignoring", agent_id) + agent_cfg = None + return agent_cfg, soul + + +def agent_display_name(agent_id: str, default: str) -> str: + """取 agent 的展示名(progress_events.agent_name 用),加载失败回退默认值。""" + try: + from deerflow.config.agents_config import load_agent_config + + cfg = load_agent_config(agent_id) + name = (cfg.name or "").strip() if cfg else "" + return name or default + except Exception: + return default + + +def resolve_agent_model_name( + agent_cfg, + state: dict, + config: RunnableConfig, + *, + explicit_fallback: str | None = None, + strict: bool = True, +) -> str | None: + """解析本次 agent 调用使用的模型名。 + + 优先级:agent config.yaml 的 ``model`` > ``explicit_fallback``(节点显式 + 传入,如 researcher 的 keyword_model/rank_model)> ``state.model_name`` + (用户写作表单选的主模型)> configurable.model_name。 + + strict=True(大纲/写作/审核):全部为空抛 ValueError(与 get_writing_model + 口径一致——不静默回退默认模型);strict=False(researcher 辅助任务): + 返回 None,由 create_chat_model 回退 config.yaml 的 models[0]。 + """ + candidates = ( + (getattr(agent_cfg, "model", None) or "") if agent_cfg else "", + explicit_fallback or "", + (state or {}).get("model_name") or "", + (config.get("configurable", {}) or {}).get("model_name") or "", + ) + for name in candidates: + if isinstance(name, str) and name.strip(): + return name.strip() + if strict: + raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试") + return None + + +def parse_marker_json(text: str, marker: str, fallback: dict) -> dict: + """从 agent 输出中按「marker + ```json``` 块」契约解析 JSON,多级降级。 + + 1. ``marker\\n```json {...} ``` ``(SOUL 规定的标准形态,正则仿 + intent.py::_INTENT_READY_PATTERN); + 2. marker 存在但没包代码块 → 取 marker 之后的全部文本交给 parse_json; + 3. marker 缺失(弱模型漏输出)→ 整段文本交给 parse_json; + 4. 全部失败 → fallback。 + """ + if not text or not text.strip(): + return fallback + + pattern = re.compile(re.escape(marker) + r"\s*```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL) + m = pattern.search(text) + if m: + result = parse_json(m.group(1), fallback) + if result is not fallback: + return result + + idx = text.find(marker) + if idx != -1: + tail = text[idx + len(marker):] + result = parse_json(tail, fallback) + if result is not fallback: + return result + + return parse_json(text, fallback) + + +async def stream_writing_agent( + *, + agent_id: str, + user_payload: str, + state: dict, + config: RunnableConfig, + on_text: Callable[[str, bool], None] | None = None, + explicit_model_fallback: str | None = None, + strict_model: bool = True, +) -> str: + """在「SOUL 作 system prompt + 单条 Human 输入」契约下流式运行一个写作 agent。 + + Args: + agent_id: 内置写作 agent id(STAGE_AGENT_IDS 的值之一)。 + user_payload: 本阶段输入(意图/素材/大纲/草稿等拼成的一段文本)。 + on_text: 每个流式片段回调 ``(text, is_thinking)``,调用方在回调里把 + chunk 包装成本阶段的 custom 事件(outline_chunk / draft_chunk / + review_chunk ...)推给前端;None = 不透传(如 researcher 的辅助任务)。 + explicit_model_fallback / strict_model: 见 resolve_agent_model_name。 + + Returns: + 累积的**正文**文本( 思考与 reasoning_content 已剥离),供 + parse_marker_json / 章节拼装使用。 + + Raises: + WritingAgentUnavailable: SOUL 缺失(调用方回退旧 prompt 路径)。 + 其余模型异常原样上抛,由调用方按既有 try/except 兜底成 status:error。 + """ + agent_cfg, soul = load_writing_agent(agent_id) + model_name = resolve_agent_model_name( + agent_cfg, state, config, + explicit_fallback=explicit_model_fallback, + strict=strict_model, + ) + + from deerflow.models import create_chat_model + + model = create_chat_model(name=model_name) + + full_text = "" + parser = ThinkTagParser() + async for chunk in model.astream([ + SystemMessage(content=soul), + HumanMessage(content=user_payload), + ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning and on_text is not None: + on_text(reasoning, True) + delta = _content_to_text(chunk.content) + if not delta: + continue + for is_thinking, text in parser.feed(delta): + if not is_thinking: + full_text += text + if on_text is not None: + on_text(text, is_thinking) + return full_text + + +async def run_writing_agent( + *, + agent_id: str, + user_payload: str, + marker: str, + fallback: dict, + state: dict, + config: RunnableConfig, + on_text: Callable[[str, bool], None] | None = None, + explicit_model_fallback: str | None = None, + strict_model: bool = True, +) -> dict: + """跑一个写作 agent 并按 marker 契约解析其结构化输出(失败回 fallback)。 + + 模型调用异常时与 ``_utils.llm_json`` 同口径:返回 + ``{**fallback, "_llm_error": str(e)}``,不上抛(SOUL 缺失除外—— + WritingAgentUnavailable 原样上抛,由节点切回旧 prompt 路径)。 + """ + try: + full_text = await stream_writing_agent( + agent_id=agent_id, + user_payload=user_payload, + state=state, + config=config, + on_text=on_text, + explicit_model_fallback=explicit_model_fallback, + strict_model=strict_model, + ) + except WritingAgentUnavailable: + raise + except Exception as e: + logger.error("run_writing_agent(%s) failed: %s", agent_id, e, exc_info=True) + return {**fallback, "_llm_error": str(e)} + return parse_marker_json(full_text, marker, fallback) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py index 3b0c9e5d..abafe383 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py @@ -11,6 +11,16 @@ from ..state import AIWritingState, ReviewResult, ReviewIssue from ..prompts.editor_prompts import ( EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER, ) +from ._agent_runner import ( + EDITOR_AGENT_ID, + REVIEW_READY_MARKER, + WritingAgentUnavailable, + agent_display_name, + load_writing_agent, + resolve_agent_model_name, + run_writing_agent, + use_builtin_agent, +) from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer logger = logging.getLogger(__name__) @@ -119,19 +129,45 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: ("language", LANGUAGE_REVIEW_USER.format(article=article)), ] - # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, - # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 - try: - model = get_writing_model(config, state) - except Exception as e: - logger.error("editor model creation failed: %s", e, exc_info=True) - return { - "status": "error", - "progress_events": [{ - "type": "error", "agent_name": "编辑", - "title": "模型错误", "detail": str(e), "message": "审核失败", - }], - } + # 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.editor): + # 三维度循环仍由节点编排(review_chunk / review_item_done 流式形态不变), + # 每个维度跑一次 ai-writing-editor agent(SOUL 作 system prompt,输出 + # [REVIEW_READY] + {"score","issues"})。agent 目录缺失则静默回退旧路径。 + use_agent = use_builtin_agent("editor") + agent_label = "编辑" + model = None + if use_agent: + try: + agent_cfg, _soul = load_writing_agent(EDITOR_AGENT_ID) + # 模型守卫提前到循环外:模型名缺失/配错时与旧路径同样转成 status:error + resolve_agent_model_name(agent_cfg, state, config) + agent_label = agent_display_name(EDITOR_AGENT_ID, "编辑") + except WritingAgentUnavailable as e: + logger.warning("editor builtin agent unavailable, falling back to legacy prompts: %s", e) + use_agent = False + except Exception as e: + logger.error("editor agent model resolution failed: %s", e, exc_info=True) + return { + "status": "error", + "progress_events": [{ + "type": "error", "agent_name": agent_label, + "title": "模型错误", "detail": str(e), "message": "审核失败", + }], + } + if not use_agent: + # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, + # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 + try: + model = get_writing_model(config, state) + except Exception as e: + logger.error("editor model creation failed: %s", e, exc_info=True) + return { + "status": "error", + "progress_events": [{ + "type": "error", "agent_name": "编辑", + "title": "模型错误", "detail": str(e), "message": "审核失败", + }], + } results: dict[str, dict] = {} review_errors: list[str] = [] # 分项审查的模型异常,供完成后非致命提示 @@ -140,8 +176,24 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: for category, user_prompt in REVIEW_TASKS: fallback = {"score": 80, "issues": []} try: - full_text = await _stream_one_review(model, user_prompt, category, writer) - res = parse_json(full_text, fallback) + if use_agent: + def _on_text(text: str, is_thinking: bool, _cat: str = category) -> None: + writer({"type": "review_chunk", "category": _cat, "text": text, "is_thinking": is_thinking}) + + res = await run_writing_agent( + agent_id=EDITOR_AGENT_ID, + user_payload=user_prompt, + marker=REVIEW_READY_MARKER, + fallback=fallback, + state=state, + config=config, + on_text=_on_text, + ) + if res.get("_llm_error"): + review_errors.append(f"「{_CATEGORY_LABEL.get(category, category)}审查」{res['_llm_error']}") + else: + full_text = await _stream_one_review(model, user_prompt, category, writer) + res = parse_json(full_text, fallback) except Exception as e: logger.warning("editor review [%s] failed: %s", category, e) res = fallback @@ -191,7 +243,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: if review_errors: notices.append({ "type": "notice", - "agent_name": "编辑", + "agent_name": agent_label, "title": "模型错误", "detail": "部分审查项调用失败,已按默认分兜底。原因:" + ";".join(review_errors[:3]), }) @@ -199,11 +251,11 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: return { "review_result": review, "progress_events": [ - {"type": "step_start", "agent_name": "编辑", "message": "开始审核"}, + {"type": "step_start", "agent_name": agent_label, "message": "开始审核"}, *notices, { "type": "review_ready", - "agent_name": "编辑", + "agent_name": agent_label, "message": f"审核完成,综合得分 {overall_score},结论:{'通过' if verdict == 'pass' else '打回修改'}", }, ], diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py index 144bd952..273819f9 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py @@ -18,10 +18,46 @@ from ..prompts.researcher_prompts import ( EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER, RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER, ) +from ._agent_runner import ( + KEYWORDS_READY_MARKER, + MATERIALS_RANKED_MARKER, + RESEARCHER_AGENT_ID, + WritingAgentUnavailable, + agent_display_name, + load_writing_agent, + run_writing_agent, + use_builtin_agent, +) from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer logger = logging.getLogger(__name__) +# ── 内置 agent 模式的子任务 payload(SOUL 已含人设与输出契约,这里只给事实输入)── + +_AGENT_KEYWORDS_USER = """任务:提取检索词 + +写作意图:{user_intent} +文章类型:{article_type} +目标读者:{target_audience} + +请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。 +关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图提炼真实检索词。 +严格按 SOUL 约定输出 [KEYWORDS_READY] + JSON。 + +/no_think""" + +_AGENT_RANK_USER = """任务:素材排序 + +写作意图:{user_intent} + +素材列表(每条含ID和摘要): +{materials_list} + +请选出最相关的素材ID(最多8条),按相关度从高到低排序。 +严格按 SOUL 约定输出 [MATERIALS_RANKED] + JSON。 + +/no_think""" + _NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search" _NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数 _NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数 @@ -89,12 +125,12 @@ def _per_keyword_limit(max_materials: int, total_keywords: int) -> int: return (max_materials + total_keywords - 1) // total_keywords -def _notice(title: str, detail: str, raw: str = "") -> dict: +def _notice(title: str, detail: str, raw: str = "", agent_name: str = "素材收集专家") -> dict: """构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。 raw 非空时附带接口的原始错误响应体,前端默认折叠展示。 """ - event = {"type": "notice", "agent_name": "素材收集专家", "title": title, "detail": detail} + event = {"type": "notice", "agent_name": agent_name, "title": title, "detail": detail} if raw: event["raw"] = raw return event @@ -254,6 +290,20 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # 素材总量上限(config.yaml: ai_writing.max_materials),按检索词数均分到每个词 max_materials = _resolve_max_materials(ai_writing_config.get("max_materials")) + # 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.researcher): + # 检索编排(并发搜索 / 技能编排 / 笔记检索)仍由节点控制,只把两个 LLM 子任务 + # (提取检索词 / 素材排序)换成跑 ai-writing-researcher agent(SOUL 可被 admin + # 编辑;模型优先级 agent.model > keyword_model/rank_model > 表单主模型)。 + use_agent = use_builtin_agent("researcher") + researcher_label = "素材收集专家" + if use_agent: + try: + load_writing_agent(RESEARCHER_AGENT_ID) + researcher_label = agent_display_name(RESEARCHER_AGENT_ID, "素材收集专家") + except WritingAgentUnavailable as e: + logger.warning("researcher builtin agent unavailable, falling back to legacy prompts: %s", e) + use_agent = False + def _emit(message: str) -> None: writer({"type": "research_progress", "message": message}) @@ -272,18 +322,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict _emit(f"追加检索 {len(keywords)} 个关键词…") else: _emit("正在生成检索词…") - kw_result = await llm_json( - EXTRACT_KEYWORDS_SYSTEM, - EXTRACT_KEYWORDS_USER.format( - user_intent=intent, - article_type=article_type, - target_audience=target_audience, - keyword_count=keyword_count, - ), - fallback={"keywords": [intent]}, - config=config, - model_name=keyword_model, - ) + if use_agent: + kw_result = await run_writing_agent( + agent_id=RESEARCHER_AGENT_ID, + user_payload=_AGENT_KEYWORDS_USER.format( + user_intent=intent, + article_type=article_type, + target_audience=target_audience, + keyword_count=keyword_count, + ), + marker=KEYWORDS_READY_MARKER, + fallback={"keywords": [intent]}, + state=state, + config=config, + explicit_model_fallback=keyword_model, + strict_model=False, + ) + else: + kw_result = await llm_json( + EXTRACT_KEYWORDS_SYSTEM, + EXTRACT_KEYWORDS_USER.format( + user_intent=intent, + article_type=article_type, + target_audience=target_audience, + keyword_count=keyword_count, + ), + fallback={"keywords": [intent]}, + config=config, + model_name=keyword_model, + ) # 清洗关键词:剔除占位符 / 空值,全空时回退写作意图 keywords = _clean_keywords(kw_result.get("keywords"), intent) if extra_keywords: @@ -321,7 +388,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # 与前端 [ai-writing:custom] 日志对齐,钉死是否实时推送。 logger.info("[skill-timing] t=%.3f emit skill_search_step type=%s skill=%s", time.monotonic(), step.get("type"), step.get("skill_name") or "") - writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step}) + writer({"type": "skill_search_step", "agent_name": researcher_label, **step}) if is_skill_search: # ── 技能编排检索 ── @@ -509,13 +576,26 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}" for m in raw_materials ) - rank_result = await llm_json( - RANK_MATERIALS_SYSTEM, - RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list), - fallback={"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"}, - config=config, - model_name=rank_model, - ) + rank_fallback = {"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"} + if use_agent: + rank_result = await run_writing_agent( + agent_id=RESEARCHER_AGENT_ID, + user_payload=_AGENT_RANK_USER.format(user_intent=intent, materials_list=materials_list), + marker=MATERIALS_RANKED_MARKER, + fallback=rank_fallback, + state=state, + config=config, + explicit_model_fallback=rank_model, + strict_model=False, + ) + else: + rank_result = await llm_json( + RANK_MATERIALS_SYSTEM, + RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list), + fallback=rank_fallback, + config=config, + model_name=rank_model, + ) rank_error = rank_result.get("_llm_error", "") ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials]) id_to_mat = {m["id"]: m for m in raw_materials} @@ -554,9 +634,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程 notices: list[dict] = [] if kw_result.get("_llm_error"): - notices.append(_notice("模型错误", f"检索词生成失败,已用默认关键词兜底。原因:{kw_result['_llm_error']}")) + notices.append(_notice("模型错误", f"检索词生成失败,已用默认关键词兜底。原因:{kw_result['_llm_error']}", agent_name=researcher_label)) if rank_error: - notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}")) + notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}", agent_name=researcher_label)) if search_errors: uniq = list(dict.fromkeys(search_errors)) # 解析失败时收集到的接口原始响应体,前端折叠展示便于定位 @@ -567,6 +647,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict "ES 波动导致检索异常", ";".join(uniq[:3]), raw="\n\n----------\n\n".join(raw_dumps[:3]), + agent_name=researcher_label, )) # 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示 @@ -583,8 +664,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点 "status": "writing_draft" if is_supplement else "awaiting_material_confirm", "progress_events": [ - {"type": "step_start", "agent_name": "素材收集专家", "message": step_start_msg}, + {"type": "step_start", "agent_name": researcher_label, "message": step_start_msg}, *notices, - {"type": "materials_ready", "agent_name": "素材收集专家", "message": summary}, + {"type": "materials_ready", "agent_name": researcher_label, "message": summary}, ], } diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py index 9313bdb0..8d28202e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py @@ -12,6 +12,15 @@ from ..prompts.writer_prompts import ( DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER, ) +from ._agent_runner import ( + WRITER_AGENT_ID, + WritingAgentUnavailable, + agent_display_name, + load_writing_agent, + resolve_agent_model_name, + stream_writing_agent, + use_builtin_agent, +) from ._utils import get_writing_model, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer logger = logging.getLogger(__name__) @@ -194,19 +203,44 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di def _section_title(idx: int, sec: dict) -> str: return sec.get("section_title") or sec.get("sectionTitle") or f"章节{idx + 1}" - # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, - # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 - try: - model = get_writing_model(config, state) - except Exception as e: - logger.error("draft model creation failed: %s", e, exc_info=True) - return { - "status": "error", - "progress_events": [{ - "type": "error", "agent_name": "作家", - "title": "模型错误", "detail": str(e), "message": "草稿生成失败", - }], - } + # 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.writer): + # 逐章循环控制权仍在节点(严格模式求助 / 逐章流式不动),每章跑一次 + # ai-writing-writer agent(SOUL 作 system prompt,直接产出章节 Markdown)。 + use_agent = use_builtin_agent("writer") + agent_label = "作家" + model = None + if use_agent: + try: + agent_cfg, _soul = load_writing_agent(WRITER_AGENT_ID) + # 模型守卫提前到循环外:模型名缺失/配错与旧路径同样转成 status:error + resolve_agent_model_name(agent_cfg, state, config) + agent_label = agent_display_name(WRITER_AGENT_ID, "作家") + except WritingAgentUnavailable as e: + logger.warning("writer builtin agent unavailable, falling back to legacy prompts: %s", e) + use_agent = False + except Exception as e: + logger.error("draft agent model resolution failed: %s", e, exc_info=True) + return { + "status": "error", + "progress_events": [{ + "type": "error", "agent_name": agent_label, + "title": "模型错误", "detail": str(e), "message": "草稿生成失败", + }], + } + if not use_agent: + # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, + # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 + try: + model = get_writing_model(config, state) + except Exception as e: + logger.error("draft model creation failed: %s", e, exc_info=True) + return { + "status": "error", + "progress_events": [{ + "type": "error", "agent_name": "作家", + "title": "模型错误", "detail": str(e), "message": "草稿生成失败", + }], + } section_titles: list[str] = [] section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换 section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示 @@ -246,36 +280,55 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di section_buf = "" # 只累积正文(非思考)用于拼接 Markdown parser = ThinkTagParser() try: - async for chunk in model.astream([ - SystemMessage(content=DRAFT_SECTION_SYSTEM), - HumanMessage(content=user_msg), - ]): - # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 - reasoning = reasoning_delta(chunk) - if reasoning: + if use_agent: + def _on_text(text: str, is_thinking: bool, _i: int = i, _t: str = sec_title) -> None: writer({ "type": "draft_chunk", - "section_index": i, - "section_title": sec_title, - "text": reasoning, - "is_thinking": True, - "is_section_done": False, - }) - delta = _content_to_text(chunk.content) - if not delta: - continue - for is_thinking, text in parser.feed(delta): - if not is_thinking: - section_buf += text - writer({ - "type": "draft_chunk", - "section_index": i, - "section_title": sec_title, + "section_index": _i, + "section_title": _t, "text": text, "is_thinking": is_thinking, "is_section_done": False, }) + section_buf = await stream_writing_agent( + agent_id=WRITER_AGENT_ID, + user_payload=user_msg, + state=state, + config=config, + on_text=_on_text, + ) + else: + async for chunk in model.astream([ + SystemMessage(content=DRAFT_SECTION_SYSTEM), + HumanMessage(content=user_msg), + ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({ + "type": "draft_chunk", + "section_index": i, + "section_title": sec_title, + "text": reasoning, + "is_thinking": True, + "is_section_done": False, + }) + delta = _content_to_text(chunk.content) + if not delta: + continue + for is_thinking, text in parser.feed(delta): + if not is_thinking: + section_buf += text + writer({ + "type": "draft_chunk", + "section_index": i, + "section_title": sec_title, + "text": text, + "is_thinking": is_thinking, + "is_section_done": False, + }) + # 章节完成 writer({ "type": "draft_chunk", @@ -319,9 +372,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di "blocked_sections": blocked_sections, "status": "awaiting_section_help", "progress_events": [ - {"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"}, + {"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"}, { - "type": "notice", "agent_name": "作家", "title": "素材不足", + "type": "notice", "agent_name": agent_label, "title": "素材不足", "detail": f"有 {len(blocked_sections)} 个章节缺少足够素材支撑写作,需要你决定如何处理。", }, ], @@ -356,14 +409,23 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di existing_text=section_bufs[idx], target_words=expand_target, ) - resp = await model.ainvoke([ - SystemMessage(content=DRAFT_SECTION_SYSTEM), - HumanMessage(content=expand_msg), - ]) - parser = ThinkTagParser() - expanded = "".join( - t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking - ).strip() + if use_agent: + expanded = (await stream_writing_agent( + agent_id=WRITER_AGENT_ID, + user_payload=expand_msg, + state=state, + config=config, + on_text=None, + )).strip() + else: + resp = await model.ainvoke([ + SystemMessage(content=DRAFT_SECTION_SYSTEM), + HumanMessage(content=expand_msg), + ]) + parser = ThinkTagParser() + expanded = "".join( + t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking + ).strip() # 仅在扩写确实更长时才采用,避免越改越短 if count_words(expanded) > count_words(section_bufs[idx]): section_bufs[idx] = expanded @@ -390,7 +452,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di if section_errors: notices.append({ "type": "notice", - "agent_name": "作家", + "agent_name": agent_label, "title": "模型错误", "detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]), }) @@ -400,11 +462,11 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di "drafts": [draft], "status": "awaiting_draft_confirm", "progress_events": [ - {"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"}, + {"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"}, *notices, { "type": "draft_ready", - "agent_name": "作家", + "agent_name": agent_label, "message": f"草稿完成,共 {word_count} 字", "draft_title": outline.get("title", "文章"), "draft_markdown": full_markdown, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py index 5f843661..8e50a4f0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py @@ -19,10 +19,51 @@ from ..prompts.writer_prompts import ( OUTLINE_USER, ) from ..state import AIWritingState, KeyPoint, Outline, OutlineSection +from ._agent_runner import ( + OUTLINE_READY_MARKER, + OUTLINER_AGENT_ID, + WritingAgentUnavailable, + agent_display_name, + load_writing_agent, + parse_marker_json, + stream_writing_agent, + use_builtin_agent, +) from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer logger = logging.getLogger(__name__) +# ── 内置 agent 模式的输入 payload(SOUL 已含人设/层级语义/输出契约,这里只给本次任务的事实输入)── + +_AGENT_OUTLINE_USER = """任务模式:新规划 + +写作意图:{user_intent} +文章类型:{article_type} +目标读者:{target_audience} +目标字数:{word_count_target}字{feedback_section} + +可用素材摘要: +{materials_summary} + +请规划文章大纲(3-5 个章节),严格按 SOUL 约定输出 [OUTLINE_READY] + JSON。""" + +_AGENT_OUTLINE_REVISE_USER = """任务模式:修改模式 + +写作意图:{user_intent} +文章类型:{article_type} +目标读者:{target_audience} +目标字数:{word_count_target}字 + +当前大纲(JSON): +{current_outline} + +用户修改要求:{feedback} + +可用素材摘要: +{materials_summary} + +请在「当前大纲」基础上落实「用户修改要求」,未提到的章节保持原样,严格按 SOUL 约定输出 [OUTLINE_READY] + 调整后的完整大纲 JSON。""" + # 上下文压缩:传给大纲规划的素材摘要做条数和单条长度双重截断,防止 token 溢出。 _MAX_OUTLINE_MATERIALS = 12 _MATERIAL_SNIPPET_CHARS = 150 @@ -212,28 +253,40 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> if user_outline_raw and not current_outline and not is_revise: return await _build_outline_from_user(state, config, pkg, user_outline_raw) + # 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.outliner): + # SOUL 作 system prompt + [OUTLINE_READY] 契约解析;目录缺失静默回退旧路径。 + use_agent = use_builtin_agent("outliner") + agent_label = "作家" + if use_agent: + try: + load_writing_agent(OUTLINER_AGENT_ID) + agent_label = agent_display_name(OUTLINER_AGENT_ID, "大纲规划师") + except WritingAgentUnavailable as e: + logger.warning("outliner builtin agent unavailable, falling back to legacy prompts: %s", e) + use_agent = False + + fmt_args = dict( + user_intent=state["user_intent"], + article_type=state.get("article_type", "analysis"), + target_audience=state.get("target_audience", "普通读者"), + word_count_target=state.get("word_count_target", 800), + materials_summary=_materials_summary(pkg), + ) if is_revise: system_prompt = OUTLINE_REVISE_SYSTEM - user_prompt = OUTLINE_REVISE_USER.format( - user_intent=state["user_intent"], - article_type=state.get("article_type", "analysis"), - target_audience=state.get("target_audience", "普通读者"), - word_count_target=state.get("word_count_target", 800), + revise_args = dict( + fmt_args, current_outline=json.dumps(current_outline, ensure_ascii=False, indent=2), feedback=feedback, - materials_summary=_materials_summary(pkg), ) + user_prompt = OUTLINE_REVISE_USER.format(**revise_args) + agent_payload = _AGENT_OUTLINE_REVISE_USER.format(**revise_args) else: system_prompt = OUTLINE_SYSTEM feedback_section = f"\n重新规划要求:{feedback}" if feedback else "" - user_prompt = OUTLINE_USER.format( - user_intent=state["user_intent"], - article_type=state.get("article_type", "analysis"), - target_audience=state.get("target_audience", "普通读者"), - word_count_target=state.get("word_count_target", 800), - materials_summary=_materials_summary(pkg), - feedback_section=feedback_section, - ) + gen_args = dict(fmt_args, feedback_section=feedback_section) + user_prompt = OUTLINE_USER.format(**gen_args) + agent_payload = _AGENT_OUTLINE_USER.format(**gen_args) # 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。 writer = safe_get_stream_writer() @@ -242,35 +295,47 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> parser = ThinkTagParser() try: - # get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError, - # 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。 - model = get_writing_model(config, state) - async for chunk in model.astream([ - SystemMessage(content=system_prompt), - HumanMessage(content=user_prompt), - ]): - # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 - reasoning = reasoning_delta(chunk) - if reasoning: - writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True}) - delta = _content_to_text(chunk.content) - if not delta: - continue - for is_thinking, text in parser.feed(delta): - if not is_thinking: - full_text += text - writer({ - "type": "outline_chunk", - "text": text, - "is_thinking": is_thinking, - }) + if use_agent: + def _on_text(text: str, is_thinking: bool) -> None: + writer({"type": "outline_chunk", "text": text, "is_thinking": is_thinking}) + + full_text = await stream_writing_agent( + agent_id=OUTLINER_AGENT_ID, + user_payload=agent_payload, + state=state, + config=config, + on_text=_on_text, + ) + else: + # get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError, + # 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。 + model = get_writing_model(config, state) + async for chunk in model.astream([ + SystemMessage(content=system_prompt), + HumanMessage(content=user_prompt), + ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True}) + delta = _content_to_text(chunk.content) + if not delta: + continue + for is_thinking, text in parser.feed(delta): + if not is_thinking: + full_text += text + writer({ + "type": "outline_chunk", + "text": text, + "is_thinking": is_thinking, + }) except Exception as e: logger.error("outline streaming failed: %s", e, exc_info=True) return { "status": "error", "progress_events": [{ "type": "error", - "agent_name": "作家", + "agent_name": agent_label, "title": "模型错误", "detail": str(e), "message": "大纲生成失败", @@ -309,7 +374,10 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> }, ], } - result = parse_json(full_text, fallback) + if use_agent: + result = parse_marker_json(full_text, OUTLINE_READY_MARKER, fallback) + else: + result = parse_json(full_text, fallback) outline = _parse_outline(result) section_count = len(outline["sections"]) @@ -318,7 +386,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> "current_outline": outline, "status": "awaiting_outline_confirm", "progress_events": [ - {"type": "step_start", "agent_name": "作家", "message": "开始规划大纲"}, - {"type": "outline_ready", "agent_name": "作家", "message": f"大纲已生成,共 {section_count} 个章节"}, + {"type": "step_start", "agent_name": agent_label, "message": "开始规划大纲"}, + {"type": "outline_ready", "agent_name": agent_label, "message": f"大纲已生成,共 {section_count} 个章节"}, ], } From 1b86373a5490d78c63231762b4b07cc246157769 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 11 Jun 2026 23:18:58 +0800 Subject: [PATCH 04/67] =?UTF-8?q?feat:=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E6=A0=87=E7=AD=BE/=E5=AE=9E=E4=BD=93=E5=85=B3=E8=81=94?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=20+=20=E4=BC=9A=E8=AF=9D=E9=87=8D=E8=BF=9E?= =?UTF-8?q?=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: - Wiki 标签与实体改为可点击,弹出 RelatedNotesDialog 查看关联知识 - 知识库首次进入自动打开最近笔记,避免空白阅读区 - 流式重连识别 "Run not found",清理 sessionStorage 残留 key 并静默回退历史 后端: - 线程鉴权区分 not found / not accessible,并支持 DB 切换产生的孤儿线程认领 - run 查询/join 在内存未命中时回退持久化存储,重启后返回干净的终止 SSE 而非 404 --- .../components/wiki/RelatedNotesDialog.tsx | 110 ++++++++++++++++++ .../src/components/wiki/WikiContent.tsx | 41 +++++-- frontend-web/src/core/threads/hooks.ts | 33 +++++- frontend-web/src/pages/KnowledgeBasePage.tsx | 11 ++ .../backend/app/gateway/authz.py | 74 ++++++++++-- .../app/gateway/routers/thread_runs.py | 51 ++++++-- .../backend/app/gateway/services.py | 14 +++ .../harness/deerflow/runtime/runs/manager.py | 46 ++++++++ 8 files changed, 357 insertions(+), 23 deletions(-) create mode 100644 frontend-web/src/components/wiki/RelatedNotesDialog.tsx diff --git a/frontend-web/src/components/wiki/RelatedNotesDialog.tsx b/frontend-web/src/components/wiki/RelatedNotesDialog.tsx new file mode 100644 index 00000000..1cc5e048 --- /dev/null +++ b/frontend-web/src/components/wiki/RelatedNotesDialog.tsx @@ -0,0 +1,110 @@ +import { FileText, Hash, Loader2, Sparkles } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useKnowledgeNotes } from "@/core/knowledge/hooks"; +import type { KnowledgeNote } from "@/core/knowledge/types"; + +import { SOURCE_TYPE_LABELS, formatDateTime } from "./wikiUtils"; + +/** What was clicked: a tag (exact filter) or an entity (keyword match in body). */ +export type RelatedNotesFilter = + | { kind: "tag"; value: string } + | { kind: "entity"; value: string }; + +/** + * Click a tag / entity chip → list every other note that shares it. + * Tags use the backend's exact `tag` filter; entities fall back to a keyword + * match (entity names appear verbatim in titles/summaries/bodies). + */ +export function RelatedNotesDialog({ + filter, + currentNoteId, + onOpenChange, + onSelect, +}: { + filter: RelatedNotesFilter | null; + currentNoteId?: string; + onOpenChange: (open: boolean) => void; + onSelect: (noteId: string) => void; +}) { + const params = + filter?.kind === "tag" + ? { tag: filter.value, limit: 50 } + : filter?.kind === "entity" + ? { keyword: filter.value, limit: 50 } + : { limit: 0 }; + + const { data, isLoading, isFetching } = useKnowledgeNotes(params); + const items = (data?.items ?? []).filter( + (n: KnowledgeNote) => n.id !== currentNoteId, + ); + + return ( + + + + + {filter?.kind === "entity" ? ( + + ) : ( + + )} + {filter?.value} + + + {filter?.kind === "entity" + ? "提到该实体的关联知识" + : "使用该标签的关联知识"} + + + + + {isLoading || (isFetching && items.length === 0) ? ( +
+ 加载中... +
+ ) : items.length === 0 ? ( +
+ 暂无其他关联知识。 +
+ ) : ( +
    + {items.map((note) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend-web/src/components/wiki/WikiContent.tsx b/frontend-web/src/components/wiki/WikiContent.tsx index 38d4705f..354f190b 100644 --- a/frontend-web/src/components/wiki/WikiContent.tsx +++ b/frontend-web/src/components/wiki/WikiContent.tsx @@ -25,6 +25,10 @@ import { DocumentToolbar } from "./DocumentToolbar"; import { FloatingToc } from "./FloatingToc"; import { MarkdownViewer } from "./MarkdownViewer"; import { RelatedDocs } from "./RelatedDocs"; +import { + RelatedNotesDialog, + type RelatedNotesFilter, +} from "./RelatedNotesDialog"; import { SOURCE_TYPE_LABELS, STATUS_LABELS, @@ -91,6 +95,10 @@ export function WikiContent({ const [draftContent, setDraftContent] = useState(note.content_md); const [draftStatus, setDraftStatus] = useState(note.status); const [activeHeadingId, setActiveHeadingId] = useState(null); + // Tag / entity chip clicked → show notes that share it. + const [relatedFilter, setRelatedFilter] = useState( + null, + ); // Pull the backend-injected `## Related` wikilinks out of the body so they // render as a structured module; editing still works on the full source. @@ -270,12 +278,15 @@ export function WikiContent({ 创建:{formatDateTime(note.created_at).slice(0, 10)} ) : null} {note.tags.map((tag) => ( - setRelatedFilter({ kind: "tag", value: tag })} + className="inline-flex h-6 cursor-pointer items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 transition-colors hover:bg-rose-100 hover:text-rose-600 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20" > #{tag} - + ))} @@ -287,11 +298,15 @@ export function WikiContent({ {note.entities.map((ent) => { const meta = entityMeta(ent.entity_type); return ( - + setRelatedFilter({ kind: "entity", value: ent.name }) + } className={cn( - "inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium", + "inline-flex cursor-pointer items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium transition-opacity hover:opacity-80", meta.className, )} > @@ -300,7 +315,7 @@ export function WikiContent({ {meta.label} - + ); })} @@ -373,6 +388,18 @@ export function WikiContent({ + + { + if (!open) setRelatedFilter(null); + }} + onSelect={(id) => { + setRelatedFilter(null); + navigate(`/page/workspace/knowledge/${id}`); + }} + /> ); } diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index caea58c4..79a1273b 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -152,7 +152,10 @@ function getStreamErrorMessage(error: unknown): string { */ function isThreadNotAccessibleError(message: string): boolean { const lowered = message.toLowerCase(); - if (/thread\s+[\w-]{8,}\s+not\s+found/i.test(message)) { + // Backend now returns "not found" (no row + no checkpoint) and + // "not accessible" (row owned by another user / stale login session) as + // distinct details — both mean the current user can't use this thread. + if (/thread\s+[\w-]{8,}\s+not\s+(found|accessible)/i.test(message)) { return true; } // Fallback: a bare 404/403 from the threads endpoint when the SDK has @@ -163,6 +166,21 @@ function isThreadNotAccessibleError(message: string): boolean { ); } +/** + * Detect a "Run not found" 404 from the LangGraph SDK. + * + * The SDK persists the in-flight run id under ``lg:stream:{threadId}`` in + * sessionStorage and, on mount, auto-``joinStream``s it to resume a dropped + * SSE. After a backend restart the in-memory run registry is gone, so the join + * 404s — and the SDK only clears that key on reconnect *success*, never on + * failure. So every switch back into the conversation re-fires the same 404. + * We detect it here to clear the stale key ourselves and stay silent (the + * conversation renders from history; there is nothing live to reconnect to). + */ +function isRunNotFoundError(message: string): boolean { + return /run\s+[\w-]{8,}\s+not\s+found/i.test(message); +} + function buildCurrentUserLabel(): string { const auth = getStoredAuth(); if (!auth) { @@ -335,6 +353,19 @@ export function useThreadStream({ onError(error) { setOptimisticMessages([]); const message = getStreamErrorMessage(error); + // A stale auto-reconnect ("Run not found") after a backend restart: + // drop the dirty ``lg:stream:{threadId}`` key so switching back into this + // conversation stops re-firing the same 404, and render from history + // silently — there is no live run to rejoin. + if (isRunNotFoundError(message)) { + const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null; + if (staleThreadId && runMetadataStorageRef.current) { + runMetadataStorageRef.current.removeItem( + `lg:stream:${staleThreadId}`, + ); + } + return; + } // A "thread not found" 404 almost always means the URL points at a // thread from a previous login session — either this tab outlived a // logout/re-login or the thread genuinely belongs to a different user. diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx index 7ce56d67..b12cfb5f 100644 --- a/frontend-web/src/pages/KnowledgeBasePage.tsx +++ b/frontend-web/src/pages/KnowledgeBasePage.tsx @@ -468,6 +468,8 @@ export default function KnowledgeBasePage() { const [status, setStatus] = useState("all"); const [expandedIds, setExpandedIds] = useState>(new Set()); const treeInitializedRef = useRef(false); + // Auto-open a default note once on entry so the reading pane is never blank. + const defaultNoteShownRef = useRef(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed); @@ -495,6 +497,15 @@ export default function KnowledgeBasePage() { const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId); const { data: wikiLinks } = useNoteWikilinks(noteId); + // On first entry without an explicit note, open the most recent one so the + // reading pane shows real content instead of the empty placeholder. + useEffect(() => { + if (defaultNoteShownRef.current || noteId || isLoading) return; + if (notes.length === 0) return; + defaultNoteShownRef.current = true; + navigate(`/page/workspace/knowledge/${notes[0].id}`, { replace: true }); + }, [noteId, isLoading, notes, navigate]); + // First load: expand the root-level folders so the tree reads as a directory. useEffect(() => { if (treeInitializedRef.current || tree.length === 0) return; diff --git a/offline-backend-20260512/backend/app/gateway/authz.py b/offline-backend-20260512/backend/app/gateway/authz.py index 89fd943c..0d8d2899 100644 --- a/offline-backend-20260512/backend/app/gateway/authz.py +++ b/offline-backend-20260512/backend/app/gateway/authz.py @@ -31,21 +31,43 @@ from __future__ import annotations import functools import inspect - -from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled +import logging from collections.abc import Callable from types import SimpleNamespace from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar from fastapi import HTTPException, Request +from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled + if TYPE_CHECKING: from app.gateway.auth.models import User +logger = logging.getLogger(__name__) + P = ParamSpec("P") T = TypeVar("T") +async def _thread_checkpoint_exists(request: Request, thread_id: str) -> bool: + """Return True if a LangGraph checkpoint exists for ``thread_id``. + + A checkpoint with no matching ``threads_meta`` row is the signature of a + DB-switch orphan (sqlite↔mysql/postgres juggling): the conversation state + survives in the checkpointer while the business row is gone. Normal thread + deletion removes the checkpoint too, so this can never resurrect a + deliberately-deleted thread. + """ + try: + from app.gateway.deps import get_checkpointer + + checkpointer = get_checkpointer(request) + tup = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id}}) + return tup is not None + except Exception: + return False + + # Permission constants class Permissions: """Permission constants for resource:action format.""" @@ -205,6 +227,7 @@ def require_permission( action: str, owner_check: bool = False, require_existing: bool = False, + claim_orphan: bool = False, ) -> Callable[[Callable[P, T]], Callable[P, T]]: """Decorator that checks permission for resource:action. @@ -221,6 +244,15 @@ def require_permission( **destructive / mutating** routes (DELETE, PATCH, state-update) so a deleted thread can't be re-targeted by another user via the missing-row code path. + claim_orphan: Only meaningful with ``owner_check=True`` and + ``require_existing=True``. When the ``threads_meta`` + row is missing **and** a LangGraph checkpoint exists + for the thread (a DB-switch orphan), adopt it for the + current user instead of 404ing. Use on **run-create** + routes so a conversation whose business row was lost + (sqlite↔mysql swap) stays answerable. Safe: normal + deletion removes the checkpoint, so this never revives + a deliberately-deleted thread. Usage: # Read-style: legacy untracked threads are allowed @@ -289,16 +321,44 @@ def require_permission( from app.gateway.deps import get_thread_store thread_store = get_thread_store(request) + user_id = str(auth.user.id) allowed = await thread_store.check_access( thread_id, - str(auth.user.id), + user_id, require_existing=require_existing, ) if not allowed: - raise HTTPException( - status_code=404, - detail=f"Thread {thread_id} not found", - ) + # Tell apart three denial causes so the client gets an + # accurate message and so DB-switch orphans can be adopted + # rather than hard-404'd: + # 1. row exists but owned by another user -> not accessible + # 2. no row, no checkpoint -> truly missing + # 3. no row + checkpoint + claim_orphan -> orphan, claim + existing = await thread_store.get(thread_id, user_id=None) + if existing is not None: + # Distinct detail string lets the frontend show "无权访问" + # instead of "不存在" for a cross-user / stale-session hit. + raise HTTPException( + status_code=404, + detail=f"Thread {thread_id} not accessible", + ) + claimed = False + if claim_orphan and await _thread_checkpoint_exists(request, thread_id): + try: + await thread_store.create(thread_id, user_id=user_id) + logger.info( + "Claimed orphan thread %s for user %s (checkpoint present, no meta row)", + thread_id, + user_id, + ) + claimed = True + except Exception: + logger.warning("Failed to claim orphan thread %s", thread_id, exc_info=True) + if not claimed: + raise HTTPException( + status_code=404, + detail=f"Thread {thread_id} not found", + ) return await func(*args, **kwargs) diff --git a/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py b/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py index 38129fa5..1f108dcc 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/thread_runs.py @@ -21,7 +21,7 @@ from pydantic import BaseModel, Field from app.gateway.authz import require_permission from app.gateway.deps import get_checkpointer, get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge -from app.gateway.services import sse_consumer, start_run +from app.gateway.services import immediate_end_sse, sse_consumer, start_run from deerflow.runtime import RunRecord, serialize_channel_values logger = logging.getLogger(__name__) @@ -96,7 +96,7 @@ def _record_to_response(record: RunRecord) -> RunResponse: @router.post("/{thread_id}/runs", response_model=RunResponse) -@require_permission("runs", "create", owner_check=True, require_existing=True) +@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True) async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -> RunResponse: """Create a background run (returns immediately).""" record = await start_run(body, thread_id, request) @@ -104,7 +104,7 @@ async def create_run(thread_id: str, body: RunCreateRequest, request: Request) - @router.post("/{thread_id}/runs/stream") -@require_permission("runs", "create", owner_check=True, require_existing=True) +@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True) async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -> StreamingResponse: """Create a run and stream events via SSE. @@ -132,7 +132,7 @@ async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) - @router.post("/{thread_id}/runs/wait", response_model=dict) -@require_permission("runs", "create", owner_check=True, require_existing=True) +@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True) async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict: """Create a run and block until it completes, returning the final state.""" record = await start_run(body, thread_id, request) @@ -173,7 +173,11 @@ async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse: run_mgr = get_run_manager(request) record = run_mgr.get(run_id) if record is None or record.thread_id != thread_id: - raise HTTPException(status_code=404, detail=f"Run {run_id} not found") + # In-memory miss: fall back to the persistent store so a run still + # resolves after a Gateway restart instead of 404ing. + record = await run_mgr.get_persisted(run_id) + if record is None or record.thread_id != thread_id: + raise HTTPException(status_code=404, detail=f"Run {run_id} not found") return _record_to_response(record) @@ -222,11 +226,27 @@ async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingRe bridge = get_stream_bridge(request) run_mgr = get_run_manager(request) record = run_mgr.get(run_id) - if record is None or record.thread_id != thread_id: + if record is not None and record.thread_id == thread_id: + return StreamingResponse( + sse_consumer(bridge, record, request, run_mgr), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + # Not in memory: a run that still exists in the store but has no live task + # means the Gateway restarted (or the finished run was cleaned up). There + # is nothing to reconnect to, so stream a clean terminal end instead of a + # 404 — the SDK then clears its reconnect key and renders history. + persisted = await run_mgr.get_persisted(run_id) + if persisted is None or persisted.thread_id != thread_id: raise HTTPException(status_code=404, detail=f"Run {run_id} not found") return StreamingResponse( - sse_consumer(bridge, record, request, run_mgr), + immediate_end_sse(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", @@ -255,7 +275,22 @@ async def stream_existing_run( run_mgr = get_run_manager(request) record = run_mgr.get(run_id) if record is None or record.thread_id != thread_id: - raise HTTPException(status_code=404, detail=f"Run {run_id} not found") + # In-memory miss. If the run survives in the store it's a dead record + # from before a restart (or already finished) — nothing to join or + # cancel, so emit a clean terminal end so the SDK stops reconnecting + # instead of 404ing. A truly unknown run still 404s. + persisted = await run_mgr.get_persisted(run_id) + if persisted is None or persisted.thread_id != thread_id: + raise HTTPException(status_code=404, detail=f"Run {run_id} not found") + return StreamingResponse( + immediate_end_sse(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) # Cancel if an action was requested (stop-button / interrupt flow) if action is not None: diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 46625e28..3f0e8c37 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -42,6 +42,20 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- +async def immediate_end_sse(): + """Yield a single terminal ``end`` SSE frame and stop. + + Used when a run exists in the persistent store but not in the in-memory + registry — typically after a Gateway restart, or once a finished run's + record has been cleaned up. There is no live stream to join, so instead of + returning 404 (which the SDK's ``joinStream`` surfaces as a hard error and + never clears its reconnect key for) we stream a clean end. The SDK treats + this as success, clears ``lg:stream:{threadId}``, and the conversation + falls back to history rendering — killing the repeating switch-back 404. + """ + yield format_sse("end", None) + + def format_sse(event: str, data: Any, *, event_id: str | None = None) -> str: """Format a single SSE frame. diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py index 9bd39c06..64deed00 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py @@ -112,6 +112,52 @@ class RunManager: """Return a run record by ID, or ``None``.""" return self._runs.get(run_id) + def _record_from_store(self, data: dict) -> RunRecord: + """Rebuild a :class:`RunRecord` from a persisted store row. + + The reconstructed record has no live ``task``/``abort_event`` wired to a + running coroutine — it represents *history*, not an in-flight run. + """ + try: + status = RunStatus(data.get("status") or RunStatus.success.value) + except ValueError: + status = RunStatus.success + return RunRecord( + run_id=data["run_id"], + thread_id=data["thread_id"], + assistant_id=data.get("assistant_id"), + status=status, + # Disconnect mode is irrelevant for a store-only record (no task to + # cancel); ``continue_`` keeps sse_consumer's finally block a no-op. + on_disconnect=DisconnectMode.continue_, + multitask_strategy=data.get("multitask_strategy", "reject"), + metadata=data.get("metadata") or {}, + kwargs=data.get("kwargs") or {}, + created_at=data.get("created_at") or "", + updated_at=data.get("updated_at") or "", + error=data.get("error"), + ) + + async def get_persisted(self, run_id: str) -> RunRecord | None: + """Load a run from the backing store (process-restart fallback). + + The in-memory registry is wiped on every Gateway restart, but the + ``RunStore`` row survives. When :meth:`get` misses, callers fall back + here so a stale ``joinStream``/``GET run`` can report the run's final + status instead of 404ing. Returns ``None`` when there is no store, no + matching row, or the lookup fails (e.g. no user context). + """ + if self._store is None: + return None + try: + data = await self._store.get(run_id) + except Exception: + logger.warning("Failed to load run %s from store", run_id, exc_info=True) + return None + if not data: + return None + return self._record_from_store(data) + async def list_by_thread(self, thread_id: str) -> list[RunRecord]: """Return all runs for a given thread, newest first.""" async with self._lock: From df8bf895efba4fe1799f085a8376484d9ed65f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 12 Jun 2026 00:37:45 +0800 Subject: [PATCH 05/67] =?UTF-8?q?=E5=9C=86=E6=A1=8C=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E6=AD=A5=E4=B8=8E=E4=B8=9A=E5=8A=A1=E9=93=BE=E6=9D=A1=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=EF=BC=9A=E4=B8=BB=E9=A2=98=E8=B7=9F=E9=9A=8F/?= =?UTF-8?q?=E6=8F=92=E8=AF=9D=E6=9A=82=E5=81=9C/=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E4=BC=98=E5=8C=96/=E7=BB=93=E6=9E=84=E5=8C=96?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 结果绘制跟随日/夜间主题(深色按深蓝 UI 规范令牌绘制),提示词注入真实生成时间 - 绘制支持暂停与随时插话(线程存活时免带原料续线程省 token)、失败自动重试一次、草稿主题不匹配提示条 - 总结报告优先执行用户最终总结要求(结构化输出/重点席位可整体替换默认章节) - 业务链条总控提示词与各阶段目标加「AI 优化」按钮(新接口 POST /api/roundtable-chains/optimize-prompt) - 总结报告 flow-json 结构化流程图:流式增量绘制、最新连线蚂蚁线动画、拖拽/缩放/复位/全屏、节点详情弹窗、沙箱切换按钮/无沙箱自动打开 - roundtable-report / roundtable-summary 两个内置智能体 SOUL 更新(双主题、多轮修改允许读底稿、KPI 估算标注、流程数据契约) Co-Authored-By: Claude Fable 5 --- .../src/roundtable-planning/api/chains.ts | 31 ++ .../src/roundtable-planning/api/drafts.ts | 5 + .../components/FlowGraphPanel.tsx | 400 ++++++++++++++++++ .../components/Step3ReportMode.tsx | 72 +++- .../components/Step3SummaryMode.tsx | 77 +++- .../hooks/useStep3Report.ts | 357 +++++++++++++--- .../hooks/useStep3Summary.ts | 16 +- .../lib/flow-graph.test.ts | 122 ++++++ .../src/roundtable-planning/lib/flow-graph.ts | 282 ++++++++++++ .../pages/BusinessChainEditorPage.tsx | 158 ++++++- .../roundtable-report/SOUL.md | 37 +- .../roundtable-summary/SOUL.md | 28 +- .../app/gateway/routers/roundtable_chains.py | 98 ++++- 13 files changed, 1600 insertions(+), 83 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx create mode 100644 frontend-web/src/roundtable-planning/lib/flow-graph.test.ts create mode 100644 frontend-web/src/roundtable-planning/lib/flow-graph.ts diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index dce9b6ca..85d6a8ac 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -174,3 +174,34 @@ export async function deleteChain(id: string): Promise { throw new Error(`delete chain failed (${res.status}): ${text}`); } } + +// ── 提示词优化 ─────────────────────────────────────────────────────────────── + +export interface OptimizeChainPromptRequest { + /** 用户当前写的提示词原文(非空)。 */ + promptText: string; + /** coordinator = 总控提示词;stage_goal = 某个 Stage 的阶段目标。 */ + kind: "coordinator" | "stage_goal"; + /** 链条结构上下文(标题 / 各 Stage 席位与目标),帮助模型贴着链条优化。 */ + context?: string; + /** 可选模型覆盖,缺省由后端用 config models[0]。 */ + modelName?: string; + signal?: AbortSignal; +} + +/** 调后端单次 LLM 重写提示词,返回优化后的全文(失败抛错,错误信息可直接 toast)。 */ +export async function optimizeChainPrompt(req: OptimizeChainPromptRequest): Promise { + const res = await apiFetch(`${BASE()}/optimize-prompt`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt_text: req.promptText, + kind: req.kind, + context: req.context ?? "", + ...(req.modelName ? { model_name: req.modelName } : {}), + }), + signal: req.signal, + }); + const data = await readJson<{ optimized: string }>(res, "optimize chain prompt"); + return data.optimized ?? ""; +} diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index 1906a07b..9ce15956 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -115,6 +115,11 @@ export interface DraftStep3Snapshot { model?: string; /** The HTML agent's closing one-liner (rendered as the report message bubble). */ summary?: string; + /** + * 绘制这份 HTML 时的界面主题(light=浅红 / dark=深蓝)。恢复草稿时与当前主题 + * 比对,不一致则提示「重新绘制以匹配当前主题」。旧草稿没有该字段 → 不提示。 + */ + themeMode?: "light" | "dark"; /** * Step 3「总结报告」snapshot — output of the `roundtable-summary` agent * (Markdown report). Stored alongside (not instead of) the HTML report so the diff --git a/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx new file mode 100644 index 00000000..25ec4815 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx @@ -0,0 +1,400 @@ +/** + * Step 3「总结报告」结构化流程图画布。 + * + * 数据来自总结智能体回复末尾的 ```flow-json 块(见 lib/flow-graph.ts),流式期间 + * 随解析增量重绘:每出现一个新节点,它的入边挂蚂蚁线动画(React Flow `animated`), + * 画布自动 fitView 跟随生长;结束后全部转实线。 + * + * 交互:画布拖动(pan)/ 滚轮缩放 / 节点可拖拽(手动位置在数据更新间保留)/ + * 工具栏 放大·缩小·复位·全屏 / 点节点弹详情(类型、说明、上下游)。 + */ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + applyNodeChanges, + Background, + BackgroundVariant, + Handle, + MarkerType, + Position, + ReactFlow, + ReactFlowProvider, + useReactFlow, + type Edge, + type Node, + type NodeChange, + type NodeProps, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { + Loader2, + LocateFixed, + Maximize2, + Minimize2, + Workflow, + X, + ZoomIn, + ZoomOut, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + FLOW_NODE_TYPE_META, + FLOW_NODE_W, + layoutFlowGraph, + type FlowGraphData, + type FlowGraphNode, +} from "@/roundtable-planning/lib/flow-graph"; + +interface FlowGraphPanelProps { + data: FlowGraphData; + /** 流式绘制中(决定蚂蚁线 + 自动 fitView 跟随)。 */ + streaming: boolean; + onClose: () => void; +} + +// ── 自定义节点(按 type 着色的卡片)──────────────────────────────────────── + +function FlowCardNode({ data }: NodeProps) { + const node = (data as { node?: FlowGraphNode }).node; + if (!node) return null; + const meta = FLOW_NODE_TYPE_META[node.type]; + return ( +
+ +
+ + + {meta.label} + +
+
{node.label}
+ +
+ ); +} + +// 必须模块级常量:每次 render 新建会让 React Flow 整树重挂。 +const NODE_TYPES = { flowCard: FlowCardNode }; + +// ── 画布内层(需在 ReactFlowProvider 内才能 useReactFlow)────────────────── + +function FlowCanvasInner({ + data, + streaming, + fullscreen, + onToggleFullscreen, + onClose, +}: FlowGraphPanelProps & { fullscreen: boolean; onToggleFullscreen: () => void }) { + const rf = useReactFlow(); + const [selectedId, setSelectedId] = useState(null); + // 用户手动拖过的节点位置 —— 数据增量更新重新布局时保留,不被布局覆盖。 + const manualPosRef = useRef>(new Map()); + + const layoutNodes = useMemo(() => layoutFlowGraph(data), [data]); + const derivedNodes: Node[] = useMemo( + () => + layoutNodes.map((n) => ({ + id: n.id, + type: "flowCard", + position: manualPosRef.current.get(n.id) ?? { x: n.x, y: n.y }, + data: { node: n }, + })), + [layoutNodes], + ); + + const [nodes, setNodes] = useState(derivedNodes); + useEffect(() => setNodes(derivedNodes), [derivedNodes]); + + const onNodesChange = useCallback((changes: NodeChange[]) => { + setNodes((nds) => applyNodeChanges(changes, nds)); + for (const c of changes) { + if (c.type === "position" && c.position) manualPosRef.current.set(c.id, c.position); + } + }, []); + + // 流式增量绘制:最新节点的连线用蚂蚁线动画(React Flow animated = marching ants)。 + const newestNodeId = data.nodes[data.nodes.length - 1]?.id; + const edges: Edge[] = useMemo( + () => + data.edges.map((e) => { + const active = streaming && (e.to === newestNodeId || e.from === newestNodeId); + const color = active ? "#3b82f6" : "#94a3b8"; + return { + id: `e-${e.from}->${e.to}`, + source: e.from, + target: e.to, + label: e.label, + animated: active, + style: { stroke: color, strokeWidth: active ? 2 : 1.5 }, + markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 }, + labelStyle: { fontSize: 10, fill: "#64748b", fontWeight: 700 }, + labelBgStyle: { fill: "#ffffff", fillOpacity: 0.85 }, + } satisfies Edge; + }), + [data.edges, streaming, newestNodeId], + ); + + // 流式中节点增加 → 画布平滑 fitView 跟随生长(用户暂停流后不再打扰)。 + useEffect(() => { + if (!streaming) return; + const t = setTimeout(() => { + void rf.fitView({ duration: 250, padding: 0.18 }); + }, 80); + return () => clearTimeout(t); + }, [data.nodes.length, streaming, rf]); + + const selectedNode = selectedId ? data.nodes.find((n) => n.id === selectedId) ?? null : null; + + return ( +
+ {/* 头部:标题 + 工具栏 */} +
+
+ + + {data.title || "方案流程图"} + + + {data.nodes.length} 节点 · {data.edges.length} 连线 + + {streaming && ( + + + 绘制中 + + )} +
+
+ void rf.zoomIn({ duration: 150 })}> + + + void rf.zoomOut({ duration: 150 })}> + + + void rf.fitView({ duration: 250, padding: 0.18 })} + > + + + + {fullscreen ? : } + + + + +
+
+ + {/* 画布 */} +
+ setSelectedId(n.id)} + fitView + fitViewOptions={{ padding: 0.18 }} + minZoom={0.2} + maxZoom={2.5} + panOnDrag + zoomOnScroll + nodesConnectable={false} + deleteKeyCode={null} + proOptions={{ hideAttribution: true }} + > + + + + {/* 图例 */} +
+ {Object.entries(FLOW_NODE_TYPE_META).map(([type, meta]) => ( + + + {meta.label} + + ))} +
+
+ + {/* 节点详情弹窗 */} + {selectedNode && ( + setSelectedId(null)} + onJump={(id) => setSelectedId(id)} + /> + )} +
+ ); +} + +function ToolButton({ + title, + onClick, + children, +}: { + title: string; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +// ── 节点详情弹窗(类型 / 说明 / 上下游连接)───────────────────────────────── + +function NodeDetailModal({ + node, + data, + onClose, + onJump, +}: { + node: FlowGraphNode; + data: FlowGraphData; + onClose: () => void; + onJump: (id: string) => void; +}) { + const meta = FLOW_NODE_TYPE_META[node.type]; + const labelOf = (id: string) => data.nodes.find((n) => n.id === id)?.label ?? id; + const incoming = data.edges.filter((e) => e.to === node.id); + const outgoing = data.edges.filter((e) => e.from === node.id); + + return ( +
+
e.stopPropagation()} + > +
+
+ + + {meta.label} + +

+ {node.label} +

+
+ +
+
+
+
+ 详细说明 +
+

+ {node.detail?.trim() || "(该节点未提供详细说明)"} +

+
+ {incoming.length > 0 && ( + ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} /> + )} + {outgoing.length > 0 && ( + ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} /> + )} +
+
+
+ ); +} + +function ConnectionList({ + title, + entries, + onJump, +}: { + title: string; + entries: Array<{ id: string; label: string; edgeLabel?: string }>; + onJump: (id: string) => void; +}) { + return ( +
+
+ {title} +
+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} + +// ── 外壳:覆盖右栏 / 全屏切换 ─────────────────────────────────────────────── + +export function FlowGraphPanel({ data, streaming, onClose }: FlowGraphPanelProps) { + const [fullscreen, setFullscreen] = useState(false); + return ( +
+ + setFullscreen((f) => !f)} + onClose={onClose} + /> + +
+ ); +} diff --git a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx index 65abc900..29e63db9 100644 --- a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx @@ -22,6 +22,7 @@ import { Loader2, PanelLeftClose, PanelLeftOpen, + Pause, RefreshCw, SendHorizontal, Sparkles, @@ -119,6 +120,9 @@ export function Step3ReportMode({ start, hydrateSaved, redraw, + pause, + interject, + themeMismatch, abort, openArtifactFromStep, openArtifactPreview, @@ -178,7 +182,9 @@ export function Step3ReportMode({ ? "绘制完成" : phase === "error" ? "绘制失败" - : "待绘制"; + : phase === "paused" + ? "已暂停" + : "待绘制"; return (
@@ -196,6 +202,17 @@ export function Step3ReportMode({
+ {(phase === "creating" || phase === "streaming") && ( + + )}
)} + {phase === "paused" && ( +
+ + 已暂停。可在下方输入修改要求继续,或点右上角「重新绘制」。 +
+ )} + {phase === "done" && (
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
)} + + {themeMismatch && ( +
+ + 界面主题已切换,这份报告还是旧主题的配色。 + + +
+ )} {!atBottom && }
+ )} + + {/* 流程图画布(覆盖右栏;全屏在组件内部切换) */} + {flowGraph && flowView && ( + setFlowView(false)} + /> + )} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts index b6102430..f8bbebda 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts @@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useArtifacts } from "@/components/workspace/artifacts"; +import { useTheme } from "@/contexts/ThemeContext"; import { apiFetch } from "@/core/api"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import { @@ -48,7 +49,13 @@ export interface ReportSeatDelivery { content: string; } -export type ReportPhase = "idle" | "creating" | "streaming" | "done" | "error"; +export type ReportPhase = + | "idle" + | "creating" + | "streaming" + | "done" + | "error" + | "paused"; interface UseStep3ReportOptions { intentReady: ReportIntent | null; @@ -77,6 +84,15 @@ export interface UseStep3ReportReturn { hydrateSaved: (snapshot: DraftStep3Snapshot) => void; /** 重新绘制(先中断在途请求再重跑)。 */ redraw: (revisionInstruction?: string) => void; + /** 暂停:中断在途绘制并把气泡冻结为「已暂停」(沙箱保留已写内容)。 */ + pause: () => void; + /** + * 插话 / 修改要求(随时可发):绘制中先打断再发;报告线程存活时免带研讨原料 + * 与旧 HTML 直接续同一线程,线程不在(草稿恢复等)才回落全量 prompt。 + */ + interject: (message: string) => void; + /** 当前展示的报告主题与界面主题不一致(建议重新绘制以匹配)。 */ + themeMismatch: boolean; /** 中断在途请求(卸载 / 切回普通视图时调用)。 */ abort: () => void; /** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */ @@ -92,15 +108,22 @@ function buildReportPrompt(opts: { intentReady: ReportIntent | null; seatDeliveries: ReportSeatDelivery[]; consensusText: string; + /** 当前界面主题——深色时报告改用深蓝设计令牌绘制(与 SOUL 的 4a/4b 主题约定对应)。 */ + themeMode: "light" | "dark"; revisionInstruction?: string; previousHtml?: string; }): string { - const { intentReady, seatDeliveries, consensusText, revisionInstruction, previousHtml } = opts; + const { intentReady, seatDeliveries, consensusText, themeMode, revisionInstruction, previousHtml } = + opts; const parts: string[] = []; parts.push( "请基于以下圆桌多智能体研讨的成果,撰写一份图文并茂、以文字叙述为主线的 HTML「方案研讨汇报」," + "用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。", ); + // 模型不知道当下日期——报告头的「生成时间」由前端注入,避免它编一个占位日期。 + parts.push( + `本次报告生成时间:${new Date().toLocaleString("zh-CN", { dateStyle: "long", timeStyle: "short" })}(请原样写进报告头的生成时间处)。`, + ); parts.push( "目标:让用户能轻松读懂这群智能体讨论出了什么方案、为什么是它。请逐个席位提炼其核心观点与交付写成文字," + "再综合成最终方案;图表用来点睛(风险矩阵、时间线、资源占比、贡献雷达等),详略得当——" + @@ -154,10 +177,33 @@ function buildReportPrompt(opts: { } } + // 页面主题跟随当前界面的日/夜间模式:浅色保持原浅红效果,深色按「深蓝 UI 规范」令牌绘制。 + // 令牌在 prompt 里再列一遍(而不只靠 SOUL 的 4b 节),确保已落盘 SOUL 未同步时深色也生效。 + parts.push("# 页面主题"); + if (themeMode === "dark") { + parts.push( + "本次绘制使用【深色(深蓝)主题】——用户当前界面为深色模式。整页必须深蓝底、浅色字," + + "严格按你职责设定「深色主题(深蓝)」的设计令牌取色:" + + "背景层级 页面 #060E24 → 容器 #0C1836 → 卡片 #122347(边框 1px #1A2F5A,圆角 4px,阴影 0 2px 8px rgba(22,119,255,.12));" + + "文字 标题 #FFFFFF / 正文 #C5CFE0 / 次级 #6E88B0 / 弱化 #4A6491;" + + "主色 #1677FF、辅色青 #13C2C2、操作强调 #4096FF;报告头标题下用发光分割线 linear-gradient(90deg, transparent, #1677FF, transparent),普通分割线 #2D4473;" + + "图表系列色序依次 #1677FF #13C2C2 #4096FF #36CFC9 #69B1FF #5CDBD3 #91CAFF #87E8DE,坐标轴/网格线 #2D4473、轴文字 #6E88B0;" + + "语义色 成功 #52C41A / 警告 #FAAD14 / 风险·错误 #F5222D / 信息 #1677FF / 处理中 #13C2C2;" + + "KPI 卡 卡底 #122347、大数值 #FFFFFF、标签 #C5CFE0。" + + "禁止使用浅红体系(#c25555 / #fef4f3);若底稿 HTML 是浅色配色,必须把整页配色重绘成上述深色。", + ); + } else { + parts.push( + "本次绘制使用【浅色(浅红)主题】:主色 #c25555、浅底 #fef4f3、描边 #ede9e9、文字 #1f2937" + + "(即你职责设定里的默认浅色主题)。若底稿 HTML 是深色配色,必须把整页配色重绘成浅色。", + ); + } + parts.push(""); + parts.push( "请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、研讨叙事型结构" + "(结论+KPI → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、" + - "文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" + + "文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、页面配色严格按上文「页面主题」执行;" + "写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。", ); // 语言 + 编码硬约束:偶发整篇输出英文 / 中文乱码的两个根因都在这里堵住。 @@ -168,6 +214,23 @@ function buildReportPrompt(opts: { return parts.join("\n"); } +/** + * 报告线程仍存活时的插话 / 修订 prompt:不重复贴研讨原料与旧 HTML——线程上下文里 + * 都有,token 直接省一半以上;让智能体必要时 read_file 自己写过的底稿找回细节。 + */ +function buildInterjectPrompt(message: string, themeMode: "light" | "dark"): string { + const themeLine = themeMode === "dark" ? "深色(深蓝)" : "浅色(浅红)"; + return [ + "用户对当前的方案总览 HTML 提出了新的修改要求:", + message, + "", + "请在你本线程里已写的 /mnt/user-data/outputs/方案总览.html 基础上落实修改" + + "(必要时可先 read_file 该文件找回最新内容;若该文件尚不存在或只写了一半,就按原始要求重新完整绘制一份)," + + `然后用 write_file 把修改后的完整 HTML 整文件重写到同一路径。页面保持【${themeLine}】主题配色。` + + "写完只用一两句话说明本次改了什么,不要把 HTML 源码贴进对话。", + ].join("\n"); +} + /** 把 SSE 阶段映射成报告智能体的中文状态标签(对齐第 2 步 phaseLabel)。 */ function reportPhaseLabel(phase: StreamPhase, toolName?: string): string { switch (phase) { @@ -195,6 +258,9 @@ export function useStep3Report( const { intentReady, seatDeliveries, consensusText, selectedModel, savedReport, onReportSaved } = options; + // 页面主题:浅色沿用浅红效果,深色让报告智能体按「深蓝 UI 规范」绘制。 + const { theme } = useTheme(); + // onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。 const onReportSavedRef = useRef(onReportSaved); useEffect(() => { @@ -221,6 +287,12 @@ export function useStep3Report( const stubRef = useRef>({}); const runningRef = useRef(false); const aborterRef = useRef(null); + // 报告线程是否仍「存活」(本会话里跑过 run/interject 建的真线程)。存活时插话 + // 直接续线程(免带原料与旧 HTML);草稿恢复的合成线程不算存活。 + const liveThreadRef = useRef(null); + const agentNameRef = useRef("roundtable-report"); + // 当前展示的报告是用哪个主题绘制的(null = 未知,旧草稿没存该字段)。 + const [reportTheme, setReportTheme] = useState<"light" | "dark" | null>(null); // 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。 const bubbleIdRef = useRef(null); // 上一次「自动」打开沙箱的 virtualUrl —— 流式一秒回调几十次,url 没变就跳过; @@ -305,6 +377,30 @@ export function useStep3Report( [], ); + const appendUserDialogue = useCallback((text: string) => { + const id = Date.now() + Math.random(); + setDialogues((prev) => [ + ...prev, + { + id, + sender: "指挥官(修改要求)", + avatarType: "human", + content: text, + time: nowTime(), + confidence: "已发送", + streaming: false, + steps: [], + }, + ]); + }, []); + + /** 把所有仍在流式的气泡冻结(暂停 / 插话打断时用),正文保留已收到的部分。 */ + const freezeStreaming = useCallback((note: string) => { + setDialogues((prev) => + prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)), + ); + }, []); + const finalizeDialogue = useCallback( (id: number, finalContent: string, confidence?: string) => { setDialogues((prev) => @@ -620,6 +716,93 @@ export function useStep3Report( runningRef.current = false; }, []); + /** 单轮 special 流式(带一次自动重试):失败且非用户中断时同线程重发一次再抛。 */ + const streamRound = useCallback( + async (args: { + bubbleId: number; + threadId: string; + agentName: string; + prompt: string; + aborter: AbortController; + }) => { + const { bubbleId, threadId, agentName, prompt, aborter } = args; + for (let attempt = 0; ; attempt += 1) { + try { + return await streamMultiAgent({ + agentType: "special", + agentName, + dAgentThreadId: { [agentName]: threadId }, + displayName: "方案总结", + newMessage: prompt, + model: selectedModel || undefined, + signal: aborter.signal, + onTextDelta: (text) => appendToDialogue(bubbleId, text), + onPhaseChange: (p, toolName, toolArgs) => { + const label = reportPhaseLabel(p, toolName); + setDialogueConfidence(bubbleId, label); + appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, toolArgs); + handleArtifactToolPhase(p, toolName, toolArgs, threadId); + }, + onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId), + }); + } catch (err) { + // 写大 HTML 偶发超时 / 断流:非用户中断时自动重试一次,重试仍失败才抛给上层。 + if (aborter.signal.aborted || attempt >= 1) throw err; + appendStepIfNew(bubbleId, "生成中断,自动重试中", `retry:${attempt + 1}`); + setDialogueConfidence(bubbleId, "自动重试中"); + } + } + }, + [ + selectedModel, + appendToDialogue, + setDialogueConfidence, + appendStepIfNew, + handleArtifactToolPhase, + handleUpstreamFrame, + ], + ); + + /** 一轮结束后的公共收尾:兜底开沙箱 → 对账真实文件回灌 stub → 完成气泡 → 存草稿。 */ + const finishRound = useCallback( + async (args: { + bubbleId: number; + threadId: string; + aborter: AbortController; + closing: string; + }) => { + const { bubbleId, threadId, aborter, closing } = args; + // stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。 + const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0; + if (!hasStubFile) await openRealArtifactFallback(threadId); + // 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。 + const html = hasStubFile + ? await reconcileStubFromRealFile(threadId, aborter.signal) + : ""; + if (aborter.signal.aborted) return; + finalizeDialogue(bubbleId, closing, "绘制完成"); + setPhase("done"); + setReportTheme(theme); + // 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3),连同绘制主题。 + if (html.trim()) { + onReportSavedRef.current?.({ + html, + generatedAt: new Date().toISOString(), + model: selectedModel || "", + summary: closing || "", + themeMode: theme, + }); + } + }, + [ + theme, + selectedModel, + openRealArtifactFallback, + reconcileStubFromRealFile, + finalizeDialogue, + ], + ); + const run = useCallback(async (revisionInstruction?: string) => { if (runningRef.current) return; runningRef.current = true; @@ -628,6 +811,7 @@ export function useStep3Report( setDialogues([]); bubbleIdRef.current = null; lastAutoOpenedUrlRef.current = null; + liveThreadRef.current = null; stubRef.current = {}; setSeatStubMessages({}); @@ -644,6 +828,8 @@ export function useStep3Report( try { const { agentName, threadId } = await initReportThread({ signal: aborter.signal }); if (aborter.signal.aborted) return; + liveThreadRef.current = threadId; + agentNameRef.current = agentName; setReportThreadId(threadId); setActiveArtifactThreadId(threadId); setDialogues((prev) => @@ -655,50 +841,13 @@ export function useStep3Report( intentReady, seatDeliveries, consensusText, + themeMode: theme, revisionInstruction, previousHtml: savedReport?.html, }); - const res = await streamMultiAgent({ - agentType: "special", - agentName, - dAgentThreadId: { [agentName]: threadId }, - displayName: "方案总结", - newMessage: prompt, - model: selectedModel || undefined, - signal: aborter.signal, - onTextDelta: (text) => appendToDialogue(bubbleId, text), - onPhaseChange: (p, toolName, args) => { - const label = reportPhaseLabel(p, toolName); - setDialogueConfidence(bubbleId, label); - appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, args); - handleArtifactToolPhase(p, toolName, args, threadId); - }, - onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId), - }); + const res = await streamRound({ bubbleId, threadId, agentName, prompt, aborter }); if (aborter.signal.aborted) return; - - // stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。 - const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0; - if (!hasStubFile) await openRealArtifactFallback(threadId); - - // 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。 - const html = hasStubFile - ? await reconcileStubFromRealFile(threadId, aborter.signal) - : ""; - if (aborter.signal.aborted) return; - - finalizeDialogue(bubbleId, res.content, "绘制完成"); - setPhase("done"); - - // 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。 - if (html.trim()) { - onReportSavedRef.current?.({ - html, - generatedAt: new Date().toISOString(), - model: selectedModel || "", - summary: res.content || "", - }); - } + await finishRound({ bubbleId, threadId, aborter, closing: res.content }); } catch (err) { if (aborter.signal.aborted) return; const msg = err instanceof Error ? err.message : String(err); @@ -706,25 +855,118 @@ export function useStep3Report( finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败"); setPhase("error"); } finally { - runningRef.current = false; + // 插话可能已打断本轮并接管 runningRef —— 只有自己还是当前请求时才释放。 + if (aborterRef.current === aborter) runningRef.current = false; } }, [ intentReady, seatDeliveries, consensusText, + theme, savedReport?.html, - selectedModel, appendStreamingDialogue, - appendToDialogue, - setDialogueConfidence, - appendStepIfNew, + streamRound, + finishRound, finalizeDialogue, - handleArtifactToolPhase, - handleUpstreamFrame, - openRealArtifactFallback, - reconcileStubFromRealFile, ]); + /** 暂停:中断在途绘制,流式气泡冻结为「已暂停」,沙箱保留已写内容。 */ + const pause = useCallback(() => { + if (!runningRef.current) return; + abort(); + freezeStreaming("已暂停"); + setPhase("paused"); + }, [abort, freezeStreaming]); + + /** + * 插话 / 修改要求(随时可发): + * - 绘制中:先打断当前轮(气泡冻结为「已打断」),再发修改要求; + * - 报告线程存活:免带研讨原料与旧 HTML,直接续同一线程(智能体上下文里都有, + * 必要时它会 read_file 自己写过的底稿); + * - 线程不在(草稿恢复 / 首轮线程没建成):新建线程,回落「原料 + 旧 HTML 底稿」全量 prompt。 + */ + const interject = useCallback( + async (message: string) => { + const text = message.trim(); + if (!text) return; + const wasBusy = runningRef.current; + abort(); + if (wasBusy) freezeStreaming("已打断"); + + runningRef.current = true; + setError(null); + const aborter = new AbortController(); + aborterRef.current = aborter; + + appendUserDialogue(text); + const bubbleId = appendStreamingDialogue({ + sender: "方案总结智能体", + avatarType: "blue", + }); + bubbleIdRef.current = bubbleId; + + try { + let threadId = liveThreadRef.current; + let prompt: string; + if (threadId) { + prompt = buildInterjectPrompt(text, theme); + } else { + setPhase("creating"); + const init = await initReportThread({ signal: aborter.signal }); + if (aborter.signal.aborted) return; + threadId = init.threadId; + liveThreadRef.current = threadId; + agentNameRef.current = init.agentName; + setReportThreadId(threadId); + setActiveArtifactThreadId(threadId); + prompt = buildReportPrompt({ + intentReady, + seatDeliveries, + consensusText, + themeMode: theme, + revisionInstruction: text, + previousHtml: savedReport?.html, + }); + } + setDialogues((prev) => + prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)), + ); + setPhase("streaming"); + const res = await streamRound({ + bubbleId, + threadId, + agentName: agentNameRef.current, + prompt, + aborter, + }); + if (aborter.signal.aborted) return; + await finishRound({ bubbleId, threadId, aborter, closing: res.content }); + } catch (err) { + if (aborter.signal.aborted) return; + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + finalizeDialogue(bubbleId, `(处理失败) ${msg}`, "处理失败"); + setPhase("error"); + } finally { + if (aborterRef.current === aborter) runningRef.current = false; + } + }, + [ + abort, + freezeStreaming, + appendUserDialogue, + appendStreamingDialogue, + theme, + intentReady, + seatDeliveries, + consensusText, + savedReport?.html, + streamRound, + finishRound, + finalizeDialogue, + ], + ); + const start = useCallback((revisionInstruction?: string) => { void run(revisionInstruction); }, [run]); @@ -735,6 +977,10 @@ export function useStep3Report( (snap: DraftStep3Snapshot) => { abort(); runningRef.current = false; + // 合成线程不是真线程:插话时走「新建线程 + 旧 HTML 底稿」的回落路径。 + liveThreadRef.current = null; + // 记录快照绘制主题(旧草稿可能没存 → null,不提示不匹配)。 + setReportTheme(snap.themeMode ?? null); const synthThreadId = "roundtable-report-restored"; const messageId = `roundtable-report-stub-${synthThreadId}`; const toolCallId = `tc-${synthThreadId}-0`; @@ -797,6 +1043,10 @@ export function useStep3Report( void run(revisionInstruction); }, [abort, run]); + // 报告已展示(done)且记录过绘制主题、而界面主题已切换 → 提示重新绘制。 + const themeMismatch = + phase === "done" && reportTheme !== null && reportTheme !== theme; + return { phase, error, @@ -807,6 +1057,9 @@ export function useStep3Report( start, hydrateSaved, redraw, + pause, + interject, + themeMismatch, abort, openArtifactFromStep, openArtifactPreview, diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index 30e19df9..8ae53352 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -108,6 +108,14 @@ function buildMaterials(opts: { parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)"); parts.push(cp); parts.push(""); + // 编排提示里常藏着用户对"最终总结"的明确要求——让模型先提取再动笔, + // 否则结构化输出/重点席位这类要求容易被默认章节结构盖掉。 + parts.push( + "> 动笔前请先从上面这段编排提示里提取用户对最终总结报告的明确要求(输出结构/格式、重点总结哪些席位、口径风格、详略取舍),并逐条满足:" + + "用户指定了输出结构(章节、表格、编号清单、固定模板等)就完全按用户结构输出、放弃默认章节结构;" + + "点名了重点席位就把它们前置并显著加大篇幅,未点名席位一两句带过;要求了详略取舍就照办,未被要求的章节可直接省略。", + ); + parts.push(""); } if (intentReady?.objective) { @@ -157,9 +165,11 @@ function buildSummaryPrompt(opts: { parts.push(""); parts.push(buildMaterials(opts)); parts.push( - "请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、叙事型结构" + - "(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、全程简体中文;" + - "若上面给了「业务链总控编排提示」,以它的要求优先组织报告。", + "请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" + + "报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" + + "但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," + + "那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" + + "若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。", ); return parts.join("\n"); } diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts new file mode 100644 index 00000000..4886d5ec --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts @@ -0,0 +1,122 @@ +/** + * flow-graph.ts 纯函数单测。 + * 运行方式(项目无 vitest,对齐 dag-types.test.ts): + * npx esbuild src/roundtable-planning/lib/flow-graph.test.ts --bundle --format=esm \ + * --platform=node --outfile=.tmp-tests/flow-graph.test.mjs + * node --test .tmp-tests/flow-graph.test.mjs + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + extractFlowGraph, + layoutFlowGraph, + stripFlowFence, +} from "./flow-graph"; + +const FULL = [ + "报告正文……", + "```flow-json", + '{"title":"方案执行流程","nodes":[', + '{"id":"n1","label":"环境评估","type":"stage","detail":"评估现场环境"},', + '{"id":"n2","label":"编制方案","type":"task","from":["n1"]},', + '{"id":"n3","label":"是否可行","type":"decision","from":[{"id":"n2","label":"评审"}]}', + "]}", + "```", +].join("\n"); + +test("extractFlowGraph:完整块解析出节点/边/标题", () => { + const g = extractFlowGraph(FULL); + assert.ok(g); + assert.equal(g.title, "方案执行流程"); + assert.equal(g.nodes.length, 3); + assert.equal(g.nodes[0].type, "stage"); + assert.deepEqual(g.edges, [ + { from: "n1", to: "n2" }, + { from: "n2", to: "n3", label: "评审" }, + ]); +}); + +test("extractFlowGraph:流式半截(fence 未闭合 + 尾对象截断)只取完整节点", () => { + const partial = [ + "正文", + "```flow-json", + '{"title":"T","nodes":[', + '{"id":"a","label":"A","type":"start"},', + '{"id":"b","label":"B","type":"task","from":["a"]},', + '{"id":"c","label":"C","ty', // 截断 + ].join("\n"); + const g = extractFlowGraph(partial); + assert.ok(g); + assert.equal(g.nodes.length, 2); + assert.deepEqual(g.edges, [{ from: "a", to: "b" }]); +}); + +test("extractFlowGraph:未知 type 落 task、重复 id 去重、悬空边丢弃", () => { + const text = [ + "```flow-json", + '{"nodes":[', + '{"id":"x","label":"X","type":"banana"},', + '{"id":"x","label":"X-dup","type":"task"},', + '{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}', + "]}", + "```", + ].join("\n"); + const g = extractFlowGraph(text); + assert.ok(g); + assert.equal(g.nodes.length, 2); + assert.equal(g.nodes[0].type, "task"); + assert.equal(g.nodes[0].label, "X"); + assert.deepEqual(g.edges, [{ from: "x", to: "y" }]); +}); + +test("extractFlowGraph:无 fence / 无完整节点返回 null", () => { + assert.equal(extractFlowGraph("普通回复,没有结构化数据"), null); + assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null); +}); + +test("extractFlowGraph:兼容独立 edges 数组(source/target 别名)", () => { + const text = [ + "```flow-json", + '{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],', + '"edges":[{"source":"a","target":"b","label":"走"}]}', + "```", + ].join("\n"); + const g = extractFlowGraph(text); + assert.ok(g); + assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "走" }]); +}); + +test("stripFlowFence:闭合块替换为引导语并保留前后文", () => { + const out = stripFlowFence(FULL, false); + assert.ok(out.includes("报告正文")); + assert.ok(!out.includes("```flow-json")); + assert.ok(out.includes("已生成结构化流程数据")); +}); + +test("stripFlowFence:未闭合(流式中)用「正在输出」措辞", () => { + const out = stripFlowFence('正文\n```flow-json\n{"nodes":[', true); + assert.ok(out.includes("正在输出结构化流程数据")); + assert.ok(!out.includes("```flow-json")); +}); + +test("layoutFlowGraph:按前驱分层(根 0 层,子层递增),保持原始顺序", () => { + const g = extractFlowGraph(FULL)!; + const positioned = layoutFlowGraph(g); + assert.equal(positioned[0].y, 0); + assert.ok(positioned[1].y > positioned[0].y); + assert.ok(positioned[2].y > positioned[1].y); + assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]); +}); + +test("layoutFlowGraph:环不死循环", () => { + const text = [ + "```flow-json", + '{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}', + "```", + ].join("\n"); + const g = extractFlowGraph(text)!; + const positioned = layoutFlowGraph(g); + assert.equal(positioned.length, 2); + positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y))); +}); diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.ts new file mode 100644 index 00000000..b921134d --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/flow-graph.ts @@ -0,0 +1,282 @@ +/** + * Step 3「总结报告」结构化流程图 —— flow-json 契约的纯函数层。 + * + * 契约(与 roundtable-summary SOUL 对齐):用户要求结构化输出/流程图时,总结智能体 + * 在对话回复末尾追加一个 ```flow-json 代码块: + * + * ```flow-json + * {"title":"…","nodes":[ + * {"id":"n1","label":"…","type":"stage","detail":"…"}, + * {"id":"n2","label":"…","type":"task","detail":"…","from":["n1"]}, + * {"id":"n3","label":"…","type":"decision","detail":"…","from":[{"id":"n2","label":"是"}]} + * ]} + * ``` + * + * 关键设计:**边长在节点身上**(`from` = 前驱列表)——流式输出时每解析出一个完整 + * 节点,它的连线随之出现,前端给「最新节点的连线」挂蚂蚁线动画即可实现「画到哪、 + * 哪条线在爬」。解析必须**容错半截 JSON**(流式中代码块尚未闭合):用平衡括号扫描 + * 把 nodes 数组里**已完整**的对象逐个 JSON.parse,截断的尾对象直接忽略。 + */ + +export type FlowNodeType = + | "start" + | "end" + | "stage" + | "task" + | "decision" + | "risk" + | "deliverable" + | "role"; + +export interface FlowGraphNode { + id: string; + label: string; + type: FlowNodeType; + detail?: string; +} + +export interface FlowGraphEdge { + from: string; + to: string; + label?: string; +} + +export interface FlowGraphData { + title: string; + nodes: FlowGraphNode[]; + edges: FlowGraphEdge[]; +} + +/** 各类型节点的配色(浅底 + 深字 + 强调点),前端按 type 区分颜色。 */ +export const FLOW_NODE_TYPE_META: Record< + FlowNodeType, + { label: string; bg: string; border: string; text: string; dot: string } +> = { + start: { label: "开始", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" }, + end: { label: "结束", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" }, + stage: { label: "阶段", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" }, + task: { label: "任务", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" }, + decision: { label: "判断", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" }, + risk: { label: "风险", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" }, + deliverable: { label: "交付物", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" }, + role: { label: "角色", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" }, +}; + +const VALID_TYPES = new Set(Object.keys(FLOW_NODE_TYPE_META)); + +export const FLOW_FENCE_OPEN = "```flow-json"; + +/** + * 平衡括号扫描:从 `key`(如 `"nodes"`)后面的 `[` 开始,把数组里**已完整闭合**的 + * 顶层 `{…}` 逐个 JSON.parse 收集;跳过字符串内的括号与转义;数组未闭合(流式半截) + * 时收集到哪算哪,截断的尾对象忽略。 + */ +function collectObjects(text: string, key: string): Record[] { + const keyIdx = text.indexOf(key); + if (keyIdx < 0) return []; + const arrStart = text.indexOf("[", keyIdx); + if (arrStart < 0) return []; + const out: Record[] = []; + let depth = 0; + let objStart = -1; + let inStr = false; + let esc = false; + for (let i = arrStart + 1; i < text.length; i++) { + const ch = text[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') { + inStr = true; + continue; + } + if (ch === "{") { + if (depth === 0) objStart = i; + depth++; + continue; + } + if (ch === "}") { + depth--; + if (depth === 0 && objStart >= 0) { + try { + const parsed = JSON.parse(text.slice(objStart, i + 1)) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + out.push(parsed as Record); + } + } catch { + /* 截断/非法对象:跳过 */ + } + objStart = -1; + } + continue; + } + if (ch === "]" && depth === 0) break; // 数组正常闭合 + } + return out; +} + +function asString(v: unknown): string { + return typeof v === "string" ? v.trim() : ""; +} + +/** 把 raw 节点对象规范化;id/label 缺失返回 null。未知 type 落到 task。 */ +function toNode(raw: Record): FlowGraphNode | null { + const id = asString(raw.id); + const label = asString(raw.label) || asString(raw.name); + if (!id || !label) return null; + const rawType = asString(raw.type).toLowerCase(); + const type = (VALID_TYPES.has(rawType) ? rawType : "task") as FlowNodeType; + const detail = asString(raw.detail) || asString(raw.description) || undefined; + return { id, label, type, detail }; +} + +/** 节点 `from` 字段(前驱列表,元素是 "n1" 或 {id:"n1",label:"是"})→ 入边列表。 */ +function edgesFromNode(raw: Record, nodeId: string): FlowGraphEdge[] { + const from = raw.from ?? raw.prev; + if (!Array.isArray(from)) return []; + const out: FlowGraphEdge[] = []; + for (const entry of from) { + if (typeof entry === "string" && entry.trim()) { + out.push({ from: entry.trim(), to: nodeId }); + } else if (entry && typeof entry === "object") { + const o = entry as Record; + const fromId = asString(o.id) || asString(o.from); + if (!fromId) continue; + const label = asString(o.label) || undefined; + out.push({ from: fromId, to: nodeId, label }); + } + } + return out; +} + +/** 独立 `edges` 数组(兼容形态:{from,to,label} / {source,target})→ 边。 */ +function toEdge(raw: Record): FlowGraphEdge | null { + const from = asString(raw.from) || asString(raw.source); + const to = asString(raw.to) || asString(raw.target); + if (!from || !to) return null; + const label = asString(raw.label) || undefined; + return { from, to, label }; +} + +/** + * 从(可能流式半截的)回复文本里提取最后一个 ```flow-json 块并解析。 + * 没有 fence 或一个完整节点都没有 → null。 + */ +export function extractFlowGraph(text: string): FlowGraphData | null { + const start = text.lastIndexOf(FLOW_FENCE_OPEN); + if (start < 0) return null; + let body = text.slice(start + FLOW_FENCE_OPEN.length); + const close = body.indexOf("```"); + if (close >= 0) body = body.slice(0, close); + + const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body); + let title = ""; + if (titleMatch) { + try { + title = JSON.parse(`"${titleMatch[1]}"`) as string; + } catch { + title = titleMatch[1]; + } + } + + const rawNodes = collectObjects(body, '"nodes"'); + const nodes: FlowGraphNode[] = []; + const seen = new Set(); + const edges: FlowGraphEdge[] = []; + for (const raw of rawNodes) { + const node = toNode(raw); + if (!node || seen.has(node.id)) continue; + seen.add(node.id); + nodes.push(node); + edges.push(...edgesFromNode(raw, node.id)); + } + // 兼容独立 edges 数组(contract 主推 from-on-node,但模型可能两种都写)。 + for (const raw of collectObjects(body, '"edges"')) { + const edge = toEdge(raw); + if (edge) edges.push(edge); + } + if (nodes.length === 0) return null; + + // 只保留两端节点都已出现的边 + 去重(流式中前驱未出现的边等下一轮解析再进来)。 + const edgeKeys = new Set(); + const validEdges = edges.filter((e) => { + if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false; + const key = `${e.from}->${e.to}`; + if (edgeKeys.has(key)) return false; + edgeKeys.add(key); + return true; + }); + return { title, nodes, edges: validEdges }; +} + +/** + * 把回复里的 flow-json 块替换成一句人话(气泡里不展示生 JSON)。 + * 块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导去看流程图画布。 + */ +export function stripFlowFence(text: string, streaming: boolean): string { + const start = text.lastIndexOf(FLOW_FENCE_OPEN); + if (start < 0) return text; + const after = text.slice(start + FLOW_FENCE_OPEN.length); + const close = after.indexOf("```"); + const tail = close >= 0 ? after.slice(close + 3) : ""; + const placeholder = + close < 0 && streaming + ? "\n\n> 📊 正在输出结构化流程数据,流程图画布同步绘制中…" + : "\n\n> 📊 已生成结构化流程数据,可在右侧「流程图」画布查看(点击节点看详情)。"; + return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`; +} + +// ── 分层布局(自上而下:前驱层 +1,同层横向铺开)──────────────────────────── + +export interface PositionedFlowNode extends FlowGraphNode { + x: number; + y: number; +} + +export const FLOW_NODE_W = 176; +const X_GAP = 224; +const Y_GAP = 132; + +/** 最长路径分层(环保护:访问中的节点按 0 层处理,保证终止)。 */ +export function layoutFlowGraph(data: FlowGraphData): PositionedFlowNode[] { + const preds = new Map(); + for (const e of data.edges) { + const list = preds.get(e.to); + if (list) list.push(e.from); + else preds.set(e.to, [e.from]); + } + const memo = new Map(); + const visiting = new Set(); + const layerOf = (id: string): number => { + const cached = memo.get(id); + if (cached !== undefined) return cached; + if (visiting.has(id)) return 0; // 环:斩断,按根层处理 + visiting.add(id); + const ps = preds.get(id) ?? []; + const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1; + visiting.delete(id); + memo.set(id, layer); + return layer; + }; + + const byLayer = new Map(); + for (const n of data.nodes) { + const layer = layerOf(n.id); + const list = byLayer.get(layer); + if (list) list.push(n); + else byLayer.set(layer, [n]); + } + + const pos = new Map(); + for (const [layer, nodes] of byLayer) { + const rowWidth = (nodes.length - 1) * X_GAP; + nodes.forEach((n, i) => { + pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP }); + }); + } + // 保持原始(流式)顺序输出,方便调用方做「最新节点」判断。 + return data.nodes.map((n) => ({ ...n, ...pos.get(n.id)! })); +} diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 9be1df56..283338e8 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -39,6 +39,7 @@ import { SplitSquareVertical, Trash2, UserPlus, + Wand2, X, } from "lucide-react"; import { @@ -79,6 +80,7 @@ import { createChain, deleteChain, listChains, + optimizeChainPrompt, setChainPublic, updateChain, } from "@/roundtable-planning/api/chains"; @@ -317,6 +319,80 @@ export default function BusinessChainEditorPage() { setStageGroups((prev) => setStageGoal(prev, stageIdx, goal)); }, []); + // ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)──────────────────────── + const [optimizingCoordinator, setOptimizingCoordinator] = useState(false); + const [goalOptimizingIdx, setGoalOptimizingIdx] = useState(null); + + // 把链条当前结构拼成上下文,让模型贴着「这条链有哪些阶段/席位」来优化。 + const buildChainContextText = useCallback((): string => { + const lines: string[] = []; + if (title.trim()) lines.push(`链条标题:${title.trim()}`); + if (description.trim()) lines.push(`链条描述:${description.trim()}`); + stageGroups.forEach((g, i) => { + const seatNames = g.seats.map((s) => s.name || s.agent_id).join("、") || "(空)"; + const goal = g.goal?.trim() ? `;阶段目标:${g.goal.trim()}` : ""; + lines.push(`Stage ${i + 1}(${g.seats.length > 1 ? "并行" : "串行"}):${seatNames}${goal}`); + }); + return lines.join("\n"); + }, [title, description, stageGroups]); + + const handleOptimizeCoordinatorPrompt = useCallback(async () => { + if (optimizingCoordinator) return; + const text = coordinatorPrompt.trim(); + if (!text) { + toast.warning("请先写一版总控提示词,再让 AI 优化"); + return; + } + setOptimizingCoordinator(true); + try { + const optimized = await optimizeChainPrompt({ + promptText: text, + kind: "coordinator", + context: buildChainContextText(), + }); + if (optimized.trim()) { + setCoordinatorPrompt(optimized.trim().slice(0, 4000)); + toast.success("总控提示词已优化,可继续手动调整"); + } else { + toast.error("优化失败:模型未返回内容"); + } + } catch (err) { + toast.error(`优化失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setOptimizingCoordinator(false); + } + }, [optimizingCoordinator, coordinatorPrompt, buildChainContextText]); + + const handleOptimizeStageGoal = useCallback( + async (stageIdx: number) => { + if (goalOptimizingIdx !== null) return; + const goal = stageGroups[stageIdx]?.goal?.trim() ?? ""; + if (!goal) { + toast.warning("请先写一版本阶段目标,再让 AI 优化"); + return; + } + setGoalOptimizingIdx(stageIdx); + try { + const optimized = await optimizeChainPrompt({ + promptText: goal, + kind: "stage_goal", + context: `${buildChainContextText()}\n本次优化的是 Stage ${stageIdx + 1} 的阶段目标。`, + }); + if (optimized.trim()) { + setStageGroups((prev) => setStageGoal(prev, stageIdx, optimized.trim().slice(0, 500))); + toast.success(`Stage ${stageIdx + 1} 阶段目标已优化`); + } else { + toast.error("优化失败:模型未返回内容"); + } + } catch (err) { + toast.error(`优化失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setGoalOptimizingIdx(null); + } + }, + [goalOptimizingIdx, stageGroups, buildChainContextText], + ); + const splitSeat = useCallback((agentId: string) => { setStageGroups((prev) => splitSeatToNewStage(prev, agentId)); }, []); @@ -759,9 +835,32 @@ export default function BusinessChainEditorPage() { >
总控提示词 - - 写给总控:怎么派活、做什么(仅分层研讨生效,可选) - +
+ + 写给总控:怎么派活、做什么(仅分层研讨生效,可选) + + +