diff --git a/AGENTS.md b/AGENTS.md index 69d4c914..d12c33ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) +- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. diff --git a/CLAUDE.md b/CLAUDE.md index 52c64c90..c0c669d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) - **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API. +- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). - **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`. diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 247bdb07..812ed72e 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -80,6 +80,12 @@ export const sidebarMenuItems: MenuItem[] = [ }, { id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, + { + id: "qa-research-chat", + label: "课题研究(对话版)", + icon: FileText, + path: "/page/canvas/ai-writing-chat", + }, ], }, { diff --git a/frontend-web/src/open-canvas/api/ai-writing-intent.ts b/frontend-web/src/open-canvas/api/ai-writing-intent.ts new file mode 100644 index 00000000..81ca39b8 --- /dev/null +++ b/frontend-web/src/open-canvas/api/ai-writing-intent.ts @@ -0,0 +1,234 @@ +/** + * 对话驱动写作台的「意图解析」客户端。 + * + * 两级解析: + * 1. `fastPathIntent`:高频确定性短句的前端规则快路径(确认/继续/重新搜索/重写大纲…), + * 零延迟、零误判;**高代价动作(定稿/强制定稿/覆盖式重搜的模糊说法)绝不走快路径**。 + * 2. `parseWritingIntent`:POST /api/ai-writing/sessions/{id}/intent —— 后端读该会话的 + * graph checkpoint(素材/大纲/草稿/审核摘要)+ LLM 解析,返回结构化意图契约。 + * + * 返回的 payload 字段与 `UserInterventionPayload` 对齐,可直接喂 `submitIntervention`。 + */ +import { getBackendBaseURL } from '@/core/config'; +import { apiFetch } from '@/core/api'; + +import type { BlockedSection, UserInterventionPayload } from '../types/ai-writing'; + +export type WritingIntentSource = 'rule' | 'llm'; + +export interface WritingIntentResult { + /** 意图标识:confirm_materials / research_again / revise_draft / ask_content / clarify / run_background… */ + intent: string; + /** 需 resume 的暂停点(后端以 checkpoint 里真实 interrupt 为准回传);无动作时为 null */ + pausePoint: string | null; + /** 映射到现有协议的 action;null = 不执行图动作(问答/澄清/控制类) */ + action: string | null; + /** action 非空时可直接喂 submitIntervention 的载荷 */ + payload: UserInterventionPayload | null; + /** 用户的要求需要现有素材之外的信息(素材确认阶段可自动重搜续跑,其余阶段仅提示) */ + needResearch: boolean; + researchQuery: string; + /** 问答类意图(ask_content / ask_meta)的直接回答 */ + answer: string; + confidence: number; + /** 非空 = 需要向用户澄清,不执行任何动作 */ + clarify: string; + source: WritingIntentSource; +} + +/** 前端判定「执行动作」的置信度下限(后端 normalize 已兜底一层,这里再保守一档)。 */ +export const INTENT_CONFIDENCE_THRESHOLD = 0.55; + +// ── 规则快路径 ──────────────────────────────────────────────────────────────── + +const CONFIRM_RE = /^(继续|下一步|确认|好的|好|可以|行|嗯|没问题|OK|ok|Ok|开始吧|通过)$/; +const RESEARCH_AGAIN_RE = /^(重新搜索|重新检索|重搜|再搜一次|重新搜)$/; +const REOUTLINE_RE = /^(重新规划大纲|重写大纲|大纲重来|重新规划)$/; +const TO_EDITOR_RE = /^((交给|让)?编辑(继续)?审核|送审|提交(编辑)?审核)$/; +const ACCEPT_REVIEW_RE = /^(按编辑(的)?意见(修改|改)|采纳编辑意见)$/; +const SECTION_ALL_SUPPLEMENT_RE = /^(都|全部|全都)(补充|补)检索(素材)?$/; +const SECTION_ALL_LOOSE_RE = /^(都|全部|全都)(按通用知识|用通用知识)?(续写|写|继续写)$/; + +function stripPunct(text: string): string { + return text.trim().replace(/[。..!!~~\s]+$/g, ''); +} + +function ruleResult( + intent: string, + pausePoint: string | null, + action: string | null, + payload: UserInterventionPayload | null, +): WritingIntentResult { + return { + intent, + pausePoint, + action, + payload, + needResearch: false, + researchQuery: '', + answer: '', + confidence: 1, + clarify: '', + source: 'rule', + }; +} + +/** + * 高频短句规则快路径。命中返回结果,未命中返回 null(走后端 LLM)。 + * + * 只做「当前暂停点语义完全无歧义」的映射;一切带自由文本的要求(补充检索内容、 + * 修改意见等)与高代价动作(定稿/强制定稿)一律交给后端 LLM + 澄清机制。 + */ +export function fastPathIntent( + rawText: string, + ctx: { + pausePoint: string | null; + isPaused: boolean; + blockedSections?: BlockedSection[]; + }, +): WritingIntentResult | null { + const text = stripPunct(rawText); + if (!text || text.length > 12) return null; + + // 「暂停中 + 继续」= 恢复运行(控制类,优先于阶段确认语义) + if (ctx.isPaused && CONFIRM_RE.test(text)) { + return ruleResult('resume_writing', null, null, null); + } + + const pp = ctx.pausePoint; + if (!pp) return null; + + if (pp === 'material_confirm') { + if (CONFIRM_RE.test(text)) { + // 不带 approvedMaterialIds → 后端默认采用全部素材 + return ruleResult('confirm_materials', pp, 'confirm', { action: 'confirm' }); + } + if (RESEARCH_AGAIN_RE.test(text)) { + return ruleResult('research_again', pp, 're_search', { action: 're_search', approvedMaterialIds: [] }); + } + return null; + } + + if (pp === 'outline_confirm') { + if (CONFIRM_RE.test(text)) { + return ruleResult('confirm_outline', pp, 'confirm', { action: 'confirm' }); + } + if (REOUTLINE_RE.test(text)) { + return ruleResult('reoutline', pp, 're_outline', { action: 're_outline' }); + } + return null; + } + + if (pp === 'draft_confirm') { + // 「继续/下一步」在草稿阶段的安全缺省是送审(绝不缺省成定稿) + if (CONFIRM_RE.test(text) || TO_EDITOR_RE.test(text)) { + return ruleResult('to_editor', pp, 'to_editor', { action: 'to_editor' }); + } + return null; + } + + if (pp === 'review_confirm') { + if (CONFIRM_RE.test(text) || ACCEPT_REVIEW_RE.test(text)) { + // 不带 selectedIssueIndices → 后端按全部意见修改(与后台挂起的默认放行一致) + return ruleResult('accept_review', pp, 'accept_review', { action: 'accept_review' }); + } + return null; + } + + if (pp === 'section_help') { + const blocked = ctx.blockedSections ?? []; + if (blocked.length === 0) return null; + const buildDecisions = (decision: 'supplement' | 'loose') => ({ + action: 'section_help', + sectionDecisions: blocked.map((b) => ({ sectionTitle: b.sectionTitle, decision })), + }); + if (SECTION_ALL_SUPPLEMENT_RE.test(text)) { + return ruleResult('section_supplement_all', pp, 'section_help', buildDecisions('supplement')); + } + if (SECTION_ALL_LOOSE_RE.test(text)) { + return ruleResult('section_loose_all', pp, 'section_help', buildDecisions('loose')); + } + // 「继续/确认」在求助点语义含糊(补检索 or 续写?),交给 LLM 澄清 + return null; + } + + return null; +} + +// ── 后端 LLM 解析 ───────────────────────────────────────────────────────────── + +interface RawIntentResponse { + intent?: string; + pause_point?: string | null; + action?: string | null; + payload?: Record | null; + need_research?: boolean; + research_query?: string | null; + answer?: string | null; + confidence?: number; + clarify?: string | null; +} + +function normalizePayload(raw: Record | null | undefined, action: string | null): UserInterventionPayload | null { + if (!action) return null; + const p = raw ?? {}; + const payload: UserInterventionPayload = { action }; + if (Array.isArray(p.approvedMaterialIds)) payload.approvedMaterialIds = p.approvedMaterialIds.map(String); + if (typeof p.userQuery === 'string' && p.userQuery.trim()) payload.userQuery = p.userQuery.trim(); + if (typeof p.outlineFeedback === 'string' && p.outlineFeedback.trim()) payload.outlineFeedback = p.outlineFeedback.trim(); + if (typeof p.userRevisionNotes === 'string' && p.userRevisionNotes.trim()) payload.userRevisionNotes = p.userRevisionNotes.trim(); + if (typeof p.overrideVerdict === 'boolean') payload.overrideVerdict = p.overrideVerdict; + if (Array.isArray(p.selectedIssueIndices)) { + payload.selectedIssueIndices = p.selectedIssueIndices + .map((n) => Number(n)) + .filter((n) => Number.isInteger(n) && n >= 0); + } + if (Array.isArray(p.sectionDecisions)) { + payload.sectionDecisions = (p.sectionDecisions as Array>) + .map((d) => ({ + sectionTitle: String(d.sectionTitle ?? d.section_title ?? ''), + decision: (['supplement', 'loose', 'delete'].includes(String(d.decision)) + ? String(d.decision) + : 'loose') as 'supplement' | 'loose' | 'delete', + })) + .filter((d) => d.sectionTitle); + } + return payload; +} + +/** 后端 LLM 意图解析。会话必须已存在(sessionId = graph thread_id)。 */ +export async function parseWritingIntent( + sessionId: string, + body: { text: string; status?: string; pausePoint?: string | null }, +): Promise { + const resp = await apiFetch( + `${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/intent`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: body.text, + status: body.status ?? null, + pause_point: body.pausePoint ?? null, + }), + }, + ); + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + throw new Error(`意图解析失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`); + } + const raw = (await resp.json()) as RawIntentResponse; + const action = raw.action || null; + return { + intent: raw.intent || 'unknown', + pausePoint: raw.pause_point || null, + action, + payload: normalizePayload(raw.payload, action), + needResearch: Boolean(raw.need_research), + researchQuery: (raw.research_query || '').trim(), + answer: (raw.answer || '').trim(), + confidence: typeof raw.confidence === 'number' ? Math.max(0, Math.min(1, raw.confidence)) : 0, + clarify: (raw.clarify || '').trim(), + source: 'llm', + }; +} diff --git a/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx b/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx new file mode 100644 index 00000000..9bd14ffc --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx @@ -0,0 +1,200 @@ +/** + * 对话驱动写作台的「统一信息流」时间线。 + * + * 与旧版 `../ai-writing/AIWritingTimeline.tsx` 的关系:复制其流程事件渲染逻辑 + * (TimelineStep + 干预槽对齐),再按业务定制:**把意图层对话气泡(用户消息 / + * 问答回答 / 澄清 / 执行确认)按时间顺序插进流程事件之间**,形成一条完整的 + * 时间流 —— 用户发消息后,由它触发的检索/操作事件出现在消息**下方**(而不是 + * 旧版「时间线在上、对话在下」两块分离导致的顺序错乱)。 + * + * 时序对齐方式:每条对话消息记录创建时的 `progressEvents.length` 作为锚点 + * (anchor),渲染时消息插在「前 anchor 条事件之后、第 anchor+1 条事件之前」。 + */ +import type { ReactNode } from 'react'; +import { BadgeCheck, Bot, CircleHelp, Info } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing'; +import { TimelineStep } from '../ai-writing/TimelineStep'; +import { CompletedInterventionCard } from '../ai-writing/CompletedInterventionCard'; +import { UserRequirementCard } from '../ai-writing/UserRequirementCard'; + +export type ChatKind = 'text' | 'clarify' | 'answer' | 'ack' | 'notice'; + +export interface ChatFeedMessage { + id: string; + role: 'user' | 'assistant'; + kind: ChatKind; + text: string; + /** 创建该消息时 progressEvents 的长度:消息渲染在前 anchor 条事件之后。 */ + anchor: number; +} + +const DISPLAYED_TYPES = new Set([ + 'step_start', + 'step_done', + 'sample_profile_ready', + 'materials_ready', + 'outline_ready', + 'draft_ready', + 'review_ready', + 'await_user', + 'resumed', + 'notice', + 'model_switch', + 'done', + 'error', +]); + +type FeedRow = + | { key: string; type: 'event'; event: ProgressEvent; slot: ReactNode; extendLineThroughSlot: boolean } + | { key: string; type: 'chat'; msg: ChatFeedMessage }; + +interface ChatFlowTimelineProps { + events: ProgressEvent[]; + completedInterventions: CompletedIntervention[]; + currentPause: ProgressEvent | null; + currentInterventionSlot?: ReactNode; + chatMessages: ChatFeedMessage[]; + /** 由底部输入框发起的干预序号集合:这些干预的「我的要求」卡不再重复渲染 + * (用户气泡本身已按时间顺序展示了原话)。 */ + suppressedRequirementIndices?: Set; +} + +export function ChatFlowTimeline({ + events, + completedInterventions, + currentPause, + currentInterventionSlot, + chatMessages, + suppressedRequirementIndices, +}: ChatFlowTimelineProps) { + if (events.length === 0 && chatMessages.length === 0) return null; + + // 消息按锚点分桶(锚点超出当前事件数的挂在末尾) + const msgsByAnchor = new Map(); + for (const msg of chatMessages) { + const anchor = Math.max(0, Math.min(msg.anchor, events.length)); + const bucket = msgsByAnchor.get(anchor); + if (bucket) bucket.push(msg); + else msgsByAnchor.set(anchor, [msg]); + } + + // 最后一个 await_user 事件的全量下标:只有它才可能挂当前干预卡 + let lastAwaitUserIdx = -1; + events.forEach((e, i) => { + if (e.type === 'await_user' && DISPLAYED_TYPES.has(e.type)) lastAwaitUserIdx = i; + }); + + const rows: FeedRow[] = []; + const pushChats = (anchor: number) => { + for (const msg of msgsByAnchor.get(anchor) ?? []) { + rows.push({ key: msg.id, type: 'chat', msg }); + } + }; + + let awaitUserIdx = 0; + events.forEach((event, fullIdx) => { + pushChats(fullIdx); + if (!DISPLAYED_TYPES.has(event.type)) return; + + let slot: ReactNode = null; + let extendLineThroughSlot = true; + + if (event.type === 'await_user') { + const interventionIdx = awaitUserIdx; + const completedRecord = completedInterventions[interventionIdx]; + awaitUserIdx += 1; + + if (completedRecord) { + const showRequirement = !suppressedRequirementIndices?.has(interventionIdx); + slot = ( +
+ {showRequirement && } + +
+ ); + } else if (currentPause && fullIdx === lastAwaitUserIdx) { + slot = currentInterventionSlot; + extendLineThroughSlot = false; + } + } + + rows.push({ key: `event-${fullIdx}`, type: 'event', event, slot, extendLineThroughSlot }); + }); + pushChats(events.length); + + return ( +
+ {rows.map((row, idx) => { + const isLast = idx === rows.length - 1; + if (row.type === 'chat') { + return ; + } + return ( + + {row.slot} + + ); + })} +
+ ); +} + +/** 信息流里的对话气泡行:左侧延续时间线竖线,与流程事件保持同一视觉脊柱。 */ +function ChatFeedRow({ msg, isLast }: { msg: ChatFeedMessage; isLast: boolean }) { + return ( +
+ {/* 竖线占位列(与 TimelineStep 的 size-4 图标列同宽),保持时间线连续 */} +
+ {!isLast &&
} +
+
+ +
+
+ ); +} + +export function ChatBubble({ msg }: { msg: Pick }) { + if (msg.role === 'user') { + return ( +
+
+ {msg.text} +
+
+ ); + } + + const iconByKind: Record = { + text: , + answer: , + clarify: , + ack: , + notice: , + }; + + return ( +
+
+ {iconByKind[msg.kind]} +
+
+ {msg.text} +
+
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing-chat/ChatInterventionCards.tsx b/frontend-web/src/open-canvas/components/ai-writing-chat/ChatInterventionCards.tsx new file mode 100644 index 00000000..745fb646 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing-chat/ChatInterventionCards.tsx @@ -0,0 +1,619 @@ +/** + * 对话版干预卡(AI 写作·对话驱动写作台专用)。 + * + * 与旧版 `../ai-writing/InterventionCard.tsx` 的关系:**复制后减法**—— + * - 保留:阶段产物展示(素材列表勾选 / 大纲可点选编辑 / 审核意见勾选)+ 全部确认/动作按钮; + * - 移除:每张卡内嵌的自由文本输入框(补充检索输入 / 大纲修改意见 textarea / + * 草稿修改意见 textarea / 审核补充指令 textarea)——这些自由输入统一改由页面 + * 底部的智能输入框(WritingChatComposer)承接,经意图解析层翻译成 action。 + * + * 按钮点击路径与旧卡完全一致:直接调 `onSubmit(UserInterventionPayload)`, + * 不经意图解析层,零延迟零误判。旧页面的 InterventionCard 不受本文件影响。 + */ +import { useState } from 'react'; +import { + ArrowRight, BookOpen, CheckCircle2, ClipboardEdit, MessageSquareText, + PauseCircle, RotateCcw, Search, ShieldAlert, Trash2, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { + AIMaterial, AIOutline, BlockedSection, ProgressEvent, ReviewIssue, + ReviewResult, SectionDecisionKind, SkillSearchStep, UserInterventionPayload, +} from '../../types/ai-writing'; +import { MaterialsCard } from '../ai-writing/MaterialsCard'; +import { OutlineCard } from '../ai-writing/OutlineCard'; +import { ReviewCard } from '../ai-writing/ReviewCard'; +import { SkillStepsCard } from '../ai-writing/SkillStepsCard'; + +interface ChatInterventionCardProps { + pauseEvent: ProgressEvent; + materials: AIMaterial[]; + outline: AIOutline | null; + reviewResult: ReviewResult | null; + draftTitle: string; + draftWordCount?: number; + wordCountTarget?: number; + revisionCount: number; + skillSearchSteps?: SkillSearchStep[]; + skillSearchInProgress?: boolean; + onSubmit: (payload: UserInterventionPayload) => void; + disabled?: boolean; +} + +export function ChatInterventionCard({ + pauseEvent, materials, outline, reviewResult, + draftTitle, draftWordCount, wordCountTarget, revisionCount, + skillSearchSteps, skillSearchInProgress, onSubmit, disabled, +}: ChatInterventionCardProps) { + const pausePoint = pauseEvent.pausePoint; + + if (pausePoint === 'material_confirm') { + return ( + + ); + } + + if (pausePoint === 'outline_confirm') { + return ( + + ); + } + + if (pausePoint === 'draft_confirm') { + return ( + + ); + } + + if (pausePoint === 'section_help') { + return ( + + ); + } + + if (pausePoint === 'review_confirm') { + return ( + + ); + } + + return ( +
+ + 等待用户确认({pausePoint})… +
+ ); +} + +// ── 工具 ────────────────────────────────────────────────────────────────────── + +function makeDefaultSelection(issues: ReviewIssue[]): Set { + return new Set(issues.map((_, i) => i)); +} + +function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set): string { + const selected = issues.filter((_, i) => selectedIndices.has(i)); + if (selected.length === 0) return ''; + return selected + .map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`) + .join('\n'); +} + +/** 卡片底部的「用对话继续」提示行:告诉用户自由输入去底部输入框。 */ +function ComposerHint({ examples }: { examples: string }) { + return ( +
+ + 也可以直接在底部输入框用一句话操作,例如:{examples} +
+ ); +} + +// ── PAUSE-1 素材确认 ────────────────────────────────────────────────────────── + +function MaterialConfirmChatCard({ + materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled, +}: { + materials: AIMaterial[]; + skillSearchSteps: SkillSearchStep[]; + skillSearchInProgress?: boolean; + onSubmit: (payload: UserInterventionPayload) => void; + disabled?: boolean; +}) { + const [selectedIds, setSelectedIds] = useState>( + () => new Set(materials.map((m) => m.id)), + ); + const [submitting, setSubmitting] = useState(false); + const allSelected = materials.length > 0 && selectedIds.size === materials.length; + + async function handleConfirm() { + setSubmitting(true); + await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds] }); + setSubmitting(false); + } + + async function handleReSearch() { + setSubmitting(true); + // 无输入 = 重新生成检索词、覆盖式重检;带自然语言的补充检索走底部输入框。 + await onSubmit({ action: 're_search', approvedMaterialIds: [] }); + setSubmitting(false); + } + + if (materials.length === 0) { + return ( +
+
+ +
+

