From a1c63a1fe543d61a52db66e7b3f9956003236c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 3 Jul 2026 22:11:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-writing-chat/ChatFlowTimeline.tsx | 1105 ++++++++++++++--- .../ai-writing-chat/ChatInterventionCards.tsx | 747 +++++++---- .../ai-writing-chat/WritingChatPanel.tsx | 320 ++++- .../ai-writing/InterventionCard.tsx | 6 +- .../ai-writing/ReviewStreamingView.tsx | 25 +- .../ai-writing/RevisionSummaryCard.tsx | 61 +- .../open-canvas/contexts/AIWritingContext.tsx | 11 +- .../open-canvas/hooks/useAIWritingStream.ts | 2 +- 8 files changed, 1763 insertions(+), 514 deletions(-) diff --git a/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx b/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx index 0da950fa..131029c4 100644 --- a/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing-chat/ChatFlowTimeline.tsx @@ -1,28 +1,33 @@ /** - * 对话驱动写作台的「统一信息流」时间线。 + * 对话驱动写作台的统一信息流。 * - * 与旧版 `../ai-writing/AIWritingTimeline.tsx` 的关系:复制其流程事件渲染逻辑 - * (TimelineStep + 干预槽对齐),再按业务定制:**把意图层对话气泡(用户消息 / - * 问答回答 / 澄清 / 执行确认)按时间顺序插进流程事件之间**,形成一条完整的 - * 时间流 —— 用户发消息后,由它触发的检索/操作事件出现在消息**下方**(而不是 - * 旧版「时间线在上、对话在下」两块分离导致的顺序错乱)。 - * - * 时序对齐方式:每条对话消息记录创建时的信息流长度作为锚点(流程事件 + - * 已经出现的稳定尾部槽位),渲染时消息插在对应位置之后。 + * 这里保留原来的时序模型:流程事件、用户消息、助手回答、完成结果按 anchor 合并。 + * 视觉层是对话版专属实现,不复用经典写作页的 TimelineStep / CompletedInterventionCard。 */ import { useMemo, useState, type ReactNode } from 'react'; -import { BadgeCheck, Bot, Check, CircleHelp, Info, Send, Sparkles, X } from 'lucide-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 { CompletedIntervention, ProgressEvent } from '../../types/ai-writing'; -import { TimelineStep } from '../ai-writing/TimelineStep'; -import { CompletedInterventionCard } from '../ai-writing/CompletedInterventionCard'; -import { UserRequirementCard } from '../ai-writing/UserRequirementCard'; +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'; +export type ChatKind = 'text' | 'clarify' | 'answer' | 'ack' | 'notice' | 'assist' | 'intent'; export interface ChatClarificationOption { id: string; @@ -63,6 +68,7 @@ const DISPLAYED_TYPES = new Set([ 'step_start', 'step_done', 'sample_profile_ready', + 'intent_ready', 'materials_ready', 'outline_ready', 'draft_ready', @@ -75,6 +81,8 @@ const DISPLAYED_TYPES = new Set([ '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 } @@ -85,19 +93,84 @@ interface ChatFlowTimelineProps { completedInterventions: CompletedIntervention[]; currentPause: ProgressEvent | null; currentInterventionSlot?: ReactNode; - /** 渲染在流程事件之后的稳定尾部槽位;后续消息会锚到它下面。 */ revisionSummarySlot?: ReactNode; chatMessages: ChatFeedMessage[]; - /** 由统一输入框触发的完成卡锚点:完成卡跟在对应用户/助手气泡后面。 */ + articleTypeLabels?: Record; completedInterventionAnchors?: Map; - /** 由底部输入框发起的干预序号集合:这些干预的「我的要求」卡不再重复渲染 - * (用户气泡本身已按时间顺序展示了原话)。 */ suppressedRequirementIndices?: Set; 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, @@ -105,6 +178,7 @@ export function ChatFlowTimeline({ currentInterventionSlot, revisionSummarySlot, chatMessages, + articleTypeLabels, completedInterventionAnchors, suppressedRequirementIndices, onClarificationSubmit, @@ -116,7 +190,6 @@ export function ChatFlowTimeline({ const tailSlotCount = revisionSummarySlot ? 1 : 0; const maxAnchor = events.length + tailSlotCount; - // 消息按锚点分桶(锚点超出当前信息流长度的挂在末尾) const msgsByAnchor = new Map(); for (const msg of chatMessages) { const anchor = Math.max(0, Math.min(msg.anchor, maxAnchor)); @@ -124,6 +197,7 @@ export function ChatFlowTimeline({ if (bucket) bucket.push(msg); else msgsByAnchor.set(anchor, [msg]); } + const completionRowsByAnchor = new Map(); completedInterventionAnchors?.forEach((rawAnchor, interventionIdx) => { const record = completedInterventions[interventionIdx]; @@ -133,12 +207,11 @@ export function ChatFlowTimeline({ rows.push({ key: `composer-completed-${interventionIdx}`, type: 'slot', - slot: , + slot: , }); completionRowsByAnchor.set(anchor, rows); }); - // 最后一个 await_user 事件的全量下标:只有它才可能挂当前干预卡 let lastAwaitUserIdx = -1; events.forEach((e, i) => { if (e.type === 'await_user' && DISPLAYED_TYPES.has(e.type)) lastAwaitUserIdx = i; @@ -173,8 +246,8 @@ export function ChatFlowTimeline({ const showRequirement = !suppressedRequirementIndices?.has(interventionIdx); slot = (
- {showRequirement && } - + {showRequirement && } +
); } @@ -186,6 +259,7 @@ export function ChatFlowTimeline({ rows.push({ key: `event-${fullIdx}`, type: 'event', event, slot, extendLineThroughSlot }); }); + if (revisionSummarySlot) { pushAnchoredRows(events.length); rows.push({ key: 'revision-summary', type: 'slot', slot: revisionSummarySlot }); @@ -195,84 +269,659 @@ export function ChatFlowTimeline({ } return ( -
- {rows.map((row, idx) => { - const isLast = idx === rows.length - 1; - if (row.type === 'chat') { +
+
+ {rows.map((row, idx) => { + const isLast = idx === rows.length - 1; + if (row.type === 'chat') { + if (row.msg.role === 'user') { + return ( + + ); + } + return ( + } + > + + + ); + } + if (row.type === 'slot') { + return ( + }> + {row.slot} + + ); + } return ( - + isLast={isLast && (!row.slot || !row.extendLineThroughSlot)} + marker={} + > + + {row.slot ?
{row.slot}
: null} + ); - } - if (row.type === 'slot') { - return {row.slot}; - } - return ( - - {row.slot} - - ); - })} -
- ); -} - -function TimelineAuxRow({ children, isLast }: { children: ReactNode; isLast: boolean }) { - return ( -
-
- {!isLast &&
} -
-
- {children} + })}
); } -/** 信息流里的对话气泡行:左侧延续时间线竖线,与流程事件保持同一视觉脊柱。 */ -function ChatFeedRow({ +function UserChatFeedRow({ msg, isLast, - onClarificationSubmit, - onSuggestionClick, - onSuggestionsDismiss, }: { msg: ChatFeedMessage; isLast: boolean; - onClarificationSubmit?: (messageId: string, answer: string) => void; - onSuggestionClick?: (suggestion: string) => void; - onSuggestionsDismiss?: (messageId: string) => void; }) { return ( -
- {/* 竖线占位列(与 TimelineStep 的 size-4 图标列同宽),保持时间线连续 */} -
- {!isLast &&
} +
+
+ {!isLast && } +
-
- +
+
); } -export function ChatBubble({ +function FeedRowShell({ + isLast, + marker, + children, +}: { + isLast: boolean; + marker: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {!isLast && } +
{marker}
+
+
{children}
+
+ ); +} + +function EventMarker({ event, isLast }: { event: ProgressEvent; isLast: boolean }) { + const status = eventStatusOf(event, isLast); + const tone = eventTone(status); + const Icon = eventIcon(event, status); + return ( + + + + ); +} + +function ChatMarker({ role, kind }: { role: ChatFeedMessage['role']; kind: ChatKind }) { + if (role === 'user') { + return ( + + + + ); + } + 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 ( + + + + ); +} + +function SlotMarker() { + return ( + + + + ); +} + +function ProcessEventCard({ + event, + isLast, + articleTypeLabels, +}: { + event: ProgressEvent; + isLast: boolean; + articleTypeLabels?: Record; +}) { + const status = eventStatusOf(event, isLast); + const tone = eventTone(status); + const summary = event.title || event.message || (status === 'error' ? '出错了' : '流程事件'); + return ( +
+
+ +
+
+ + {eventLabel(event)} + + {event.agentName && ( + {event.agentName} + )} +
+

{summary}

+ {event.type === 'intent_ready' && ( + + )} + {event.detail && ( +

+ {event.detail} +

+ )} + {event.raw && ( +
+ + 错误返回结果(点击展开) + +
+                {event.raw}
+              
+
+ )} +
+
+
+ ); +} + +function readString(record: Record, ...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, ...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; +}) { + const raw = event as unknown as Record; + 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 ( +
+
+
+ +
+
+

已识别写作需求

+

根据你的描述生成写作参数

+
+
+ {metrics.length > 0 && ( +
+ {metrics.map((item) => ( + + ))} +
+ )} + {refinedIntent && ( +
+
+
+ + 写作需求 +
+

{refinedIntent}

+
+
+ )} +
+ ); +} + +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 ( +
+
+ + + + {item.label} +
+

{item.value}

+
+ ); +} + +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 ( +
+
+ {showIssueList && ( + + {issues.length} 条意见 + + )} + + 我的要求 +
+ {showIssueList ? ( +
+ {issues.map((issue) => ( +
+
+ + {issue.index} + + + {reviewCategoryText(issue.category)} + + {issue.location && ( + + 位置:{issue.location} + + )} +
+

{issue.issue}

+ {issue.suggestion && ( +
+ 建议: + {issue.suggestion} +
+ )} +
+ ))} +
+ ) : ( +

{record.userInput}

+ )} +
+ ); +} + +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 ( +
+ + {count > 0 && ( + +
+ {record.materials?.map((material) => ( + + ))} +
+
+ )} +
+ ); + } + + if (record.pausePoint === 'outline_confirm') { + const sectionCount = record.outline?.sections.length ?? 0; + return ( +
+ + +
+ {record.outline && } +
+
+
+ ); + } + + 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 ( +
+
+ +
+
+

{actionLabel}

+ {wordCount > 0 && {wordCount} 字} +
+

{title}

+ {record.reviewResult && } +
+
+
+ ); + } + + if (record.pausePoint === 'review_confirm') { + const isForce = record.action === 'force_finalize'; + return ( +
+
+ +
+
+

{isForce ? '已强制定稿' : '已采纳审核意见'}

+ {record.reviewResult && ( + + {record.reviewResult.overallScore} / 100 + + )} +
+

+ {isForce ? '忽略编辑意见,进入定稿。' : '作家将按已选审核意见重新修改。'} +

+ {record.reviewResult && } +
+
+
+ ); + } + + 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 ( +
+ + + +
+ {material.content && ( +

+ {material.content.slice(0, 300)} + {material.content.length > 300 && '…'} +

+ )} + {material.recUuid && ( + + )} +
+
+
+ ); +} + +function ReviewMiniSummary({ review, className }: { review: ReviewResult; className?: string }) { + const issues = review.revisionNotes.slice(0, 3); + return ( +
+
+ 审核结果 + + {reviewStatusText(review)} · {review.overallScore}/100 + +
+
+ 事实 {review.factScore} + 逻辑 {review.logicScore} + 语言 {review.languageScore} +
+ {issues.length > 0 && ( +
+ {issues.map((issue, index) => ( +
+ {issueCategoryLabel(issue.category)} + {issue.issue} +
+ ))} +
+ )} +
+ ); +} + +function ChatBubble({ msg, onClarificationSubmit, onSuggestionClick, @@ -286,60 +935,127 @@ export function ChatBubble({ if (msg.role === 'user') { return (
-
- {msg.text} +
+
+
+ 我 +
+
+ +

{msg.text}

+
+
+
+ +
); } - const iconByKind: Record = { - text: , - answer: , - clarify: , - assist: , - ack: , - notice: , - }; + if (msg.kind === 'assist' && msg.clarification) { + return ; + } + + const meta = assistantBubbleMeta(msg.kind); + const Icon = meta.icon; return ( -
-
- {iconByKind[msg.kind]} +
+
+ + {meta.label} + {msg.streaming && ( + + 生成中 + + )}
- {msg.kind === 'assist' && msg.clarification ? ( - - ) : msg.kind === 'answer' ? ( -
-
- {msg.text || (msg.streaming ? '正在生成回答…' : '')} - {msg.streaming && ( - - )} +
+
+ +
+

+ {msg.text || (msg.streaming ? '正在生成回答…' : '')} + {msg.streaming && ( + + )} +

- -
- ) : ( -
- {msg.text}
+
+ {msg.kind === 'answer' && ( + )}
); } +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, @@ -355,13 +1071,13 @@ function AnswerSuggestionChips({ } return ( -
+
- 继续问 + 可以继续问 - ); - })} -
- ) : null} +
+ {payload.context ? ( +

+ {payload.context} +

+ ) : null} +

{payload.question || msg.text}

- {canUseCustom ? ( -
+ {options.length > 0 ? ( +
+ {options.map((option) => { + const optionId = option.id || option.label; + const selected = selection.selectedIds.includes(optionId); + return ( + + ); + })} +
+ ) : null} + + {canUseCustom ? (