feat: add AI writing chat workspace

This commit is contained in:
董宏凯 committed 2026-07-02 13:56:46 +08:00
1 parent 54190d7a83
commit 845c87e3ee
13 files changed
+2622

No files matched your search

+1
View File
@@ -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-<appId>`). 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.
+1
View File
@@ -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-<appId>`). 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`.
@@ -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",
},
],
},
{
@@ -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<string, unknown> | null;
need_research?: boolean;
research_query?: string | null;
answer?: string | null;
confidence?: number;
clarify?: string | null;
}
function normalizePayload(raw: Record<string, unknown> | 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<Record<string, unknown>>)
.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<WritingIntentResult> {
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',
};
}
@@ -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<number>;
}
export function ChatFlowTimeline({
events,
completedInterventions,
currentPause,
currentInterventionSlot,
chatMessages,
suppressedRequirementIndices,
}: ChatFlowTimelineProps) {
if (events.length === 0 && chatMessages.length === 0) return null;
// 消息按锚点分桶(锚点超出当前事件数的挂在末尾)
const msgsByAnchor = new Map<number, ChatFeedMessage[]>();
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 = (
<div className="space-y-2">
{showRequirement && <UserRequirementCard record={completedRecord} />}
<CompletedInterventionCard record={completedRecord} />
</div>
);
} else if (currentPause && fullIdx === lastAwaitUserIdx) {
slot = currentInterventionSlot;
extendLineThroughSlot = false;
}
}
rows.push({ key: `event-${fullIdx}`, type: 'event', event, slot, extendLineThroughSlot });
});
pushChats(events.length);
return (
<div className="px-4 py-3 space-y-0">
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
if (row.type === 'chat') {
return <ChatFeedRow key={row.key} msg={row.msg} isLast={isLast} />;
}
return (
<TimelineStep
key={row.key}
event={row.event}
isLast={isLast && (!row.slot || !row.extendLineThroughSlot)}
>
{row.slot}
</TimelineStep>
);
})}
</div>
);
}
/** 信息流里的对话气泡行:左侧延续时间线竖线,与流程事件保持同一视觉脊柱。 */
function ChatFeedRow({ msg, isLast }: { msg: ChatFeedMessage; isLast: boolean }) {
return (
<div className="flex gap-3">
{/* 竖线占位列(与 TimelineStep 的 size-4 图标列同宽),保持时间线连续 */}
<div className="flex w-4 shrink-0 flex-col items-center">
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className={cn('min-w-0 flex-1 pb-3', isLast && 'pb-0')}>
<ChatBubble msg={msg} />
</div>
</div>
);
}
export function ChatBubble({ msg }: { msg: Pick<ChatFeedMessage, 'role' | 'kind' | 'text'> }) {
if (msg.role === 'user') {
return (
<div className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-3 py-2 text-xs leading-relaxed text-primary-foreground whitespace-pre-wrap">
{msg.text}
</div>
</div>
);
}
const iconByKind: Record<ChatKind, ReactNode> = {
text: <Bot className="size-3.5 text-primary" />,
answer: <Bot className="size-3.5 text-primary" />,
clarify: <CircleHelp className="size-3.5 text-amber-500" />,
ack: <BadgeCheck className="size-3.5 text-emerald-500" />,
notice: <Info className="size-3.5 text-muted-foreground" />,
};
return (
<div className="flex items-start gap-2">
<div className="mt-1 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted">
{iconByKind[msg.kind]}
</div>
<div
className={cn(
'max-w-[85%] rounded-2xl rounded-bl-sm px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap',
msg.kind === 'clarify' && 'border border-amber-200 bg-amber-50/60 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200',
msg.kind === 'notice' && 'bg-muted/60 text-muted-foreground',
msg.kind === 'ack' && 'bg-emerald-50/70 text-emerald-800 dark:bg-emerald-950/20 dark:text-emerald-200',
(msg.kind === 'answer' || msg.kind === 'text') && 'bg-muted text-foreground',
)}
>
{msg.text}
</div>
</div>
);
}
@@ -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 (
<MaterialConfirmChatCard
materials={materials}
skillSearchSteps={skillSearchSteps ?? []}
skillSearchInProgress={skillSearchInProgress}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'outline_confirm') {
return (
<OutlineConfirmChatCard
outline={outline ?? pauseEvent.outline ?? null}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'draft_confirm') {
return (
<DraftConfirmChatCard
draftTitle={draftTitle}
draftWordCount={draftWordCount}
wordCountTarget={wordCountTarget}
revisionCount={revisionCount}
reviewResult={reviewResult}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'section_help') {
return (
<SectionHelpChatCard
blockedSections={pauseEvent.blockedSections ?? []}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'review_confirm') {
return (
<ReviewConfirmChatCard
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
revisionCount={revisionCount}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
return (
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
<PauseCircle className="size-3.5 text-yellow-500 shrink-0" />
等待用户确认({pausePoint})…
</div>
);
}
// ── 工具 ──────────────────────────────────────────────────────────────────────
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
return new Set(issues.map((_, i) => i));
}
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): 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 (
<div className="flex items-start gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5 text-[11px] leading-relaxed text-muted-foreground">
<MessageSquareText className="mt-0.5 size-3 shrink-0 text-primary/70" />
<span>也可以直接在底部输入框用一句话操作,例如:{examples}</span>
</div>
);
}
// ── 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<Set<string>>(
() => 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 (
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
<div className="flex items-start gap-2">
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。可以点「重新搜索」,或在底部输入框描述想检索的内容,也可以在无素材的情况下直接继续写作。
</p>
</div>
</div>
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 min-w-[140px] h-8 text-xs gap-1.5"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })}
disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />无数据继续写作
</Button>
</div>
<ComposerHint examples="「补充关于××的素材」「换个方向重新搜」" />
</div>
);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。可取消勾选不相关的素材后点确认。
</p>
</div>
</div>
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
<MaterialsCard
materials={materials}
selectedIds={selectedIds}
onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}}
/>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedIds.size} / {materials.length} 条素材
</span>
<div className="flex-1" />
<button
type="button"
onClick={() => setSelectedIds(allSelected ? new Set() : new Set(materials.map((m) => m.id)))}
className="text-xs text-primary hover:underline"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 min-w-[180px] h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
</div>
<ComposerHint examples="「补充关于××的素材」「只用第 1、3 条」「素材可以,继续」" />
</div>
);
}
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
function OutlineConfirmChatCard({
outline: initialOutline, onSubmit, disabled,
}: {
outline: AIOutline | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({
action: 'confirm',
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
});
setSubmitting(false);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">请确认大纲后继续写作</p>
<p className="text-xs text-muted-foreground mt-0.5">
可直接点击章节标题或要点微调;想让 AI 重新规划,就在底部输入框说出你的要求。
</p>
</div>
</div>
{editedOutline
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
>
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
</Button>
<ComposerHint examples="「第二章拆成两节」「重新规划大纲」「加一章讲发展趋势」" />
</div>
);
}
// ── 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<Set<number>>(
() => 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 (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">草稿已完成,请确认后继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容
</p>
{showWordCmp && (
<p className={cn('text-xs mt-0.5', wordWithin ? 'text-muted-foreground' : 'text-orange-600 dark:text-orange-400')}>
实际 {draftWordCount} 字 / 目标 {wordCountTarget} 字
{wordDiff !== 0 && `(${wordDiff > 0 ? '超出' : '不足'} ${Math.abs(wordDiff)} 字${wordWithin ? ',在误差范围内' : ''})`}
</p>
)}
</div>
</div>
{reviewResult && (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasReview ? selectedIssueIndices : undefined}
onToggleIssue={hasReview ? toggleIssue : undefined}
/>
)}
<div className="flex flex-col gap-1.5 pt-1">
{hasReview && (
<Button
type="button" variant="outline" size="sm"
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
onClick={handleApplyReview}
disabled={disabled || submitting || selectedIssueIndices.size === 0}
>
<ClipboardEdit className="size-3.5" />
按编辑审核意见修改
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
)}
<div className="flex gap-1.5">
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('to_editor')} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
</div>
</div>
<ComposerHint examples="「第三段太啰嗦,精简一下」「结尾补个案例」「更口语一点」" />
</div>
);
}
// ── 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<Set<number>>(
() => 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 (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">编辑认为文章需要修改</p>
<p className="text-xs text-muted-foreground mt-0.5">
勾选您希望采纳的意见,再让作家重写;或忽略编辑意见强制定稿。
</p>
</div>
</div>
{reviewResult ? (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
onToggleIssue={hasIssues ? toggleIssue : undefined}
/>
) : (
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
)}
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleAccept}
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0)}
>
<ClipboardEdit className="size-3.5" />按编辑意见修改
{hasIssues && selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
onClick={handleForce} disabled={disabled || submitting}
>
<ShieldAlert className="size-3.5" />强制定稿
</Button>
</div>
<ComposerHint examples="「再补一条意见:核查数据来源」「只按第 1、2 条改」「不改了,定稿」" />
</div>
);
}
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
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<Record<string, SectionDecisionKind>>(
() => 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 (
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
<div className="flex items-start gap-2">
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">部分章节素材不足,需要你决定</p>
<p className="text-xs text-muted-foreground mt-0.5">
严格依据素材模式下,以下 {blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。
</p>
</div>
</div>
<div className="space-y-2">
{blockedSections.map((b) => (
<div key={b.sectionTitle} className="rounded-md border bg-background p-2.5 space-y-2">
<div>
<p className="text-xs font-medium">{b.sectionTitle}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{b.reason}</p>
</div>
<div className="flex gap-1">
{DECISION_OPTIONS.map((opt) => {
const active = (decisions[b.sectionTitle] ?? 'supplement') === opt.value;
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
disabled={disabled || submitting}
onClick={() => setDecisions((p) => ({ ...p, [b.sectionTitle]: opt.value }))}
className={cn(
'flex-1 flex items-center justify-center gap-1 rounded px-1.5 py-1 text-[11px] border transition-colors',
active
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-input text-muted-foreground hover:bg-muted/50',
)}
>
<Icon className="size-3 shrink-0" />
{opt.label}
</button>
);
})}
</div>
</div>
))}
</div>
{hasSupplement && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。
</p>
)}
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleSubmit} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />提交并继续
</Button>
<ComposerHint examples="「都补充检索」「都按通用知识写」「第一个删掉,其他补检索」" />
</div>
);
}
@@ -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<string, string> = {
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<string, { expert: string; placeholder: string; chips: string[] }> = {
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<ChatFeedMessage[]>([]);
const [resolving, setResolving] = useState(false);
const [pendingIntent, setPendingIntent] = useState<PendingIntent | null>(null);
const pendingIntentRef = useRef<PendingIntent | null>(null);
pendingIntentRef.current = pendingIntent;
// 澄清多轮上下文:上一句用户原文 + 系统追问,拼给下一次解析
const clarifyContextRef = useRef<{ originalText: string; question: string } | null>(null);
// 由输入框发起的干预序号:ChatFlowTimeline 不再重复渲染「我的要求」卡(气泡已按序展示原话)
const suppressedRequirementsRef = useRef<Set<number>>(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<AIWritingSessionSummary | null>(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<HTMLDivElement>(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 (
<div className="flex flex-col h-full overflow-hidden bg-background dark:bg-transparent">
{!isHistoryMode && (
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
)}
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
{canRestoreHistory && (
<Button
variant="ghost" size="sm"
className="h-7 px-2 text-xs gap-1 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
onClick={handleRestoreHistory}
title="恢复写作:跳过已完成阶段,从未完成阶段重新开始"
>
<Play className="size-3 fill-current" />
恢复
</Button>
)}
<span
className={cn('text-sm font-semibold shrink-0 min-w-0 truncate', isHistoryMode && 'flex-1 mr-2')}
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
>
{isHistoryMode
? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
: 'AI 智能写作 · 对话版'}
</span>
</div>
<div className="flex items-center gap-1.5">
{showNewButton && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建
</Button>
)}
{isRunning && !isHistoryMode && state.sessionId && (
<Button
variant="ghost" size="sm"
className="h-7 px-2 text-xs gap-1 text-sky-500 hover:text-sky-600 hover:bg-sky-50 dark:hover:bg-sky-950/30"
onClick={handleBackgroundSuspend}
title="转入后台:自动确认素材/大纲、写作、编辑审核直至定稿,离开页面也不中断"
>
<CloudUpload className="size-3" />
后台挂起
</Button>
)}
{isRunning && !state.isPaused && (
<Button
variant="ghost" size="sm"
className="h-7 px-2 text-xs gap-1 text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={pauseWriting}
>
<Pause className="size-3 fill-current" />
暂停
</Button>
)}
{state.isPaused && (
<>
<Button
variant="ghost" size="sm"
className="h-7 px-2 text-xs gap-1 text-primary hover:text-primary hover:bg-primary/8"
onClick={resumeWriting}
>
<Play className="size-3 fill-current" />
继续
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<RotateCcw className="size-3" />
重置
</Button>
</>
)}
{(isDone || isError) && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<RotateCcw className="size-3" />
重置
</Button>
)}
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={historySession?.id ?? null}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onShowProgress={setProgressSession}
onDelete={remove}
onRename={rename}
onRefresh={refresh}
/>
</div>
</div>
{/* 主体滚动区 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{isHistoryMode && historySession && (
<AIWritingHistoryView session={historySession} />
)}
{/* 意图解析流式 / 结果(写作起点,锚定内容顶部) */}
{!isHistoryMode && (state.intentStreamingText || state.intentThinkingText) && (
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{!isHistoryMode && state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} hideTargetAudience />
)}
{/* 统一信息流:流程事件 + 意图层对话气泡按时间顺序合并 */}
{!isIdle && !isHistoryMode && (
<ChatFlowTimeline
events={state.progressEvents}
completedInterventions={state.completedInterventions}
currentPause={state.currentPause}
chatMessages={chatMessages}
suppressedRequirementIndices={suppressedRequirementsRef.current}
currentInterventionSlot={
state.currentPause ? (
<ChatInterventionCard
pauseEvent={state.currentPause}
materials={state.materials}
outline={state.outline}
reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle}
draftWordCount={countArticleWords(state.currentDraftMarkdown)}
wordCountTarget={wordTarget}
revisionCount={state.revisionCount}
skillSearchSteps={state.skillSearchSteps}
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
onSubmit={submitIntervention}
disabled={resolving}
/>
) : undefined
}
/>
)}
{/* 各阶段流式视图(当前正在发生的活动,固定在信息流末尾) */}
{!isHistoryMode && state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
<OutlineStreamingView
text={state.outlineStreamingText}
thinkingText={state.outlineThinkingText}
organizing={!!state.request?.userOutline}
/>
)}
{!isHistoryMode && state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
<ReviewStreamingView items={state.streamingReviewItems} />
)}
{!isHistoryMode && state.revisionSummary && (
<div className="px-4 pb-2">
<RevisionSummaryCard summary={state.revisionSummary} />
</div>
)}
{!isHistoryMode && state.status === 'writing_draft' && (
<DraftGeneratingView sections={state.streamingDraftSections} />
)}
{!isHistoryMode && state.status === 'researching' && (
state.skillSelectStreamingText
|| state.skillSelectThinkingText
|| (state.skillSearchSteps.some((s) => s.type === 'match_skills')
&& !state.skillSearchSteps.some((s) => s.type === 'select_skills'))
) && (
<SkillSelectStreamingView
text={state.skillSelectStreamingText}
thinkingText={state.skillSelectThinkingText}
/>
)}
{!isHistoryMode && (state.skillSearchPending
|| (state.status === 'researching' && state.skillSearchSteps.length > 0)) && (
<div className="px-4 pb-2">
<SkillStepsCard steps={state.skillSearchSteps} inProgress />
</div>
)}
{!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 && (
<div className="px-4 pb-4">
<WritingSkeletonCard
status={state.status}
researchProgress={state.researchProgress}
organizing={!!state.request?.userOutline}
/>
</div>
)}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{state.error}
</div>
)}
{/* 瞬态提示(正在理解意图 / pending 续跑),贴着输入框 */}
{!isHistoryMode && resolving && (
<div className="flex items-center gap-2 px-4 pb-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
正在理解你的意图…
</div>
)}
{!isHistoryMode && pendingIntent && (
<div className="mx-4 mb-3 flex items-start justify-between gap-2 rounded-md border border-sky-200 bg-sky-50/60 px-3 py-2 text-xs text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/20 dark:text-sky-300">
<span className="leading-relaxed">
待续跑:素材补充完成后将自动执行「{pendingIntent.note}」
</span>
<button
type="button"
className="shrink-0 text-sky-600 underline-offset-2 hover:underline dark:text-sky-300"
onClick={() => setPendingIntent(null)}
>
取消
</button>
</div>
)}
</div>
{isRunning && !autoScroll && (
<div className="absolute inset-x-0 bottom-4 flex justify-center z-10 pointer-events-none">
<button
onClick={scrollToBottom}
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-full bg-background border shadow-md hover:bg-muted transition-colors whitespace-nowrap"
>
<ChevronsDown className="size-3.5" />
回到底部
</button>
</div>
)}
</div>
{/* 状态栏 */}
{!isIdle && !isHistoryMode && (
<div
className={cn(
'shrink-0 px-4 py-1.5 border-t text-xs flex items-center gap-2',
isError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{state.isPaused ? (
<>
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
已暂停
</>
) : (
<>
{isRunning && (
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
)}
{state.status === 'researching' && state.researchProgress
? state.researchProgress
: STATUS_LABEL[state.status] ?? state.status}
{stageUI && (
<span className="ml-auto flex items-center gap-1 text-[11px] text-primary/80">
<Bot className="size-3" />
当前交互对象:{stageUI.expert}
</span>
)}
</>
)}
</div>
)}
{/* 底部统一智能输入框 */}
{!isHistoryMode && (
<div className="shrink-0 border-t bg-background px-4 py-3 dark:bg-transparent">
{chips.length > 0 && (
<div className="mb-2 flex flex-wrap items-center gap-1.5">
{chips.map((chip) => (
<button
key={chip}
type="button"
disabled={resolving}
onClick={() => void handleSend(chip)}
className="rounded-full border border-input bg-background px-2.5 py-1 text-[11px] text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/5 hover:text-foreground disabled:opacity-50"
>
{chip}
</button>
))}
</div>
)}
<div className="rounded-2xl border border-[#ece5e4] bg-card p-2.5 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
<PromptInput onSubmit={({ text }) => void handleSend(text ?? '')}>
<PromptInputTextarea placeholder={placeholder} disabled={resolving} />
<PromptInputFooter className="items-center justify-end">
<PromptInputSubmit status={resolving ? 'submitted' : 'ready'} disabled={resolving} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
)}
<AIWritingFlowDialog
session={progressSession}
open={progressSession !== null}
onOpenChange={(o) => { if (!o) setProgressSession(null); }}
/>
</div>
);
}
@@ -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 (
<div className="relative flex h-full">
<div className="min-h-0 flex-1 overflow-hidden">
<WritingSetupChat />
</div>
<div
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width,min-width] duration-300 ease-in-out',
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0',
)}
>
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields />
</div>
</div>
{!formExpanded && (
<button
type="button"
onClick={() => setFormExpanded(true)}
title="展开写作配置"
className="absolute right-3 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightOpen className="size-4" />
</button>
)}
</div>
);
}
/** 写作中 / 历史回看:对话驱动工作台(左信息流 + 右草稿预览)。 */
function ChatWorkbenchLayout() {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
<ResizablePanel
id={CHAT_ID}
defaultSize={40}
minSize={25}
className="overflow-hidden flex flex-col"
>
<WritingChatPanel />
</ResizablePanel>
<ResizableHandle
withHandle
className="after:left-0 after:w-3 after:translate-x-0 [&>div]:translate-x-1.5"
/>
<ResizablePanel
id={DRAFT_ID}
defaultSize={60}
minSize={35}
className="overflow-hidden"
>
<AIWritingDraftPanel />
</ResizablePanel>
</ResizablePanelGroup>
);
}
function AIWritingChatBody() {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
// idle 且非历史回看 → 经典初始布局;开始写作 / 查看历史 → 对话驱动工作台。
// 两套布局都保持挂载态简单切换:SetupLayout 内的 WritingSetupChat 在开始写作后
// 卸载没有代价(它只负责启动前配置,线程与写作 graph 解耦)。
if (state.status === 'idle' && !isHistoryMode) {
return <SetupLayout />;
}
return <ChatWorkbenchLayout />;
}
export default function AIWritingChatPage() {
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingChatBody />
</div>
</div>
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</AIWritingHistoryProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -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() {
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing/*" element={<AIWritingPage hideAdvancedFields />} />
<Route path="ai-writing-chat/*" element={<AIWritingChatPage />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
@@ -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):
@@ -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
@@ -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"<think>.*?</think>", 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)
@@ -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:"""