未检索到任何素材

+

+ 素材收集专家本次没有检索到相关数据。可以点「重新搜索」,或在底部输入框描述想检索的内容,也可以在无素材的情况下直接继续写作。 +

+
+
+ + {(skillSearchSteps.length > 0 || skillSearchInProgress) && ( + + )} + +
+ + +
+ +
+ ); + } + + return ( +
+
+ +
+

需要您确认素材,才能继续

+

+ 已搜集到 {materials.length} 条素材(默认全选)。可取消勾选不相关的素材后点确认。 +

+
+
+ + {skillSearchSteps.length > 0 && } + + { + setSelectedIds((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }} + /> + +
+ + 已选 {selectedIds.size} / {materials.length} 条素材 + +
+ +
+ +
+ + +
+ +
+ ); +} + +// ── PAUSE-2 大纲确认 ────────────────────────────────────────────────────────── + +function OutlineConfirmChatCard({ + outline: initialOutline, onSubmit, disabled, +}: { + outline: AIOutline | null; + onSubmit: (payload: UserInterventionPayload) => void; + disabled?: boolean; +}) { + const [editedOutline, setEditedOutline] = useState(initialOutline); + const [submitting, setSubmitting] = useState(false); + + async function handleConfirm() { + setSubmitting(true); + await onSubmit({ + action: 'confirm', + editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined, + }); + setSubmitting(false); + } + + return ( +
+
+ +
+

