Files
deerflow-code/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx
T
2026-05-29 11:19:58 +08:00

712 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
Trash2, BookOpen, Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
ProgressEvent, UserInterventionPayload,
BlockedSection, SectionDecisionKind,
SkillSearchStep,
} from '../../types/ai-writing';
import { MaterialsCard } from './MaterialsCard';
import { MaterialsToolbar } from './MaterialsToolbar';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
import { SkillStepsCard } from './SkillStepsCard';
interface InterventionCardProps {
pauseEvent: ProgressEvent;
materials: AIMaterial[];
outline: AIOutline | null;
reviewResult: ReviewResult | null;
draftTitle: string;
revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') {
return (
<MaterialConfirmCard
materials={materials}
skillSearchSteps={skillSearchSteps ?? []}
skillSearchInProgress={skillSearchInProgress}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'outline_confirm') {
return (
<OutlineConfirmCard
outline={outline ?? pauseEvent.outline ?? null}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'draft_confirm') {
return (
<DraftConfirmCard
draftTitle={draftTitle}
revisionCount={revisionCount}
reviewResult={reviewResult}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'section_help') {
return (
<SectionHelpCard
blockedSections={pauseEvent.blockedSections ?? []}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'review_confirm') {
return (
<ReviewConfirmCard
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
revisionCount={revisionCount}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
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" />
等待用户确认({pausePoint})…
</div>
);
}
// ── 工具函数 ──────────────────────────────────────────────────────────────────
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
return new Set(issues.map((_, i) => i));
}
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): string {
const selected = issues.filter((_, i) => selectedIndices.has(i));
if (selected.length === 0) return '';
return selected
.map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`)
.join('\n');
}
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
interface MaterialConfirmCardProps {
materials: AIMaterial[];
skillSearchSteps: SkillSearchStep[];
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [userQuery, setUserQuery] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
async function handleReSearch() {
setSubmitting(true);
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
async function handleEnableSkillSearch() {
setSubmitting(true);
await onSubmit({ action: 'enable_skill_search', userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
const hasQuery = userQuery.trim().length > 0;
// 当前素材是否来自技能检索:根据素材 sourceType 推断(覆盖式替换后全是 skill)
const isSkillResult = materials.length > 0 && materials.every((m) => m.sourceType === 'skill');
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
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>
</div>
</div>
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set())}
onDeselectAll={() => setSelectedIds(new Set())}
userQuery={userQuery}
onUserQueryChange={setUserQuery}
onSubmitQuery={handleReSearch}
disabled={disabled || submitting}
/>
<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}
>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles 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>
);
}
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} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
</p>
</div>
</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;
});
}} />
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
onDeselectAll={() => setSelectedIds(new Set())}
userQuery={userQuery}
onUserQueryChange={setUserQuery}
onSubmitQuery={handleReSearch}
disabled={disabled || submitting}
/>
<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>
{!isSkillResult && (
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles 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}
>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
</div>
);
}
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
interface OutlineConfirmCardProps {
outline: AIOutline | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
const [feedback, setFeedback] = useState('');
const [submitting, setSubmitting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({
action: 'confirm',
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
});
setSubmitting(false);
}
async function handleReplan() {
const text = feedback.trim();
if (!text) return;
setSubmitting(true);
// 带上卡片里的当前大纲,让 AI 在此基础上按要求增删/调整,而非从零重生成
await onSubmit({
action: 're_outline',
outlineFeedback: text,
editedOutline: editedOutline ?? undefined,
});
setSubmitting(false);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleReplan();
}
}
// 自动调整高度
function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
setFeedback(e.target.value);
const el = e.target;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}
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>
{editedOutline
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
{/* 重新规划输入框 */}
<div className="flex items-end gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 focus-within:ring-1 focus-within:ring-ring">
<textarea
ref={textareaRef}
rows={1}
value={feedback}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder="输入修改意见后按 Enter 重新规划大纲,Shift+Enter 换行…"
disabled={disabled || submitting}
className="flex-1 resize-none overflow-hidden bg-transparent text-xs placeholder:text-muted-foreground focus:outline-none disabled:opacity-50 leading-5 py-0.5 min-h-[20px] max-h-32"
/>
<button
type="button"
onClick={handleReplan}
disabled={disabled || submitting || !feedback.trim()}
className="shrink-0 mb-0.5 p-1 rounded text-muted-foreground hover:text-primary hover:bg-primary/8 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="发送(Enter)"
>
{submitting
? <RefreshCw className="size-3.5 animate-spin" />
: <SendHorizonal className="size-3.5" />}
</button>
</div>
<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>
</div>
);
}
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
interface DraftConfirmCardProps {
draftTitle: string;
revisionCount: number;
reviewResult: ReviewResult | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
const [notes, setNotes] = useState('');
const [showNotes, setShowNotes] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function submit(action: string, extra?: Partial<UserInterventionPayload>) {
setSubmitting(true);
await onSubmit({ action, userRevisionNotes: notes.trim(), ...extra });
setSubmitting(false);
}
async function handleApplyReview() {
if (!reviewResult) return;
const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices);
setSubmitting(true);
await onSubmit({
action: 'user_revise',
userRevisionNotes: formattedNotes,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<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>
</div>
</div>
{/* 编辑审核结果(若已审核,支持勾选意见) */}
{reviewResult && (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasReview ? selectedIssueIndices : undefined}
onToggleIssue={hasReview ? toggleIssue : undefined}
/>
)}
{/* 用户自定义修改意见 */}
{showNotes && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">您的修改意见</label>
<textarea
rows={2} value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="描述希望修改的内容,例如:第二段数据来源不清晰,结尾过于仓促…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
)}
<div className="flex flex-col gap-1.5 pt-1">
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
{hasReview && (
<Button
type="button" variant="outline" size="sm"
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
onClick={handleApplyReview}
disabled={disabled || submitting || selectedIssueIndices.size === 0}
>
<ClipboardEdit className="size-3.5" />
按编辑审核意见修改
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
)}
<div className="flex gap-1.5">
{showNotes ? (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('user_revise')} disabled={disabled || submitting || !notes.trim()}
>
<PenLine className="size-3.5" />按我的意见修改
</Button>
) : (
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => setShowNotes(true)} disabled={disabled || submitting}
>
<PenLine className="size-3.5" />给出修改意见…
</Button>
)}
<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>
</div>
</div>
</div>
);
}
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
interface ReviewConfirmCardProps {
reviewResult: ReviewResult | null;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }: ReviewConfirmCardProps) {
const [extraNotes, setExtraNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function handleAccept() {
setSubmitting(true);
const issueNotes = reviewResult
? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices)
: '';
const combined = [issueNotes, extraNotes.trim()].filter(Boolean).join('\n\n');
await onSubmit({
action: 'accept_review',
userRevisionNotes: combined || undefined,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
async function handleForce() {
setSubmitting(true);
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
setSubmitting(false);
}
const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 0;
return (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
<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>
</div>
</div>
{reviewResult ? (
<ReviewCard
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
onToggleIssue={hasIssues ? toggleIssue : undefined}
/>
) : (
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
)}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充修改指令(可选)</label>
<textarea
rows={2} value={extraNotes} onChange={(e) => setExtraNotes(e.target.value)}
placeholder="留空则只采纳已勾选意见;或在此补充您自己的修改方向…"
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<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 && !extraNotes.trim())}
>
<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>
);
}
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
interface SectionHelpCardProps {
blockedSections: BlockedSection[];
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
const DECISION_OPTIONS: { value: SectionDecisionKind; label: string; icon: typeof Search }[] = [
{ value: 'supplement', label: '补充检索素材', icon: Search },
{ value: 'loose', label: '按通用知识续写', icon: BookOpen },
{ value: 'delete', label: '删除该章节', icon: Trash2 },
];
function SectionHelpCard({ blockedSections, onSubmit, disabled }: SectionHelpCardProps) {
const [decisions, setDecisions] = useState<Record<string, SectionDecisionKind>>(
() => Object.fromEntries(blockedSections.map((b) => [b.sectionTitle, 'supplement'])),
);
const [submitting, setSubmitting] = useState(false);
const hasSupplement = Object.values(decisions).some((d) => d === 'supplement');
async function handleSubmit() {
setSubmitting(true);
await onSubmit({
action: 'section_help',
sectionDecisions: blockedSections.map((b) => ({
sectionTitle: b.sectionTitle,
decision: decisions[b.sectionTitle] ?? 'supplement',
})),
});
setSubmitting(false);
}
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>
</div>
</div>
<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>
<p className="text-xs font-medium">{b.sectionTitle}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{b.reason}</p>
</div>
<div className="flex gap-1">
{DECISION_OPTIONS.map((opt) => {
const active = (decisions[b.sectionTitle] ?? 'supplement') === opt.value;
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
disabled={disabled || submitting}
onClick={() => setDecisions((p) => ({ ...p, [b.sectionTitle]: opt.value }))}
className={cn(
'flex-1 flex items-center justify-center gap-1 rounded px-1.5 py-1 text-[11px] border transition-colors',
active
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-input text-muted-foreground hover:bg-muted/50',
)}
>
<Icon className="size-3 shrink-0" />
{opt.label}
</button>
);
})}
</div>
</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>
</div>
);
}