feat: add AI writing chat workspace
This commit is contained in:
1 parent
54190d7a83
commit
845c87e3ee
13 files changed
+2622
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+409
@@ -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)
|
||||
+104
@@ -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:"""
|
||||
Reference in new issue
Block a user