1255 lines
48 KiB
TypeScript
1255 lines
48 KiB
TypeScript
/**
|
||
* 对话驱动写作台的统一信息流。
|
||
*
|
||
* 这里保留原来的时序模型:流程事件、用户消息、助手回答、完成结果按 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>
|
||
);
|
||
}
|