代码提交

This commit is contained in:
董宏凯 committed 2026-07-03 22:11:50 +08:00
1 parent da856f2b58
commit a1c63a1fe5
8 files changed
+1763 -514

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -10,10 +10,11 @@
* 按钮点击路径与旧卡完全一致:直接调 `onSubmit(UserInterventionPayload)`,
* 不经意图解析层,零延迟零误判。旧页面的 InterventionCard 不受本文件影响。
*/
import { useState } from 'react';
import { useState, type ReactNode } from 'react';
import {
ArrowRight, BookOpen, CheckCircle2, ClipboardEdit, MessageSquareText,
PauseCircle, RotateCcw, Search, ShieldAlert, Trash2,
ArrowRight, BookOpen, CheckCircle2, ClipboardEdit, FileCheck2, FileText,
ListTree, MessageSquareText, RotateCcw, Search, ShieldAlert, ShieldCheck, Trash2,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -22,9 +23,9 @@ import type {
AIMaterial, AIOutline, BlockedSection, ProgressEvent, ReviewIssue,
ReviewResult, SectionDecisionKind, SkillSearchStep, UserInterventionPayload,
} from '../../types/ai-writing';
import { normalizeKeyPoint } from '../../types/ai-writing';
import { MaterialsCard } from '../ai-writing/MaterialsCard';
import { OutlineCard } from '../ai-writing/OutlineCard';
import { ReviewCard } from '../ai-writing/ReviewCard';
import { SkillStepsCard } from '../ai-writing/SkillStepsCard';
interface ChatInterventionCardProps {
@@ -108,7 +109,7 @@ export function ChatInterventionCard({
return (
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
<PauseCircle className="size-3.5 text-yellow-500 shrink-0" />
<ShieldAlert className="size-3.5 text-yellow-500 shrink-0" />
等待用户确认({pausePoint})…
</div>
);
@@ -124,20 +125,305 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
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}`)
.map((iss, n) => {
const location = iss.location ? `【位置】${iss.location};` : '';
const suggestion = iss.suggestion ? `;【建议】${iss.suggestion}` : '';
return `${n + 1}. [${iss.category}] ${location}${iss.issue}${suggestion}`;
})
.join('\n');
}
type DecisionTone = 'blue' | 'amber' | 'emerald';
interface DecisionMetric {
label: string;
value: string;
tone?: DecisionTone | 'muted';
}
const PANEL_TONE: Record<DecisionTone, {
shell: string;
icon: string;
badge: string;
}> = {
blue: {
shell: 'border-blue-200/80 bg-blue-50/35 dark:border-blue-900/40 dark:bg-blue-950/15',
icon: 'border-blue-200 bg-background text-blue-600 dark:border-blue-900/50 dark:text-blue-300',
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300',
},
amber: {
shell: 'border-amber-200/90 bg-amber-50/45 dark:border-amber-900/40 dark:bg-amber-950/20',
icon: 'border-amber-200 bg-background text-amber-600 dark:border-amber-900/50 dark:text-amber-300',
badge: 'bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300',
},
emerald: {
shell: 'border-emerald-200/80 bg-emerald-50/35 dark:border-emerald-900/40 dark:bg-emerald-950/15',
icon: 'border-emerald-200 bg-background text-emerald-600 dark:border-emerald-900/50 dark:text-emerald-300',
badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300',
},
};
function metricClass(tone: DecisionMetric['tone']) {
if (tone === 'blue') return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-blue-300';
if (tone === 'amber') return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300';
if (tone === 'emerald') return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-300';
return 'border-border bg-background/80 text-muted-foreground';
}
function DecisionMetricPill({ metric }: { metric: DecisionMetric }) {
return (
<span className={cn('inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px]', metricClass(metric.tone))}>
<span className="text-muted-foreground">{metric.label}</span>
<span className="font-medium text-foreground/85 dark:text-foreground/80">{metric.value}</span>
</span>
);
}
function DecisionPanel({
tone = 'blue',
icon: Icon,
badge,
title,
description,
metrics,
children,
actions,
hint,
}: {
tone?: DecisionTone;
icon: LucideIcon;
badge: string;
title: string;
description: ReactNode;
metrics?: DecisionMetric[];
children?: ReactNode;
actions?: ReactNode;
hint?: ReactNode;
}) {
const classes = PANEL_TONE[tone];
return (
<section className={cn('overflow-hidden rounded-lg border shadow-sm', classes.shell)}>
<div className="p-3">
<div className="flex items-start gap-2.5">
<div className={cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border', classes.icon)}>
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-sm font-semibold leading-5">{title}</h3>
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', classes.badge)}>
{badge}
</span>
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{description}
</p>
</div>
</div>
{metrics && metrics.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{metrics.map((metric) => (
<DecisionMetricPill key={`${metric.label}-${metric.value}`} metric={metric} />
))}
</div>
)}
</div>
{children && (
<div className="border-y border-background/80 bg-background/72 p-3 dark:bg-background/35">
{children}
</div>
)}
{(actions || hint) && (
<div className="space-y-2 bg-background/55 p-3 dark:bg-background/25">
{actions}
{hint}
</div>
)}
</section>
);
}
/** 卡片底部的「用对话继续」提示行:告诉用户自由输入去底部输入框。 */
function ComposerHint({ examples }: { examples: string }) {
return (
<div className="flex items-start gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5 text-[11px] leading-relaxed text-muted-foreground">
<div className="flex items-start gap-1.5 rounded-md border bg-muted/35 px-2.5 py-1.5 text-[11px] leading-relaxed text-muted-foreground">
<MessageSquareText className="mt-0.5 size-3 shrink-0 text-primary/70" />
<span>也可以直接在底部输入框用一句话操作,例如:{examples}</span>
</div>
);
}
function getOutlineStats(outline: AIOutline | null): DecisionMetric[] {
if (!outline) return [];
let keyPointCount = 0;
let subPointCount = 0;
for (const section of outline.sections) {
const keyPoints = section.keyPoints ?? [];
keyPointCount += keyPoints.length;
subPointCount += keyPoints.reduce((sum, keyPoint) => sum + normalizeKeyPoint(keyPoint).subPoints.length, 0);
}
return [
{ label: '章节', value: `${outline.sections.length}`, tone: 'blue' },
{ label: '小节', value: `${keyPointCount}`, tone: 'emerald' },
{ label: '子要点', value: `${subPointCount}`, tone: 'muted' },
];
}
function reviewMetrics(review: ReviewResult | null): DecisionMetric[] {
if (!review) return [];
return [
{ label: '综合', value: `${review.overallScore}`, tone: review.verdict === 'pass' ? 'emerald' : 'amber' },
{ label: '事实', value: `${review.factScore}` },
{ label: '逻辑', value: `${review.logicScore}` },
{ label: '语言', value: `${review.languageScore}` },
{ label: '意见', value: `${review.revisionNotes.length}` },
];
}
function issueCategoryLabel(category: ReviewIssue['category']): string {
if (category === 'fact') return '事实';
if (category === 'logic') return '逻辑';
return '语言';
}
function issueTone(issue: ReviewIssue) {
if (issue.severity === 'major') return 'border-red-200 bg-red-50/65 text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300';
if (issue.category === 'logic') return 'border-amber-200 bg-amber-50/65 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300';
if (issue.category === 'fact') return 'border-blue-200 bg-blue-50/65 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-blue-300';
return 'border-violet-200 bg-violet-50/65 text-violet-700 dark:border-violet-900/40 dark:bg-violet-950/20 dark:text-violet-300';
}
function ChatReviewDecisionView({
review,
revisionCount,
selectedIndices,
onToggleIssue,
}: {
review: ReviewResult;
revisionCount?: number;
selectedIndices?: Set<number>;
onToggleIssue?: (index: number) => void;
}) {
const selectable = selectedIndices !== undefined && !!onToggleIssue;
return (
<div className="overflow-hidden rounded-lg border bg-background text-xs">
<div className="border-b px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold">编辑审核结果</p>
{revisionCount !== undefined && revisionCount > 0 && (
<p className="mt-0.5 text-[11px] text-muted-foreground">第 {revisionCount} 次修改稿</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={cn(
'rounded-md px-2 py-1 text-[11px] font-medium',
review.verdict === 'pass'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
)}
>
{review.verdict === 'pass' ? '通过' : '打回'}
</span>
<span className="text-sm font-semibold tabular-nums">
{review.overallScore}
<span className="text-xs font-normal text-muted-foreground"> / 100</span>
</span>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-1.5">
{[
['事实', review.factScore],
['逻辑', review.logicScore],
['语言', review.languageScore],
].map(([label, score]) => (
<div key={label} className="rounded-md border bg-muted/30 px-2 py-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{score}</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full',
Number(score) >= review.passThreshold ? 'bg-emerald-500' : 'bg-amber-500',
)}
style={{ width: `${Math.max(0, Math.min(100, Number(score)))}%` }}
/>
</div>
</div>
))}
</div>
</div>
{review.revisionNotes.length > 0 ? (
<div className="space-y-2 px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-muted-foreground">修改意见</p>
{selectable && (
<p className="text-[10px] text-muted-foreground">
已选 {selectedIndices.size} / {review.revisionNotes.length}
</p>
)}
</div>
{review.revisionNotes.map((issue, index) => {
const selected = selectedIndices?.has(index) ?? false;
return (
<button
key={`${issue.category}-${issue.location}-${index}`}
type="button"
disabled={!selectable}
onClick={() => onToggleIssue?.(index)}
className={cn(
'w-full rounded-md border p-2 text-left transition-colors',
selectable ? 'hover:border-primary/40 hover:bg-primary/5' : 'cursor-default',
selected && 'border-primary/50 bg-primary/5',
)}
>
<div className="flex items-start gap-2">
{selectable && (
<span
className={cn(
'mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border',
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-input bg-background',
)}
>
{selected && <CheckCircle2 className="size-3" />}
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className={cn('rounded border px-1.5 py-0.5 text-[10px] font-medium', issueTone(issue))}>
{issue.severity === 'major' ? '严重' : '轻微'} · {issueCategoryLabel(issue.category)}
</span>
{issue.location && (
<span className="truncate text-[10px] text-muted-foreground">位置:{issue.location}</span>
)}
</div>
<p className="mt-1 leading-relaxed text-foreground/90">{issue.issue}</p>
{issue.suggestion && (
<p className="mt-1 rounded bg-muted/45 px-2 py-1 leading-relaxed text-muted-foreground">
建议:{issue.suggestion}
</p>
)}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="px-3 py-2.5 text-xs text-muted-foreground">没有需要修改的审核意见。</div>
)}
</div>
);
}
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
function MaterialConfirmChatCard({
@@ -170,97 +456,99 @@ function MaterialConfirmChatCard({
if (materials.length === 0) {
return (
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
<div className="flex items-start gap-2">
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。可以点「重新搜索」,或在底部输入框描述想检索的内容,也可以在无素材的情况下直接继续写作。
</p>
<DecisionPanel
tone="amber"
icon={ShieldAlert}
badge="素材确认"
title="未检索到可用素材"
description="素材收集专家本次没有检索到相关数据。可以重新搜索、用一句话补充检索方向,或在无素材情况下继续写作。"
metrics={[{ label: '素材', value: '0 条', tone: 'amber' }]}
actions={(
<div className="flex flex-wrap gap-2">
<Button
type="button" size="sm" className="flex-1 min-w-[140px] h-8 text-xs gap-1.5"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })}
disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />无数据继续写作
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「补充关于××的素材」「换个方向重新搜」" />}
>
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 min-w-[140px] h-8 text-xs gap-1.5"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })}
disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />无数据继续写作
</Button>
</div>
<ComposerHint examples="「补充关于××的素材」「换个方向重新搜」" />
</div>
</DecisionPanel>
);
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。可取消勾选不相关的素材后点确认。
</p>
<DecisionPanel
tone="blue"
icon={FileText}
badge="素材确认"
title="确认素材范围"
description="已搜集到候选素材,默认全选。可取消不相关素材,确认后进入三层大纲规划。"
metrics={[
{ label: '已检索', value: `${materials.length} 条`, tone: 'blue' },
{ label: '已选择', value: `${selectedIds.size} 条`, tone: selectedIds.size > 0 ? 'emerald' : 'amber' },
]}
actions={(
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedIds.size} / {materials.length} 条素材
</span>
<div className="flex-1" />
<button
type="button"
onClick={() => setSelectedIds(allSelected ? new Set() : new Set(materials.map((m) => m.id)))}
className="rounded-md px-2 py-1 text-xs text-primary hover:bg-primary/5"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button" size="sm" className="flex-1 min-w-[180px] h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「补充关于××的素材」「只用第 1、3 条」「素材可以,继续」" />}
>
<div className="space-y-3">
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
<MaterialsCard
materials={materials}
selectedIds={selectedIds}
onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}}
/>
</div>
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
<MaterialsCard
materials={materials}
selectedIds={selectedIds}
onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}}
/>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedIds.size} / {materials.length} 条素材
</span>
<div className="flex-1" />
<button
type="button"
onClick={() => setSelectedIds(allSelected ? new Set() : new Set(materials.map((m) => m.id)))}
className="text-xs text-primary hover:underline"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 min-w-[180px] h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
</div>
<ComposerHint examples="「补充关于××的素材」「只用第 1、3 条」「素材可以,继续」" />
</div>
</DecisionPanel>
);
}
@@ -286,29 +574,27 @@ function OutlineConfirmChatCard({
}
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">请确认大纲后继续写作</p>
<p className="text-xs text-muted-foreground mt-0.5">
可直接点击章节标题或要点微调;想让 AI 重新规划,就在底部输入框说出你的要求。
</p>
</div>
</div>
<DecisionPanel
tone="blue"
icon={ListTree}
badge="三层大纲"
title="确认三层大纲结构"
description="大纲结构为「章节 → 小节 → 子要点」。可直接在大纲里微调节点,也可以通过底部输入框要求 AI 重新规划。"
metrics={getOutlineStats(editedOutline)}
actions={(
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
>
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
</Button>
)}
hint={<ComposerHint examples="「第二章拆成两节」「重新规划大纲」「加一章讲发展趋势」" />}
>
{editedOutline
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
>
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
</Button>
<ComposerHint examples="「第二章拆成两节」「重新规划大纲」「加一章讲发展趋势」" />
</div>
</DecisionPanel>
);
}
@@ -363,69 +649,81 @@ function DraftConfirmChatCard({
const wordWithin = Math.abs(wordDiff) <= 100;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">草稿已完成,请确认后继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容
</p>
{showWordCmp && (
<p className={cn('text-xs mt-0.5', wordWithin ? 'text-muted-foreground' : 'text-orange-600 dark:text-orange-400')}>
实际 {draftWordCount} 字 / 目标 {wordCountTarget} 字
{wordDiff !== 0 && `(${wordDiff > 0 ? '超出' : '不足'} ${Math.abs(wordDiff)} 字${wordWithin ? ',在误差范围内' : ''})`}
</p>
<DecisionPanel
tone="blue"
icon={FileCheck2}
badge="草稿确认"
title="草稿已完成,请确认下一步"
description={(
<>
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容。
</>
)}
metrics={[
...(draftWordCount ? [{ label: '当前字数', value: `${draftWordCount}`, tone: wordWithin ? 'emerald' as const : 'amber' as const }] : []),
...(wordCountTarget ? [{ label: '目标字数', value: `${wordCountTarget}` }] : []),
...(reviewResult ? reviewMetrics(reviewResult).slice(0, 2) : []),
]}
actions={(
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>下一步处理</span>
{showWordCmp && wordDiff !== 0 && (
<span className={wordWithin ? 'text-muted-foreground' : 'text-orange-600 dark:text-orange-400'}>
{wordDiff > 0 ? '超出' : '不足'} {Math.abs(wordDiff)} 字{wordWithin ? ',在误差范围内' : ''}
</span>
)}
</div>
{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>
</div>
{reviewResult && (
<ReviewCard
<div className="flex gap-1.5">
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('to_editor')} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「第三段太啰嗦,精简一下」「结尾补个案例」「更口语一点」" />}
>
{reviewResult ? (
<ChatReviewDecisionView
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasReview ? selectedIssueIndices : undefined}
onToggleIssue={hasReview ? toggleIssue : undefined}
/>
)}
<div className="flex flex-col gap-1.5 pt-1">
{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">
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('to_editor')} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
) : (
<div className="rounded-md border bg-background/80 px-3 py-2 text-xs text-muted-foreground">
当前草稿暂无编辑审核结果,可直接送审或定稿。
</div>
</div>
<ComposerHint examples="「第三段太啰嗦,精简一下」「结尾补个案例」「更口语一点」" />
</div>
)}
</DecisionPanel>
);
}
@@ -474,19 +772,42 @@ function ReviewConfirmChatCard({
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">
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">编辑认为文章需要修改</p>
<p className="text-xs text-muted-foreground mt-0.5">
勾选您希望采纳的意见,再让作家重写;或忽略编辑意见强制定稿。
</p>
<DecisionPanel
tone="amber"
icon={ShieldCheck}
badge="审核确认"
title="编辑认为文章需要修改"
description="勾选希望采纳的意见,再让作家重写;也可以忽略编辑意见并强制定稿。"
metrics={reviewMetrics(reviewResult)}
actions={(
<div className="space-y-2">
<div className="text-[11px] text-muted-foreground">请选择审核意见处理方式</div>
<div className="flex gap-2">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleAccept}
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0)}
>
<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"
onClick={handleForce} disabled={disabled || submitting}
>
<ShieldAlert className="size-3.5" />强制定稿
</Button>
</div>
</div>
</div>
)}
hint={<ComposerHint examples="「再补一条意见:核查数据来源」「只按第 1、2 条改」「不改了,定稿」" />}
>
{reviewResult ? (
<ReviewCard
<ChatReviewDecisionView
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
@@ -495,29 +816,7 @@ function ReviewConfirmChatCard({
) : (
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
)}
<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 || (hasIssues && selectedIssueIndices.size === 0)}
>
<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"
onClick={handleForce} disabled={disabled || submitting}
>
<ShieldAlert className="size-3.5" />强制定稿
</Button>
</div>
<ComposerHint examples="「再补一条意见:核查数据来源」「只按第 1、2 条改」「不改了,定稿」" />
</div>
</DecisionPanel>
);
}
@@ -556,20 +855,36 @@ function SectionHelpChatCard({
}
return (
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
<div className="flex items-start gap-2">
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">部分章节素材不足,需要你决定</p>
<p className="text-xs text-muted-foreground mt-0.5">
严格依据素材模式下,以下 {blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。
</p>
<DecisionPanel
tone="amber"
icon={ShieldAlert}
badge="素材不足"
title="部分章节素材不足,需要你决定"
description={`严格依据素材模式下,以下 ${blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。`}
metrics={[
{ label: '受影响章节', value: `${blockedSections.length}`, tone: 'amber' },
{ label: '需补检索', value: `${Object.values(decisions).filter((d) => d === 'supplement').length}` },
]}
actions={(
<div className="space-y-2">
{hasSupplement && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。
</p>
)}
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleSubmit} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />提交并继续
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「都补充检索」「都按通用知识写」「第一个删掉,其他补检索」" />}
>
<div className="space-y-2">
{blockedSections.map((b) => (
<div key={b.sectionTitle} className="rounded-md border bg-background p-2.5 space-y-2">
<div key={b.sectionTitle} className="rounded-md border bg-background/85 p-2.5 space-y-2">
<div>
<p className="text-xs font-medium">{b.sectionTitle}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{b.reason}</p>
@@ -600,20 +915,6 @@ function SectionHelpChatCard({
</div>
))}
</div>
{hasSupplement && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。
</p>
)}
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleSubmit} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />提交并继续
</Button>
<ComposerHint examples="「都补充检索」「都按通用知识写」「第一个删掉,其他补检索」" />
</div>
</DecisionPanel>
);
}
@@ -15,7 +15,7 @@
*/
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
Bot, ChevronsDown, CloudUpload, Loader2, Pause, Play, Plus, RotateCcw,
Bot, CheckCircle2, ChevronsDown, CloudUpload, Pause, Play, Plus, RotateCcw,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -44,7 +44,7 @@ import {
type WritingSuggestionMessage,
} from '../../api/ai-writing-intent';
import { countArticleWords } from '../../utils/wordCount';
import type { UserInterventionPayload } from '../../types/ai-writing';
import type { ProgressEvent, UserInterventionPayload } from '../../types/ai-writing';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
import { AIWritingHistoryDropdown } from '../ai-writing/AIWritingHistoryDropdown';
@@ -52,8 +52,6 @@ import { AIWritingHistoryView } from '../ai-writing/AIWritingHistoryView';
import { AIWritingFlowDialog } from '../ai-writing/AIWritingFlowDialog';
import { DraftSync } from '../ai-writing/DraftSync';
import { DraftGeneratingView } from '../ai-writing/DraftGeneratingView';
import { IntentResultCard } from '../ai-writing/IntentResultCard';
import { IntentStreamingView } from '../ai-writing/IntentStreamingView';
import { OutlineStreamingView } from '../ai-writing/OutlineStreamingView';
import { ReviewStreamingView } from '../ai-writing/ReviewStreamingView';
import { RevisionSummaryCard } from '../ai-writing/RevisionSummaryCard';
@@ -111,6 +109,104 @@ const STAGE_UI: Record<string, { expert: string; placeholder: string; chips: str
},
};
const WORKFLOW_STEPS = ['需求理解', '素材检索', '大纲生成', '草稿写作', '审核润色'];
const WORKFLOW_STATUS_TEXT: Record<string, string> = {
idle: '准备开始写作',
analyzing_sample: '正在分析样文风格',
researching: '正在检索并整理素材',
awaiting_material_confirm: '素材已就绪,等待确认',
writing_outline: '正在生成三层大纲',
awaiting_outline_confirm: '大纲已生成,等待确认',
writing_draft: '正在撰写草稿',
awaiting_section_help: '部分章节素材不足,等待处理',
awaiting_draft_confirm: '草稿已完成,等待确认',
reviewing: '正在审核事实、逻辑与表达',
awaiting_review_confirm: '审核意见已生成,等待处理',
revising: '正在按意见修改草稿',
done: '写作已完成',
error: '写作流程异常',
};
function workflowStepIndex(status: string): number {
if (status === 'researching' || status === 'awaiting_material_confirm') return 1;
if (status === 'writing_outline' || status === 'awaiting_outline_confirm') return 2;
if (
status === 'writing_draft' ||
status === 'awaiting_section_help' ||
status === 'awaiting_draft_confirm' ||
status === 'revising'
) {
return 3;
}
if (status === 'reviewing' || status === 'awaiting_review_confirm' || status === 'done') return 4;
return 0;
}
function headerStatusLabel(status: string, paused: boolean): string {
if (paused) return '已暂停';
if (status === 'done') return '已完成';
if (status === 'error') return '异常';
if (status.startsWith('awaiting_')) return '等待确认';
return '进行中';
}
function headerStatusClass(status: string, paused: boolean): string {
if (status === 'error') return 'border-red-200 bg-red-50 text-red-600 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300';
if (paused || status.startsWith('awaiting_')) {
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300';
}
if (status === 'done') return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-300';
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-blue-300';
}
function WorkflowStepper({ activeIndex }: { activeIndex: number }) {
return (
<div className="px-4 pb-3">
<div className="rounded-md border bg-muted/20 px-3 py-2">
<div className="flex">
{WORKFLOW_STEPS.map((label, index) => {
const done = index < activeIndex;
const active = index === activeIndex;
return (
<div key={label} className="relative flex min-w-0 flex-1 flex-col items-center gap-1">
{index < WORKFLOW_STEPS.length - 1 && (
<span
className={cn(
'absolute left-1/2 top-2 h-px w-full',
done ? 'bg-emerald-300 dark:bg-emerald-700' : 'bg-border',
)}
aria-hidden="true"
/>
)}
<span
className={cn(
'relative z-10 flex size-4 items-center justify-center rounded-full border bg-background text-[10px] font-medium',
done && 'border-emerald-300 bg-emerald-50 text-emerald-600 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300',
active && 'border-blue-500 bg-blue-50 text-blue-600 shadow-[0_0_0_3px_rgba(59,130,246,0.12)] dark:bg-blue-950/40 dark:text-blue-300',
!done && !active && 'border-border text-muted-foreground',
)}
>
{done ? <CheckCircle2 className="size-3" /> : index + 1}
</span>
<span
className={cn(
'max-w-full truncate text-[11px]',
active ? 'font-medium text-foreground' : done ? 'text-emerald-700 dark:text-emerald-300' : 'text-muted-foreground',
)}
title={label}
>
{label}
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
const QA_HINT_RE = /(介绍|概括|总结|讲讲|说说|说明|解释|分析一下|这篇文章|文章内容|主要内容|主题|标题|大纲|结构|用了哪些素材|引用了哪些|现在到哪|进度|多少字|为什么|是什么|有哪些|吗|呢|\?|?)/;
const ACTION_HINT_RE = /(搜索|检索|补充|确认|继续|审核|送审|修改|改一下|重写|定稿|采纳|接受|删除|只用|不用|重新规划|后台|挂起|暂停|恢复|新增|增加|添加|加入|加上|补上|生成|撰写|写成|扩写|缩写|精简|润色|调整|拆成|合并|挪到|移动|规划)/;
const WRITING_ACTION_VERB_RE = /(新增|增加|添加|加入|加上|补充|补上|补个|补一个|生成|撰写|写一|写个|写成|扩写|缩写|精简|改写|润色|调整|修改|重写|删除|删掉|拆成|合并|挪到|移动|规划|重新规划|设计|安排)/;
@@ -135,6 +231,52 @@ function shouldStreamAsAnswer(text: string): boolean {
return QA_HINT_RE.test(t) && !ACTION_HINT_RE.test(t);
}
function formatIntentParsingText(thinkingText: string): string {
const trimmed = thinkingText.trim();
return trimmed ? `正在理解你的意图…\n${trimmed}` : '正在理解你的意图…';
}
function intentActionLabel(result: WritingIntentResult): string {
if (result.intent === 'resume_writing') return '恢复写作';
if (result.intent === 'pause_writing') return '暂停写作';
if (result.intent === 'run_background') return '后台挂起';
if (result.intent === 'ask_content' || result.intent === 'ask_meta' || result.answer) return '回答问题';
if (result.clarify) return '需要澄清';
switch (result.action) {
case 'confirm':
return result.pausePoint === 'material_confirm' ? '确认素材' : '确认继续';
case 're_search':
return '补充检索';
case 're_outline':
return '重新规划大纲';
case 'to_editor':
return '提交编辑审核';
case 'user_revise':
return '修改草稿';
case 'accept_review':
return '采纳审核意见';
case 'force_finalize':
return '强制定稿';
case 'section_help':
return '处理素材不足章节';
default:
return result.intent || '已识别';
}
}
function summarizeIntentResult(result: WritingIntentResult): string {
const label = intentActionLabel(result);
const confidence = Number.isFinite(result.confidence)
? ` · 置信度 ${Math.round(result.confidence * 100)}%`
: '';
const source = result.source === 'rule' ? '规则快路径' : '模型解析';
if (result.clarify) return `${source}:需要补充说明${confidence}`;
if (result.needResearch && result.researchQuery) {
return `${source}:识别为「${label}」,将补充检索「${result.researchQuery}」${confidence}`;
}
return `${source}:识别为「${label}」${confidence}`;
}
/** 素材不足自动重搜后待续跑的原始意图(仅 material_confirm 阶段,graph 支持范围内)。 */
interface PendingIntent {
payload: UserInterventionPayload;
@@ -190,8 +332,6 @@ export function WritingChatPanel() {
const chatMessagesRef = useRef<ChatFeedMessage[]>([]);
const [resolving, setResolving] = useState(false);
const [intentParsing, setIntentParsing] = useState(false);
const [intentStreamText, setIntentStreamText] = useState('');
const [intentThinkingText, setIntentThinkingText] = useState('');
const intentAbortRef = useRef<AbortController | null>(null);
const answerAbortRef = useRef<AbortController | null>(null);
const [pendingIntent, setPendingIntent] = useState<PendingIntent | null>(null);
@@ -392,8 +532,6 @@ export function WritingChatPanel() {
chatMessages.length,
resolving,
intentParsing,
intentStreamText,
intentThinkingText,
pendingIntent,
]);
@@ -433,6 +571,12 @@ export function WritingChatPanel() {
return id;
}, [forceAutoScrollToBottom]);
const updateMsg = useCallback((id: string, patch: Partial<ChatFeedMessage>) => {
setChatMessages((prev) => prev.map((msg) => (
msg.id === id ? { ...msg, ...patch } : msg
)));
}, []);
/** 由输入框发起的干预:提交前登记序号,抑制时间线里重复的「我的要求」卡。 */
const submitFromComposer = useCallback(async (
payload: UserInterventionPayload,
@@ -455,8 +599,6 @@ export function WritingChatPanel() {
intentAbortRef.current = null;
answerAbortRef.current = null;
setChatMessages([]);
setIntentStreamText('');
setIntentThinkingText('');
setIntentParsing(false);
setResolving(false);
setPendingIntent(null);
@@ -806,8 +948,11 @@ export function WritingChatPanel() {
intentAbortRef.current?.abort();
const controller = new AbortController();
intentAbortRef.current = controller;
setIntentStreamText('');
setIntentThinkingText('');
const intentMsgId = appendMsg(
{ role: 'assistant', kind: 'intent', text: '正在理解你的意图…', streaming: true },
{ anchor: turnAnchor, forceScroll: true },
);
let thinkingText = '';
setIntentParsing(true);
setResolving(true);
try {
@@ -823,15 +968,21 @@ export function WritingChatPanel() {
)) {
if (controller.signal.aborted) return;
if (event.type === 'thinking') {
setIntentThinkingText((prev) => prev + event.chunk);
thinkingText += event.chunk;
updateMsg(intentMsgId, {
text: formatIntentParsingText(thinkingText),
streaming: true,
});
} else if (event.type === 'text') {
// 这里的正文通常是结构化 JSON,只用于后端解析;窄栏不直接展示。
void event.chunk;
} else if (event.type === 'result') {
result = event.result;
setIntentParsing(false);
setIntentStreamText('');
setIntentThinkingText('');
updateMsg(intentMsgId, {
text: summarizeIntentResult(event.result),
streaming: false,
});
} else if (event.type === 'error') {
throw new Error(event.message);
}
@@ -843,23 +994,18 @@ export function WritingChatPanel() {
} catch (e) {
if (controller.signal.aborted) return;
setIntentParsing(false);
appendMsg(
{
role: 'assistant', kind: 'notice',
text: `意图解析暂时不可用(${e instanceof Error ? e.message : '未知错误'})。可以直接点击上方卡片里的按钮继续操作。`,
},
{ anchor: turnAnchor, forceScroll: true },
);
updateMsg(intentMsgId, {
text: `意图解析暂时不可用(${e instanceof Error ? e.message : '未知错误'})。可以直接点击上方卡片里的按钮继续操作。`,
streaming: false,
});
} finally {
if (intentAbortRef.current === controller) {
intentAbortRef.current = null;
}
setResolving(false);
setIntentParsing(false);
setIntentStreamText('');
setIntentThinkingText('');
}
}, [appendMsg, executeIntent, isHistoryMode, isIdle, pausePoint, resolving, state.currentPause, state.isPaused, state.sessionId, state.status, streamAnswerFromComposer]);
}, [appendMsg, executeIntent, isHistoryMode, isIdle, pausePoint, resolving, state.currentPause, state.isPaused, state.sessionId, state.status, streamAnswerFromComposer, updateMsg]);
const handleClarificationSubmit = useCallback((messageId: string, answer: string) => {
const text = answer.trim();
@@ -922,6 +1068,30 @@ export function WritingChatPanel() {
const showNewButton = isHistoryMode || state.status !== 'idle';
const wordTarget = state.request?.wordCountTarget;
const activeWorkflowStep = workflowStepIndex(state.status);
const statusText = state.researchProgress && state.status === 'researching'
? state.researchProgress
: WORKFLOW_STATUS_TEXT[state.status] ?? STATUS_LABEL[state.status] ?? state.status;
const statusLabel = headerStatusLabel(state.status, state.isPaused);
const articleTypeLabels = useMemo(
() => Object.fromEntries(articleTypes.map((type) => [type.key, type.label])),
[articleTypes],
);
const timelineEvents = useMemo<ProgressEvent[]>(() => {
const hasIntentReady = state.progressEvents.some((event) => event.type === 'intent_ready');
const intentDetail = state.intentThinkingText || state.intentStreamingText;
if (!intentDetail || hasIntentReady) return state.progressEvents;
return [
...state.progressEvents,
{
type: 'step_start',
sessionId: state.sessionId ?? '',
agentName: '意图解析',
message: '正在解析写作需求',
detail: intentDetail,
},
];
}, [state.intentStreamingText, state.intentThinkingText, state.progressEvents, state.sessionId]);
return (
<div className="flex flex-col h-full overflow-hidden bg-background dark:bg-transparent">
@@ -930,29 +1100,59 @@ export function WritingChatPanel() {
)}
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
{canRestoreHistory && (
<Button
variant="ghost" size="sm"
className="h-7 px-2 text-xs gap-1 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
onClick={handleRestoreHistory}
title="恢复写作:跳过已完成阶段,从未完成阶段重新开始"
>
<Play className="size-3 fill-current" />
恢复
</Button>
)}
<span
className={cn('text-sm font-semibold shrink-0 min-w-0 truncate', isHistoryMode && 'flex-1 mr-2')}
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
>
{isHistoryMode
? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
: 'AI 智能写作 · 对话版'}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="shrink-0 border-b bg-background/95">
<div className="flex items-start justify-between gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{canRestoreHistory && (
<Button
variant="ghost" size="sm"
className="h-7 px-2 text-xs gap-1 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
onClick={handleRestoreHistory}
title="恢复写作:跳过已完成阶段,从未完成阶段重新开始"
>
<Play className="size-3 fill-current" />
恢复
</Button>
)}
<span
className={cn('min-w-0 truncate text-sm font-semibold', isHistoryMode && 'mr-2 flex-1')}
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
>
{isHistoryMode
? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
: 'AI 智能写作 · 对话版'}
</span>
{!isHistoryMode && !isIdle && (
<span
className={cn(
'shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium',
headerStatusClass(state.status, state.isPaused),
)}
>
{statusLabel}
</span>
)}
</div>
{!isHistoryMode && !isIdle && (
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
{isRunning && !state.isPaused && (
<span className="inline-block size-1.5 shrink-0 animate-pulse rounded-full bg-blue-500" />
)}
{state.isPaused && (
<span className="inline-block size-1.5 shrink-0 rounded-full bg-amber-400" />
)}
<span className="truncate">{statusText}</span>
{stageUI && (
<span className="hidden min-w-0 items-center gap-1 text-primary/80 xl:flex">
<Bot className="size-3 shrink-0" />
<span className="truncate">当前交互对象:{stageUI.expert}</span>
</span>
)}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{showNewButton && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
@@ -1012,7 +1212,10 @@ export function WritingChatPanel() {
onRename={rename}
onRefresh={refresh}
/>
</div>
</div>
{!isHistoryMode && !isIdle && <WorkflowStepper activeIndex={activeWorkflowStep} />}
</div>
{/* 主体滚动区 */}
@@ -1028,21 +1231,14 @@ export function WritingChatPanel() {
<AIWritingHistoryView session={historySession} />
)}
{/* 意图解析流式 / 结果(写作起点,锚定内容顶部) */}
{!isHistoryMode && (state.intentStreamingText || state.intentThinkingText) && (
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{!isHistoryMode && state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} hideTargetAudience />
)}
{/* 统一信息流:流程事件 + 意图层对话气泡按时间顺序合并 */}
{!isIdle && !isHistoryMode && (
<ChatFlowTimeline
events={state.progressEvents}
events={timelineEvents}
completedInterventions={state.completedInterventions}
currentPause={state.currentPause}
chatMessages={chatMessages}
articleTypeLabels={articleTypeLabels}
suppressedRequirementIndices={suppressedRequirementsRef.current}
completedInterventionAnchors={completedInterventionAnchorsRef.current}
onClarificationSubmit={handleClarificationSubmit}
@@ -1126,16 +1322,6 @@ export function WritingChatPanel() {
</div>
)}
{/* 瞬态提示(仅意图解析阶段显示;动作提交/素材检索开始后立即收起) */}
{!isHistoryMode && intentParsing && intentThinkingText && (
<IntentStreamingView text="" thinkingText={intentThinkingText} />
)}
{!isHistoryMode && intentParsing && !intentThinkingText && (
<div className="flex items-center gap-2 px-4 pb-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
正在理解你的意图…
</div>
)}
{!isHistoryMode && pendingIntent && (
<div className="mx-4 mb-3 flex items-start justify-between gap-2 rounded-md border border-sky-200 bg-sky-50/60 px-3 py-2 text-xs text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/20 dark:text-sky-300">
<span className="leading-relaxed">
@@ -129,7 +129,11 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
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}`)
.map((iss, n) => {
const location = iss.location ? `【位置】${iss.location};` : '';
const suggestion = iss.suggestion ? `;【建议】${iss.suggestion}` : '';
return `${n + 1}. [${iss.category}] ${location}${iss.issue}${suggestion}`;
})
.join('\n');
}
@@ -34,16 +34,23 @@ function ScoreBar({ score }: { score: number }) {
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>
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5 space-y-0.5">
<div>
<span className={cn(
'font-medium mr-1',
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
)}>
{issue.severity === 'major' ? '严重' : '轻微'}
</span>
{issue.location && (
<span className="text-muted-foreground/70 mr-1">【{issue.location}】</span>
)}
<span className="text-muted-foreground">{issue.issue}</span>
</div>
{issue.suggestion && (
<span className="text-orange-600 dark:text-orange-400"> → {issue.suggestion}</span>
<div className="text-orange-600 dark:text-orange-400">
建议:{issue.suggestion}
</div>
)}
</div>
);
@@ -1,6 +1,6 @@
import { CheckCircle2, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { RevisionSummary } from '../../types/ai-writing';
import type { ReviewIssue, RevisionSummary } from '../../types/ai-writing';
interface RevisionSummaryCardProps {
summary: RevisionSummary;
@@ -12,6 +12,45 @@ const CATEGORY_LABEL: Record<string, string> = {
language: '语言',
};
function IssueSummaryRow({ issue, skipped = false }: { issue: ReviewIssue; skipped?: boolean }) {
return (
<div
className={cn(
'flex items-start gap-1.5 pl-5 leading-snug',
skipped ? 'text-muted-foreground/60' : 'text-muted-foreground',
)}
>
<span
className={cn(
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
issue.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
issue.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',
skipped && 'opacity-70',
)}
>
{CATEGORY_LABEL[issue.category] ?? issue.category}
</span>
<div className={cn('min-w-0 flex-1 space-y-0.5', skipped && 'line-through decoration-muted-foreground/50')}>
{issue.location && (
<div className="text-[11px] text-muted-foreground/70">
位置:{issue.location}
</div>
)}
<div className={skipped ? 'text-muted-foreground/70' : 'text-foreground/85 dark:text-foreground/80'}>
{issue.issue}
</div>
{issue.suggestion && (
<div className={skipped ? 'text-muted-foreground/60' : 'text-muted-foreground'}>
<span className="text-primary mr-1">建议:</span>
{issue.suggestion}
</div>
)}
</div>
</div>
);
}
export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
const { revisedIssues, skippedIssues, oldWordCount, newWordCount } = summary;
const wordDiff = newWordCount - oldWordCount;
@@ -39,19 +78,7 @@ export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
<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>
))}
{revisedIssues.map((iss, i) => <IssueSummaryRow key={i} issue={iss} />)}
</div>
)}
@@ -62,11 +89,7 @@ export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
<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>
))}
{skippedIssues.map((iss, i) => <IssueSummaryRow key={i} issue={iss} skipped />)}
</div>
)}
@@ -513,10 +513,17 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
}
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
const isApplyingReview =
state.reviewResult != null &&
(
(pausePoint === 'review_confirm' && payload.action === 'accept_review') ||
payload.selectedIssueIndices !== undefined
);
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
const selectedSet = payload.selectedIssueIndices !== undefined
? new Set(payload.selectedIssueIndices)
: new Set(allIssues.map((_, i) => i));
pendingRevisionContext = {
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
@@ -145,7 +145,7 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
? { request: { ...prev.request, materialSource: action.materialSource } }
: {}),
completedInterventions: [...prev.completedInterventions, action.record],
...(action.pendingRevisionContext ? { pendingRevisionContext: action.pendingRevisionContext } : {}),
pendingRevisionContext: action.pendingRevisionContext,
};
}