代码提交
This commit is contained in:
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
+524
-223
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user