AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
448 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|