Files
deerflow-code/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx
T
董宏凯andCursor 5de74e90dc feat(ai写作): 左栏智能体 Tab 内嵌问答并优化模块侧栏
AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 15:53:51 +08:00

448 lines
15 KiB
TypeScript

import { useMemo } from 'react';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
import {
normalizeProgressDict,
normalizeMaterial,
normalizeOutline,
normalizeReviewResult,
} from '../../hooks/useAIWritingStream';
import type { CompletedIntervention, ProgressEvent, ReviewResult } from '../../types/ai-writing';
import { countArticleWords } from '../../utils/wordCount';
import { AIWritingTimeline } from './AIWritingTimeline';
const PAUSE_POINTS = new Set<CompletedIntervention['pausePoint']>([
'material_confirm',
'outline_confirm',
'draft_confirm',
'review_confirm',
'section_help',
]);
const PAUSE_MESSAGES: Record<CompletedIntervention['pausePoint'], string> = {
material_confirm: '素材收集专家已完成素材搜集,请确认素材后继续。',
outline_confirm: '作家已完成大纲规划,请确认或编辑大纲后继续。',
draft_confirm: '草稿已完成,请确认或给出修改意见。',
review_confirm: '编辑认为文章需要修改,请决定下一步操作。',
section_help: '部分章节缺少足够素材支撑写作,请决定如何处理。',
};
interface TimelineSnapshot {
materials?: CompletedIntervention['materials'];
outline?: CompletedIntervention['outline'];
draftTitle?: string;
draftWordCount?: number;
reviewResult?: ReviewResult | null;
}
function asPausePoint(value: unknown): CompletedIntervention['pausePoint'] | null {
const pausePoint = String(value ?? '');
return PAUSE_POINTS.has(pausePoint as CompletedIntervention['pausePoint'])
? (pausePoint as CompletedIntervention['pausePoint'])
: null;
}
function normalizeCompletedIntervention(rec: Record<string, unknown>): CompletedIntervention | null {
const pausePoint = asPausePoint(rec.pausePoint ?? rec.pause_point);
if (!pausePoint) return null;
const out: CompletedIntervention = {
pausePoint,
action: String(rec.action ?? defaultActionForPause(pausePoint)),
};
if (Array.isArray(rec.materials)) {
out.materials = (rec.materials as Record<string, unknown>[]).map(normalizeMaterial);
}
const outline = normalizeOutline(rec.outline);
if (outline) out.outline = outline;
const reviewResult = normalizeReviewResult(rec.reviewResult ?? rec.review_result);
if (reviewResult) out.reviewResult = reviewResult;
const draftTitle = rec.draftTitle ?? rec.draft_title;
if (typeof draftTitle === 'string' && draftTitle.trim()) out.draftTitle = draftTitle;
const draftWordCount = Number(rec.draftWordCount ?? rec.draft_word_count);
if (Number.isFinite(draftWordCount) && draftWordCount > 0) {
out.draftWordCount = draftWordCount;
}
const userInput =
rec.userInput ??
rec.user_input ??
rec.userQuery ??
rec.user_query ??
rec.outlineFeedback ??
rec.outline_feedback ??
rec.userRevisionNotes ??
rec.user_revision_notes;
if (typeof userInput === 'string' && userInput.trim()) out.userInput = userInput;
return out;
}
function normalizeInterventionList(records: unknown): CompletedIntervention[] {
if (!Array.isArray(records)) return [];
return records
.map((rec) => (
rec && typeof rec === 'object'
? normalizeCompletedIntervention(rec as Record<string, unknown>)
: null
))
.filter((rec): rec is CompletedIntervention => rec != null);
}
function defaultActionForPause(pausePoint: CompletedIntervention['pausePoint']): string {
switch (pausePoint) {
case 'draft_confirm':
return 'to_editor';
case 'review_confirm':
return 'accept_review';
case 'section_help':
return 'section_help';
default:
return 'confirm';
}
}
function classifyResumeEvent(message: string): Pick<CompletedIntervention, 'pausePoint' | 'action'> | null {
const m = message || '';
if (m.includes('确认素材')) return { pausePoint: 'material_confirm', action: 'confirm' };
if (m.includes('匹配技能检索') || m.includes('重新检索') || m.includes('重新搜索')) {
return { pausePoint: 'material_confirm', action: 're_search' };
}
if (m.includes('确认大纲')) return { pausePoint: 'outline_confirm', action: 'confirm' };
if (m.includes('重新规划大纲')) return { pausePoint: 'outline_confirm', action: 're_outline' };
if (m.includes('提交编辑审核')) return { pausePoint: 'draft_confirm', action: 'to_editor' };
if (m.includes('强制定稿')) return { pausePoint: 'review_confirm', action: 'force_finalize' };
if (m.includes('确认定稿')) return { pausePoint: 'draft_confirm', action: 'finalize' };
if (m.includes('按用户意见重写')) return { pausePoint: 'draft_confirm', action: 'user_revise' };
if (m.includes('按编辑意见重写')) return { pausePoint: 'review_confirm', action: 'accept_review' };
if (m.includes('补充检索素材')) return { pausePoint: 'section_help', action: 'supplement' };
if (m.includes('继续写作')) return { pausePoint: 'section_help', action: 'loose' };
return null;
}
function actionFromNextResume(
pausePoint: CompletedIntervention['pausePoint'],
events: ProgressEvent[],
startIndex: number,
): string {
for (let i = startIndex; i < events.length; i += 1) {
const event = events[i];
if (event.type === 'await_user') break;
if (event.type !== 'resumed') continue;
const classified = classifyResumeEvent(event.message);
if (classified?.pausePoint === pausePoint) return classified.action;
break;
}
return defaultActionForPause(pausePoint);
}
function materialsFromEvent(event: ProgressEvent | undefined): CompletedIntervention['materials'] | undefined {
if (!event) return undefined;
if (event.materials?.length) return event.materials;
const raw = event.pauseContext?.materials;
return Array.isArray(raw)
? raw.map((item) => normalizeMaterial(item as Record<string, unknown>))
: undefined;
}
function outlineFromEvent(event: ProgressEvent | undefined): CompletedIntervention['outline'] | undefined {
if (!event) return undefined;
if (event.outline) return event.outline;
const outline = normalizeOutline(event.pauseContext?.outline);
return outline ?? undefined;
}
function reviewFromEvent(event: ProgressEvent | undefined): ReviewResult | null | undefined {
if (!event) return undefined;
if (event.reviewResult) return event.reviewResult;
return normalizeReviewResult(event.pauseContext?.reviewResult ?? event.pauseContext?.review_result) ?? undefined;
}
function updateSnapshot(snapshot: TimelineSnapshot, event: ProgressEvent): void {
if (event.type === 'materials_ready' && event.materials) {
snapshot.materials = event.materials;
}
if (event.type === 'outline_ready' && event.outline) {
snapshot.outline = event.outline;
}
if (event.type === 'draft_ready') {
if (event.draftTitle) snapshot.draftTitle = event.draftTitle;
if (event.draftMarkdown) snapshot.draftWordCount = countArticleWords(event.draftMarkdown);
}
if (event.type === 'review_ready' && event.reviewResult) {
snapshot.reviewResult = event.reviewResult;
}
}
function enrichIntervention(
record: CompletedIntervention,
sourceEvent: ProgressEvent | undefined,
snapshot: TimelineSnapshot,
sessionDraftTitle: string | null,
sessionDraftMarkdown: string | null,
sessionReviewResult: ReviewResult | null,
): CompletedIntervention {
const enriched: CompletedIntervention = { ...record };
if (record.pausePoint === 'material_confirm') {
enriched.materials = record.materials?.length
? record.materials
: (materialsFromEvent(sourceEvent) ?? snapshot.materials ?? []);
}
if (record.pausePoint === 'outline_confirm') {
enriched.outline = record.outline ?? outlineFromEvent(sourceEvent) ?? snapshot.outline;
}
if (record.pausePoint === 'draft_confirm') {
enriched.draftTitle =
record.draftTitle ??
sourceEvent?.draftTitle ??
snapshot.draftTitle ??
sessionDraftTitle ??
undefined;
enriched.draftWordCount =
record.draftWordCount ??
snapshot.draftWordCount ??
(sessionDraftMarkdown ? countArticleWords(sessionDraftMarkdown) : undefined);
enriched.reviewResult =
record.reviewResult ??
reviewFromEvent(sourceEvent) ??
snapshot.reviewResult ??
sessionReviewResult ??
undefined;
}
if (record.pausePoint === 'review_confirm') {
enriched.reviewResult =
record.reviewResult ??
reviewFromEvent(sourceEvent) ??
snapshot.reviewResult ??
sessionReviewResult ??
undefined;
}
return enriched;
}
function synthesizeInterventions(
events: ProgressEvent[],
sessionDraftTitle: string | null,
sessionDraftMarkdown: string | null,
sessionReviewResult: ReviewResult | null,
): CompletedIntervention[] {
const records: CompletedIntervention[] = [];
const snapshot: TimelineSnapshot = {
draftTitle: sessionDraftTitle ?? undefined,
draftWordCount: sessionDraftMarkdown ? countArticleWords(sessionDraftMarkdown) : undefined,
reviewResult: sessionReviewResult,
};
const hasAwaitUser = events.some((event) => event.type === 'await_user');
for (let i = 0; i < events.length; i += 1) {
const event = events[i];
if (event.type === 'await_user') {
const pausePoint = asPausePoint(event.pausePoint);
if (pausePoint) {
const record: CompletedIntervention = {
pausePoint,
action: actionFromNextResume(pausePoint, events, i + 1),
};
records.push(
enrichIntervention(
record,
event,
snapshot,
sessionDraftTitle,
sessionDraftMarkdown,
sessionReviewResult,
),
);
}
} else if (!hasAwaitUser && event.type === 'resumed') {
const classified = classifyResumeEvent(event.message);
if (classified) {
records.push(
enrichIntervention(
classified,
event,
snapshot,
sessionDraftTitle,
sessionDraftMarkdown,
sessionReviewResult,
),
);
}
}
updateSnapshot(snapshot, event);
}
return records;
}
function mergeIntervention(primary: CompletedIntervention, fallback: CompletedIntervention): CompletedIntervention {
const defaultAction = defaultActionForPause(primary.pausePoint);
const action = primary.action === defaultAction && fallback.action
? fallback.action
: (primary.action || fallback.action);
return {
pausePoint: primary.pausePoint,
action,
userInput: primary.userInput ?? fallback.userInput,
materials: primary.materials?.length ? primary.materials : fallback.materials,
outline: primary.outline ?? fallback.outline,
draftTitle: primary.draftTitle ?? fallback.draftTitle,
draftWordCount: primary.draftWordCount ?? fallback.draftWordCount,
reviewResult: primary.reviewResult ?? fallback.reviewResult,
};
}
function mergeInterventionLists(
synthesized: CompletedIntervention[],
transcriptRecords: CompletedIntervention[],
sessionRecords: CompletedIntervention[],
): CompletedIntervention[] {
const max = Math.max(synthesized.length, transcriptRecords.length, sessionRecords.length);
const merged: CompletedIntervention[] = [];
for (let i = 0; i < max; i += 1) {
const anchor = synthesized[i] ?? transcriptRecords[i] ?? sessionRecords[i];
if (!anchor) continue;
let record = anchor;
const candidates = [transcriptRecords[i], sessionRecords[i]];
for (const candidate of candidates) {
if (!candidate || candidate.pausePoint !== record.pausePoint) continue;
record = mergeIntervention(record, candidate);
}
merged.push(record);
}
return merged;
}
function eventFromIntervention(record: CompletedIntervention, sessionId: string): ProgressEvent {
return {
type: 'await_user',
sessionId,
message: PAUSE_MESSAGES[record.pausePoint],
pausePoint: record.pausePoint,
materials: record.materials,
outline: record.outline,
reviewResult: record.reviewResult,
draftTitle: record.draftTitle,
};
}
function ensureInterventionAnchors(
events: ProgressEvent[],
records: CompletedIntervention[],
sessionId: string,
): ProgressEvent[] {
if (events.some((event) => event.type === 'await_user')) return events;
if (records.length === 0) return events;
if (events.length === 0) return records.map((record) => eventFromIntervention(record, sessionId));
const nextEvents: ProgressEvent[] = [];
let recordIndex = 0;
for (const event of events) {
if (event.type === 'resumed' && recordIndex < records.length) {
nextEvents.push(eventFromIntervention(records[recordIndex], sessionId));
recordIndex += 1;
}
nextEvents.push(event);
}
while (recordIndex < records.length) {
nextEvents.push(eventFromIntervention(records[recordIndex], sessionId));
recordIndex += 1;
}
return nextEvents;
}
interface AIWritingHistoryViewProps {
session: AIWritingSessionSummary;
}
export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
const transcript = session.transcript;
const sessionReviewResult = useMemo(
() => normalizeReviewResult(session.review_result),
[session.review_result],
);
const normalizedEvents = useMemo(
() => (transcript?.progressEvents ?? []).map(
(event) => normalizeProgressDict(event as unknown as Record<string, unknown>, session.id),
),
[transcript, session.id],
);
const transcriptInterventions = useMemo(
() => normalizeInterventionList(transcript?.completedInterventions),
[transcript],
);
const sessionInterventions = useMemo(
() => normalizeInterventionList(session.completed_interventions),
[session.completed_interventions],
);
const synthesizedInterventions = useMemo(
() => synthesizeInterventions(
normalizedEvents,
session.draft_title,
session.draft_markdown,
sessionReviewResult,
),
[normalizedEvents, session.draft_title, session.draft_markdown, sessionReviewResult],
);
const completedInterventions = useMemo(
() => mergeInterventionLists(synthesizedInterventions, transcriptInterventions, sessionInterventions),
[synthesizedInterventions, transcriptInterventions, sessionInterventions],
);
const timelineEvents = useMemo(
() => ensureInterventionAnchors(normalizedEvents, completedInterventions, session.id),
[normalizedEvents, completedInterventions, session.id],
);
const createdAt = session.created_at
? new Date(session.created_at).toLocaleString('zh-CN', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
: '';
return (
<div className="flex-1 overflow-y-auto">
<div className="px-4 pt-3">
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
{session.user_intent && (
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{session.user_intent}
</p>
)}
{createdAt && (
<p className="text-[10px] text-muted-foreground">{createdAt}</p>
)}
</div>
</div>
{timelineEvents.length > 0 ? (
<AIWritingTimeline
events={timelineEvents}
completedInterventions={completedInterventions}
currentPause={null}
/>
) : (
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
暂无阶段记录
</div>
)}
</div>
);
}