请确认大纲后继续写作

+

+ 可直接点击章节标题或要点微调;想让 AI 重新规划,就在底部输入框说出你的要求。 +

+
+
+ + {editedOutline + ? + :

大纲加载中…

} + + + +
+ ); +} + +// ── PAUSE-3 草稿确认 ────────────────────────────────────────────────────────── + +function DraftConfirmChatCard({ + draftTitle, draftWordCount, wordCountTarget, revisionCount, reviewResult, onSubmit, disabled, +}: { + draftTitle: string; + draftWordCount?: number; + wordCountTarget?: number; + revisionCount: number; + reviewResult: ReviewResult | null; + onSubmit: (payload: UserInterventionPayload) => void; + disabled?: boolean; +}) { + const [submitting, setSubmitting] = useState(false); + const [selectedIssueIndices, setSelectedIssueIndices] = useState>( + () => makeDefaultSelection(reviewResult?.revisionNotes ?? []), + ); + + function toggleIssue(index: number) { + setSelectedIssueIndices((prev) => { + const next = new Set(prev); + next.has(index) ? next.delete(index) : next.add(index); + return next; + }); + } + + async function submit(action: string) { + setSubmitting(true); + await onSubmit({ action }); + setSubmitting(false); + } + + async function handleApplyReview() { + if (!reviewResult) return; + const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices); + setSubmitting(true); + await onSubmit({ + action: 'user_revise', + userRevisionNotes: formattedNotes, + selectedIssueIndices: [...selectedIssueIndices], + }); + setSubmitting(false); + } + + const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0; + + const showWordCmp = wordCountTarget != null && wordCountTarget > 0 && draftWordCount != null && draftWordCount > 0; + const wordDiff = showWordCmp ? draftWordCount! - wordCountTarget! : 0; + const wordWithin = Math.abs(wordDiff) <= 100; + + return ( +
+
+ +
+

草稿已完成,请确认后继续

+

+ {draftTitle ? `《${draftTitle}》` : '草稿'} + {revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'} + · 右侧可预览完整内容 +

+ {showWordCmp && ( +

+ 实际 {draftWordCount} 字 / 目标 {wordCountTarget} 字 + {wordDiff !== 0 && `(${wordDiff > 0 ? '超出' : '不足'} ${Math.abs(wordDiff)} 字${wordWithin ? ',在误差范围内' : ''})`} +

+ )} +
+
+ + {reviewResult && ( + + )} + +
+ {hasReview && ( + + )} + +
+ + +
+
+ +
+ ); +} + +// ── PAUSE-4 编辑打回确认 ────────────────────────────────────────────────────── + +function ReviewConfirmChatCard({ + reviewResult, revisionCount, onSubmit, disabled, +}: { + reviewResult: ReviewResult | null; + revisionCount: number; + onSubmit: (payload: UserInterventionPayload) => void; + disabled?: boolean; +}) { + const [submitting, setSubmitting] = useState(false); + const [selectedIssueIndices, setSelectedIssueIndices] = useState>( + () => makeDefaultSelection(reviewResult?.revisionNotes ?? []), + ); + + function toggleIssue(index: number) { + setSelectedIssueIndices((prev) => { + const next = new Set(prev); + next.has(index) ? next.delete(index) : next.add(index); + return next; + }); + } + + async function handleAccept() { + setSubmitting(true); + const issueNotes = reviewResult + ? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices) + : ''; + await onSubmit({ + action: 'accept_review', + userRevisionNotes: issueNotes || undefined, + selectedIssueIndices: [...selectedIssueIndices], + }); + setSubmitting(false); + } + + async function handleForce() { + setSubmitting(true); + await onSubmit({ action: 'force_finalize', overrideVerdict: true }); + setSubmitting(false); + } + + const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 0; + + return ( +
+
+ +
+

编辑认为文章需要修改

+

+ 勾选您希望采纳的意见,再让作家重写;或忽略编辑意见强制定稿。 +

+
+
+ + {reviewResult ? ( + + ) : ( +

审核结果加载中…

+ )} + +
+ + +
+ +
+ ); +} + +// ── 素材不足求助 ────────────────────────────────────────────────────────────── + +const DECISION_OPTIONS: { value: SectionDecisionKind; label: string; icon: typeof Search }[] = [ + { value: 'supplement', label: '补充检索素材', icon: Search }, + { value: 'loose', label: '按通用知识续写', icon: BookOpen }, + { value: 'delete', label: '删除该章节', icon: Trash2 }, +]; + +function SectionHelpChatCard({ + blockedSections, onSubmit, disabled, +}: { + blockedSections: BlockedSection[]; + onSubmit: (payload: UserInterventionPayload) => void; + disabled?: boolean; +}) { + const [decisions, setDecisions] = useState>( + () => Object.fromEntries(blockedSections.map((b) => [b.sectionTitle, 'supplement'])), + ); + const [submitting, setSubmitting] = useState(false); + + const hasSupplement = Object.values(decisions).some((d) => d === 'supplement'); + + async function handleSubmit() { + setSubmitting(true); + await onSubmit({ + action: 'section_help', + sectionDecisions: blockedSections.map((b) => ({ + sectionTitle: b.sectionTitle, + decision: decisions[b.sectionTitle] ?? 'supplement', + })), + }); + setSubmitting(false); + } + + return ( +
+
+ +
+

部分章节素材不足,需要你决定

+

