This commit is contained in:
董宏凯 committed 2026-05-17 17:25:18 +08:00
1 parent 10d7190ea4
commit 772e75f04d
13 files changed
+1001 -127

No files matched your search

+12 -1
View File
@@ -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<string, unknown>): Partial<ProgressEvent>
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<string, unknown>)
);
}
return out as Partial<ProgressEvent>;
}
@@ -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}`);
@@ -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<string, string> = {
idle: '就绪',
@@ -68,7 +70,9 @@ export function AIWritingPanel() {
{!isIdle && (
<AIWritingTimeline
events={state.progressEvents}
interventionSlot={
completedInterventions={state.completedInterventions}
currentPause={state.currentPause}
currentInterventionSlot={
state.currentPause ? (
<InterventionCard
pauseEvent={state.currentPause}
@@ -85,12 +89,24 @@ export function AIWritingPanel() {
/>
)}
{/* 大纲流式思考框 */}
{/* 大纲流式思考框(有内容时显示,同时隐藏骨架屏) */}
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
)}
{/* 草稿流式打字机视图 */}
{/* 编辑审核流式结果(逐条到达时显示) */}
{state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
<ReviewStreamingView items={state.streamingReviewItems} />
)}
{/* 修改完成摘要 */}
{state.revisionSummary && (
<div className="px-4 pb-2">
<RevisionSummaryCard summary={state.revisionSummary} />
</div>
)}
{/* 草稿流式打字机视图(有内容时显示) */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
@@ -98,13 +114,21 @@ export function AIWritingPanel() {
/>
)}
{/* 等待骨架屏:模型正在计算时显示,有干预卡则隐藏,写作时由流式视图代替 */}
{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) && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} />
</div>
)}
{/* 草稿阶段:流式数据还未到达前显示骨架 */}
{/* 草稿阶段:流式数据未到达前显示骨架 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
<div className="px-4 pb-4">
<WritingSkeletonCard status="writing_draft" />
@@ -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 (
<div className="px-4 py-3 space-y-0">
{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 = <CompletedInterventionCard record={completedRecord} />;
} else if (currentPause && isLast) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
}
}
return (
<TimelineStep key={idx} event={event} isLast={isLast && !showSlot}>
{showSlot ? interventionSlot : null}
<TimelineStep key={idx} event={event} isLast={isLast && !slot}>
{slot}
</TimelineStep>
);
})}
@@ -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 <MaterialCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'outline_confirm') {
return <OutlineCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'draft_confirm') {
return <DraftCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
if (record.pausePoint === 'review_confirm') {
return <ReviewCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
}
return null;
}
// ── 素材确认历史卡 ────────────────────────────────────────────────────────────
function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const count = record.materials?.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<FileText className="size-3.5 text-blue-500 shrink-0" />
<span className="flex-1 font-medium">
已确认{' '}
<span className="text-muted-foreground font-normal">{count} 条素材</span>
</span>
{count > 0 && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.materials && record.materials.length > 0 && (
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
{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 (
<div key={m.id} className="rounded border border-border/50 bg-background px-2.5 py-1.5 space-y-1">
<p className="font-medium leading-snug line-clamp-2">{m.title}</p>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="truncate max-w-[100px]">{m.source}</span>
{m.publishedAt && <><span className="text-muted-foreground/40">·</span><span>{m.publishedAt}</span></>}
<div className="flex flex-1 items-center gap-1 min-w-0">
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${pct}%` }} />
</div>
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
</div>
{m.url && (
<a href={m.url} target="_blank" rel="noopener noreferrer"
className="shrink-0 text-primary hover:underline inline-flex items-center gap-0.5">
原文 <ExternalLink className="size-2.5" />
</a>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ── 大纲确认历史卡 ────────────────────────────────────────────────────────────
function OutlineCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
const sectionCount = record.outline?.sections.length ?? 0;
return (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<ListTree className="size-3.5 text-purple-500 shrink-0" />
<span className="flex-1 font-medium">
已确认大纲
{record.outline?.title && (
<span className="ml-1 text-muted-foreground font-normal">
《{record.outline.title}》
</span>
)}
<span className="ml-1 text-muted-foreground font-normal">共 {sectionCount} 章节</span>
</span>
{record.outline && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.outline && (
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
<OutlineCard outline={record.outline} readOnly />
</div>
)}
</div>
);
}
// ── 草稿确认历史卡 ────────────────────────────────────────────────────────────
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 (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium shrink-0', actionColor)}>{actionLabel}</span>
<span className="text-muted-foreground flex-1 truncate">
{title}
{wordCount > 0 && <span className="ml-1">({wordCount} 字)</span>}
</span>
{hasReview && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.reviewResult && (
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
<ReviewCard review={record.reviewResult} />
</div>
)}
</div>
);
}
// ── 编辑审核确认历史卡 ────────────────────────────────────────────────────────
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 (
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
<button
type="button"
onClick={hasReview ? onToggle : undefined}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left',
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
)}
>
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
<span className={cn('font-medium flex-1', actionColor)}>{actionLabel}</span>
{hasReview && record.reviewResult && (
<span className="text-muted-foreground tabular-nums shrink-0">
{record.reviewResult.overallScore} / 100
</span>
)}
{hasReview && (
expanded
? <ChevronDown className="size-3 text-muted-foreground" />
: <ChevronRight className="size-3 text-muted-foreground" />
)}
</button>
{expanded && record.reviewResult && (
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
</div>
)}
</div>
);
}
@@ -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<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');
}
// ── 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<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, extra?: Partial<UserInterventionPayload>) {
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 (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
@@ -272,12 +311,17 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d
</div>
</div>
{/* 编辑审核分数(若已经过编辑)*/}
{/* 编辑审核结果(若已审核,支持勾选意见) */}
{reviewResult && (
<ReviewCard review={reviewResult} revisionCount={revisionCount} />
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasReview ? selectedIssueIndices : undefined}
onToggleIssue={hasReview ? toggleIssue : undefined}
/>
)}
{/* 修改意见输入 */}
{/* 用户自定义修改意见 */}
{showNotes && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">您的修改意见</label>
@@ -297,6 +341,24 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
{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">
{showNotes ? (
<Button
@@ -338,12 +400,28 @@ interface ReviewConfirmCardProps {
function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }: ReviewConfirmCardProps) {
const [extraNotes, setExtraNotes] = useState('');
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)
: '';
const combined = [issueNotes, extraNotes.trim()].filter(Boolean).join('\n\n');
await onSubmit({
action: 'accept_review',
userRevisionNotes: extraNotes.trim() || undefined,
userRevisionNotes: combined || undefined,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
@@ -354,6 +432,8 @@ function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }:
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">
@@ -361,20 +441,27 @@ function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }:
<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} />
: <p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>}
{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="space-y-1.5">
<label className="text-xs text-muted-foreground">补充修改指令(可选,将覆盖编辑意见)</label>
<label className="text-xs text-muted-foreground">补充修改指令(可选)</label>
<textarea
rows={2} value={extraNotes} onChange={(e) => setExtraNotes(e.target.value)}
placeholder="留空则直接采纳编辑意见;或在此补充您自己的修改方向…"
placeholder="留空则只采纳已勾选意见;或在此补充您自己的修改方向…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
@@ -382,9 +469,15 @@ function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }:
<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}
onClick={handleAccept}
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0 && !extraNotes.trim())}
>
<RotateCcw className="size-3.5" />采纳编辑意见,让作家重写
<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"
@@ -3,6 +3,8 @@ import type { ReviewIssue } from '../../types/ai-writing';
interface IssueListProps {
issues: ReviewIssue[];
selectedIndices?: Set<number>;
onToggle?: (index: number) => void;
}
const CATEGORY_LABEL: Record<string, string> = {
@@ -17,21 +19,37 @@ const CATEGORY_COLOR: Record<string, string> = {
language: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
};
export function IssueList({ issues }: IssueListProps) {
export function IssueList({ issues, selectedIndices, onToggle }: IssueListProps) {
if (issues.length === 0) {
return <p className="text-xs text-muted-foreground text-center py-1">无需修改意见</p>;
}
const major = issues.filter((i) => i.severity === 'major');
const minor = issues.filter((i) => i.severity === 'minor');
const indexed = issues.map((issue, i) => ({ issue, index: i }));
const major = indexed.filter(({ issue }) => issue.severity === 'major');
const minor = indexed.filter(({ issue }) => issue.severity === 'minor');
const selectable = selectedIndices !== undefined && onToggle !== undefined;
return (
<div className="space-y-3">
{major.length > 0 && (
<IssueGroup label="必须修改" labelClass="text-red-500" issues={major} />
<IssueGroup
label="必须修改"
labelClass="text-red-500"
items={major}
selectable={selectable}
selectedIndices={selectedIndices}
onToggle={onToggle}
/>
)}
{minor.length > 0 && (
<IssueGroup label="建议修改" labelClass="text-yellow-500" issues={minor} />
<IssueGroup
label="建议修改"
labelClass="text-yellow-500"
items={minor}
selectable={selectable}
selectedIndices={selectedIndices}
onToggle={onToggle}
/>
)}
</div>
);
@@ -40,28 +58,53 @@ export function IssueList({ issues }: IssueListProps) {
interface IssueGroupProps {
label: string;
labelClass: string;
issues: ReviewIssue[];
items: { issue: ReviewIssue; index: number }[];
selectable: boolean;
selectedIndices?: Set<number>;
onToggle?: (index: number) => void;
}
function IssueGroup({ label, labelClass, issues }: IssueGroupProps) {
function IssueGroup({ label, labelClass, items, selectable, selectedIndices, onToggle }: IssueGroupProps) {
return (
<div className="space-y-1.5">
<p className={cn('text-xs font-semibold', labelClass)}>{label}</p>
{issues.map((issue, i) => (
<div key={i} className="rounded border border-border/60 bg-background px-2.5 py-2 space-y-1 text-xs">
<div className="flex items-center gap-1.5">
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', CATEGORY_COLOR[issue.category] ?? 'bg-muted text-muted-foreground')}>
{CATEGORY_LABEL[issue.category] ?? issue.category}
</span>
<span className="text-muted-foreground truncate">{issue.location}</span>
{items.map(({ issue, index }) => {
const isSelected = !selectable || (selectedIndices?.has(index) ?? true);
return (
<div
key={index}
className={cn(
'rounded border bg-background px-2.5 py-2 space-y-1 text-xs transition-opacity',
selectable ? 'cursor-pointer' : '',
selectable && !isSelected ? 'opacity-40 border-border/30' : 'border-border/60',
)}
onClick={selectable ? () => onToggle?.(index) : undefined}
>
<div className="flex items-center gap-1.5">
{selectable && (
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggle?.(index)}
onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-primary cursor-pointer"
/>
)}
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', CATEGORY_COLOR[issue.category] ?? 'bg-muted text-muted-foreground')}>
{CATEGORY_LABEL[issue.category] ?? issue.category}
</span>
<span className="text-muted-foreground truncate">{issue.location}</span>
</div>
<p className={cn('text-foreground leading-snug', selectable ? 'pl-5' : '')}>
{issue.issue}
</p>
<p className={cn('text-muted-foreground leading-snug', selectable ? 'pl-5' : '')}>
<span className="text-primary mr-1">→</span>
{issue.suggestion}
</p>
</div>
<p className="text-foreground leading-snug">{issue.issue}</p>
<p className="text-muted-foreground leading-snug">
<span className="text-primary mr-1">→</span>
{issue.suggestion}
</p>
</div>
))}
);
})}
</div>
);
}
@@ -6,9 +6,11 @@ import { IssueList } from './IssueList';
interface ReviewCardProps {
review: ReviewResult;
revisionCount?: number;
selectedIndices?: Set<number>;
onToggleIssue?: (index: number) => void;
}
export function ReviewCard({ review, revisionCount }: ReviewCardProps) {
export function ReviewCard({ review, revisionCount, selectedIndices, onToggleIssue }: ReviewCardProps) {
const isPassed = review.verdict === 'pass';
return (
@@ -49,11 +51,22 @@ export function ReviewCard({ review, revisionCount }: ReviewCardProps) {
</p>
</div>
{/* 修改意见 */}
{/* 修改意见(支持勾选) */}
{review.revisionNotes.length > 0 && (
<div className="px-3 py-2.5">
<p className="text-muted-foreground font-medium mb-2">修改意见</p>
<IssueList issues={review.revisionNotes} />
<div className="flex items-center justify-between mb-2">
<p className="text-muted-foreground font-medium">修改意见</p>
{selectedIndices !== undefined && onToggleIssue && (
<p className="text-[10px] text-muted-foreground">
已选 {selectedIndices.size} / {review.revisionNotes.length}
</p>
)}
</div>
<IssueList
issues={review.revisionNotes}
selectedIndices={selectedIndices}
onToggle={onToggleIssue}
/>
</div>
)}
</div>
@@ -0,0 +1,148 @@
import { CheckCircle2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StreamingReviewItem, ReviewIssue } from '../../types/ai-writing';
const CATEGORY_META: Record<
'fact' | 'logic' | 'language',
{ label: string; order: number }
> = {
fact: { label: '事实准确性', order: 0 },
logic: { label: '逻辑结构', order: 1 },
language: { label: '语言文字', order: 2 },
};
const CATEGORIES = (['fact', 'logic', 'language'] as const);
function ScoreBar({ score }: { score: number }) {
const color =
score >= 80 ? 'bg-green-400' :
score >= 60 ? 'bg-amber-400' :
'bg-red-400';
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', color)}
style={{ width: `${score}%` }}
/>
</div>
<span className="text-xs font-semibold tabular-nums w-8 text-right">{score}</span>
</div>
);
}
function IssueRow({ issue }: { issue: ReviewIssue }) {
return (
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5">
<span className={cn(
'font-medium mr-1',
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
)}>
{issue.severity === 'major' ? '严重' : '轻微'}
</span>
<span className="text-muted-foreground">{issue.issue}</span>
{issue.suggestion && (
<span className="text-orange-600 dark:text-orange-400"> → {issue.suggestion}</span>
)}
</div>
);
}
interface ReviewStreamingViewProps {
items: StreamingReviewItem[];
}
export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
if (items.length === 0) return null;
const doneSet = new Set(items.map((it) => it.category));
const totalDone = items.length;
const overallScore =
totalDone > 0
? Math.round(items.reduce((s, it) => s + it.score, 0) / totalDone)
: 0;
return (
<div className="px-4 pb-2">
<div className="rounded-xl border border-orange-200 dark:border-orange-900 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden shadow-sm">
{/* 标题栏 */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-200/60 dark:border-orange-900/60 bg-orange-100/40 dark:bg-orange-900/30">
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
编辑正在审核…
</span>
{totalDone < 3 && (
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-orange-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
)}
{totalDone === 3 && (
<span className="ml-auto text-xs text-orange-600 dark:text-orange-400 font-semibold tabular-nums">
综合 {overallScore}
</span>
)}
</div>
{/* 逐条审核结果 */}
<div className="divide-y divide-orange-100 dark:divide-orange-900/50">
{CATEGORIES.map((cat) => {
const meta = CATEGORY_META[cat];
const item = items.find((it) => it.category === cat);
const isDone = doneSet.has(cat);
const isNext = !isDone && CATEGORIES.slice(0, meta.order).every((c) => doneSet.has(c));
return (
<div key={cat} className="px-3 py-2 space-y-1.5">
{/* 分类行 */}
<div className="flex items-center gap-2">
{isDone ? (
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
) : isNext ? (
<Loader2 className="size-3.5 text-orange-400 animate-spin shrink-0" />
) : (
<span className="size-3.5 rounded-full border border-muted-foreground/30 shrink-0" />
)}
<span className={cn(
'text-xs font-medium',
isDone ? 'text-foreground' : 'text-muted-foreground',
)}>
{meta.label}
</span>
</div>
{/* 得分 + 问题列表 */}
{isDone && item && (
<>
<ScoreBar score={item.score} />
{item.issues.length > 0 && (
<div className="space-y-1 mt-1">
{item.issues.slice(0, 3).map((iss, i) => (
<IssueRow key={i} issue={iss} />
))}
{item.issues.length > 3 && (
<p className="text-[11px] text-muted-foreground pl-2">
+{item.issues.length - 3} 条意见…
</p>
)}
</div>
)}
{item.issues.length === 0 && (
<p className="text-[11px] text-green-600 dark:text-green-400 pl-5">
无问题
</p>
)}
</>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import { CheckCircle2, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { RevisionSummary } from '../../types/ai-writing';
interface RevisionSummaryCardProps {
summary: RevisionSummary;
}
const CATEGORY_LABEL: Record<string, string> = {
fact: '事实',
logic: '逻辑',
language: '语言',
};
export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
const { revisedIssues, skippedIssues, oldWordCount, newWordCount } = summary;
const wordDiff = newWordCount - oldWordCount;
return (
<div className="rounded-md border border-green-200 dark:border-green-900/50 bg-green-50/50 dark:bg-green-950/20 overflow-hidden text-xs">
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-green-200/60 dark:border-green-900/60 bg-green-100/40 dark:bg-green-900/30">
<span className="font-medium text-green-700 dark:text-green-400">修改完成</span>
<span className="text-muted-foreground tabular-nums">
{newWordCount.toLocaleString()} 字
{wordDiff !== 0 && (
<span className={cn('ml-1', wordDiff > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>
({wordDiff > 0 ? '+' : ''}{wordDiff})
</span>
)}
</span>
</div>
<div className="px-3 py-2.5 space-y-2">
{/* 已修改 */}
{revisedIssues.length > 0 && (
<div className="space-y-1">
<p className="text-green-600 dark:text-green-400 font-medium flex items-center gap-1">
<CheckCircle2 className="size-3.5" />
已修改 {revisedIssues.length} 条意见
</p>
{revisedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground leading-snug">
<span className={cn(
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
iss.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
iss.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
)}>
{CATEGORY_LABEL[iss.category] ?? iss.category}
</span>
<span>{iss.issue}</span>
</div>
))}
</div>
)}
{/* 已跳过 */}
{skippedIssues.length > 0 && (
<div className="space-y-1">
<p className="text-muted-foreground font-medium flex items-center gap-1">
<XCircle className="size-3.5" />
跳过 {skippedIssues.length} 条意见
</p>
{skippedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground/60 leading-snug line-through">
<span>{iss.issue}</span>
</div>
))}
</div>
)}
{revisedIssues.length === 0 && skippedIssues.length === 0 && (
<p className="text-muted-foreground text-center py-1">已完成修改</p>
)}
</div>
</div>
);
}
@@ -5,83 +5,125 @@ interface WritingSkeletonCardProps {
status: WritingStatus;
}
// ── 基础骨架元素 ──────────────────────────────────────────────────────────────
function SkeletonLine({ className }: { className?: string }) {
return (
<div
className={cn(
'h-3 rounded-md bg-muted animate-pulse',
className,
)}
/>
<div className={cn('h-3 rounded-md bg-muted animate-pulse', className)} />
);
}
function ThinkingDots() {
function ThinkingDots({ color = 'bg-blue-400' }: { color?: string }) {
return (
<span className="inline-flex gap-1 items-center">
<span className="size-1.5 rounded-full bg-current animate-bounce [animation-delay:0ms]" />
<span className="size-1.5 rounded-full bg-current animate-bounce [animation-delay:150ms]" />
<span className="size-1.5 rounded-full bg-current animate-bounce [animation-delay:300ms]" />
{[0, 1, 2].map((i) => (
<span
key={i}
className={cn('size-1.5 rounded-full animate-bounce', color)}
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
);
}
// ── 素材收集骨架屏 ────────────────────────────────────────────────────────────
function MaterialsSkeleton() {
return (
<div className="space-y-3 p-4">
{/* 标题行 */}
<div className="flex items-center gap-2 text-xs text-blue-500 font-medium">
<ThinkingDots />
<ThinkingDots color="bg-blue-400" />
<span>素材收集专家搜索中…</span>
</div>
{[0.85, 0.6, 0.7].map((_, i) => (
<div key={i} className="rounded-lg border p-3 space-y-2">
<SkeletonLine className="w-3/4" />
<SkeletonLine className="w-1/3 h-2" />
{/* 关键词标签占位 */}
<div className="flex gap-1.5 flex-wrap">
{[60, 80, 50].map((w, i) => (
<div
key={i}
className="h-5 rounded-full bg-blue-100/60 dark:bg-blue-900/30 animate-pulse"
style={{ width: `${w}px` }}
/>
))}
</div>
{/* 素材卡片占位 */}
{[0, 1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border bg-muted/30 p-3 space-y-2">
{/* 标题 */}
<div className="flex items-start gap-2">
<SkeletonLine className={cn('flex-1 h-3.5', i % 3 === 0 ? 'w-4/5' : i % 3 === 1 ? 'w-3/4' : 'w-2/3')} />
</div>
{/* 来源 + 时间 + 相关度 */}
<div className="flex items-center gap-2">
<SkeletonLine className="w-20 h-2" />
<SkeletonLine className="w-16 h-2" />
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-blue-200/60 dark:bg-blue-700/40 animate-pulse"
style={{ width: `${55 + i * 8}%` }}
/>
</div>
</div>
{/* 摘要行 */}
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-5/6 h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-5/6' : 'w-4/5')} />
</div>
))}
</div>
);
}
// ── 大纲规划骨架屏 ────────────────────────────────────────────────────────────
function OutlineSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots />
<ThinkingDots color="bg-purple-400" />
<span>作家规划大纲中…</span>
</div>
{/* 文章标题占位 */}
<SkeletonLine className="w-1/2 h-4" />
{/* 章节 */}
{[0, 1, 2].map((i) => (
<div key={i} className="pl-3 border-l-2 border-muted space-y-1.5">
<SkeletonLine className="w-2/5 h-3" />
<SkeletonLine className="w-3/5 h-2" />
<SkeletonLine className="w-1/2 h-2" />
<SkeletonLine className="w-3/5 h-4 mx-auto" />
{/* 章节列表 */}
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-2 pl-1">
<div className="mt-1 size-2 shrink-0 rounded-sm bg-purple-200/70 dark:bg-purple-800/40 animate-pulse" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className={cn('h-3', i % 2 === 0 ? 'w-2/5' : 'w-1/2')} />
<SkeletonLine className="w-3/5 h-2" />
{i % 2 === 0 && <SkeletonLine className="w-2/5 h-2" />}
</div>
</div>
))}
</div>
);
}
// ── 正文写作骨架屏 ────────────────────────────────────────────────────────────
function DraftSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots />
<ThinkingDots color="bg-purple-400" />
<span>作家写作中…</span>
</div>
{/* 文章标题 */}
{/* 标题占位 */}
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{/* 3 个章节 */}
{/* 章节内容 */}
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-2">
<div key={i} className="space-y-1.5">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-4/5 h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-11/12')} />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-3/4 h-2" />
</div>
@@ -90,59 +132,84 @@ function DraftSkeleton() {
);
}
// ── 编辑审核骨架屏 ────────────────────────────────────────────────────────────
function ReviewSkeleton() {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-orange-500 font-medium">
<ThinkingDots />
<ThinkingDots color="bg-orange-400" />
<span>编辑审核中…</span>
</div>
{/* 分数条 */}
{['事实准确度', '逻辑结构', '语言质量'].map((label) => (
{/* 综合评分占位 */}
<div className="flex items-center justify-between">
<SkeletonLine className="w-24 h-3" />
<SkeletonLine className="w-12 h-4" />
</div>
{/* 分项评分条 */}
{['事实准确度', '逻辑结构', '语言质量'].map((label, i) => (
<div key={label} className="space-y-1">
<div className="flex justify-between">
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">{label}</span>
<SkeletonLine className="w-8 h-3" />
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full w-2/3 rounded-full bg-muted-foreground/30 animate-pulse" />
<div
className="h-full rounded-full bg-orange-200/60 dark:bg-orange-700/40 animate-pulse"
style={{ width: `${55 + i * 10}%` }}
/>
</div>
</div>
))}
{/* 问题列表占位 */}
<div className="space-y-2 pt-2">
{/* 意见条目占位 */}
<div className="space-y-2 pt-1 border-t">
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-5/6 h-2" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-4/5 h-2" />
</div>
</div>
);
}
function RevisingSkeletonInner() {
// ── 按意见修改骨架屏 ──────────────────────────────────────────────────────────
function RevisingSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots />
<ThinkingDots color="bg-purple-400" />
<span>作家按意见修改中…</span>
</div>
<SkeletonLine className="w-2/5 h-5 mx-auto" />
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-2">
<div key={i} className="space-y-1.5">
<SkeletonLine className="w-1/4 h-3.5" />
<SkeletonLine className="w-full h-2" />
<SkeletonLine className="w-4/5 h-2" />
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-full')} />
</div>
))}
</div>
);
}
// ── 状态映射 ──────────────────────────────────────────────────────────────────
const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
// 实际运行状态
researching: MaterialsSkeleton,
writing_outline: OutlineSkeleton,
writing_draft: DraftSkeleton,
reviewing: ReviewSkeleton,
revising: RevisingSkeletonInner,
revising: RevisingSkeleton,
// 过渡等待状态(用户提交后、后端事件到达前的空白期)
awaiting_material_confirm: MaterialsSkeleton, // 重新搜索 / 过渡到大纲
awaiting_outline_confirm: OutlineSkeleton, // 过渡到写作
awaiting_draft_confirm: DraftSkeleton, // 提交修改意见或编辑审核后
awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后
};
export function WritingSkeletonCard({ status }: WritingSkeletonCardProps) {
@@ -7,9 +7,11 @@ import type {
AIWritingState,
AIMaterial,
AIOutline,
CompletedIntervention,
ProgressEvent,
ReviewResult,
StreamingDraftSection,
StreamingReviewItem,
UserInterventionPayload,
} from '../types/ai-writing';
@@ -24,11 +26,15 @@ const INITIAL_STATE: AIWritingState = {
reviewResult: null,
revisionCount: 0,
currentPause: null,
completedInterventions: [],
error: null,
streamingDraftTitle: '',
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
};
export function useAIWriting() {
@@ -47,9 +53,17 @@ export function useAIWriting() {
switch (event.type) {
case 'step_start':
// 兜底:编辑节点完成时若没有流式事件,也切到审核态
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
return { ...base, status: 'reviewing' };
}
return base;
case 'step_done':
return base;
case 'resumed':
return event.type === 'resumed' ? { ...base, currentPause: null } : base;
return { ...base, currentPause: null };
case 'materials_ready':
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹)
@@ -107,16 +121,45 @@ export function useAIWriting() {
};
}
case 'draft_ready':
case 'review_item_done': {
const newItem: StreamingReviewItem = {
category: event.reviewItemCategory ?? 'fact',
score: event.reviewItemScore ?? 80,
issues: event.reviewItemIssues ?? [],
};
// 首条 review_item_done 即进入审核态;新一轮审核先清空上一轮残留
const isNewCycle = prev.status !== 'reviewing';
return {
...prev,
status: 'reviewing',
streamingReviewItems: isNewCycle
? [newItem]
: [...prev.streamingReviewItems, newItem],
};
}
case 'draft_ready': {
const newMarkdown = event.draftMarkdown ?? '';
const newWordCount = newMarkdown.replace(/\s/g, '').length;
const summary = prev.pendingRevisionContext
? {
revisedIssues: prev.pendingRevisionContext.submittedIssues,
skippedIssues: prev.pendingRevisionContext.skippedIssues,
oldWordCount: prev.pendingRevisionContext.oldWordCount,
newWordCount,
}
: null;
return {
...base,
currentDraftMarkdown: event.draftMarkdown ?? '',
currentDraftMarkdown: newMarkdown,
currentDraftTitle: event.draftTitle ?? '',
revisionCount: event.revisionCount ?? 0,
// 草稿完成后清空流式缓冲
streamingDraftSections: [],
streamingDraftTitle: '',
revisionSummary: summary,
pendingRevisionContext: null,
};
}
case 'review_ready':
return {
@@ -203,15 +246,56 @@ export function useAIWriting() {
const submitIntervention = useCallback(
async (payload: UserInterventionPayload) => {
if (!state.sessionId) return;
if (!state.sessionId || !state.currentPause) return;
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
if (pausePoint === 'material_confirm') {
record.materials = state.materials;
} else if (pausePoint === 'outline_confirm') {
record.outline = state.outline ?? undefined;
} else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = state.currentDraftMarkdown.replace(/\s/g, '').length;
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
// 提交编辑意见修改时,记录本次选中/跳过的意见用于后续生成摘要
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview =
payload.selectedIssueIndices !== undefined &&
state.reviewResult != null;
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
const submittedIssues = allIssues.filter((_, i) => selectedSet.has(i));
const skippedIssues = allIssues.filter((_, i) => !selectedSet.has(i));
pendingRevisionContext = {
submittedIssues,
skippedIssues,
oldWordCount: state.currentDraftMarkdown.replace(/\s/g, '').length,
};
}
try {
await resumeWriting(state.sessionId, payload);
setState((prev) => ({ ...prev, currentPause: null }));
setState((prev) => ({
...prev,
currentPause: null,
completedInterventions: [...prev.completedInterventions, record],
...(pendingRevisionContext ? { pendingRevisionContext } : {}),
}));
} catch (err) {
setState((prev) => ({ ...prev, error: String(err) }));
}
},
[state.sessionId],
[state.sessionId, state.currentPause, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult],
);
const stopWriting = useCallback(() => {
@@ -72,6 +72,7 @@ export type ProgressEventType =
| 'outline_ready'
| 'draft_chunk'
| 'draft_ready'
| 'review_item_done'
| 'review_ready'
| 'await_user'
| 'resumed'
@@ -93,6 +94,10 @@ export interface ProgressEvent {
draftMarkdown?: string;
draftTitle?: string;
revisionCount?: number;
// review_item_done (streaming per-category result)
reviewItemCategory?: 'fact' | 'logic' | 'language';
reviewItemScore?: number;
reviewItemIssues?: ReviewIssue[];
// review_ready
reviewResult?: ReviewResult;
// await_user
@@ -110,6 +115,8 @@ export interface UserInterventionPayload {
outlineFeedback?: string;
userRevisionNotes?: string;
overrideVerdict?: boolean;
// 用户勾选的审核意见索引(基于 reviewResult.revisionNotes),未传则默认全选
selectedIssueIndices?: number[];
}
// ── 写作请求 ──────────────────────────────────────────────────────────────────
@@ -122,6 +129,23 @@ export interface AIWritingRequest {
modelName?: string;
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
export interface StreamingReviewItem {
category: 'fact' | 'logic' | 'language';
score: number;
issues: ReviewIssue[];
}
// ── 修改摘要 ──────────────────────────────────────────────────────────────────
export interface RevisionSummary {
revisedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
newWordCount: number;
}
// ── 流式草稿章节 ──────────────────────────────────────────────────────────────
export interface StreamingDraftSection {
@@ -132,6 +156,22 @@ export interface StreamingDraftSection {
isDone: boolean;
}
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
export interface CompletedIntervention {
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
action: string;
// material_confirm
materials?: AIMaterial[];
// outline_confirm
outline?: AIOutline;
// draft_confirm
draftTitle?: string;
draftWordCount?: number;
reviewResult?: ReviewResult;
// review_confirm
}
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
export interface AIWritingState {
@@ -145,6 +185,7 @@ export interface AIWritingState {
reviewResult: ReviewResult | null;
revisionCount: number;
currentPause: ProgressEvent | null;
completedInterventions: CompletedIntervention[];
error: string | null;
// 流式草稿
streamingDraftTitle: string;
@@ -152,4 +193,14 @@ export interface AIWritingState {
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 流式审核(逐条到达)
streamingReviewItems: StreamingReviewItem[];
// 修改摘要
revisionSummary: RevisionSummary | null;
// 待修改上下文(用于生成摘要)
pendingRevisionContext: {
submittedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
} | null;
}
@@ -1,4 +1,4 @@
"""编辑节点:三项独立 LLM 审查(事实/逻辑/语言)。"""
"""编辑节点:三项独立 LLM 审查(事实/逻辑/语言),顺序执行以支持流式推送。"""
from __future__ import annotations
import asyncio
@@ -14,8 +14,6 @@ from ._utils import llm_json
logger = logging.getLogger(__name__)
# 弱模型产出质量有限,阈值过高会导致编辑反复打回直至触发修改上限。
# 默认 75,可通过 ai_writing_config.pass_threshold 按实测调整。
_DEFAULT_PASS_THRESHOLD = 75
@@ -51,34 +49,54 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
pkg = state.get("material_package") or {}
mats = _materials_summary(pkg)
ai_writing_config = config.get("configurable", {}).get("ai_writing_config", {}) or {}
cfg = config.get("configurable", {})
sse_queue: asyncio.Queue | None = cfg.get("sse_queue")
session_id: str = cfg.get("sse_session_id", "")
ai_writing_config = cfg.get("ai_writing_config", {}) or {}
pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD))
# 三项独立审查并发执行
fact_task = llm_json(EDITOR_SYSTEM, FACT_REVIEW_USER.format(materials_summary=mats, article=article), {"score": 80, "issues": []}, config)
logic_task = llm_json(EDITOR_SYSTEM, LOGIC_REVIEW_USER.format(article=article), {"score": 80, "issues": []}, config)
lang_task = llm_json(EDITOR_SYSTEM, LANGUAGE_REVIEW_USER.format(article=article), {"score": 80, "issues": []}, config)
REVIEW_TASKS = [
("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article), {"score": 80, "issues": []}),
("logic", LOGIC_REVIEW_USER.format(article=article), {"score": 80, "issues": []}),
("language", LANGUAGE_REVIEW_USER.format(article=article), {"score": 80, "issues": []}),
]
fact_res, logic_res, lang_res = await asyncio.gather(fact_task, logic_task, lang_task, return_exceptions=True)
CATEGORY_LABEL = {"fact": "事实准确性", "logic": "逻辑结构", "language": "语言文字"}
def safe(res, default):
return default if isinstance(res, Exception) else res
results: dict[str, dict] = {}
fact_res = safe(fact_res, {"score": 80, "issues": []})
logic_res = safe(logic_res, {"score": 80, "issues": []})
lang_res = safe(lang_res, {"score": 80, "issues": []})
for category, user_prompt, fallback in REVIEW_TASKS:
try:
res = await llm_json(EDITOR_SYSTEM, user_prompt, fallback, config)
except Exception as e:
logger.warning("editor review [%s] failed: %s", category, e)
res = fallback
fact_score = int(fact_res.get("score", 80))
logic_score = int(logic_res.get("score", 80))
lang_score = int(lang_res.get("score", 80))
results[category] = res
# 实时推送这一项审查结果
if sse_queue is not None:
issues = _parse_issues(res.get("issues", []), category)
await sse_queue.put(("review_item_done", {
"session_id": session_id,
"category": category,
"score": int(res.get("score", 80)),
"issues": [dict(iss) for iss in issues],
}))
else:
logger.debug("editor [%s] done (no sse_queue)", category)
# 汇总
fact_score = int(results["fact"].get("score", 80))
logic_score = int(results["logic"].get("score", 80))
lang_score = int(results["language"].get("score", 80))
overall_score = (fact_score + logic_score + lang_score) // 3
all_issues: list[ReviewIssue] = (
_parse_issues(fact_res.get("issues", []), "fact") +
_parse_issues(logic_res.get("issues", []), "logic") +
_parse_issues(lang_res.get("issues", []), "language")
_parse_issues(results["fact"].get("issues", []), "fact") +
_parse_issues(results["logic"].get("issues", []), "logic") +
_parse_issues(results["language"].get("issues", []), "language")
)
# major issues first
all_issues.sort(key=lambda x: (0 if x["severity"] == "major" else 1))
verdict = "pass" if overall_score >= pass_threshold else "reject"
@@ -101,7 +119,6 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
"type": "review_ready",
"agent_name": "编辑",
"message": f"审核完成,综合得分 {overall_score},结论:{'通过' if verdict == 'pass' else '打回修改'}",
"review_result": review,
},
],
}