feat: add AI writing chat workspace

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

No files matched your search

@@ -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 />} />