+ 严格依据素材模式下,以下 {blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。 +

+
+
+ +
+ {blockedSections.map((b) => ( +
+
+

{b.sectionTitle}

+

{b.reason}

+
+
+ {DECISION_OPTIONS.map((opt) => { + const active = (decisions[b.sectionTitle] ?? 'supplement') === opt.value; + const Icon = opt.icon; + return ( + + ); + })} +
+
+ ))} +
+ + {hasSupplement && ( +

+ 含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。 +

+ )} + + + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing-chat/WritingChatPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing-chat/WritingChatPanel.tsx new file mode 100644 index 00000000..8acb49ae --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing-chat/WritingChatPanel.tsx @@ -0,0 +1,802 @@ +/** + * 对话驱动写作台左栏主面板(仅在写作开始后挂载;idle 阶段由页面沿用经典版的 + * 「写作助手 + 写作配置表单」布局,见 AIWritingChatPage)。 + * + * 与旧版 `../ai-writing/AIWritingPanel.tsx` 的关系:复制骨架(头部工具条 / + * 各阶段流式视图 / 自动滚底 / 状态栏 / 历史回看),交互层重做: + * - 时间线换成 `ChatFlowTimeline`:流程事件与意图层对话气泡按时间顺序合并成 + * **一条信息流**——用户发消息后,由它触发的检索/操作事件出现在消息下方; + * - 干预卡换成 `ChatInterventionCards`(保留按钮、去掉内嵌输入框); + * - 底部统一智能输入框:自然语言经 `fastPathIntent`(规则快路径)/ + * `parseWritingIntent`(后端 LLM)翻译成 `submitIntervention` / 控制指令 / + * 问答旁路 / 澄清追问。 + * + * 引擎层完全复用 `AIWritingContext`(状态机 + LangGraph useStream),不改旧页面。 + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Bot, ChevronsDown, CloudUpload, Loader2, Pause, Play, Plus, RotateCcw, +} from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { + PromptInput, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, +} from '@/components/ai-elements/prompt-input'; +import { uuid } from '@/core/utils/uuid'; +import { cn } from '@/lib/utils'; + +import { useAIWriting } from '../../contexts/AIWritingContext'; +import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext'; +import { useAIWritingSessions } from '../../hooks/useAIWritingSessions'; +import { useArticleTypes } from '../../hooks/useArticleTypes'; +import { suspendAIWritingToBackground } from '../../api/ai-writing-sessions'; +import { + fastPathIntent, + INTENT_CONFIDENCE_THRESHOLD, + parseWritingIntent, + type WritingIntentResult, +} from '../../api/ai-writing-intent'; +import { countArticleWords } from '../../utils/wordCount'; +import type { UserInterventionPayload } from '../../types/ai-writing'; +import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions'; + +import { AIWritingHistoryDropdown } from '../ai-writing/AIWritingHistoryDropdown'; +import { AIWritingHistoryView } from '../ai-writing/AIWritingHistoryView'; +import { AIWritingFlowDialog } from '../ai-writing/AIWritingFlowDialog'; +import { DraftSync } from '../ai-writing/DraftSync'; +import { DraftGeneratingView } from '../ai-writing/DraftGeneratingView'; +import { IntentResultCard } from '../ai-writing/IntentResultCard'; +import { IntentStreamingView } from '../ai-writing/IntentStreamingView'; +import { OutlineStreamingView } from '../ai-writing/OutlineStreamingView'; +import { ReviewStreamingView } from '../ai-writing/ReviewStreamingView'; +import { RevisionSummaryCard } from '../ai-writing/RevisionSummaryCard'; +import { SkillSelectStreamingView } from '../ai-writing/SkillSelectStreamingView'; +import { SkillStepsCard } from '../ai-writing/SkillStepsCard'; +import { WritingSkeletonCard } from '../ai-writing/WritingSkeletonCard'; +import { ChatInterventionCard } from './ChatInterventionCards'; +import { ChatFlowTimeline, type ChatFeedMessage, type ChatKind } from './ChatFlowTimeline'; + +const RESUMABLE_HISTORY_STATUSES = new Set(['in_progress', 'draft_ready', 'review_ready']); + +const STATUS_LABEL: Record = { + idle: '就绪', + analyzing_sample: '样文分析师分析文风中…', + researching: '素材收集专家搜集素材中…', + awaiting_material_confirm: '等待确认素材', + writing_outline: '作家规划大纲中…', + awaiting_outline_confirm: '等待确认大纲', + writing_draft: '作家写作中…', + awaiting_section_help: '等待处理素材不足的章节', + awaiting_draft_confirm: '等待确认草稿', + reviewing: '编辑审核中…', + awaiting_review_confirm: '等待处理编辑意见', + revising: '按意见修改中…', + done: '写作完成 ✓', + error: '出错了', +}; + +/** 当前阶段的「交互对象」标签 + 输入框占位 + 建议 chips(一键发送,只放低风险动作)。 */ +const STAGE_UI: Record = { + material_confirm: { + expert: '素材收集专家', + placeholder: '对素材收集专家说:确认继续 / 补充关于××的素材 / 只用第 1、3 条…', + chips: ['素材可以,继续', '重新搜索'], + }, + outline_confirm: { + expert: '作家(大纲规划)', + placeholder: '对作家说:确认大纲 / 第二章拆成两节 / 加一章讲××…', + chips: ['确认大纲,开始写作', '重新规划大纲'], + }, + draft_confirm: { + expert: '作家(草稿)', + placeholder: '对作家说:交给编辑审核 / 第三段精简一下 / 就这样定稿…', + chips: ['交给编辑审核'], + }, + review_confirm: { + expert: '编辑', + placeholder: '处理编辑意见:按编辑意见修改 / 只接受第 1 条 / 不改了强制定稿…', + chips: ['按编辑意见修改'], + }, + section_help: { + expert: '作家(素材不足求助)', + placeholder: '处理缺素材章节:都补充检索 / 都按通用知识写 / 第一个删掉其他补检索…', + chips: ['都补充检索', '都按通用知识写'], + }, +}; + +/** 素材不足自动重搜后待续跑的原始意图(仅 material_confirm 阶段,graph 支持范围内)。 */ +interface PendingIntent { + payload: UserInterventionPayload; + note: string; + /** 创建 pending 时所处的暂停事件对象:自动续跑只认「新的」素材确认暂停, + * 防止在 re_search 尚未提交完成时误触发(对象同一性判定)。 */ + createdAtPause: unknown; +} + +function ackTextFor(payload: UserInterventionPayload): string { + switch (payload.action) { + case 'confirm': + return payload.approvedMaterialIds !== undefined && payload.outlineFeedback === undefined + ? '好的,已确认素材,作家开始规划大纲' + : '好的,已确认,继续下一步'; + case 're_search': + return payload.userQuery + ? `好的,正在按「${payload.userQuery}」补充检索素材(新素材会追加进列表)…` + : '好的,已发起重新搜索'; + case 're_outline': + return payload.outlineFeedback + ? '好的,已让作家按你的意见重新规划大纲' + : '好的,作家将重新规划大纲'; + case 'to_editor': + return payload.userRevisionNotes + ? '好的,已提交编辑审核(会重点核查你提到的方面)' + : '好的,已提交编辑审核'; + case 'finalize': + return '好的,已确认定稿'; + case 'user_revise': + return '好的,已让作家按你的意见修改草稿'; + case 'accept_review': + return '好的,已让作家按编辑意见修改'; + case 'force_finalize': + return '好的,已忽略编辑意见、强制定稿'; + case 'section_help': + return '好的,已提交各章节的处理决定'; + default: + return '好的,已执行'; + } +} + +export function WritingChatPanel() { + const { state, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset } = useAIWriting(); + const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory(); + const { sessions, loading: sessionsLoading, refresh, rename, remove, markBackground, clearBackground } = useAIWritingSessions(); + const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes(); + + useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]); + + // ── 意图层本地状态 ────────────────────────────────────────────────────────── + const [chatMessages, setChatMessages] = useState([]); + const [resolving, setResolving] = useState(false); + const [pendingIntent, setPendingIntent] = useState(null); + const pendingIntentRef = useRef(null); + pendingIntentRef.current = pendingIntent; + // 澄清多轮上下文:上一句用户原文 + 系统追问,拼给下一次解析 + const clarifyContextRef = useRef<{ originalText: string; question: string } | null>(null); + // 由输入框发起的干预序号:ChatFlowTimeline 不再重复渲染「我的要求」卡(气泡已按序展示原话) + const suppressedRequirementsRef = useRef>(new Set()); + // 消息锚点:追加消息时记录当前 progressEvents 长度,渲染时插到对应事件之后 + const eventsLenRef = useRef(0); + eventsLenRef.current = state.progressEvents.length; + const completedLenRef = useRef(0); + completedLenRef.current = state.completedInterventions.length; + + const [progressSession, setProgressSession] = useState(null); + + const appendMsg = useCallback((msg: { role: 'user' | 'assistant'; kind: ChatKind; text: string }) => { + setChatMessages((prev) => [...prev, { ...msg, id: uuid(), anchor: eventsLenRef.current }]); + }, []); + + /** 由输入框发起的干预:提交前登记序号,抑制时间线里重复的「我的要求」卡。 */ + const submitFromComposer = useCallback(async (payload: UserInterventionPayload) => { + suppressedRequirementsRef.current.add(completedLenRef.current); + await submitIntervention(payload); + }, [submitIntervention]); + + const isIdle = state.status === 'idle'; + const isDone = state.status === 'done'; + const isError = state.status === 'error'; + const isRunning = !isIdle && !isDone && !isError; + const pausePoint = state.currentPause?.pausePoint ?? null; + const showSkeleton = isRunning && !state.currentPause && !state.isPaused; + + // ── 自动滚底(与旧面板同套逻辑) ──────────────────────────────────────────── + const scrollRef = useRef(null); + const isAutoScrollingRef = useRef(false); + const autoScrollRef = useRef(true); + const [autoScroll, setAutoScroll] = useState(true); + + const disableAutoScroll = useCallback(() => { + autoScrollRef.current = false; + setAutoScroll(false); + }, []); + + useEffect(() => { + if (isRunning && !state.isPaused) { + autoScrollRef.current = true; + setAutoScroll(true); + } + }, [isRunning, state.isPaused]); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + if (e.deltaY < 0) disableAutoScroll(); + }; + let lastTop = el.scrollTop; + const onScroll = () => { + const cur = el.scrollTop; + if (!isAutoScrollingRef.current && cur < lastTop - 2) { + disableAutoScroll(); + } + lastTop = cur; + }; + el.addEventListener('wheel', onWheel, { passive: true }); + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('wheel', onWheel); + el.removeEventListener('scroll', onScroll); + }; + }, [disableAutoScroll]); + + useEffect(() => { + if (!autoScrollRef.current || !scrollRef.current) return; + if (!isRunning && chatMessages.length === 0) return; + const el = scrollRef.current; + isAutoScrollingRef.current = true; + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => { isAutoScrollingRef.current = false; }); + }, [ + autoScroll, + isRunning, + state.progressEvents.length, + state.streamingDraftSections.length, + state.streamingReviewItems.length, + state.outlineStreamingText, + state.currentPause, + chatMessages.length, + resolving, + ]); + + const scrollToBottom = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + isAutoScrollingRef.current = true; + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => { isAutoScrollingRef.current = false; }); + autoScrollRef.current = true; + setAutoScroll(true); + }, []); + + // ── 历史 / 会话操作 ───────────────────────────────────────────────────────── + const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => { + void openHistory(session); + }, [openHistory]); + + const clearChatState = useCallback(() => { + setChatMessages([]); + setPendingIntent(null); + clarifyContextRef.current = null; + suppressedRequirementsRef.current = new Set(); + }, []); + + const handleNewSession = useCallback(() => { + closeHistory(); + reset(); + clearChatState(); + }, [closeHistory, reset, clearChatState]); + + const canRestoreHistory = + isHistoryMode && !!historySession && RESUMABLE_HISTORY_STATUSES.has(historySession.status); + + const handleRestoreHistory = useCallback(() => { + if (!historySession || !RESUMABLE_HISTORY_STATUSES.has(historySession.status)) return; + closeHistory(); + clearChatState(); + continueWriting(historySession.id); + }, [historySession, closeHistory, clearChatState, continueWriting]); + + const handleBackgroundSuspend = useCallback(() => { + const sid = state.sessionId; + if (!sid) { + toast.error('当前没有可挂起的写作会话'); + return; + } + markBackground(sid); + reset(); + clearChatState(); + toast.success('已转入后台自动完成,稍后可在「历史记录」查看进度与结果'); + void (async () => { + try { + await suspendAIWritingToBackground(sid); + } catch (e) { + toast.error(e instanceof Error ? e.message : '后台挂起失败'); + } finally { + await refresh(); + clearBackground(sid); + } + })(); + }, [state.sessionId, reset, refresh, markBackground, clearBackground, clearChatState]); + + // ── 执行意图结果 ──────────────────────────────────────────────────────────── + const executeIntent = useCallback(async (result: WritingIntentResult, originalText: string) => { + // 控制类:由前端直接执行 + if (result.intent === 'resume_writing') { + appendMsg({ role: 'assistant', kind: 'ack', text: '好的,继续从上次的进度写作' }); + resumeWriting(); + return; + } + if (result.intent === 'pause_writing') { + if (isRunning && !state.isPaused) { + appendMsg({ role: 'assistant', kind: 'ack', text: '好的,已暂停(可随时说「继续」恢复)' }); + pauseWriting(); + } else { + appendMsg({ role: 'assistant', kind: 'notice', text: '当前没有正在进行的写作可暂停' }); + } + return; + } + if (result.intent === 'run_background') { + handleBackgroundSuspend(); + return; + } + + // 问答旁路:直接渲染回答,不动写作状态 + if (result.answer && !result.action) { + appendMsg({ role: 'assistant', kind: 'answer', text: result.answer }); + clarifyContextRef.current = null; + return; + } + + // 澄清:反问并记住上下文,等用户下一句 + if (result.clarify || (result.action && result.confidence < INTENT_CONFIDENCE_THRESHOLD)) { + const question = result.clarify || '我理解得不够确定,能再说得具体一点吗?'; + appendMsg({ role: 'assistant', kind: 'clarify', text: question }); + clarifyContextRef.current = { originalText, question }; + return; + } + + if (!result.action || !result.payload) { + appendMsg({ + role: 'assistant', kind: 'notice', + text: '我没有识别出可执行的操作。可以点击上方卡片里的按钮,或换一种说法。', + }); + return; + } + + clarifyContextRef.current = null; + + const pause = state.currentPause; + if (!pause) { + appendMsg({ + role: 'assistant', kind: 'notice', + text: 'AI 正在处理当前阶段,暂时不能执行该操作。等这一步完成暂停后,我会再等你确认。', + }); + return; + } + if (result.pausePoint && result.pausePoint !== pause.pausePoint) { + appendMsg({ + role: 'assistant', kind: 'notice', + text: '流程刚刚推进到了新的阶段,请针对当前阶段重新说明你的要求。', + }); + return; + } + + // 素材不足自动回退重搜(仅素材确认阶段,graph 原生支持的路径): + // 先追加检索,新素材到位(回到下一轮素材确认)后自动续跑原意图。 + if ( + result.needResearch && + result.researchQuery && + pause.pausePoint === 'material_confirm' && + result.action !== 're_search' + ) { + const pending: PendingIntent = { + payload: result.payload, + note: originalText, + createdAtPause: pause, + }; + setPendingIntent(pending); + appendMsg({ + role: 'assistant', kind: 'ack', + text: `这条要求需要补充素材支撑,我先按「${result.researchQuery}」补充检索,素材到位后自动继续执行你的要求。`, + }); + await submitFromComposer({ action: 're_search', approvedMaterialIds: [], userQuery: result.researchQuery }); + return; + } + + const ack = ackTextFor(result.payload); + let advisory = ''; + if (result.needResearch && pause.pausePoint !== 'material_confirm') { + advisory = '(提示:这条要求可能需要现有素材之外的信息;若写作时素材不足,AI 会自动向你求助补充检索)'; + } + appendMsg({ role: 'assistant', kind: 'ack', text: advisory ? `${ack}\n${advisory}` : ack }); + await submitFromComposer(result.payload); + }, [appendMsg, handleBackgroundSuspend, isRunning, pauseWriting, resumeWriting, state.currentPause, state.isPaused, submitFromComposer]); + + // ── 底部输入框发送 ────────────────────────────────────────────────────────── + const handleSend = useCallback(async (rawText: string) => { + const text = rawText.trim(); + if (!text || resolving || isIdle || isHistoryMode) return; + + appendMsg({ role: 'user', kind: 'text', text }); + + // 有 pending 意图时,用户又发新话 → 视为改主意,丢弃旧 pending + if (pendingIntentRef.current) { + setPendingIntent(null); + appendMsg({ role: 'assistant', kind: 'notice', text: '已放弃之前待续跑的要求,按你的新指示处理。' }); + } + + // 规则快路径(零延迟;澄清上下文存在时跳过,让 LLM 结合上下文理解) + if (!clarifyContextRef.current) { + const fast = fastPathIntent(text, { + pausePoint, + isPaused: state.isPaused, + blockedSections: state.currentPause?.blockedSections, + }); + if (fast) { + await executeIntent(fast, text); + return; + } + } + + if (!state.sessionId) { + appendMsg({ role: 'assistant', kind: 'notice', text: '会话尚未建立,请稍候片刻再试。' }); + return; + } + + // 澄清多轮:把上一句原文 + 追问拼进本次解析文本 + const clarifyCtx = clarifyContextRef.current; + const effectiveText = clarifyCtx + ? `此前用户说:「${clarifyCtx.originalText}」\n系统追问:「${clarifyCtx.question}」\n用户现在回答:「${text}」` + : text; + + setResolving(true); + try { + const result = await parseWritingIntent(state.sessionId, { + text: effectiveText, + status: state.status, + pausePoint, + }); + await executeIntent(result, text); + } catch (e) { + appendMsg({ + role: 'assistant', kind: 'notice', + text: `意图解析暂时不可用(${e instanceof Error ? e.message : '未知错误'})。可以直接点击上方卡片里的按钮继续操作。`, + }); + } finally { + setResolving(false); + } + }, [appendMsg, executeIntent, isHistoryMode, isIdle, pausePoint, resolving, state.currentPause, state.isPaused, state.sessionId, state.status]); + + // ── 素材补充完成 → 自动续跑 pending 意图 ──────────────────────────────────── + const autoResumeBusyRef = useRef(false); + useEffect(() => { + const pending = pendingIntentRef.current; + if (!pending || autoResumeBusyRef.current) return; + if (state.currentPause?.pausePoint !== 'material_confirm') return; + // 只认补充检索完成后「新的」素材确认暂停(对象同一性),旧暂停不触发 + if (state.currentPause === pending.createdAtPause) return; + autoResumeBusyRef.current = true; + setPendingIntent(null); + appendMsg({ + role: 'assistant', kind: 'ack', + text: `素材已补充完成,继续执行你之前的要求:「${pending.note}」`, + }); + void submitFromComposer(pending.payload).finally(() => { + autoResumeBusyRef.current = false; + }); + }, [state.currentPause, appendMsg, submitFromComposer]); + + // ── 输入框占位 / 阶段提示 ─────────────────────────────────────────────────── + const stageUI = pausePoint ? STAGE_UI[pausePoint] : undefined; + const placeholder = isHistoryMode + ? '' + : state.isPaused + ? '已暂停。说「继续」恢复写作,或问我任何问题…' + : stageUI?.placeholder + ?? (isDone + ? '写作已完成。可以对文章内容提问,或点「新建」开始下一篇' + : 'AI 正在处理中,可以随时提问(例如:现在到哪一步了?)'); + + const chips = useMemo(() => { + if (isIdle || isHistoryMode || resolving) return []; + if (state.isPaused) return ['继续']; + return stageUI?.chips ?? []; + }, [isIdle, isHistoryMode, resolving, state.isPaused, stageUI]); + + const showNewButton = isHistoryMode || state.status !== 'idle'; + const wordTarget = state.request?.wordCountTarget; + + return ( +
+ {!isHistoryMode && ( + + )} + + {/* 头部 */} +
+
+ {canRestoreHistory && ( + + )} + + {isHistoryMode + ? (historySession?.draft_title ?? historySession?.title ?? '写作历史') + : 'AI 智能写作 · 对话版'} + +
+
+ {showNewButton && ( + + )} + {isRunning && !isHistoryMode && state.sessionId && ( + + )} + {isRunning && !state.isPaused && ( + + )} + {state.isPaused && ( + <> + + + + )} + {(isDone || isError) && ( + + )} + +
+
+ + {/* 主体滚动区 */} +
+
+ {isHistoryMode && historySession && ( + + )} + + {/* 意图解析流式 / 结果(写作起点,锚定内容顶部) */} + {!isHistoryMode && (state.intentStreamingText || state.intentThinkingText) && ( + + )} + {!isHistoryMode && state.parsedIntent && !state.intentStreamingText && ( + + )} + + {/* 统一信息流:流程事件 + 意图层对话气泡按时间顺序合并 */} + {!isIdle && !isHistoryMode && ( + 0} + onSubmit={submitIntervention} + disabled={resolving} + /> + ) : undefined + } + /> + )} + + {/* 各阶段流式视图(当前正在发生的活动,固定在信息流末尾) */} + {!isHistoryMode && state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && ( + + )} + {!isHistoryMode && state.status === 'reviewing' && state.streamingReviewItems.length > 0 && ( + + )} + {!isHistoryMode && state.revisionSummary && ( +
+ +
+ )} + {!isHistoryMode && state.status === 'writing_draft' && ( + + )} + {!isHistoryMode && state.status === 'researching' && ( + state.skillSelectStreamingText + || state.skillSelectThinkingText + || (state.skillSearchSteps.some((s) => s.type === 'match_skills') + && !state.skillSearchSteps.some((s) => s.type === 'select_skills')) + ) && ( + + )} + {!isHistoryMode && (state.skillSearchPending + || (state.status === 'researching' && state.skillSearchSteps.length > 0)) && ( +
+ +
+ )} + {!isHistoryMode && showSkeleton && + state.status !== 'writing_draft' && + !(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) && + !(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && + !state.skillSearchPending && + !(state.status === 'researching' && state.skillSearchSteps.length > 0) && + !(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) && + !state.intentStreamingText && !state.intentThinkingText && ( +
+ +
+ )} + + {isError && state.error && ( +
+ {state.error} +
+ )} + + {/* 瞬态提示(正在理解意图 / pending 续跑),贴着输入框 */} + {!isHistoryMode && resolving && ( +
+ + 正在理解你的意图… +
+ )} + {!isHistoryMode && pendingIntent && ( +
+ + 待续跑:素材补充完成后将自动执行「{pendingIntent.note}」 + + +
+ )} +
+ + {isRunning && !autoScroll && ( +
+ +
+ )} +
+ + {/* 状态栏 */} + {!isIdle && !isHistoryMode && ( +
+ {state.isPaused ? ( + <> + + 已暂停 + + ) : ( + <> + {isRunning && ( + + )} + {state.status === 'researching' && state.researchProgress + ? state.researchProgress + : STATUS_LABEL[state.status] ?? state.status} + {stageUI && ( + + + 当前交互对象:{stageUI.expert} + + )} + + )} +
+ )} + + {/* 底部统一智能输入框 */} + {!isHistoryMode && ( +
+ {chips.length > 0 && ( +
+ {chips.map((chip) => ( + + ))} +
+ )} +
+ void handleSend(text ?? '')}> + + + + + +
+
+ )} + + { if (!o) setProgressSession(null); }} + /> +
+ ); +} diff --git a/frontend-web/src/open-canvas/pages/AIWritingChatPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingChatPage.tsx new file mode 100644 index 00000000..db221ac7 --- /dev/null +++ b/frontend-web/src/open-canvas/pages/AIWritingChatPage.tsx @@ -0,0 +1,155 @@ +/** + * AI 写作 · 对话版(对话驱动写作台)。 + * + * 与经典版 `AIWritingPage` 的关系:**并存的新入口,旧页面零改动**。 + * + * 布局分两态(与用户预期对齐:「初始和原来一样,进入写作才进入新页面」): + * - idle(未开始写作、非历史回看):沿用经典版的初始双栏——左「写作助手 Q&A」 + * (WritingSetupChat)+ 右「写作配置选项」表单(AIWritingPanel,可整块收起), + * 由 WritingFormProvider 承载表单状态;点表单「开始写作」或助手「开始写作」 + * 后 status 离开 idle; + * - 写作中 / 历史回看:切到对话驱动工作台——左 WritingChatPanel(统一信息流 + + * 按钮版干预卡 + 底部智能输入框),右 AIWritingDraftPanel(草稿实时预览)。 + * + * 引擎层完全复用:AIWritingProvider(状态机 + LangGraph useStream)、 + * AIWritingHistoryProvider(历史回看)。路由:/page/canvas/ai-writing-chat。 + */ +import { useRef } from 'react'; +import { PanelRightOpen } from 'lucide-react'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import type { GroupImperativeHandle } from 'react-resizable-panels'; + +import { cn } from '@/lib/utils'; +import { CanvasThreadProvider } from '../contexts/CanvasThreadContext'; +import { CanvasGraphProvider } from '../contexts/CanvasGraphContext'; +import { CanvasUIProvider } from '../contexts/CanvasUIContext'; +import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext'; +import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext'; +import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext'; +import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel'; +import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel'; +import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat'; +import { WritingChatPanel } from '../components/ai-writing-chat/WritingChatPanel'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from '@/components/ui/resizable'; + +const CHAT_ID = 'awc-chat'; +const DRAFT_ID = 'awc-draft'; +// 对话区略宽于经典版左栏,以容纳信息流 + 干预卡 +const DEFAULT_LAYOUT = { [CHAT_ID]: 40, [DRAFT_ID]: 60 }; + +/** + * idle 初始布局 —— 与经典版 AIWritingPage 的 idle 态一致: + * 左「写作助手 Q&A」恒为第一个子节点(位置稳定不重挂,避免 WritingSetupChat + * 内部线程丢失),右「写作配置表单」整块可收起(formExpanded 联动)。 + */ +function SetupLayout() { + const { formExpanded, setFormExpanded } = useWritingForm(); + + return ( +
+
+ +
+ +
+
+ +
+
+ + {!formExpanded && ( + + )} +
+ ); +} + +/** 写作中 / 历史回看:对话驱动工作台(左信息流 + 右草稿预览)。 */ +function ChatWorkbenchLayout() { + const layoutRef = useRef(null); + + return ( + + + + + + + + + + + + ); +} + +function AIWritingChatBody() { + const { state } = useAIWriting(); + const { isHistoryMode } = useAIWritingHistory(); + + // idle 且非历史回看 → 经典初始布局;开始写作 / 查看历史 → 对话驱动工作台。 + // 两套布局都保持挂载态简单切换:SetupLayout 内的 WritingSetupChat 在开始写作后 + // 卸载没有代价(它只负责启动前配置,线程与写作 graph 解耦)。 + if (state.status === 'idle' && !isHistoryMode) { + return ; + } + return ; +} + +export default function AIWritingChatPage() { + return ( + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+ ); +} diff --git a/frontend-web/src/open-canvas/routes/OpenCanvasRoutes.tsx b/frontend-web/src/open-canvas/routes/OpenCanvasRoutes.tsx index 1b7c6144..ed819ed5 100644 --- a/frontend-web/src/open-canvas/routes/OpenCanvasRoutes.tsx +++ b/frontend-web/src/open-canvas/routes/OpenCanvasRoutes.tsx @@ -3,6 +3,7 @@ import { Route, Routes } from "react-router-dom"; import CanvasHomePage from "../pages/CanvasHomePage"; import CanvasThreadPage from "../pages/CanvasThreadPage"; import AIWritingPage from "../pages/AIWritingPage"; +import AIWritingChatPage from "../pages/AIWritingChatPage"; import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage"; import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage"; import ResearchTemplateEditorPage from "../pages/ResearchTemplateEditorPage"; @@ -12,6 +13,7 @@ export default function OpenCanvasRoutes() { } /> } /> + } /> } /> } /> } /> diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index d8235583..e0604422 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -264,6 +264,10 @@ 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 Intent Router (对话驱动写作台意图路由) + +Backs the frontend「AI 写作·对话版」page's unified bottom composer. `POST /api/ai-writing/sessions/{session_id}/intent` (owner-checked; in `routers/ai_writing.py`) reads the session's graph checkpoint via the shared `app.state.checkpointer` — the **pending interrupt is the authoritative pause point** (the frontend's claimed `pause_point` is only a fallback when the checkpoint can't be read) — then routes the user's free text through `deerflow/agents/ai_writing/intent_router.py::parse_user_intent`: an LLM call (session's `model_name` + `fallback_chain` model fault-tolerance) that returns `{intent, pause_point, action, payload, need_research, research_query, answer, confidence, clarify}`. Safety boundaries in `normalize_intent_result`: **per-pause action whitelist strictly aligned to graph.py's real resume actions** (`ALLOWED_ACTIONS`), no pause → no action, high-cost actions (`finalize` / `force_finalize` / query-less覆盖式 `re_search`) require confidence ≥ 0.85 else downgrade to `clarify`, payload key-whitelist cleaning (material-id filtering, review-issue index bounds, section-decision back-fill with the majority decision). QA intents return `answer` directly from the context snapshot (`build_context_snapshot` — stage-tailored: materials with ids / outline / draft body capped / review issues with indices). Model failures never 5xx — they degrade to clarify. Prompts in `agents/ai_writing/prompts/intent_router_prompts.py`; tests `tests/test_ai_writing_intent.py`. + ### 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): 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 3f036645..5078e12c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py +++ b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py @@ -327,6 +327,91 @@ async def save_transcript(session_id: str, body: SaveTranscriptRequest, request: return {"success": True} +# ── 意图解析(对话驱动写作台) ──────────────────────────────────────────────── + + +class IntentParseRequest(BaseModel): + # 用户在底部智能输入框发的自由文本 + text: str + # 前端当前 WritingStatus(仅参考;暂停点以 checkpoint 里真实 interrupt 为准) + status: Optional[str] = None + # 前端认为的当前暂停点(checkpoint 读取失败时的兜底) + pause_point: Optional[str] = None + + +class IntentParseResponse(BaseModel): + intent: str + pause_point: Optional[str] = None + action: Optional[str] = None + # 驼峰键(approvedMaterialIds / userQuery / outlineFeedback / userRevisionNotes / + # overrideVerdict / selectedIssueIndices / sectionDecisions),前端可直接喂 submitIntervention + payload: dict = Field(default_factory=dict) + need_research: bool = False + research_query: str = "" + answer: str = "" + confidence: float = 0.0 + clarify: str = "" + + +@router.post("/sessions/{session_id}/intent", response_model=IntentParseResponse) +async def parse_session_intent(session_id: str, body: IntentParseRequest, request: Request): + """对话驱动写作台:把用户自由文本翻译成结构化写作指令(或问答/澄清)。 + + 读该会话 graph checkpoint 的素材/大纲/草稿/审核摘要 + 当前 pending interrupt + (权威暂停点),交给 ``deerflow.agents.ai_writing.intent_router`` 的 LLM 意图 + 路由。动作白名单 / 高代价置信度门槛 / payload 清洗都在 intent_router 内完成, + 这里只做鉴权与 state 读取。模型失败不抛 5xx —— intent_router 会降级成 clarify。 + """ + from app.gateway.ai_writing_job_executor import _pending_interrupt + from app.gateway.deps import get_ai_writing_session_store, get_current_user + from deerflow.agents.ai_writing.intent_router import parse_user_intent + + text = (body.text or "").strip() + if not text: + raise HTTPException(status_code=400, detail="text 不能为空") + + repo = get_ai_writing_session_store(request) + if repo is None: + raise HTTPException(status_code=503, detail="Session store not available") + user_id = await get_current_user(request) + record = await repo.get(session_id, user_id=user_id) + if record is None: + raise HTTPException(status_code=404, detail="Session not found") + + # 读 checkpoint:暂停点以 pending interrupt 为权威(frontend 声明只作读取失败兜底)。 + values: dict = {} + interrupt_value: Optional[dict] = None + pause_point: Optional[str] = None + checkpoint_ok = False + _app_state = getattr(getattr(request, "app", None), "state", None) + checkpointer = getattr(_app_state, "checkpointer", None) + if checkpointer is not None: + from deerflow.agents.ai_writing.graph import make_ai_writing_graph + + graph = make_ai_writing_graph() + graph.checkpointer = checkpointer + try: + snapshot = await graph.aget_state({"configurable": {"thread_id": session_id}}) + values = getattr(snapshot, "values", None) or {} + interrupt_value = _pending_interrupt(snapshot) + checkpoint_ok = bool(values) + if interrupt_value: + pause_point = interrupt_value.get("pause_point") + except Exception: + logger.warning("ai_writing intent: read checkpoint failed for %s", session_id, exc_info=True) + if pause_point is None and not checkpoint_ok: + pause_point = (body.pause_point or "").strip() or None + + result = await parse_user_intent( + text=text, + pause_point=pause_point, + values=values, + interrupt_value=interrupt_value, + model_name=str(values.get("model_name") or "") or None, + ) + return IntentParseResponse(**result) + + class BackgroundResponse(BaseModel): # 是否新拉起了后台任务(False = 已在后台跑,幂等返回) started: bool diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/intent_router.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/intent_router.py new file mode 100644 index 00000000..fecee69d --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/intent_router.py @@ -0,0 +1,409 @@ +"""对话驱动写作台的意图路由:用户自由文本 → 结构化 action/payload。 + +供 Gateway 的 ``POST /api/ai-writing/sessions/{id}/intent`` 调用:读该会话的 +graph checkpoint state(素材/大纲/草稿/审核摘要)+ 当前暂停点,用 LLM 把用户的 +一句话翻译成 ``UserInterventionPayload`` 可直接消费的指令;或识别为提问(直接 +带回 answer)、控制指令、需澄清。 + +核心安全边界(避免"原逻辑不支持硬套用"): + - action 白名单严格对齐 graph.py 各 pause 节点真实接受的动作(见 + ``ALLOWED_ACTIONS``),LLM 输出不在白名单内 → 一律降级为 clarify; + - 高代价动作(finalize / force_finalize / 无查询词的覆盖式 re_search)要求 + confidence ≥ 0.85,不够 → 降级 clarify; + - 无暂停点(运行中/已结束)时不允许输出任何 action; + - payload 逐 key 白名单清洗,杜绝 LLM 编造字段污染 resume 载荷。 +""" +from __future__ import annotations + +import logging +import re +from typing import Any + +from .prompts.intent_router_prompts import ( + INTENT_ROUTER_SYSTEM, + INTENT_ROUTER_USER_TEMPLATE, + NO_PAUSE_GUIDE, + STAGE_ACTION_GUIDES, +) + +logger = logging.getLogger(__name__) + +# 各暂停点合法动作 —— 与 graph.py 的 pause_material_confirm / pause_outline_confirm / +# pause_draft_confirm / pause_review_confirm / pause_section_help 严格对齐。 +# 注意 material_confirm 的 enable_skill_search 是旧按钮兼容入口,语义 = 带 +# userQuery 的 re_search,意图层统一输出 re_search,不再暴露旧动作。 +ALLOWED_ACTIONS: dict[str, set[str]] = { + "material_confirm": {"confirm", "re_search"}, + "outline_confirm": {"confirm", "re_outline"}, + "draft_confirm": {"to_editor", "finalize", "user_revise"}, + "review_confirm": {"accept_review", "force_finalize"}, + "section_help": {"section_help"}, +} + +# 高代价动作的置信度下限(misfire 代价大:结束流程 / 丢弃现有素材)。 +HIGH_COST_CONFIDENCE = 0.85 +# 普通动作的置信度下限。 +ACTION_CONFIDENCE = 0.5 + +_SECTION_DECISIONS = {"supplement", "loose", "delete"} + +_THINK_RE = re.compile(r".*?", re.S) + + +# ── 上下文快照 ──────────────────────────────────────────────────────────────── + +def _shorten(text: str, limit: int) -> str: + text = (text or "").strip().replace("\n", " ") + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def _materials_block(values: dict, limit: int = 30) -> str: + pkg = values.get("material_package") or {} + materials = pkg.get("materials") or [] + if not materials: + return "(当前没有素材)" + lines = [] + for i, m in enumerate(materials[:limit]): + mid = m.get("id", "") + title = _shorten(str(m.get("title") or ""), 60) + source = _shorten(str(m.get("source") or ""), 20) + excerpt = _shorten(str(m.get("content") or ""), 80) + lines.append(f"{i + 1}. [id={mid}] {title}(来源:{source}){excerpt}") + if len(materials) > limit: + lines.append(f"…共 {len(materials)} 条,仅列出前 {limit} 条") + keywords = pkg.get("keywords") or [] + head = f"检索关键词:{'、'.join(str(k) for k in keywords[:10])}\n" if keywords else "" + return head + "\n".join(lines) + + +def _outline_block(values: dict) -> str: + outline = values.get("current_outline") or {} + sections = outline.get("sections") or [] + if not sections: + return "(尚无大纲)" + lines = [f"文章标题:{outline.get('title') or ''}"] + for i, s in enumerate(sections): + title = s.get("section_title") or s.get("sectionTitle") or "" + points = s.get("key_points") or s.get("keyPoints") or [] + point_titles = [] + for p in points[:6]: + if isinstance(p, dict): + point_titles.append(str(p.get("point_title") or p.get("pointTitle") or "")) + else: + point_titles.append(str(p)) + suffix = f"(要点:{_shorten(';'.join(t for t in point_titles if t), 120)})" if point_titles else "" + lines.append(f"第{i + 1}章 {title}{suffix}") + return "\n".join(lines) + + +def _draft_block(values: dict, body_limit: int = 4000) -> str: + draft = values.get("current_draft") or {} + markdown = str(draft.get("full_markdown") or draft.get("fullMarkdown") or "") + if not markdown: + return "(尚无草稿)" + title = draft.get("title") or "" + word_count = draft.get("word_count") or draft.get("wordCount") or "" + revision = draft.get("revision_count") or draft.get("revisionCount") or 0 + body = markdown if len(markdown) <= body_limit else markdown[:body_limit] + "\n…(正文过长已截断)" + return f"草稿标题:《{title}》 修改次数:{revision} 字数:{word_count}\n--- 草稿正文 ---\n{body}" + + +def _review_block(values: dict) -> str: + review = values.get("review_result") or {} + if not review: + return "(尚无编辑审核结果)" + notes = review.get("revision_notes") or review.get("revisionNotes") or [] + lines = [ + f"编辑结论:{review.get('verdict', '')}(总分 {review.get('overall_score', review.get('overallScore', ''))}," + f"事实 {review.get('fact_score', review.get('factScore', ''))} / 逻辑 {review.get('logic_score', review.get('logicScore', ''))} / " + f"语言 {review.get('language_score', review.get('languageScore', ''))})", + ] + for i, n in enumerate(notes): + issue = _shorten(str(n.get("issue") or ""), 80) + suggestion = _shorten(str(n.get("suggestion") or ""), 80) + lines.append(f"意见[index={i}] [{n.get('category', '')}/{n.get('severity', '')}] {issue} → {suggestion}") + return "\n".join(lines) + + +def _blocked_block(values: dict, interrupt_value: dict | None) -> str: + blocked = (interrupt_value or {}).get("blocked_sections") or values.get("blocked_sections") or [] + if not blocked: + return "" + lines = ["受阻章节(素材不足):"] + for b in blocked: + title = b.get("section_title") or b.get("sectionTitle") or "" + reason = _shorten(str(b.get("reason") or ""), 80) + lines.append(f"- {title}:{reason}") + return "\n".join(lines) + + +def build_context_snapshot(values: dict, pause_point: str | None, interrupt_value: dict | None = None) -> str: + """把 checkpoint state 压成 LLM 可读的中文上下文快照。 + + 按当前阶段裁剪重点:素材阶段给全量素材清单;大纲阶段给大纲;草稿/审核阶段给 + 草稿正文(截断)+ 审核意见(带 index,供 selectedIssueIndices 映射)。 + """ + status = str(values.get("status") or "") + intent = _shorten(str(values.get("user_intent") or ""), 200) + parts = [f"写作主题:{intent}", f"流程状态:{status or '未知'}"] + + if pause_point == "material_confirm": + parts.append("素材清单:\n" + _materials_block(values)) + outline = _outline_block(values) + if outline != "(尚无大纲)": + parts.append("已有大纲:\n" + outline) + elif pause_point == "outline_confirm": + parts.append("当前大纲:\n" + _outline_block(values)) + parts.append("素材清单(供判断 need_research):\n" + _materials_block(values, limit=20)) + elif pause_point in ("draft_confirm", "review_confirm"): + parts.append(_draft_block(values)) + parts.append("编辑审核:\n" + _review_block(values)) + parts.append("素材清单(供判断 need_research):\n" + _materials_block(values, limit=15)) + elif pause_point == "section_help": + blocked = _blocked_block(values, interrupt_value) + if blocked: + parts.append(blocked) + parts.append("当前大纲:\n" + _outline_block(values)) + else: + # 运行中 / 已结束:给全景概要,支撑 ask_meta / ask_content + parts.append("素材清单:\n" + _materials_block(values, limit=15)) + outline = _outline_block(values) + if outline != "(尚无大纲)": + parts.append("大纲:\n" + outline) + draft = _draft_block(values, body_limit=2500) + if draft != "(尚无草稿)": + parts.append(draft) + review = _review_block(values) + if review != "(尚无编辑审核结果)": + parts.append("编辑审核:\n" + review) + + return "\n\n".join(p for p in parts if p) + + +# ── 结果清洗 ────────────────────────────────────────────────────────────────── + +def _clean_payload(raw: Any, action: str, values: dict, interrupt_value: dict | None) -> dict: + """按动作白名单清洗 payload(键名统一驼峰,供前端直接喂 submitIntervention)。""" + p = raw if isinstance(raw, dict) else {} + out: dict[str, Any] = {} + + def _str(key: str) -> str: + v = p.get(key) + return v.strip() if isinstance(v, str) else "" + + if action == "confirm": + ids = p.get("approvedMaterialIds") or p.get("approved_material_ids") + if isinstance(ids, list) and ids: + known = {str(m.get("id")) for m in ((values.get("material_package") or {}).get("materials") or [])} + kept = [str(i) for i in ids if not known or str(i) in known] + if kept: + out["approvedMaterialIds"] = kept + elif action == "re_search": + query = _str("userQuery") or _str("user_query") + if query: + out["userQuery"] = query + out["approvedMaterialIds"] = [] + elif action == "re_outline": + feedback = _str("outlineFeedback") or _str("outline_feedback") + if feedback: + out["outlineFeedback"] = feedback + elif action in ("user_revise", "to_editor", "accept_review"): + notes = _str("userRevisionNotes") or _str("user_revision_notes") + if notes: + out["userRevisionNotes"] = notes + indices = p.get("selectedIssueIndices") or p.get("selected_issue_indices") + if isinstance(indices, list): + review = values.get("review_result") or {} + total = len(review.get("revision_notes") or review.get("revisionNotes") or []) + cleaned = sorted({int(i) for i in indices if isinstance(i, (int, float)) and 0 <= int(i) < max(total, 0)}) + if cleaned: + out["selectedIssueIndices"] = cleaned + # user_revise 必须有意见原文,没有就说明 LLM 输出不完整 → 由上层降级 clarify + elif action == "force_finalize": + out["overrideVerdict"] = True + elif action == "section_help": + blocked = (interrupt_value or {}).get("blocked_sections") or values.get("blocked_sections") or [] + blocked_titles = [ + str(b.get("section_title") or b.get("sectionTitle") or "") for b in blocked + ] + raw_decisions = p.get("sectionDecisions") or p.get("section_decisions") or [] + by_title: dict[str, str] = {} + for d in raw_decisions if isinstance(raw_decisions, list) else []: + if not isinstance(d, dict): + continue + title = str(d.get("sectionTitle") or d.get("section_title") or "") + decision = str(d.get("decision") or "") + if title and decision in _SECTION_DECISIONS: + by_title[title] = decision + # 覆盖全部受阻章节;LLM 漏掉的按多数倾向补齐,无倾向用 loose(与后台挂起默认一致) + majority = "loose" + if by_title: + counts: dict[str, int] = {} + for v in by_title.values(): + counts[v] = counts.get(v, 0) + 1 + majority = max(counts, key=lambda k: counts[k]) + out["sectionDecisions"] = [ + {"sectionTitle": t, "decision": by_title.get(t, majority)} for t in blocked_titles if t + ] + + return out + + +def normalize_intent_result(raw: dict, pause_point: str | None, values: dict, interrupt_value: dict | None = None) -> dict: + """LLM 原始输出 → 安全的意图契约(动作白名单 / 置信度门槛 / payload 清洗)。""" + intent = str(raw.get("intent") or "unknown").strip() or "unknown" + action = raw.get("action") + action = action.strip() if isinstance(action, str) and action.strip() else None + answer = str(raw.get("answer") or "").strip() + clarify = str(raw.get("clarify") or "").strip() + need_research = bool(raw.get("need_research")) + research_query = str(raw.get("research_query") or "").strip() + try: + confidence = max(0.0, min(1.0, float(raw.get("confidence") or 0.0))) + except (TypeError, ValueError): + confidence = 0.0 + + def _clarified(message: str) -> dict: + return { + "intent": intent if intent != "unknown" else "clarify", + "pause_point": pause_point, + "action": None, + "payload": {}, + "need_research": need_research, + "research_query": research_query, + "answer": answer, + "confidence": confidence, + "clarify": clarify or message, + } + + # clarify 非空 / 问答类 → 不执行动作 + if clarify: + return _clarified(clarify) + if action is None: + return { + "intent": intent, + "pause_point": pause_point, + "action": None, + "payload": {}, + "need_research": need_research, + "research_query": research_query, + "answer": answer, + "confidence": confidence, + "clarify": "", + } + + # 无暂停点:图没有可 resume 的 interrupt,任何动作都执行不了 + allowed = ALLOWED_ACTIONS.get(pause_point or "") + if not allowed: + return _clarified("当前流程正在自动执行或已结束,没有等待确认的步骤,暂时不能执行该操作。") + + if action not in allowed: + return _clarified( + f"这个操作在当前阶段做不了(可用:{'、'.join(sorted(allowed))})。请换一种说法,或等流程走到对应阶段再提。" + ) + + payload = _clean_payload(raw.get("payload"), action, values, interrupt_value) + + # user_revise 没带意见原文 = 输出不完整,宁可反问 + if action == "user_revise" and not payload.get("userRevisionNotes"): + return _clarified("你希望怎么修改草稿?请具体说明(例如:第三段太啰嗦,精简一下)。") + + # 高代价动作置信度门槛 + is_high_cost = action in ("finalize", "force_finalize") or ( + action == "re_search" and not payload.get("userQuery") + ) + threshold = HIGH_COST_CONFIDENCE if is_high_cost else ACTION_CONFIDENCE + if confidence < threshold: + hint = { + "finalize": "确认要定稿并结束本次写作吗?", + "force_finalize": "确认忽略编辑意见、强制定稿吗?", + "re_search": "重新搜索会重新生成检索词并覆盖现有素材,确认吗?(若只想补充素材,请描述想补充的内容)", + }.get(action, "我理解得不够确定,能再说得具体一点吗?") + return _clarified(hint) + + return { + "intent": intent, + "pause_point": pause_point, + "action": action, + "payload": payload, + "need_research": need_research, + "research_query": research_query, + "answer": answer, + "confidence": confidence, + "clarify": "", + } + + +# ── LLM 调用 ────────────────────────────────────────────────────────────────── + +def _build_messages(text: str, pause_point: str | None, values: dict, interrupt_value: dict | None): + from langchain_core.messages import HumanMessage, SystemMessage + + stage_guide = STAGE_ACTION_GUIDES.get(pause_point or "", NO_PAUSE_GUIDE) + context = build_context_snapshot(values, pause_point, interrupt_value) + user = INTENT_ROUTER_USER_TEMPLATE.format(stage_guide=stage_guide, context=context, text=text.strip()) + return [SystemMessage(content=INTENT_ROUTER_SYSTEM), HumanMessage(content=user)] + + +async def parse_user_intent( + *, + text: str, + pause_point: str | None, + values: dict, + interrupt_value: dict | None = None, + model_name: str | None = None, + model: Any | None = None, +) -> dict: + """用户自由文本 → 意图契约 dict(snake_case 顶层字段 + 驼峰 payload)。 + + ``model`` 参数仅供测试注入 fake;生产走 ``create_chat_model`` + 配置模型 + 容错链(与 intent_parser 同套 fallback_chain)。任何模型层失败都不抛给调用方, + 降级为 clarify(意图解析是辅助能力,绝不能把写作页面搞挂)。 + """ + from .nodes._utils import _content_to_text, parse_json + + fallback = { + "intent": "clarify", + "action": None, + "payload": {}, + "need_research": False, + "research_query": "", + "answer": "", + "confidence": 0.0, + "clarify": "我没有理解你的意思,能换个说法再试一次吗?也可以直接点击上方卡片里的按钮操作。", + } + + messages = _build_messages(text, pause_point, values, interrupt_value) + + raw_text = "" + if model is not None: + try: + resp = await model.ainvoke(messages) + raw_text = _content_to_text(getattr(resp, "content", "") or "") + except Exception: + logger.exception("intent_router: injected model failed") + else: + from deerflow.models import create_chat_model + + from .nodes._model_fallback import classify_exception, fallback_chain + + chain = fallback_chain((model_name or "").strip() or None) or [None] + for candidate in chain: + try: + m = create_chat_model(name=candidate) if candidate else create_chat_model() + resp = await m.ainvoke(messages) + raw_text = _content_to_text(getattr(resp, "content", "") or "") + break + except Exception as exc: # noqa: BLE001 — 换下一个模型继续 + logger.warning( + "intent_router: model=%s failed (reason=%s): %s", + candidate, classify_exception(exc), exc, + ) + continue + + raw_text = _THINK_RE.sub("", raw_text or "").strip() + if not raw_text: + return normalize_intent_result(fallback, pause_point, values, interrupt_value) + + parsed = parse_json(raw_text, fallback) + return normalize_intent_result(parsed, pause_point, values, interrupt_value) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/intent_router_prompts.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/intent_router_prompts.py new file mode 100644 index 00000000..0f58f858 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/intent_router_prompts.py @@ -0,0 +1,104 @@ +"""对话驱动写作台的意图路由 prompt。 + +场景:新版「AI 写作·对话版」页面用底部统一输入框接管全流程交互,用户的自由文本 +需要被翻译成现有 ai_writing graph 的 ``Command(resume={action, ...payload})`` 指令, +或被识别为「对内容/流程提问」「控制指令」「需要澄清」。 + +设计要点(与 graph.py 各 pause 节点严格对齐,绝不发明图不支持的动作): + - material_confirm 只有 confirm / re_search; + - outline_confirm 只有 confirm / re_outline; + - draft_confirm 只有 to_editor / finalize / user_revise; + - review_confirm 只有 accept_review / force_finalize; + - section_help 只有 section_help(带逐章节 decisions)。 +高代价动作(finalize / force_finalize / 无查询词的覆盖式 re_search)要求高置信, +否则输出 clarify 反问 —— 防误触发是硬指标。 +""" +from __future__ import annotations + +INTENT_ROUTER_SYSTEM = """你是「AI 智能写作」工作台的意图路由器。 +写作流程由多位专家协作:素材收集专家(检索素材)→ 作家(规划大纲、逐章写作)→ 编辑(事实/逻辑/语言三维审核)。流程在每个阶段完成后暂停,等待用户确认后才继续。 +你的任务:读懂用户在当前阶段发来的一句话,把它翻译成一条结构化指令 JSON;如果用户是在提问,就直接回答。 + +【输出要求】 +- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块,必须用英文双引号。 +- JSON 结构(所有字段都必须出现): +{"intent": "意图标识", "action": "动作或null", "payload": {}, "need_research": false, "research_query": "", "answer": "", "confidence": 0.0, "clarify": ""} + +【字段说明】 +- intent:你判定的意图标识,如 confirm_materials / add_material / select_materials / research_again / confirm_outline / revise_outline / reoutline / to_editor / finalize / revise_draft / accept_review / partial_accept_review / add_review_opinion / force_finalize / section_decision / ask_content / ask_meta / run_background / pause_writing / clarify。 +- action:要执行的图动作,只能从下面「当前阶段可用动作」中选择;不执行动作时为 null。 +- payload:动作参数(键名用驼峰): + approvedMaterialIds(素材 id 数组,确认时保留哪些素材;不筛选就省略) + userQuery(补充检索的自然语言检索意图,配合 re_search 表示"追加检索") + outlineFeedback(大纲修改意见原文,配合 re_outline) + userRevisionNotes(草稿修改意见/给编辑的关注点/补充审核意见,配合 user_revise / to_editor / accept_review) + selectedIssueIndices(采纳的审核意见序号数组,从 0 开始,配合 user_revise / accept_review) + overrideVerdict(true,仅配合 force_finalize) + sectionDecisions([{"sectionTitle": "...", "decision": "supplement|loose|delete"}],仅配合 section_help,必须覆盖全部受阻章节) +- need_research:用户的要求需要「当前素材列表里没有的信息」才能完成时置 true,并在 research_query 给出简短检索词(不需要就置 false + 空串)。 +- answer:用户在提问(ask_content 对文章内容提问 / ask_meta 问进度、用了哪些素材等)时,直接根据上下文用中文简洁回答;答案必须基于给定上下文,不得编造。提问类 action 必须为 null。 +- confidence:0~1,你对「动作映射正确」的把握。 +- clarify:拿不准用户想干什么、或该操作在当前阶段做不了时,写一句具体的中文反问/解释(可给出可选项);clarify 非空时 action 必须为 null。 + +【硬规则】 +1. action 只能从「当前阶段可用动作」里选。用户要求的事在当前阶段做不了(例如写作阶段要求重新搜索素材),action 置 null,用 clarify 解释当前能做什么、该要求何时能做。 +2. 高代价动作必须用户意图非常明确才输出,且 confidence ≥ 0.85,否则改用 clarify 反问确认: + - finalize(定稿,结束流程) + - force_finalize(无视编辑意见强制定稿) + - 不带 userQuery 的 re_search(覆盖式重搜,会丢掉现有素材重来) + 注意:带 userQuery 的 re_search 是"追加检索"(保留现有素材,把新素材追加进列表),不算高代价。 +3. 用户说"只用第 X、Y 条素材"→ action=confirm + approvedMaterialIds(按素材清单序号找对应 id);"去掉第 X 条"→ confirm + 反选后的 approvedMaterialIds。 +4. 用户给出修改意见时把**原文完整**放进对应字段(outlineFeedback / userRevisionNotes),不要概括缩写。 +5. 用户说"只接受第 X 条意见"→ selectedIssueIndices 用审核意见清单的序号(从 0 开始)。用户补充自己的审核/修改要求 → 并入 userRevisionNotes。 +6. 控制类:用户要"转后台/后台跑完"→ intent=run_background;"暂停"→ intent=pause_writing。二者 action 都为 null(由前端执行)。 +7. 完全无法判断 → intent=clarify,confidence ≤ 0.3,clarify 给一个具体问题。""" + + +# 每个暂停点的「可用动作说明」——注入 user prompt,让模型只在合法动作里选。 +STAGE_ACTION_GUIDES: dict[str, str] = { + "material_confirm": """当前阶段:素材确认(素材收集专家已完成搜集,等你处理素材)。 +可用动作: +- confirm:确认素材、进入大纲规划。可带 approvedMaterialIds 只保留部分素材。 +- re_search:重新检索。带 userQuery = 按用户描述追加检索(保留现有素材);不带 userQuery = 覆盖式重搜(高代价,需高置信)。 +典型说法 → 动作: +「可以,继续」→ confirm;「补充一些关于X的素材」「再查查Y」→ re_search + userQuery;「只用第1、3条」→ confirm + approvedMaterialIds;「重新搜索」→ re_search(覆盖式,需高置信)。""", + "outline_confirm": """当前阶段:大纲确认(作家已完成大纲规划,等你确认或提修改要求)。 +可用动作: +- confirm:确认大纲、开始写作。 +- re_outline:按意见重新规划大纲,outlineFeedback 放用户意见原文(可空 = 完全重来)。 +典型说法 → 动作: +「大纲可以」→ confirm;「第二章拆成两节」「把X挪到前面」「加一章讲Y」→ re_outline + outlineFeedback;「重写大纲」→ re_outline(feedback 空)。 +注意:本阶段不能检索素材。用户想补素材时若其本质是"大纲里加上X内容",可映射 re_outline + outlineFeedback 并视情况置 need_research。""", + "draft_confirm": """当前阶段:草稿确认(作家已完成草稿,右侧可预览,等你处理)。 +可用动作: +- to_editor:交给编辑审核。可带 userRevisionNotes = 让编辑重点核查的方面。 +- user_revise:按用户意见修改草稿,userRevisionNotes 放意见原文(必填)。若已有编辑审核意见且用户说按意见改,可带 selectedIssueIndices。 +- finalize:确认定稿,结束流程(高代价,需高置信)。 +典型说法 → 动作: +「交给编辑」「审核一下」→ to_editor;「第三段精简」「补个案例」「更口语一点」「重写第二章」→ user_revise + userRevisionNotes;「就这样定稿」→ finalize(需高置信);「让编辑重点看数据准确性」→ to_editor + userRevisionNotes。""", + "review_confirm": """当前阶段:编辑打回处理(编辑认为文章需要修改,列出了审核意见,等你决定)。 +可用动作: +- accept_review:按编辑意见修改。可带 selectedIssueIndices 只采纳部分意见;用户自己补充的修改要求并入 userRevisionNotes。 +- force_finalize:忽略编辑意见强制定稿,payload 带 {"overrideVerdict": true}(高代价,需高置信)。 +典型说法 → 动作: +「按编辑意见改」→ accept_review;「只接受第1、2条」→ accept_review + selectedIssueIndices;「再补一条:核查数据来源」→ accept_review + userRevisionNotes;「不改了,定稿」→ force_finalize(需高置信)。""", + "section_help": """当前阶段:素材不足求助(严格模式下部分章节缺素材,需逐章节决定处理方式)。 +可用动作: +- section_help:sectionDecisions 必须覆盖全部受阻章节,每章 decision ∈ supplement(补充检索素材)/ loose(按通用知识续写)/ delete(删除该章节)。 +典型说法 → 动作: +「都补充检索」→ 全部 supplement;「都按通用知识写」→ 全部 loose;「第一个删掉,其他补检索」→ 对应组合。用户没说全 → 未提到的章节按用户整体倾向补齐,倾向不明则用 loose。""", +} + +NO_PAUSE_GUIDE = """当前没有等待确认的步骤(流程正在自动执行中,或已结束)。 +不能执行任何图动作(action 必须为 null)。用户提问 → 正常回答(answer);用户提出操作要求 → 用 clarify 告知「当前阶段正在进行中,完成后会暂停等确认,届时再提出即可」,并简述现在进行到哪一步。控制类(run_background / pause_writing)照常输出 intent。""" + + +INTENT_ROUTER_USER_TEMPLATE = """{stage_guide} + +【写作上下文】 +{context} + +【用户这句话】 +{text} + +请输出意图 JSON:"""