Files
deerflow-code/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx
T
2026-07-03 22:11:50 +08:00

1255 lines
48 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.
/**
* 对话驱动写作台的统一信息流。
*
* 这里保留原来的时序模型:流程事件、用户消息、助手回答、完成结果按 anchor 合并。
* 视觉层是对话版专属实现,不复用经典写作页的 TimelineStep / CompletedInterventionCard。
*/
import { useMemo, useState, type ReactNode } from 'react';
import {
AlertCircle, AlertTriangle, BadgeCheck, Bot, Check, CheckCircle2,
ChevronRight, CircleHelp, ExternalLink, FileCheck2, FileText, Info, ListTree,
Loader2, MessageSquareText, PauseCircle, PenLine, Search, Send, ShieldCheck,
Sparkles, Target, Type, UserRound, UsersRound, X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { buildIntranetSourceUrl } from '@/core/config';
import { cn } from '@/lib/utils';
import { resolveAllowMultiple } from '@/roundtable-planning/lib/clarification';
import type {
AIMaterial,
CompletedIntervention,
ProgressEvent,
ReviewIssue,
ReviewResult,
} from '../../types/ai-writing';
import { OutlineCard } from '../ai-writing/OutlineCard';
import { CollapseTransition } from '../ai-writing/CollapseTransition';
export type ChatKind = 'text' | 'clarify' | 'answer' | 'ack' | 'notice' | 'assist' | 'intent';
export interface ChatClarificationOption {
id: string;
label: string;
description?: string;
}
export interface ChatClarificationPayload {
question?: string;
context?: string | null;
clarification_type?: string;
options?: ChatClarificationOption[];
allow_custom?: boolean;
allow_multiple?: boolean;
}
export interface ChatFeedMessage {
id: string;
role: 'user' | 'assistant';
kind: ChatKind;
text: string;
clarification?: ChatClarificationPayload;
answered?: boolean;
suggestions?: string[];
suggestionsLoading?: boolean;
suggestionsHidden?: boolean;
streaming?: boolean;
/** 创建该消息时的信息流长度:消息渲染在前 anchor 个流程单元之后。 */
anchor: number;
}
interface ClarificationSelection {
selectedIds: string[];
custom: string;
}
const DISPLAYED_TYPES = new Set([
'step_start',
'step_done',
'sample_profile_ready',
'intent_ready',
'materials_ready',
'outline_ready',
'draft_ready',
'review_ready',
'await_user',
'resumed',
'notice',
'model_switch',
'done',
'error',
]);
type EventStatus = 'done' | 'running' | 'paused' | 'warning' | 'error';
type FeedRow =
| { key: string; type: 'event'; event: ProgressEvent; slot: ReactNode; extendLineThroughSlot: boolean }
| { key: string; type: 'slot'; slot: ReactNode }
| { key: string; type: 'chat'; msg: ChatFeedMessage };
interface ChatFlowTimelineProps {
events: ProgressEvent[];
completedInterventions: CompletedIntervention[];
currentPause: ProgressEvent | null;
currentInterventionSlot?: ReactNode;
revisionSummarySlot?: ReactNode;
chatMessages: ChatFeedMessage[];
articleTypeLabels?: Record<string, string>;
completedInterventionAnchors?: Map<number, number>;
suppressedRequirementIndices?: Set<number>;
onClarificationSubmit?: (messageId: string, answer: string) => void;
onSuggestionClick?: (suggestion: string) => void;
onSuggestionsDismiss?: (messageId: string) => void;
}
function eventStatusOf(event: ProgressEvent, isLast: boolean): EventStatus {
if (event.type === 'error') return 'error';
if (event.type === 'notice' || event.type === 'model_switch') return 'warning';
if (event.type === 'await_user') return 'paused';
if (event.type === 'step_start') return isLast ? 'running' : 'done';
return 'done';
}
function eventLabel(event: ProgressEvent): string {
if (event.type === 'await_user') return '需要确认';
if (event.type === 'intent_ready') return '意图解析';
if (event.type === 'materials_ready') return '素材摘要';
if (event.type === 'outline_ready') return '大纲生成';
if (event.type === 'draft_ready') return '草稿完成';
if (event.type === 'review_ready') return '审核完成';
if (event.type === 'model_switch') return '模型切换';
if (event.type === 'notice') return '提示';
if (event.type === 'error') return '异常';
if (event.type === 'done') return '完成';
return event.agentName || '流程事件';
}
function eventIcon(event: ProgressEvent, status: EventStatus) {
if (status === 'running') return Loader2;
if (status === 'paused') return PauseCircle;
if (status === 'warning') return AlertTriangle;
if (status === 'error') return AlertCircle;
if (event.type === 'materials_ready') return Search;
if (event.type === 'intent_ready') return Sparkles;
if (event.type === 'outline_ready') return ListTree;
if (event.type === 'draft_ready') return FileCheck2;
if (event.type === 'review_ready') return ShieldCheck;
return CheckCircle2;
}
function eventTone(status: EventStatus) {
if (status === 'running') {
return {
marker: 'border-blue-200 bg-blue-50 text-blue-600 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-300',
card: 'border-blue-100 bg-blue-50/35 dark:border-blue-900/40 dark:bg-blue-950/15',
accent: 'bg-blue-500',
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300',
};
}
if (status === 'paused' || status === 'warning') {
return {
marker: 'border-amber-200 bg-amber-50 text-amber-600 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300',
card: 'border-amber-200/80 bg-amber-50/45 dark:border-amber-900/40 dark:bg-amber-950/20',
accent: 'bg-amber-500',
badge: 'bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300',
};
}
if (status === 'error') {
return {
marker: 'border-red-200 bg-red-50 text-red-600 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300',
card: 'border-red-200 bg-red-50/60 dark:border-red-900/40 dark:bg-red-950/20',
accent: 'bg-red-500',
badge: 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300',
};
}
return {
marker: 'border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-300',
card: 'border-border bg-card',
accent: 'bg-emerald-500',
badge: 'bg-muted text-muted-foreground',
};
}
export function ChatFlowTimeline({
events,
completedInterventions,
currentPause,
currentInterventionSlot,
revisionSummarySlot,
chatMessages,
articleTypeLabels,
completedInterventionAnchors,
suppressedRequirementIndices,
onClarificationSubmit,
onSuggestionClick,
onSuggestionsDismiss,
}: ChatFlowTimelineProps) {
if (events.length === 0 && chatMessages.length === 0) return null;
const tailSlotCount = revisionSummarySlot ? 1 : 0;
const maxAnchor = events.length + tailSlotCount;
const msgsByAnchor = new Map<number, ChatFeedMessage[]>();
for (const msg of chatMessages) {
const anchor = Math.max(0, Math.min(msg.anchor, maxAnchor));
const bucket = msgsByAnchor.get(anchor);
if (bucket) bucket.push(msg);
else msgsByAnchor.set(anchor, [msg]);
}
const completionRowsByAnchor = new Map<number, FeedRow[]>();
completedInterventionAnchors?.forEach((rawAnchor, interventionIdx) => {
const record = completedInterventions[interventionIdx];
if (!record) return;
const anchor = Math.max(0, Math.min(rawAnchor, maxAnchor));
const rows = completionRowsByAnchor.get(anchor) ?? [];
rows.push({
key: `composer-completed-${interventionIdx}`,
type: 'slot',
slot: <ChatCompletedInterventionCard record={record} />,
});
completionRowsByAnchor.set(anchor, rows);
});
let lastAwaitUserIdx = -1;
events.forEach((e, i) => {
if (e.type === 'await_user' && DISPLAYED_TYPES.has(e.type)) lastAwaitUserIdx = i;
});
const rows: FeedRow[] = [];
const pushAnchoredRows = (anchor: number) => {
for (const msg of msgsByAnchor.get(anchor) ?? []) {
rows.push({ key: msg.id, type: 'chat', msg });
}
for (const completionRow of completionRowsByAnchor.get(anchor) ?? []) {
rows.push(completionRow);
}
};
let awaitUserIdx = 0;
events.forEach((event, fullIdx) => {
pushAnchoredRows(fullIdx);
if (!DISPLAYED_TYPES.has(event.type)) return;
let slot: ReactNode = null;
let extendLineThroughSlot = true;
if (event.type === 'await_user') {
const interventionIdx = awaitUserIdx;
const completedRecord = completedInterventions[interventionIdx];
awaitUserIdx += 1;
if (completedRecord) {
const movedToComposerAnchor = completedInterventionAnchors?.has(interventionIdx);
if (!movedToComposerAnchor) {
const showRequirement = !suppressedRequirementIndices?.has(interventionIdx);
slot = (
<div className="space-y-2">
{showRequirement && <ChatRequirementCard record={completedRecord} />}
<ChatCompletedInterventionCard record={completedRecord} />
</div>
);
}
} else if (currentPause && fullIdx === lastAwaitUserIdx) {
slot = currentInterventionSlot;
extendLineThroughSlot = false;
}
}
rows.push({ key: `event-${fullIdx}`, type: 'event', event, slot, extendLineThroughSlot });
});
if (revisionSummarySlot) {
pushAnchoredRows(events.length);
rows.push({ key: 'revision-summary', type: 'slot', slot: revisionSummarySlot });
pushAnchoredRows(events.length + 1);
} else {
pushAnchoredRows(events.length);
}
return (
<div className="px-4 py-4">
<div className="space-y-3">
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
if (row.type === 'chat') {
if (row.msg.role === 'user') {
return (
<UserChatFeedRow key={row.key} msg={row.msg} isLast={isLast} />
);
}
return (
<FeedRowShell
key={row.key}
isLast={isLast}
marker={<ChatMarker role={row.msg.role} kind={row.msg.kind} />}
>
<ChatBubble
msg={row.msg}
onClarificationSubmit={onClarificationSubmit}
onSuggestionClick={onSuggestionClick}
onSuggestionsDismiss={onSuggestionsDismiss}
/>
</FeedRowShell>
);
}
if (row.type === 'slot') {
return (
<FeedRowShell key={row.key} isLast={isLast} marker={<SlotMarker />}>
{row.slot}
</FeedRowShell>
);
}
return (
<FeedRowShell
key={row.key}
isLast={isLast && (!row.slot || !row.extendLineThroughSlot)}
marker={<EventMarker event={row.event} isLast={isLast && (!row.slot || !row.extendLineThroughSlot)} />}
>
<ProcessEventCard
event={row.event}
isLast={isLast && (!row.slot || !row.extendLineThroughSlot)}
articleTypeLabels={articleTypeLabels}
/>
{row.slot ? <div className="mt-3">{row.slot}</div> : null}
</FeedRowShell>
);
})}
</div>
</div>
);
}
function UserChatFeedRow({
msg,
isLast,
}: {
msg: ChatFeedMessage;
isLast: boolean;
}) {
return (
<div className="grid grid-cols-[30px_minmax(0,1fr)] gap-3">
<div className="relative flex justify-center">
{!isLast && <span className="absolute top-3 bottom-[-14px] w-px bg-border/70" />}
<span className="relative z-10 mt-2 size-2 rounded-full border border-primary/30 bg-primary/70 shadow-sm" />
</div>
<div className="min-w-0">
<ChatBubble msg={msg} />
</div>
</div>
);
}
function FeedRowShell({
isLast,
marker,
children,
}: {
isLast: boolean;
marker: ReactNode;
children: ReactNode;
}) {
return (
<div className="grid grid-cols-[30px_minmax(0,1fr)] gap-3">
<div className="relative flex justify-center">
{!isLast && <span className="absolute top-7 bottom-[-14px] w-px bg-border/70" />}
<div className="relative z-10">{marker}</div>
</div>
<div className="min-w-0">{children}</div>
</div>
);
}
function EventMarker({ event, isLast }: { event: ProgressEvent; isLast: boolean }) {
const status = eventStatusOf(event, isLast);
const tone = eventTone(status);
const Icon = eventIcon(event, status);
return (
<span className={cn('flex size-7 items-center justify-center rounded-md border shadow-sm', tone.marker)}>
<Icon className={cn('size-3.5', status === 'running' && 'animate-spin')} />
</span>
);
}
function ChatMarker({ role, kind }: { role: ChatFeedMessage['role']; kind: ChatKind }) {
if (role === 'user') {
return (
<span className="flex size-7 items-center justify-center rounded-md border border-primary/25 bg-primary/10 text-primary shadow-sm">
<UserRound className="size-3.5" />
</span>
);
}
const Icon = kind === 'ack' ? BadgeCheck : kind === 'clarify' || kind === 'assist' ? CircleHelp : kind === 'intent' ? Sparkles : Bot;
const tone =
kind === 'ack'
? 'border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-300'
: kind === 'clarify' || kind === 'assist'
? 'border-amber-200 bg-amber-50 text-amber-600 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300'
: kind === 'intent'
? 'border-violet-200 bg-violet-50 text-violet-600 dark:border-violet-900/50 dark:bg-violet-950/30 dark:text-violet-300'
: 'border-blue-200 bg-blue-50 text-blue-600 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-300';
return (
<span className={cn('flex size-7 items-center justify-center rounded-md border shadow-sm', tone)}>
<Icon className="size-3.5" />
</span>
);
}
function SlotMarker() {
return (
<span className="flex size-7 items-center justify-center rounded-md border border-emerald-200 bg-emerald-50 text-emerald-600 shadow-sm dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-300">
<CheckCircle2 className="size-3.5" />
</span>
);
}
function ProcessEventCard({
event,
isLast,
articleTypeLabels,
}: {
event: ProgressEvent;
isLast: boolean;
articleTypeLabels?: Record<string, string>;
}) {
const status = eventStatusOf(event, isLast);
const tone = eventTone(status);
const summary = event.title || event.message || (status === 'error' ? '出错了' : '流程事件');
return (
<div className={cn('overflow-hidden rounded-lg border shadow-sm', tone.card)}>
<div className="flex">
<span className={cn('w-1 shrink-0', tone.accent)} />
<div className="min-w-0 flex-1 px-3 py-2.5">
<div className="mb-1.5 flex min-w-0 items-center gap-1.5">
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', tone.badge)}>
{eventLabel(event)}
</span>
{event.agentName && (
<span className="min-w-0 truncate text-[11px] text-muted-foreground">{event.agentName}</span>
)}
</div>
<p className="text-xs font-medium leading-relaxed text-foreground/90">{summary}</p>
{event.type === 'intent_ready' && (
<IntentEventDetails event={event} articleTypeLabels={articleTypeLabels} />
)}
{event.detail && (
<p className="mt-1 line-clamp-3 break-words text-[11px] leading-relaxed text-muted-foreground">
{event.detail}
</p>
)}
{event.raw && (
<details className="mt-2">
<summary className="cursor-pointer select-none text-[11px] font-medium text-muted-foreground hover:text-foreground">
错误返回结果(点击展开)
</summary>
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-all rounded bg-background/70 p-2 text-[10px] leading-relaxed text-muted-foreground">
{event.raw}
</pre>
</details>
)}
</div>
</div>
</div>
);
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return '';
}
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value);
}
return null;
}
function IntentEventDetails({
event,
articleTypeLabels,
}: {
event: ProgressEvent;
articleTypeLabels?: Record<string, string>;
}) {
const raw = event as unknown as Record<string, unknown>;
const articleTypeKey = readString(raw, 'articleType', 'article_type');
const articleType = articleTypeKey ? articleTypeLabels?.[articleTypeKey] ?? articleTypeKey : '';
const targetAudience = readString(raw, 'targetAudience', 'target_audience');
const refinedIntent = readString(raw, 'refinedIntent', 'refined_intent');
const wordCountTarget = readNumber(raw, 'wordCountTarget', 'word_count_target');
const metrics = [
articleType ? { label: '文章类型', value: articleType, icon: Type, tone: 'blue' } : null,
targetAudience ? { label: '目标受众', value: targetAudience, icon: UsersRound, tone: 'emerald' } : null,
wordCountTarget ? { label: '目标字数', value: `${wordCountTarget} 字`, icon: FileText, tone: 'amber' } : null,
].filter(Boolean) as Array<{
label: string;
value: string;
icon: typeof Type;
tone: 'blue' | 'emerald' | 'amber';
}>;
if (metrics.length === 0 && !refinedIntent) return null;
return (
<div className="mt-2.5 overflow-hidden rounded-lg border border-violet-200/70 bg-violet-50/35 text-[11px] shadow-sm dark:border-violet-900/40 dark:bg-violet-950/15">
<div className="flex items-center gap-2 border-b border-violet-200/50 bg-background/65 px-3 py-2 dark:border-violet-900/30 dark:bg-background/25">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-violet-200 bg-violet-50 text-violet-600 dark:border-violet-900/50 dark:bg-violet-950/30 dark:text-violet-300">
<Sparkles className="size-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-foreground">已识别写作需求</p>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">根据你的描述生成写作参数</p>
</div>
</div>
{metrics.length > 0 && (
<div className="grid gap-1.5 p-2.5 sm:grid-cols-3">
{metrics.map((item) => (
<IntentMetric key={item.label} item={item} />
))}
</div>
)}
{refinedIntent && (
<div className={cn('px-2.5 pb-2.5', metrics.length === 0 && 'pt-2.5')}>
<div className="rounded-md border bg-background/80 px-2.5 py-2">
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] font-medium text-violet-700 dark:text-violet-300">
<Target className="size-3" />
<span>写作需求</span>
</div>
<p className="line-clamp-4 break-words leading-relaxed text-foreground/90">{refinedIntent}</p>
</div>
</div>
)}
</div>
);
}
function IntentMetric({
item,
}: {
item: {
label: string;
value: string;
icon: typeof Type;
tone: 'blue' | 'emerald' | 'amber';
};
}) {
const Icon = item.icon;
const toneClass =
item.tone === 'emerald'
? 'border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-300'
: item.tone === 'amber'
? 'border-amber-200 bg-amber-50 text-amber-600 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300'
: 'border-blue-200 bg-blue-50 text-blue-600 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-blue-300';
return (
<div className="min-w-0 rounded-md border bg-background/80 px-2 py-1.5">
<div className="flex items-center gap-1.5">
<span className={cn('flex size-5 shrink-0 items-center justify-center rounded border', toneClass)}>
<Icon className="size-3" />
</span>
<span className="truncate text-[10px] text-muted-foreground">{item.label}</span>
</div>
<p className="mt-1 truncate pl-6 font-semibold text-foreground/90" title={item.value}>{item.value}</p>
</div>
);
}
interface ParsedRequirementIssue {
index: string;
category: string;
location: string;
issue: string;
suggestion: string;
}
function reviewCategoryText(category: string): string {
if (category === 'fact') return '事实';
if (category === 'logic') return '逻辑';
if (category === 'language') return '语言';
return category;
}
function reviewCategoryTone(category: string): string {
if (category === 'fact') 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 (category === 'logic') 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 (category === 'language') return 'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/40 dark:bg-violet-950/20 dark:text-violet-300';
return 'border-border bg-muted/40 text-muted-foreground';
}
function parseRequirementIssues(text: string): ParsedRequirementIssue[] {
return text
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const match = line.match(/^(\d+)\.\s*\[([^\]]+)\]\s*(.*)$/);
if (!match) return null;
let body = match[3].trim();
let location = '';
let suggestion = '';
const locationMatch = body.match(/^【位置】(.+?);(.*)$/);
if (locationMatch) {
location = locationMatch[1].trim();
body = locationMatch[2].trim();
}
const suggestionMatch = body.match(/^(.*?);【建议】(.+)$/);
if (suggestionMatch) {
body = suggestionMatch[1].trim();
suggestion = suggestionMatch[2].trim();
}
return {
index: match[1],
category: match[2].trim(),
location,
issue: body,
suggestion,
};
})
.filter((item): item is ParsedRequirementIssue => item !== null);
}
function ChatRequirementCard({ record }: { record: CompletedIntervention }) {
if (!record.userInput?.trim()) return null;
const issues = parseRequirementIssues(record.userInput);
const showIssueList = issues.length >= 2;
return (
<div className="ml-auto max-w-[92%] overflow-hidden rounded-lg border border-primary/20 bg-primary/5 text-xs shadow-sm">
<div className="flex items-center justify-end gap-1.5 border-b border-primary/10 bg-background/55 px-3 py-2 text-[11px] font-medium text-primary">
{showIssueList && (
<span className="mr-auto rounded-md border bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{issues.length} 条意见
</span>
)}
<PenLine className="size-3 shrink-0" />
<span>我的要求</span>
</div>
{showIssueList ? (
<div className="space-y-2 px-3 py-2.5">
{issues.map((issue) => (
<div key={`${issue.index}-${issue.category}-${issue.location}`} className="rounded-md border bg-background/85 p-2.5 shadow-sm">
<div className="mb-1.5 flex flex-wrap items-center gap-1.5">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-semibold text-primary">
{issue.index}
</span>
<span className={cn('rounded border px-1.5 py-0.5 text-[10px] font-medium', reviewCategoryTone(issue.category))}>
{reviewCategoryText(issue.category)}
</span>
{issue.location && (
<span className="min-w-0 truncate text-[10px] text-muted-foreground" title={issue.location}>
位置:{issue.location}
</span>
)}
</div>
<p className="break-words leading-relaxed text-foreground/90">{issue.issue}</p>
{issue.suggestion && (
<div className="mt-2 rounded-md border border-emerald-200/70 bg-emerald-50/45 px-2 py-1.5 text-[11px] leading-relaxed text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-300">
<span className="font-medium">建议:</span>
{issue.suggestion}
</div>
)}
</div>
))}
</div>
) : (
<p className="whitespace-pre-wrap break-words px-3 py-2.5 leading-relaxed text-foreground/90">{record.userInput}</p>
)}
</div>
);
}
function issueCategoryLabel(category: ReviewIssue['category']): string {
if (category === 'fact') return '事实';
if (category === 'logic') return '逻辑';
return '语言';
}
function reviewStatusText(review: ReviewResult): string {
return review.verdict === 'pass' ? '通过' : '需修改';
}
function ChatCompletedInterventionCard({ record }: { record: CompletedIntervention }) {
const [expanded, setExpanded] = useState(false);
const [materialsExpanded, setMaterialsExpanded] = useState(false);
if (record.pausePoint === 'material_confirm') {
const count = record.materials?.length ?? 0;
return (
<div className="overflow-hidden rounded-md border border-green-200/60 bg-green-50/30 text-xs shadow-sm dark:border-green-900/30 dark:bg-green-950/10">
<button
type="button"
onClick={() => count > 0 && setMaterialsExpanded((v) => !v)}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-left',
count > 0 && 'transition-colors hover:bg-green-50/60 dark:hover:bg-green-950/20',
)}
>
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
<FileText className="size-3.5 shrink-0 text-blue-500" />
<span className="min-w-0 flex-1 font-medium">
{record.action === 're_search'
? '已发起补充检索'
: count === 0
? '已在无素材情况下继续'
: `已确认 ${count} 条素材`}
</span>
{count > 0 && (
<ChevronRight
className={cn(
'size-3 shrink-0 text-muted-foreground transition-transform duration-200',
materialsExpanded && 'rotate-90',
)}
/>
)}
</button>
{count > 0 && (
<CollapseTransition open={materialsExpanded}>
<div className="space-y-1.5 border-t border-green-200/40 px-2.5 pb-2.5 pt-1.5 dark:border-green-900/20">
{record.materials?.map((material) => (
<CompletedTimelineMaterialItem key={material.id} material={material} />
))}
</div>
</CollapseTransition>
)}
</div>
);
}
if (record.pausePoint === 'outline_confirm') {
const sectionCount = record.outline?.sections.length ?? 0;
return (
<div className="overflow-hidden rounded-lg border border-emerald-200/70 bg-emerald-50/40 text-xs shadow-sm dark:border-emerald-900/40 dark:bg-emerald-950/15">
<button
type="button"
onClick={() => record.outline && setExpanded((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-emerald-50/70 dark:hover:bg-emerald-950/25"
>
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
<ListTree className="size-3.5 shrink-0 text-violet-500" />
<span className="min-w-0 flex-1 font-medium">
已确认三层大纲
{record.outline?.title && <span className="ml-1 font-normal text-muted-foreground">《{record.outline.title}》</span>}
{sectionCount > 0 && <span className="ml-1 font-normal text-muted-foreground">共 {sectionCount} 章节</span>}
</span>
{record.outline && (
<ChevronRight className={cn('size-3 text-muted-foreground transition-transform', expanded && 'rotate-90')} />
)}
</button>
<CollapseTransition open={expanded && !!record.outline}>
<div className="border-t border-emerald-200/50 bg-background/70 px-2.5 py-2.5 dark:border-emerald-900/30">
{record.outline && <OutlineCard outline={record.outline} readOnly />}
</div>
</CollapseTransition>
</div>
);
}
if (record.pausePoint === 'draft_confirm') {
const wordCount = record.draftWordCount ?? 0;
const title = record.draftTitle ? `《${record.draftTitle}》` : '草稿';
const actionLabel =
record.action === 'finalize' ? '已确认定稿' :
record.action === 'user_revise' ? '已要求修改草稿' :
'已提交编辑审核';
return (
<div className="rounded-lg border border-blue-200/70 bg-blue-50/35 p-3 text-xs shadow-sm dark:border-blue-900/40 dark:bg-blue-950/15">
<div className="flex items-start gap-2">
<FileCheck2 className="mt-0.5 size-4 shrink-0 text-blue-500" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate font-semibold">{actionLabel}</p>
{wordCount > 0 && <span className="shrink-0 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">{wordCount} 字</span>}
</div>
<p className="mt-0.5 truncate text-muted-foreground">{title}</p>
{record.reviewResult && <ReviewMiniSummary review={record.reviewResult} className="mt-2" />}
</div>
</div>
</div>
);
}
if (record.pausePoint === 'review_confirm') {
const isForce = record.action === 'force_finalize';
return (
<div className="rounded-lg border border-amber-200/80 bg-amber-50/45 p-3 text-xs shadow-sm dark:border-amber-900/40 dark:bg-amber-950/15">
<div className="flex items-start gap-2">
<ShieldCheck className={cn('mt-0.5 size-4 shrink-0', isForce ? 'text-amber-500' : 'text-blue-500')} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate font-semibold">{isForce ? '已强制定稿' : '已采纳审核意见'}</p>
{record.reviewResult && (
<span className="shrink-0 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{record.reviewResult.overallScore} / 100
</span>
)}
</div>
<p className="mt-0.5 text-muted-foreground">
{isForce ? '忽略编辑意见,进入定稿。' : '作家将按已选审核意见重新修改。'}
</p>
{record.reviewResult && <ReviewMiniSummary review={record.reviewResult} className="mt-2" />}
</div>
</div>
</div>
);
}
return null;
}
function timelineMaterialRelevanceLabel(score: number): string {
if (score >= 0.8) return '高相关';
if (score >= 0.5) return '中相关';
return '低相关';
}
function CompletedTimelineMaterialItem({ material }: { material: AIMaterial }) {
const [open, setOpen] = useState(false);
const pct = Math.round(material.relevanceScore * 100);
const barColor =
material.relevanceScore >= 0.8 ? 'bg-green-500'
: material.relevanceScore >= 0.5 ? 'bg-orange-400'
: 'bg-red-400';
const hasDetail = !!material.content || !!material.recUuid;
return (
<div className="overflow-hidden rounded border border-border/50 bg-background">
<button
type="button"
onClick={hasDetail ? () => setOpen((v) => !v) : undefined}
className={cn(
'w-full space-y-1 px-2.5 py-1.5 text-left',
hasDetail && 'transition-colors hover:bg-muted/30',
)}
>
<div className="flex items-start gap-1.5">
<span className="line-clamp-2 flex-1 font-medium leading-snug">{material.title}</span>
{hasDetail && (
<ChevronRight
className={cn(
'mt-0.5 size-3 shrink-0 text-muted-foreground transition-transform duration-200',
open && 'rotate-90',
)}
/>
)}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="max-w-[100px] truncate">{material.source}</span>
{material.publishedAt && (
<>
<span className="text-muted-foreground/40">·</span>
<span>{material.publishedAt}</span>
</>
)}
<div className="flex min-w-0 flex-1 items-center gap-1">
<div className="h-1 min-w-[40px] flex-1 overflow-hidden rounded-full bg-muted">
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${pct}%` }} />
</div>
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
<span className="shrink-0 text-[10px] text-muted-foreground/60">
({timelineMaterialRelevanceLabel(material.relevanceScore)})
</span>
</div>
</div>
</button>
<CollapseTransition open={open && hasDetail}>
<div className="space-y-1.5 border-t border-border/40 px-2.5 pb-2 pt-1.5">
{material.content && (
<p className="line-clamp-6 leading-relaxed text-muted-foreground">
{material.content.slice(0, 300)}
{material.content.length > 300 && '…'}
</p>
)}
{material.recUuid && (
<button
type="button"
onClick={() => window.open(buildIntranetSourceUrl(material.recUuid), '_blank', 'noopener,noreferrer')}
className="inline-flex items-center gap-1 text-primary hover:underline"
>
查看原文
<ExternalLink className="size-2.5" />
</button>
)}
</div>
</CollapseTransition>
</div>
);
}
function ReviewMiniSummary({ review, className }: { review: ReviewResult; className?: string }) {
const issues = review.revisionNotes.slice(0, 3);
return (
<div className={cn('rounded-md border bg-background/85 p-2', className)}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="font-medium">审核结果</span>
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] 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-700 dark:bg-amber-950/40 dark:text-amber-300',
)}
>
{reviewStatusText(review)} · {review.overallScore}/100
</span>
</div>
<div className="grid grid-cols-3 gap-1.5 text-[10px] text-muted-foreground">
<span className="rounded bg-muted/60 px-1.5 py-1 text-center">事实 {review.factScore}</span>
<span className="rounded bg-muted/60 px-1.5 py-1 text-center">逻辑 {review.logicScore}</span>
<span className="rounded bg-muted/60 px-1.5 py-1 text-center">语言 {review.languageScore}</span>
</div>
{issues.length > 0 && (
<div className="mt-2 space-y-1">
{issues.map((issue, index) => (
<div key={`${issue.category}-${index}`} className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
<span className="mt-0.5 shrink-0 rounded bg-muted px-1 py-0 text-[10px]">{issueCategoryLabel(issue.category)}</span>
<span className="line-clamp-2">{issue.issue}</span>
</div>
))}
</div>
)}
</div>
);
}
function ChatBubble({
msg,
onClarificationSubmit,
onSuggestionClick,
onSuggestionsDismiss,
}: {
msg: ChatFeedMessage;
onClarificationSubmit?: (messageId: string, answer: string) => void;
onSuggestionClick?: (suggestion: string) => void;
onSuggestionsDismiss?: (messageId: string) => void;
}) {
if (msg.role === 'user') {
return (
<div className="flex justify-end">
<div className="flex max-w-[92%] items-start justify-end gap-2">
<div className="min-w-0 max-w-[calc(100%-2.25rem)]">
<div className="mb-1 flex items-center justify-end gap-1.5 text-[10px] font-medium text-primary/80">
<span>我</span>
</div>
<div className="relative rounded-lg border border-primary/20 bg-primary px-3.5 py-2.5 text-xs leading-relaxed text-primary-foreground shadow-sm">
<span className="pointer-events-none absolute right-[-4px] top-3 size-2 rotate-45 border-r border-t border-primary/20 bg-primary" />
<p className="whitespace-pre-wrap break-words">{msg.text}</p>
</div>
</div>
<div className="mt-4 flex size-7 shrink-0 items-center justify-center rounded-full border border-primary/25 bg-primary text-primary-foreground shadow-sm">
<UserRound className="size-3.5" />
</div>
</div>
</div>
);
}
if (msg.kind === 'assist' && msg.clarification) {
return <ChatClarificationCard msg={msg} onSubmit={onClarificationSubmit} />;
}
const meta = assistantBubbleMeta(msg.kind);
const Icon = meta.icon;
return (
<div className="min-w-0 max-w-[92%] space-y-2">
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-medium text-muted-foreground">
<Icon className={cn('size-3.5', meta.iconClass)} />
<span>{meta.label}</span>
{msg.streaming && (
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-950/30 dark:text-blue-300">
生成中
</span>
)}
</div>
<div className={cn('overflow-hidden rounded-lg border text-xs shadow-sm', meta.shellClass)}>
<div className="flex">
<span className={cn('w-1 shrink-0', meta.accentClass)} />
<div className="min-w-0 flex-1 px-3 py-2.5">
<p className="whitespace-pre-wrap break-words leading-relaxed">
{msg.text || (msg.streaming ? '正在生成回答…' : '')}
{msg.streaming && (
<span className="ml-0.5 inline-block h-3 w-0.5 animate-pulse align-text-bottom bg-primary/60" />
)}
</p>
</div>
</div>
</div>
{msg.kind === 'answer' && (
<AnswerSuggestionChips
msg={msg}
onSuggestionClick={onSuggestionClick}
onDismiss={onSuggestionsDismiss}
/>
)}
</div>
);
}
function assistantBubbleMeta(kind: ChatKind): {
label: string;
icon: typeof Bot;
iconClass: string;
shellClass: string;
accentClass: string;
} {
if (kind === 'ack') {
return {
label: '已执行',
icon: BadgeCheck,
iconClass: 'text-emerald-600 dark:text-emerald-300',
shellClass: 'border-emerald-200/80 bg-emerald-50/40 text-emerald-950 dark:border-emerald-900/40 dark:bg-emerald-950/15 dark:text-emerald-100',
accentClass: 'bg-emerald-500',
};
}
if (kind === 'clarify') {
return {
label: '需要补充',
icon: CircleHelp,
iconClass: 'text-amber-600 dark:text-amber-300',
shellClass: 'border-amber-200/90 bg-amber-50/45 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100',
accentClass: 'bg-amber-500',
};
}
if (kind === 'notice') {
return {
label: '系统提示',
icon: Info,
iconClass: 'text-muted-foreground',
shellClass: 'border-border bg-muted/35 text-muted-foreground',
accentClass: 'bg-muted-foreground/40',
};
}
if (kind === 'intent') {
return {
label: '意图解析',
icon: Sparkles,
iconClass: 'text-violet-600 dark:text-violet-300',
shellClass: 'border-violet-200/80 bg-violet-50/35 text-foreground dark:border-violet-900/40 dark:bg-violet-950/15',
accentClass: 'bg-violet-500',
};
}
if (kind === 'answer') {
return {
label: 'AI 回答',
icon: MessageSquareText,
iconClass: 'text-blue-600 dark:text-blue-300',
shellClass: 'border-blue-200/80 bg-blue-50/35 text-foreground dark:border-blue-900/40 dark:bg-blue-950/15',
accentClass: 'bg-blue-500',
};
}
return {
label: '写作助手',
icon: Bot,
iconClass: 'text-primary',
shellClass: 'border-border bg-card text-card-foreground',
accentClass: 'bg-primary/70',
};
}
function AnswerSuggestionChips({
msg,
onSuggestionClick,
onDismiss,
}: {
msg: ChatFeedMessage;
onSuggestionClick?: (suggestion: string) => void;
onDismiss?: (messageId: string) => void;
}) {
const suggestions = (msg.suggestions ?? []).filter((item) => item.trim());
if (msg.suggestionsHidden || (!msg.suggestionsLoading && suggestions.length === 0)) {
return null;
}
return (
<div className="rounded-lg border bg-card p-2 shadow-sm">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Sparkles className="size-3 text-primary/70" />
<span className="truncate">可以继续问</span>
<button
type="button"
className="ml-auto rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="关闭推荐问题"
onClick={() => onDismiss?.(msg.id)}
>
<X className="size-3" />
</button>
</div>
{msg.suggestionsLoading ? (
<div className="flex flex-wrap gap-1.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-6 w-20 animate-pulse rounded-md bg-muted"
style={{ animationDelay: `${i * 120}ms` }}
/>
))}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="max-w-full rounded-md border border-input bg-background px-2.5 py-1 text-left text-[11px] leading-snug text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/5 hover:text-foreground"
onClick={() => onSuggestionClick?.(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
</div>
);
}
function ChatClarificationCard({
msg,
onSubmit,
}: {
msg: ChatFeedMessage;
onSubmit?: (messageId: string, answer: string) => void;
}) {
const payload = msg.clarification;
const [selection, setSelection] = useState<ClarificationSelection>({ selectedIds: [], custom: '' });
const options = useMemo(
() =>
(payload?.options ?? []).filter(
(option) => typeof option.label === 'string' && option.label.trim(),
),
[payload?.options],
);
if (!payload) return null;
const allowMultiple = resolveAllowMultiple(payload.allow_multiple, payload.clarification_type);
const disabled = Boolean(msg.answered);
const canUseCustom = payload.allow_custom !== false;
const buildAnswer = (next: ClarificationSelection = selection) => {
const selected = options
.filter((option) => next.selectedIds.includes(option.id || option.label))
.map((option) => option.label.trim())
.filter(Boolean);
const custom = next.custom.trim();
return [...selected, custom].filter(Boolean).join('\n');
};
const canSubmit = Boolean(buildAnswer());
const handleOptionClick = (optionId: string) => {
if (disabled) return;
const nextIds = allowMultiple
? selection.selectedIds.includes(optionId)
? selection.selectedIds.filter((id) => id !== optionId)
: [...selection.selectedIds, optionId]
: [optionId];
setSelection((prev) => ({ ...prev, selectedIds: nextIds }));
};
const handleSubmit = () => {
if (disabled) return;
const answer = buildAnswer();
if (!answer) return;
onSubmit?.(msg.id, answer);
};
return (
<div className="w-full max-w-[94%] overflow-hidden rounded-lg border border-amber-200/90 bg-amber-50/40 text-xs shadow-sm dark:border-amber-900/40 dark:bg-amber-950/20">
<div className="border-b border-amber-200/60 bg-background/55 px-3 py-2.5 dark:border-amber-900/30 dark:bg-background/25">
<div className="flex items-center gap-2">
<CircleHelp className="size-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1">
<p className="font-semibold">需要你补充判断</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{allowMultiple ? '可多选,也可以补充文字说明' : '选择一个方向,必要时补充说明'}
</p>
</div>
{disabled && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">已回答</span>
)}
</div>
</div>
<div className="space-y-3 px-3 py-3">
{payload.context ? (
<p className="rounded-md border bg-background/70 px-2.5 py-2 leading-relaxed text-muted-foreground">
{payload.context}
</p>
) : null}
<p className="leading-relaxed text-foreground">{payload.question || msg.text}</p>
{options.length > 0 ? (
<div className="grid gap-1.5">
{options.map((option) => {
const optionId = option.id || option.label;
const selected = selection.selectedIds.includes(optionId);
return (
<button
key={optionId}
type="button"
disabled={disabled}
onClick={() => handleOptionClick(optionId)}
className={cn(
'flex w-full items-start gap-2 rounded-md border bg-background/75 px-2.5 py-2 text-left transition-colors',
selected
? 'border-primary/55 bg-primary/5 text-foreground'
: 'hover:border-primary/35 hover:bg-primary/5',
disabled && 'cursor-default opacity-80',
)}
>
<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 && <Check className="size-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium leading-5">{option.label}</span>
{option.description && (
<span className="mt-0.5 block text-[11px] leading-relaxed text-muted-foreground">
{option.description}
</span>
)}
</span>
</button>
);
})}
</div>
) : null}
{canUseCustom ? (
<Textarea
value={selection.custom}
onChange={(event) => setSelection((prev) => ({ ...prev, custom: event.currentTarget.value }))}
placeholder={options.length > 0 ? '可选:补充说明或自定义回答' : '请输入你的回答'}
disabled={disabled}
className="min-h-20 resize-none bg-background/85 text-xs"
/>
) : null}
{!disabled ? (
<div className="flex justify-end">
<Button type="button" size="sm" className="h-8 gap-1.5 text-xs" disabled={!canSubmit} onClick={handleSubmit}>
<Send className="size-3.5" />
发送回答
</Button>
</div>
) : null}
</div>
</div>
);
}