diff --git a/frontend-web/src/open-canvas/api/ai-writing.ts b/frontend-web/src/open-canvas/api/ai-writing.ts index a4dea3e2..2ffaadc9 100644 --- a/frontend-web/src/open-canvas/api/ai-writing.ts +++ b/frontend-web/src/open-canvas/api/ai-writing.ts @@ -32,7 +32,8 @@ const SSE_EVENT_TYPES: ProgressEventType[] = [ 'connected', 'step_start', 'step_done', 'materials_ready', 'outline_chunk', 'outline_ready', 'draft_chunk', 'draft_ready', - 'review_ready', 'await_user', 'resumed', 'done', 'error', + 'review_item_done', 'review_ready', + 'await_user', 'resumed', 'done', 'error', ]; // ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ────────────────── @@ -113,6 +114,15 @@ function normalizeEvent(parsed: Record): Partial if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result); if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult); + // review_item_done (streaming per-category result from sse_queue) + if ('category' in out) out.reviewItemCategory = out.category; + if ('score' in out) out.reviewItemScore = out.score; + if ('issues' in out && Array.isArray(out.issues)) { + out.reviewItemIssues = out.issues.map((x: unknown) => + normalizeReviewIssue(x as Record) + ); + } + return out as Partial; } @@ -191,6 +201,7 @@ export async function resumeWriting( outline_feedback: payload.outlineFeedback ?? '', user_revision_notes: payload.userRevisionNotes ?? '', override_verdict: payload.overrideVerdict ?? false, + selected_issue_indices: payload.selectedIssueIndices ?? null, }), }); if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`); diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 0f9424e6..aa26f917 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -10,6 +10,8 @@ import { InterventionCard } from './InterventionCard'; import { WritingSkeletonCard } from './WritingSkeletonCard'; import { DraftStreamingView } from './DraftStreamingView'; import { OutlineStreamingView } from './OutlineStreamingView'; +import { ReviewStreamingView } from './ReviewStreamingView'; +import { RevisionSummaryCard } from './RevisionSummaryCard'; const STATUS_LABEL: Record = { idle: '就绪', @@ -68,7 +70,9 @@ export function AIWritingPanel() { {!isIdle && ( )} - {/* 大纲流式思考框 */} + {/* 大纲流式思考框(有内容时显示,同时隐藏骨架屏) */} {state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && ( )} - {/* 草稿流式打字机视图 */} + {/* 编辑审核流式结果(逐条到达时显示) */} + {state.status === 'reviewing' && state.streamingReviewItems.length > 0 && ( + + )} + + {/* 修改完成摘要 */} + {state.revisionSummary && ( +
+ +
+ )} + + {/* 草稿流式打字机视图(有内容时显示) */} {state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && ( )} - {/* 等待骨架屏:模型正在计算时显示,有干预卡则隐藏,写作时由流式视图代替 */} - {showSkeleton && state.status !== 'writing_draft' && ( + {/* + 骨架屏统一规则: + - 正在运行 + 无干预卡 + 非草稿写作阶段(草稿由下方单独处理) + - writing_outline 有流式内容时由 OutlineStreamingView 接管,骨架隐藏 + */} + {showSkeleton && + state.status !== 'writing_draft' && + !(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) && + !(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
)} - {/* 草稿阶段:流式数据还未到达前显示骨架 */} + + {/* 草稿阶段:流式数据未到达前显示骨架 */} {state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx index e59a9da1..e8de81e6 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx @@ -1,13 +1,12 @@ -import type { ProgressEvent } from '../../types/ai-writing'; +import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing'; import { TimelineStep } from './TimelineStep'; +import { CompletedInterventionCard } from './CompletedInterventionCard'; interface AIWritingTimelineProps { events: ProgressEvent[]; - /** - * 各干预点的专属内容插槽。 - * 在阶段四/五/六填充素材卡、大纲卡、审核卡等组件。 - */ - interventionSlot?: React.ReactNode; + completedInterventions: CompletedIntervention[]; + currentPause: ProgressEvent | null; + currentInterventionSlot?: React.ReactNode; } const DISPLAYED_TYPES = new Set([ @@ -23,21 +22,41 @@ const DISPLAYED_TYPES = new Set([ 'error', ]); -export function AIWritingTimeline({ events, interventionSlot }: AIWritingTimelineProps) { +export function AIWritingTimeline({ + events, + completedInterventions, + currentPause, + currentInterventionSlot, +}: AIWritingTimelineProps) { const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type)); if (visible.length === 0) return null; + // 遍历时追踪 await_user 事件的序号,以便从 completedInterventions 取对应记录 + let awaitUserIdx = 0; + return (
{visible.map((event, idx) => { const isLast = idx === visible.length - 1; - // 在最后一个 await_user 事件后插入干预插槽 - const showSlot = isLast && event.type === 'await_user' && interventionSlot; + let slot: React.ReactNode = null; + + if (event.type === 'await_user') { + const completedRecord = completedInterventions[awaitUserIdx]; + awaitUserIdx += 1; + + if (completedRecord) { + // 已完成的干预点 → 渲染折叠历史卡 + slot = ; + } else if (currentPause && isLast) { + // 当前活跃干预点 → 渲染操作卡 + slot = currentInterventionSlot; + } + } return ( - - {showSlot ? interventionSlot : null} + + {slot} ); })} diff --git a/frontend-web/src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx new file mode 100644 index 00000000..db95fb3c --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx @@ -0,0 +1,225 @@ +import { useState } from 'react'; +import { + CheckCircle2, ChevronRight, ChevronDown, + ExternalLink, ListTree, FileText, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CompletedIntervention } from '../../types/ai-writing'; +import { OutlineCard } from './OutlineCard'; +import { ReviewCard } from './ReviewCard'; +import { ScoreBar } from './ScoreBar'; + +interface Props { + record: CompletedIntervention; +} + +export function CompletedInterventionCard({ record }: Props) { + const [expanded, setExpanded] = useState(false); + + if (record.pausePoint === 'material_confirm') { + return setExpanded(v => !v)} />; + } + if (record.pausePoint === 'outline_confirm') { + return setExpanded(v => !v)} />; + } + if (record.pausePoint === 'draft_confirm') { + return setExpanded(v => !v)} />; + } + if (record.pausePoint === 'review_confirm') { + return setExpanded(v => !v)} />; + } + return null; +} + +// ── 素材确认历史卡 ──────────────────────────────────────────────────────────── + +function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) { + const count = record.materials?.length ?? 0; + + return ( +
+ + + {expanded && record.materials && record.materials.length > 0 && ( +
+ {record.materials.map((m) => { + const pct = Math.round(m.relevanceScore * 100); + const barColor = + m.relevanceScore >= 0.8 ? 'bg-green-500' + : m.relevanceScore >= 0.5 ? 'bg-orange-400' + : 'bg-red-400'; + return ( +
+

{m.title}

+
+ {m.source} + {m.publishedAt && <>·{m.publishedAt}} +
+
+
+
+ {pct}% +
+ {m.url && ( + + 原文 + + )} +
+
+ ); + })} +
+ )} +
+ ); +} + +// ── 大纲确认历史卡 ──────────────────────────────────────────────────────────── + +function OutlineCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) { + const sectionCount = record.outline?.sections.length ?? 0; + + return ( +
+ + + {expanded && record.outline && ( +
+ +
+ )} +
+ ); +} + +// ── 草稿确认历史卡 ──────────────────────────────────────────────────────────── + +function DraftCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) { + const title = record.draftTitle ? `《${record.draftTitle}》` : '草稿'; + const wordCount = record.draftWordCount ?? 0; + const hasReview = !!record.reviewResult; + + const actionLabel = + record.action === 'finalize' ? '已确认定稿' : + record.action === 'user_revise' ? '已要求修改' : + '已提交编辑审核'; + + const actionColor = + record.action === 'finalize' ? 'text-green-600 dark:text-green-400' : + record.action === 'user_revise' ? 'text-orange-500 dark:text-orange-400' : + 'text-blue-500 dark:text-blue-400'; + + return ( +
+ + + {expanded && record.reviewResult && ( +
+ +
+ )} +
+ ); +} + +// ── 编辑审核确认历史卡 ──────────────────────────────────────────────────────── + +function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) { + const isForce = record.action === 'force_finalize'; + const hasReview = !!record.reviewResult; + + const actionLabel = isForce ? '已强制定稿(忽略编辑意见)' : '已采纳编辑意见,重新写作'; + const actionColor = isForce ? 'text-orange-500 dark:text-orange-400' : 'text-blue-500 dark:text-blue-400'; + + return ( +
+ + + {expanded && record.reviewResult && ( +
+ + + +
+ )} +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx index 1ece8f44..824b9567 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx @@ -1,11 +1,11 @@ import { useState } from 'react'; import { PauseCircle, CheckCircle2, RotateCcw, Search, - RefreshCw, PenLine, ArrowRight, ShieldAlert, + RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import type { - AIMaterial, AIOutline, ReviewResult, + AIMaterial, AIOutline, ReviewResult, ReviewIssue, ProgressEvent, UserInterventionPayload, } from '../../types/ai-writing'; import { MaterialsCard } from './MaterialsCard'; @@ -75,6 +75,20 @@ export function InterventionCard({ ); } +// ── 工具函数 ────────────────────────────────────────────────────────────────── + +function makeDefaultSelection(issues: ReviewIssue[]): Set { + return new Set(issues.map((_, i) => i)); +} + +function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set): 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'); +} + // ── PAUSE-1 素材确认 ────────────────────────────────────────────────────────── interface MaterialConfirmCardProps { @@ -251,6 +265,17 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d const [notes, setNotes] = useState(''); const [showNotes, setShowNotes] = useState(false); const [submitting, setSubmitting] = useState(false); + const [selectedIssueIndices, setSelectedIssueIndices] = useState>( + () => 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, extra?: Partial) { setSubmitting(true); @@ -258,6 +283,20 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d 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; + return (
@@ -272,12 +311,17 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d
- {/* 编辑审核分数(若已经过编辑)*/} + {/* 编辑审核结果(若已审核,支持勾选意见) */} {reviewResult && ( - + )} - {/* 修改意见输入 */} + {/* 用户自定义修改意见 */} {showNotes && (
@@ -297,6 +341,24 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d 确认定稿 + {/* 按编辑审核意见修改(有审核结果时始终显示) */} + {hasReview && ( + + )} +
{showNotes ? (