ai写作
This commit is contained in:
1 parent
10d7190ea4
commit
772e75f04d
13 files changed
+1001
-127
No files matched your search
@@ -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;
|
||||
}
|
||||
+39
-22
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in new issue
Block a user