mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
3080 lines
98 KiB
Vue
3080 lines
98 KiB
Vue
<template>
|
||
<div
|
||
class="message-wrapper"
|
||
:class="[role, { 'is-last': isLast }]"
|
||
:data-role="role"
|
||
:data-status="status"
|
||
@mouseenter="hovered = true"
|
||
@mouseleave="hovered = false"
|
||
>
|
||
<!-- 头像 -->
|
||
<div class="msg-avatar" :class="`${role}-avatar`">
|
||
<slot name="avatar">
|
||
<!-- When the assistant has an active goal, wrap the logo in
|
||
GoalAvatarRing so the progress ring + breathing halo + hover
|
||
tooltip all sit naturally around the avatar. The component
|
||
renders only the slot content when no goal exists, so non-
|
||
goal turns look identical to before. The followup ↻ glyph
|
||
appears on messages that came from an auto-followup turn. -->
|
||
<GoalAvatarRing
|
||
v-if="role === 'assistant'"
|
||
:conversation-id="message.conversationId"
|
||
:show-followup-mark="isFollowupTurn"
|
||
>
|
||
<img src="/logo/mateclaw_logo_s.png" alt="" class="avatar-logo" />
|
||
</GoalAvatarRing>
|
||
<span v-else>{{ avatarIcon }}</span>
|
||
</slot>
|
||
</div>
|
||
|
||
<!-- 消息体 -->
|
||
<div class="msg-body" :class="`${role}-body`">
|
||
<div class="msg-bubble" :class="`${role}-bubble`">
|
||
<!-- Plan-step panel — always rendered at the top of the bubble whenever
|
||
this turn has a plan, in both the segmented and fallback render
|
||
paths, so plan-mode progress is never buried in a collapsed panel. -->
|
||
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
|
||
|
||
<!-- 首包前占位:从发送到第一个 delta 之间(上下文准备 + LLM 首 token,
|
||
可达数秒)气泡完全空白会读作"卡死"。占位动效让气泡从第一帧起就是
|
||
活的;任何真实内容(thinking/tool/content/phase 面板)出现后自动让位。 -->
|
||
<div v-if="showPendingPlaceholder" class="pending-placeholder">
|
||
<span class="pending-placeholder__dots"><i /><i /><i /></span>
|
||
<span class="pending-placeholder__text">{{ $t('chat.pendingReply') }}</span>
|
||
<span v-if="phaseElapsed" class="pending-placeholder__elapsed">{{ phaseElapsed }}</span>
|
||
</div>
|
||
|
||
<!-- ===== 分段式渲染模式(Claude Code 风格)===== -->
|
||
<template v-if="useSegmentedView">
|
||
<div class="segments-view">
|
||
<!-- The "full reasoning" preference is hiding earlier spans. Say so:
|
||
silently removing them reads as reasoning that went missing. -->
|
||
<button
|
||
v-if="hiddenThinkingCount > 0 && showThinking"
|
||
class="superseded-toggle"
|
||
type="button"
|
||
@click="earlyThinkingExpanded = true"
|
||
>
|
||
<el-icon><InfoFilled /></el-icon>
|
||
<span>{{ $t('chat.earlierThinkingCollapsed', { count: hiddenThinkingCount }) }}</span>
|
||
<span class="superseded-toggle__action">{{ $t('chat.expand') }}</span>
|
||
</button>
|
||
<template v-for="iter in groupedIterations" :key="iter.key">
|
||
<!-- Iteration interrupted before any output landed — surface a chip
|
||
so the user knows the agent moved on instead of silently
|
||
skipping a turn. -->
|
||
<div v-if="iter.empty" class="iter-empty-chip">
|
||
<el-icon><WarningFilled /></el-icon>
|
||
<span>{{ $t('chat.iterationEmpty', { index: iter.index + 1 }) }}</span>
|
||
</div>
|
||
<!-- Render each iteration's segments in their original emission
|
||
order (thinking / tool_call / content interleaved exactly as
|
||
the model produced them) so tool boxes never reorder. -->
|
||
<template v-else>
|
||
<template v-for="seg in iter.items" :key="seg.id">
|
||
<ThinkingSegment v-if="seg.type === 'thinking' && showThinking" :segment="seg" />
|
||
<ToolCallSegment v-else-if="seg.type === 'tool_call'" :segment="seg" />
|
||
<template v-else-if="seg.type === 'content'">
|
||
<div v-if="seg.repetitionWarning" class="repetition-warning">
|
||
<el-icon><WarningFilled /></el-icon>
|
||
<span class="repetition-warning__text">{{ $t('chat.contentRepetitionWarning') }}</span>
|
||
<span v-if="seg.truncatedChars" class="repetition-warning__meta">({{ seg.truncatedChars }} chars)</span>
|
||
</div>
|
||
<ContentSegment
|
||
:segment="seg"
|
||
:show-cursor="showCursor && seg.status === 'running'"
|
||
:generated-file-names="generatedFileNames"
|
||
/>
|
||
</template>
|
||
</template>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ===== 传统合并渲染模式(降级兼容)===== -->
|
||
<template v-else>
|
||
|
||
<!-- 思考面板 -->
|
||
<div v-if="showThinkingPanel" class="thinking-section">
|
||
<button class="thinking-toggle" type="button" @click="toggleThinking">
|
||
<span class="thinking-toggle__indicator" :class="{ active: isGenerating && !hasContent }">
|
||
<el-icon><Opportunity /></el-icon>
|
||
</span>
|
||
<span class="thinking-toggle__label">{{ $t('chat.thinking') }}</span>
|
||
<span class="thinking-toggle__duration" v-if="thinkingDuration">{{ thinkingDuration }}</span>
|
||
<span class="thinking-toggle__arrow" :class="{ expanded: localThinkingExpanded }">
|
||
<el-icon><ArrowDown /></el-icon>
|
||
</span>
|
||
</button>
|
||
|
||
<!-- 思考内容(带折叠动画) -->
|
||
<Transition name="thinking-slide">
|
||
<div
|
||
v-if="localThinkingExpanded"
|
||
class="thinking-content markdown-body"
|
||
v-html="renderedThinkingContent"
|
||
></div>
|
||
</Transition>
|
||
</div>
|
||
|
||
<!-- 执行过程面板(折叠式) -->
|
||
<div v-if="showExecutionPanel" class="execution-section">
|
||
<button class="execution-toggle" type="button" @click="executionExpanded = !executionExpanded">
|
||
<span class="execution-toggle__indicator" :class="{ active: isGenerating }">
|
||
<el-icon v-if="isGenerating && !toolCallsMeta.length" class="spin"><Loading /></el-icon>
|
||
<el-icon v-else><Tools /></el-icon>
|
||
</span>
|
||
<span class="execution-toggle__label">{{ executionPhaseLabel }}</span>
|
||
<span class="execution-toggle__count" v-if="phaseElapsed">{{ phaseElapsed }}</span>
|
||
<span class="execution-toggle__count" v-if="toolCallsMeta.length">{{ toolCallsMeta.length }} calls</span>
|
||
<span class="execution-toggle__arrow" :class="{ expanded: executionExpanded }">
|
||
<el-icon><ArrowDown /></el-icon>
|
||
</span>
|
||
</button>
|
||
|
||
<Transition name="thinking-slide">
|
||
<div v-if="executionExpanded" class="execution-content">
|
||
<!-- 工具调用列表 -->
|
||
<div v-if="toolCallsMeta.length" class="tool-calls">
|
||
<div
|
||
v-for="(tc, i) in toolCallsMeta"
|
||
:key="i"
|
||
class="tool-call"
|
||
:class="{ 'tool-call--running': tc.status === 'running', 'tool-call--awaiting': tc.status === 'awaiting_approval', 'tool-call--error': tc.status === 'completed' && tc.success === false }"
|
||
>
|
||
<span class="tool-call__status">
|
||
<el-icon v-if="tc.status === 'running'" class="spin"><Loading /></el-icon>
|
||
<el-icon v-else-if="tc.status === 'awaiting_approval'" class="tc-icon--warning"><WarningFilled /></el-icon>
|
||
<el-icon v-else-if="tc.success !== false" class="tc-icon--success"><Select /></el-icon>
|
||
<el-icon v-else class="tc-icon--error"><CloseBold /></el-icon>
|
||
</span>
|
||
<span class="tool-call__name">{{ getToolLabel(tc.name) }}</span>
|
||
<span class="tool-call__args" v-if="tc.arguments">{{ truncateArgs(tc.arguments) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!toolCallsMeta.length" class="execution-empty">
|
||
{{ currentPhaseName }}...
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
|
||
<!-- 浏览器执行时间线 -->
|
||
<BrowserTimeline v-if="browserActionsMeta.length" :actions="browserActionsMeta" />
|
||
|
||
<!-- 工具审批状态(极简一行,操作在输入栏) -->
|
||
<div v-if="pendingApproval" class="approval-inline">
|
||
<el-icon class="approval-inline__icon"><WarningFilled /></el-icon>
|
||
<span v-if="pendingApproval.status === 'pending_approval'" class="approval-inline__text">
|
||
{{ $t('chat.approvalWaiting') }} <code>{{ getToolLabel(pendingApproval.toolName) }}</code>
|
||
</span>
|
||
<span v-else-if="pendingApproval.status === 'approved'" class="approval-inline__text approval-inline--approved">
|
||
{{ $t('chat.approved') }}: <code>{{ getToolLabel(pendingApproval.toolName) }}</code>
|
||
</span>
|
||
<span v-else class="approval-inline__text approval-inline--denied">
|
||
{{ $t('chat.denied') }}: <code>{{ getToolLabel(pendingApproval.toolName) }}</code>
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 主要内容 -->
|
||
<div
|
||
v-if="displayContent"
|
||
class="msg-content"
|
||
:class="{ 'with-cursor': showCursor }"
|
||
>
|
||
<!--
|
||
User-authored messages render as plain text (no markdown) and
|
||
auto-collapse beyond 8 lines. Assistant content goes through the
|
||
normal markdown pipeline.
|
||
-->
|
||
<UserMessageContent v-if="role === 'user'" :content="displayContent" />
|
||
<template v-else>
|
||
<div class="markdown-body" v-html="renderedContent"></div>
|
||
<TypingCursor v-if="showCursor" :typing="isGenerating" />
|
||
</template>
|
||
</div>
|
||
|
||
|
||
<!-- 停止指示器 -->
|
||
<div v-if="status === 'stopped' || status === 'interrupted'" class="stopped-indicator">
|
||
<el-icon><CloseBold /></el-icon>
|
||
<span>{{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }}</span>
|
||
</div>
|
||
|
||
<!-- parse_error content block -->
|
||
<div v-if="parseErrorText" class="parse-error-card">
|
||
<el-icon class="parse-error-card__icon"><WarningFilled /></el-icon>
|
||
<span class="parse-error-card__text">{{ parseErrorText }}</span>
|
||
</div>
|
||
|
||
<!-- 错误卡片 -->
|
||
<div v-if="status === 'failed'" class="error-card">
|
||
<div class="error-card__header">
|
||
<el-icon class="error-card__icon"><WarningFilled /></el-icon>
|
||
<span class="error-card__title">{{ errorTitle }}</span>
|
||
</div>
|
||
<p class="error-card__description">{{ errorDescription }}</p>
|
||
<p class="error-card__action">{{ errorAction }}</p>
|
||
<div class="error-card__footer">
|
||
<span v-if="errorCode" class="error-card__code">{{ errorCode }}</span>
|
||
<button v-if="errorRetryable" class="error-card__retry" type="button" @click="$emit('regenerate')">
|
||
<el-icon><RefreshRight /></el-icon>
|
||
{{ $t('chat.retry') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</template><!-- /传统合并渲染模式 -->
|
||
|
||
<!--
|
||
INCOMPLETE banner: graph emitted finishReason=incomplete after
|
||
the thinking-only soft cap stopped the stream.
|
||
Lives outside the segmented/traditional fork so both rendering
|
||
modes show it. Click "regenerate" reuses the existing emit path
|
||
that the error-card already relies on.
|
||
-->
|
||
<div v-if="isIncomplete" class="incomplete-card">
|
||
<div class="incomplete-card__header">
|
||
<el-icon class="incomplete-card__icon"><WarningFilled /></el-icon>
|
||
<span class="incomplete-card__title">{{ $t('chat.incompleteTitle') }}</span>
|
||
</div>
|
||
<p class="incomplete-card__description">{{ $t('chat.incompleteDescription') }}</p>
|
||
<div class="incomplete-card__footer">
|
||
<button class="incomplete-card__retry" type="button" @click="$emit('regenerate')">
|
||
<el-icon><RefreshRight /></el-icon>
|
||
{{ $t('chat.incompleteRetry') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
EVIDENCE_INSUFFICIENT banner (info color, not warning). Run completed
|
||
fully — the answer text is preserved above; the model just cited
|
||
source files / classes it didn't actually open. Without an explicit
|
||
card the trailing "[证据不足] …" line reads like a mid-answer cut.
|
||
No regenerate button: the user typically wants to either accept
|
||
the gap or follow up asking the model to read the listed files.
|
||
-->
|
||
<div v-if="isEvidenceInsufficient" class="evidence-card">
|
||
<div class="evidence-card__header">
|
||
<el-icon class="evidence-card__icon"><InfoFilled /></el-icon>
|
||
<span class="evidence-card__title">{{ $t('chat.evidenceTitle') }}</span>
|
||
</div>
|
||
<p class="evidence-card__description">{{ $t('chat.evidenceDescription') }}</p>
|
||
</div>
|
||
|
||
<!--
|
||
Runtime warning chips (metadata.warnings): loop-guard interventions
|
||
and other backend runtime notices. Populated live via the 'warning'
|
||
SSE event (useChat merges it into metadata.warnings) and persisted by
|
||
the stream accumulator, so streaming and history reload render the
|
||
same chips. Message text arrives pre-localized from the backend.
|
||
-->
|
||
<div v-if="runtimeWarnings.length" class="runtime-warnings">
|
||
<div v-for="(warning, wIdx) in runtimeWarnings" :key="wIdx" class="runtime-warning-chip">
|
||
<el-icon class="runtime-warning-chip__icon"><WarningFilled /></el-icon>
|
||
<span class="runtime-warning-chip__text">{{ warning }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
feedback_event card: recovery affordances for turns that ended
|
||
in a non-transient error. Backend's NodeStreamingChatHelper
|
||
handles transient TLS / IO retries silently; this card only
|
||
appears for the residue (auth, billing, model-not-found, raw
|
||
parse failures, etc.) that no amount of retry can fix without
|
||
user input. Buttons are data-driven from the event's `actions`
|
||
array so the backend can narrow the offering per error type
|
||
without a frontend release.
|
||
-->
|
||
<div v-if="feedbackInfo" class="feedback-card">
|
||
<div class="feedback-card__header">
|
||
<el-icon class="feedback-card__icon"><WarningFilled /></el-icon>
|
||
<span class="feedback-card__title">{{ $t('chat.feedback.title') }}</span>
|
||
</div>
|
||
<p class="feedback-card__description">{{ $t('chat.feedback.description') }}</p>
|
||
<div class="feedback-card__actions">
|
||
<button
|
||
v-for="action in feedbackInfo.actions"
|
||
:key="action"
|
||
class="feedback-card__btn"
|
||
:class="`feedback-card__btn--${action}`"
|
||
type="button"
|
||
@click="handleFeedbackAction(action)"
|
||
>
|
||
<el-icon v-if="action === 'retry' || action === 'regenerate'"><RefreshRight /></el-icon>
|
||
{{ feedbackActionLabel(action) }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 附件列表 -->
|
||
<div v-if="attachments?.length" class="message-attachments">
|
||
<div
|
||
v-for="attachment in imageAttachments"
|
||
:key="attachment.storedName"
|
||
class="message-attachment-image"
|
||
>
|
||
<img
|
||
:src="getDisplayUrl(attachment)"
|
||
:alt="attachment.name"
|
||
loading="lazy"
|
||
@click="openImage(getDisplayUrl(attachment))"
|
||
/>
|
||
<span class="message-attachment-image__name">{{ attachment.name }}</span>
|
||
</div>
|
||
<div
|
||
v-for="attachment in videoAttachments"
|
||
:key="attachment.storedName"
|
||
class="message-attachment-video"
|
||
>
|
||
<video
|
||
:src="getDisplayUrl(attachment)"
|
||
controls
|
||
preload="metadata"
|
||
playsinline
|
||
/>
|
||
<span class="message-attachment-video__name">{{ attachment.name }}</span>
|
||
</div>
|
||
<div
|
||
v-for="attachment in audioAttachments"
|
||
:key="'audio-' + attachment.storedName"
|
||
class="message-attachment-audio"
|
||
>
|
||
<audio
|
||
:src="getDisplayUrl(attachment)"
|
||
controls
|
||
preload="metadata"
|
||
/>
|
||
<span class="message-attachment-audio__name">{{ attachment.name }}</span>
|
||
</div>
|
||
<!-- 3D model preview via @google/model-viewer Web Component
|
||
(registered globally in src/main.ts; renders <model-viewer>
|
||
as a custom HTML element). -->
|
||
<div
|
||
v-for="attachment in model3dAttachments"
|
||
:key="'model3d-' + attachment.storedName"
|
||
class="message-attachment-model3d"
|
||
>
|
||
<model-viewer
|
||
:src="getDisplayUrl(attachment)"
|
||
camera-controls
|
||
auto-rotate
|
||
shadow-intensity="1"
|
||
exposure="1"
|
||
alt="Generated 3D model"
|
||
class="message-attachment-model3d__viewer"
|
||
/>
|
||
<span class="message-attachment-model3d__name">{{ attachment.name }}</span>
|
||
</div>
|
||
<button
|
||
v-for="attachment in fileAttachments"
|
||
:key="attachment.storedName"
|
||
class="message-attachment"
|
||
type="button"
|
||
@click="openFileAttachment(attachment)"
|
||
>
|
||
<el-icon class="message-attachment__icon"><Document /></el-icon>
|
||
<span class="message-attachment__name">{{ attachment.name }}</span>
|
||
<span class="message-attachment__meta">{{ formatFileSize(attachment.size) }}</span>
|
||
<el-icon
|
||
class="message-attachment__download"
|
||
:title="$t('chat.preview.download')"
|
||
@click.stop="downloadFile(attachment)"
|
||
><Download /></el-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 消息操作栏:始终占位,hover 时显示 -->
|
||
<div
|
||
class="msg-actions"
|
||
:class="{
|
||
'msg-actions--right': role === 'user',
|
||
'msg-actions--visible': showActions
|
||
}"
|
||
>
|
||
<!-- 复制 -->
|
||
<button
|
||
class="action-btn"
|
||
:class="{ copied: copyState === 'copied' }"
|
||
type="button"
|
||
:title="copyState === 'copied' ? $t('chat.copied') : $t('chat.copy')"
|
||
@click="copyMessage"
|
||
>
|
||
<el-icon v-if="copyState !== 'copied'"><CopyDocument /></el-icon>
|
||
<el-icon v-else><Select /></el-icon>
|
||
</button>
|
||
<!-- 朗读 TTS(仅 assistant) -->
|
||
<button
|
||
v-if="role === 'assistant' && !isGenerating"
|
||
class="action-btn"
|
||
:class="{ 'tts-playing': ttsState === 'playing' }"
|
||
type="button"
|
||
:title="ttsState === 'playing' ? $t('chat.ttsStop') : $t('chat.ttsPlay')"
|
||
:disabled="ttsState === 'loading'"
|
||
@click="handleTts"
|
||
>
|
||
<el-icon v-if="ttsState === 'loading'" class="tts-loading-icon"><Loading /></el-icon>
|
||
<el-icon v-else-if="ttsState === 'playing'"><VideoPause /></el-icon>
|
||
<el-icon v-else><Microphone /></el-icon>
|
||
</button>
|
||
<!-- 重新生成(仅会话末尾的 assistant 回答;服务端只支持对末条回答重生成) -->
|
||
<button
|
||
v-if="role === 'assistant' && !isGenerating && isLast"
|
||
class="action-btn"
|
||
type="button"
|
||
:title="$t('chat.regenerate')"
|
||
@click="$emit('regenerate')"
|
||
>
|
||
<el-icon><RefreshRight /></el-icon>
|
||
</button>
|
||
<!-- 回退到此处:删除本条及之后的所有消息。仅已持久化的消息可回退
|
||
(客户端临时 id 带下划线,持久化雪花 id 是纯数字) -->
|
||
<button
|
||
v-if="!isGenerating && canRewind"
|
||
class="action-btn"
|
||
type="button"
|
||
:title="$t('chat.rewindHere')"
|
||
@click="$emit('rewind')"
|
||
>
|
||
<el-icon><RefreshLeft /></el-icon>
|
||
</button>
|
||
<!-- Reply model attribution (assistant only) -->
|
||
<span
|
||
v-if="role === 'assistant' && replyModel"
|
||
class="action-model"
|
||
:title="replyModelTitle"
|
||
>{{ replyModel }}</span>
|
||
<!-- Turn token total (assistant only): own usage + delegated sub-agents.
|
||
Click opens the per-turn consumption breakdown panel (cache hit/miss/write,
|
||
reasoning vs reply, cache hit rate). -->
|
||
<el-popover
|
||
v-if="role === 'assistant' && tokenUsage"
|
||
placement="top-end"
|
||
trigger="click"
|
||
:width="288"
|
||
popper-class="mc-usage-popover"
|
||
>
|
||
<template #reference>
|
||
<button
|
||
class="action-tokens usage-trigger"
|
||
type="button"
|
||
:title="tokenUsage.delegated > 0
|
||
? $t('chat.tokenUsageTooltipDelegated', {
|
||
total: tokenUsage.total.toLocaleString(),
|
||
input: tokenUsage.input.toLocaleString(),
|
||
output: tokenUsage.output.toLocaleString(),
|
||
delegated: tokenUsage.delegated.toLocaleString(),
|
||
})
|
||
: $t('chat.tokenUsageTooltip', {
|
||
total: tokenUsage.total.toLocaleString(),
|
||
input: tokenUsage.input.toLocaleString(),
|
||
output: tokenUsage.output.toLocaleString(),
|
||
})"
|
||
>Σ {{ fmtTokens(tokenUsage.total) }} tok</button>
|
||
</template>
|
||
<div class="mc-usage-panel">
|
||
<div class="mc-usage-header">
|
||
<span class="mc-usage-title">{{ $t('chat.usageDetail.title') }}</span>
|
||
<span class="mc-usage-total-label">{{ $t('chat.usageDetail.total') }}</span>
|
||
<span class="mc-usage-total">{{ tokenUsage.total.toLocaleString() }}</span>
|
||
</div>
|
||
<div class="mc-usage-row mc-usage-row-main">
|
||
<i class="mc-usage-dot mc-dot-input"></i>
|
||
<span class="mc-usage-label">{{ $t('chat.usageDetail.input') }}</span>
|
||
<span class="mc-usage-value">{{ tokenUsage.input.toLocaleString() }}</span>
|
||
</div>
|
||
<template v-if="tokenUsage.hasCacheData">
|
||
<div class="mc-usage-row mc-usage-row-sub">
|
||
<i class="mc-usage-dot mc-dot-hit"></i>
|
||
<span class="mc-usage-label">{{ $t('chat.usageDetail.cacheHit') }}</span>
|
||
<span class="mc-usage-value">{{ tokenUsage.cacheRead.toLocaleString() }}</span>
|
||
</div>
|
||
<div class="mc-usage-row mc-usage-row-sub">
|
||
<i class="mc-usage-dot mc-dot-miss"></i>
|
||
<span class="mc-usage-label">{{ $t('chat.usageDetail.cacheMiss') }}</span>
|
||
<span class="mc-usage-value">{{ tokenUsage.cacheMiss.toLocaleString() }}</span>
|
||
</div>
|
||
<div class="mc-usage-row mc-usage-row-sub">
|
||
<i class="mc-usage-dot mc-dot-write"></i>
|
||
<span class="mc-usage-label">{{ $t('chat.usageDetail.cacheWrite') }}</span>
|
||
<span class="mc-usage-value">{{ tokenUsage.cacheWrite.toLocaleString() }}</span>
|
||
</div>
|
||
</template>
|
||
<div class="mc-usage-divider"></div>
|
||
<div class="mc-usage-row mc-usage-row-main">
|
||
<i class="mc-usage-dot mc-dot-output"></i>
|
||
<span class="mc-usage-label">{{ $t('chat.usageDetail.output') }}</span>
|
||
<span class="mc-usage-value">{{ tokenUsage.output.toLocaleString() }}</span>
|
||
</div>
|
||
<div class="mc-usage-row mc-usage-row-sub">
|
||
<span class="mc-usage-label mc-usage-label-indent">{{ $t('chat.usageDetail.reasoning') }}</span>
|
||
<span class="mc-usage-value">{{ tokenUsage.reasoning.toLocaleString() }}</span>
|
||
</div>
|
||
<div class="mc-usage-row mc-usage-row-sub">
|
||
<span class="mc-usage-label mc-usage-label-indent">{{ $t('chat.usageDetail.reply') }}</span>
|
||
<span class="mc-usage-value">{{ tokenUsage.reply.toLocaleString() }}</span>
|
||
</div>
|
||
<div v-if="tokenUsage.delegated > 0" class="mc-usage-row mc-usage-row-sub">
|
||
<span class="mc-usage-label mc-usage-label-indent">{{ $t('chat.usageDetail.delegated') }}</span>
|
||
<span class="mc-usage-value">{{ tokenUsage.delegated.toLocaleString() }}</span>
|
||
</div>
|
||
<template v-if="tokenUsage.hasCacheData">
|
||
<div class="mc-usage-divider"></div>
|
||
<div class="mc-usage-row mc-usage-row-main">
|
||
<span class="mc-usage-hit-icon">⚡</span>
|
||
<span class="mc-usage-label">{{ $t('chat.usageDetail.hitRate') }}</span>
|
||
<span class="mc-usage-value mc-usage-hit-rate">{{ (tokenUsage.hitRate * 100).toFixed(1) }}%</span>
|
||
</div>
|
||
<div class="mc-usage-bar">
|
||
<i class="mc-bar-hit" :style="{ width: usageBarPct(tokenUsage.cacheRead) }"></i>
|
||
<i class="mc-bar-write" :style="{ width: usageBarPct(tokenUsage.cacheWrite) }"></i>
|
||
<i class="mc-bar-miss" :style="{ width: usageBarPct(tokenUsage.cacheMiss) }"></i>
|
||
</div>
|
||
<div class="mc-usage-legend">
|
||
<span><i class="mc-usage-dot mc-dot-hit"></i>{{ $t('chat.usageDetail.legendHit') }}</span>
|
||
<span><i class="mc-usage-dot mc-dot-write"></i>{{ $t('chat.usageDetail.legendWrite') }}</span>
|
||
<span><i class="mc-usage-dot mc-dot-miss"></i>{{ $t('chat.usageDetail.legendMiss') }}</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</el-popover>
|
||
<!-- Multimodal sidecar routing badge (assistant only, when sidecar fired) -->
|
||
<span
|
||
v-if="role === 'assistant' && routingBadge"
|
||
class="action-routing"
|
||
:title="routingBadge.tooltip"
|
||
>🔀 {{ routingBadge.label }}</span>
|
||
<!-- 时间戳(inline) -->
|
||
<span class="action-time">{{ formattedTime }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { mcToast } from '@/composables/useMcToast'
|
||
import {
|
||
ArrowDown,
|
||
CloseBold,
|
||
CopyDocument,
|
||
Document,
|
||
Download,
|
||
InfoFilled,
|
||
Loading,
|
||
Microphone,
|
||
Opportunity,
|
||
RefreshLeft,
|
||
RefreshRight,
|
||
Select,
|
||
Tools,
|
||
VideoPause,
|
||
WarningFilled,
|
||
} from '@element-plus/icons-vue'
|
||
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
||
import { buildGeneratedFileNameMap, linkifyGeneratedFileUrls } from '@/utils/generatedFileLinks'
|
||
import { ensureModelViewer } from '@/utils/lazyModelViewer'
|
||
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
|
||
import { useToolLabel } from '@/composables/useToolLabel'
|
||
import { http } from '@/api'
|
||
import { copyToClipboard } from '@/utils/clipboard'
|
||
import TypingCursor from './TypingCursor.vue'
|
||
import { previewKindOf } from './preview/previewKind'
|
||
import { openFilePreview } from './preview/previewBus'
|
||
import BrowserTimeline from './BrowserTimeline.vue'
|
||
import ToolCallSegment from './ToolCallSegment.vue'
|
||
import ThinkingSegment from './ThinkingSegment.vue'
|
||
import ContentSegment from './ContentSegment.vue'
|
||
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
|
||
import { useGoalStore } from '@/stores/useGoalStore'
|
||
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
|
||
import { storeToRefs } from 'pinia'
|
||
import PlanStepsPanel from './PlanStepsPanel.vue'
|
||
import UserMessageContent from './UserMessageContent.vue'
|
||
import type { BrowserAction } from './BrowserTimeline.vue'
|
||
import type { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta, DelegationNode } from '@/types'
|
||
import type { ChatErrorInfo } from '@/types/chatError'
|
||
|
||
const { t, locale } = useI18n()
|
||
const { getToolLabel } = useToolLabel()
|
||
const { blobUrls, loadAllImages, loadAllVideos, loadAllAudios, loadAllModels, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
|
||
|
||
// Document attachments: preview in the global dialog when the format is
|
||
// supported, otherwise fall back to the legacy download behavior.
|
||
function openFileAttachment(attachment: ChatAttachment) {
|
||
if (previewKindOf(attachment)) {
|
||
openFilePreview(attachment)
|
||
} else {
|
||
void downloadFile(attachment)
|
||
}
|
||
}
|
||
|
||
interface Props {
|
||
message: Message
|
||
isLast?: boolean
|
||
assistantIcon?: string
|
||
userIcon?: string
|
||
showCursor?: boolean
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
isLast: false,
|
||
assistantIcon: '🤖',
|
||
userIcon: 'U',
|
||
showCursor: false,
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
regenerate: []
|
||
rewind: []
|
||
'toggle-thinking': [expanded: boolean]
|
||
approve: [pendingId: string]
|
||
deny: [pendingId: string]
|
||
}>()
|
||
|
||
// --- 基础计算 ---
|
||
const role = computed(() => props.message.role)
|
||
const status = computed(() => props.message.status)
|
||
const isGenerating = computed(() => status.value === 'generating' || status.value === 'awaiting_approval')
|
||
// Only persisted messages can be rewound: DB snowflake ids are pure digits,
|
||
// client temp ids carry an underscore (`${Date.now()}_${random}`).
|
||
const canRewind = computed(() => /^\d+$/.test(String(props.message.id ?? '')))
|
||
const hovered = ref(false)
|
||
|
||
const avatarIcon = computed(() => {
|
||
return role.value === 'user' ? props.userIcon : props.assistantIcon
|
||
})
|
||
|
||
// Followup attribution: an assistant message that opened right after a
|
||
// `goal_followup` SSE event belongs to an auto-followup turn. The chat
|
||
// composable stamps the message via goalStore on `message_start`; this
|
||
// computed reads it back so the ↻ glyph renders on exactly those turns.
|
||
const goalStore = useGoalStore()
|
||
const isFollowupTurn = computed(() => {
|
||
if (role.value !== 'assistant') return false
|
||
const cid = props.message.conversationId
|
||
const mid = props.message.id
|
||
if (!cid || mid == null) return false
|
||
return goalStore.isFollowupMessage(String(cid), String(mid))
|
||
})
|
||
|
||
// --- 错误卡片 ---
|
||
const errorInfo = computed<ChatErrorInfo | undefined>(() => props.message.errorInfo)
|
||
|
||
const errorTitle = computed(() => {
|
||
if (!errorInfo.value) return t('chat.failed')
|
||
return t(`chat.error.${errorInfo.value.category}.title`)
|
||
})
|
||
|
||
const errorDescription = computed(() => {
|
||
if (!errorInfo.value) return ''
|
||
// 优先展示后端 extractUserFriendlyError 生成的具体消息(比泛化模板更有指向性,
|
||
// 比如"当前模型不支持工具调用,请切换到 qwen3 / qwen2.5 ...")。
|
||
// 仅当 rawMessage 为空/过短时才回退到分类模板。
|
||
// 阈值用 3:可过滤 "OK"/"fail" 之类无意义短串,又能放行 "无权操作该会话"
|
||
// 这类 7 字中文 / "Forbidden" 这类英文短消息,避免被泛模板覆盖。
|
||
const raw = errorInfo.value.rawMessage?.trim() || ''
|
||
if (raw.length > 3) {
|
||
// 去掉后端冗余前缀,错误卡标题已经表达了类别
|
||
return raw
|
||
.replace(/^Bad request:\s*/i, '')
|
||
.replace(/^LLM 调用失败[::]\s*/, '')
|
||
.replace(/^认证失败[::]\s*/, '')
|
||
.replace(/^\[错误]\s*/, '')
|
||
}
|
||
return t(`chat.error.${errorInfo.value.category}.description`)
|
||
})
|
||
|
||
const errorAction = computed(() => {
|
||
if (!errorInfo.value) return ''
|
||
return t(`chat.error.${errorInfo.value.category}.action`)
|
||
})
|
||
|
||
const errorCode = computed(() => {
|
||
if (!errorInfo.value) return ''
|
||
const parts: string[] = []
|
||
if (errorInfo.value.httpStatus) parts.push(`HTTP ${errorInfo.value.httpStatus}`)
|
||
if (errorInfo.value.requestId) parts.push(`ID: ${errorInfo.value.requestId}`)
|
||
return parts.join(' | ')
|
||
})
|
||
|
||
const errorRetryable = computed(() => errorInfo.value?.retryable ?? true)
|
||
|
||
// --- Thinking 面板 ---
|
||
const localThinkingExpanded = ref(props.message.thinkingExpanded || false)
|
||
|
||
watch(() => props.message.thinkingExpanded, (val) => {
|
||
if (val !== undefined) localThinkingExpanded.value = val
|
||
})
|
||
|
||
const thinkingContent = computed(() => {
|
||
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking')
|
||
return thinkingPart?.text || ''
|
||
})
|
||
|
||
const hasContent = computed(() => {
|
||
const textPart = props.message.contentParts?.find(p => p.type === 'text')
|
||
return !!(textPart?.text || props.message.content)
|
||
})
|
||
|
||
// showThinking (default on) gates whether the model's reasoning is rendered.
|
||
// The segments auto-collapse when a thinking phase completes, so the final
|
||
// answer stays the focal point even with reasoning visible. debugMode remains
|
||
// a separate switch for tool-call internals and other diagnostics.
|
||
const { showThinking, thinkingFull } = storeToRefs(useSystemSettingsStore())
|
||
|
||
const showThinkingPanel = computed(() => showThinking.value && !!thinkingContent.value)
|
||
|
||
// 思考耗时(生成结束后显示)
|
||
const thinkingDuration = computed(() => {
|
||
if (isGenerating.value) return ''
|
||
if (!thinkingContent.value) return ''
|
||
// 优先使用 segment 真实时间戳
|
||
const segs = (props.message as any).segments || []
|
||
const thinkSeg = segs.find((s: any) => s.type === 'thinking')
|
||
const contentSeg = segs.find((s: any) => s.type === 'content')
|
||
// Best signal: the thinking segment's own persisted bounds. Persisted
|
||
// metadata serializes longs as strings, so coerce before comparing.
|
||
const thinkStart = Number(thinkSeg?.timestamp) || 0
|
||
const thinkEnd = Number(thinkSeg?.endTimestamp) || 0
|
||
if (thinkStart && thinkEnd && thinkEnd >= thinkStart) {
|
||
const sec = Math.max(1, Math.round((thinkEnd - thinkStart) / 1000))
|
||
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
|
||
}
|
||
if (thinkSeg?.timestamp && contentSeg?.timestamp) {
|
||
const sec = Math.max(1, Math.round((contentSeg.timestamp - thinkSeg.timestamp) / 1000))
|
||
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
|
||
}
|
||
// 回退:从内容长度估算
|
||
const len = thinkingContent.value.length
|
||
if (len < 50) return ''
|
||
const sec = Math.max(1, Math.round(len / 100))
|
||
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
|
||
})
|
||
|
||
watch(
|
||
[thinkingContent, hasContent, isGenerating],
|
||
([thinking, content, generating]) => {
|
||
if (!generating) return
|
||
if (thinking && !content) {
|
||
localThinkingExpanded.value = true
|
||
} else if (content) {
|
||
localThinkingExpanded.value = false
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
const toggleThinking = () => {
|
||
localThinkingExpanded.value = !localThinkingExpanded.value
|
||
emit('toggle-thinking', localThinkingExpanded.value)
|
||
}
|
||
|
||
// Throttle thinking + main-content markdown while the turn streams; both render
|
||
// once at full fidelity when generation stops.
|
||
const { html: renderedThinkingContent } = useStreamingMarkdown(
|
||
() => thinkingContent.value,
|
||
() => isGenerating.value,
|
||
)
|
||
|
||
// --- 主内容 ---
|
||
const isApprovalPlaceholder = (text: string) => {
|
||
return text.includes('[APPROVAL_PENDING]')
|
||
|| text.includes('[⏳ 等待审批]')
|
||
|| text.includes('[等待审批]')
|
||
}
|
||
|
||
const displayContent = computed(() => {
|
||
const textPart = props.message.contentParts?.find(p => p.type === 'text')
|
||
const text = textPart?.text || props.message.content || ''
|
||
if (isGenerating.value && !text) return ''
|
||
// 过滤审批占位文本 — 这些消息由审批面板展示,不应作为正文显示
|
||
if (text && isApprovalPlaceholder(text)) return ''
|
||
// 有错误卡片时隐藏 [错误] 原始文本,避免重复展示
|
||
if (status.value === 'failed' && errorInfo.value && text.startsWith('[错误]')) return ''
|
||
return linkifyGeneratedFileUrls(text, generatedFileNames.value)
|
||
})
|
||
|
||
// id → filename map for generated-file downloads, sourced from the metadata
|
||
// the server builds out of tool results. Used to rewrite bare download URLs
|
||
// the model echoed as plain text into [name](url) links (the persisted copy
|
||
// is rewritten server-side; this covers the live-streamed bubble).
|
||
const generatedFileNames = computed(() =>
|
||
buildGeneratedFileNameMap((props.message.metadata as any)?.generatedFiles))
|
||
|
||
// --- parse_error detection ---
|
||
const parseErrorText = computed(() => {
|
||
const errorPart = props.message.contentParts?.find(p => p.type === 'parse_error')
|
||
return errorPart?.text || ''
|
||
})
|
||
|
||
const { html: renderedContent } = useStreamingMarkdown(
|
||
() => displayContent.value,
|
||
() => isGenerating.value,
|
||
)
|
||
|
||
const showLoadingIndicator = computed(() => {
|
||
return isGenerating.value && !displayContent.value
|
||
})
|
||
|
||
|
||
// --- 操作栏 ---
|
||
const showActions = computed(() => {
|
||
if (isGenerating.value) return false
|
||
return hovered.value && (displayContent.value || status.value === 'stopped' || status.value === 'interrupted' || status.value === 'failed')
|
||
})
|
||
|
||
const copyState = ref<'idle' | 'copied'>('idle')
|
||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
function copyMessage() {
|
||
const text = displayContent.value || props.message.content || ''
|
||
if (!text) return
|
||
copyToClipboard(text).then(() => {
|
||
copyState.value = 'copied'
|
||
if (copyTimer) clearTimeout(copyTimer)
|
||
copyTimer = setTimeout(() => { copyState.value = 'idle' }, 2000)
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// --- TTS 朗读 ---
|
||
const ttsState = ref<'idle' | 'loading' | 'playing'>('idle')
|
||
let ttsAudio: HTMLAudioElement | null = null
|
||
|
||
async function handleTts() {
|
||
if (ttsState.value === 'playing') {
|
||
// 停止播放
|
||
ttsAudio?.pause()
|
||
ttsAudio = null
|
||
ttsState.value = 'idle'
|
||
return
|
||
}
|
||
|
||
const text = displayContent.value || props.message.content || ''
|
||
if (!text) return
|
||
|
||
const conversationId = props.message.conversationId
|
||
if (!conversationId) return
|
||
|
||
ttsState.value = 'loading'
|
||
try {
|
||
const res: any = await http.post('/tts/synthesize', {
|
||
conversationId,
|
||
text,
|
||
})
|
||
if (res.data?.success && res.data?.audioUrl) {
|
||
// 通过认证 fetch 获取音频 blob
|
||
const audioRes = await fetch(res.data.audioUrl, {
|
||
headers: { Authorization: `Bearer ${localStorage.getItem('token') || ''}` },
|
||
})
|
||
const blob = await audioRes.blob()
|
||
const blobUrl = URL.createObjectURL(blob)
|
||
ttsAudio = new Audio(blobUrl)
|
||
ttsAudio.onended = () => {
|
||
ttsState.value = 'idle'
|
||
URL.revokeObjectURL(blobUrl)
|
||
ttsAudio = null
|
||
}
|
||
ttsAudio.onerror = () => {
|
||
ttsState.value = 'idle'
|
||
URL.revokeObjectURL(blobUrl)
|
||
ttsAudio = null
|
||
}
|
||
ttsState.value = 'playing'
|
||
await ttsAudio.play()
|
||
} else {
|
||
ttsState.value = 'idle'
|
||
}
|
||
} catch {
|
||
ttsState.value = 'idle'
|
||
}
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
if (copyTimer) clearTimeout(copyTimer)
|
||
if (ttsAudio) { ttsAudio.pause(); ttsAudio = null }
|
||
revokeAll()
|
||
})
|
||
|
||
// --- 附件 ---
|
||
// MessageContentPart media (image/audio/video produced by generation tools) live
|
||
// in `contentParts` rather than `attachments`. Synthesize virtual attachment
|
||
// entries so the existing render + auth-blob loader works for them too.
|
||
//
|
||
// Dedup against `props.message.attachments` by URL — user-uploaded images often
|
||
// land in BOTH lists (the upload endpoint registers them as ChatAttachment AND
|
||
// the message persistence echoes them back as a `type: 'image'` MessageContentPart).
|
||
// Without this guard each user image shows twice in the bubble.
|
||
const mediaPartAttachments = computed<ChatAttachment[]>(() => {
|
||
const parts = (props.message as any).contentParts as Array<any> | undefined
|
||
if (!parts || !parts.length) return []
|
||
const existingUrls = new Set(
|
||
(props.message.attachments || []).map(a => a.url).filter(Boolean)
|
||
)
|
||
const out: ChatAttachment[] = []
|
||
const seen = new Set<string>()
|
||
for (const p of parts) {
|
||
if (!p || !p.fileUrl) continue
|
||
if (p.type !== 'image' && p.type !== 'audio' && p.type !== 'video' && p.type !== 'model3d') continue
|
||
if (existingUrls.has(p.fileUrl) || seen.has(p.fileUrl)) continue
|
||
seen.add(p.fileUrl)
|
||
const fileName = p.fileName || p.fileUrl.split('/').pop() || `${p.type}-${out.length}`
|
||
const ct = p.contentType
|
||
|| (p.type === 'image' ? 'image/png'
|
||
: p.type === 'audio' ? 'audio/mpeg'
|
||
: p.type === 'video' ? 'video/mp4'
|
||
: 'model/gltf-binary')
|
||
out.push({
|
||
name: fileName,
|
||
size: 0,
|
||
url: p.fileUrl,
|
||
storedName: fileName,
|
||
path: p.fileUrl,
|
||
contentType: ct,
|
||
})
|
||
}
|
||
return out
|
||
})
|
||
|
||
const attachments = computed(() => [
|
||
...(props.message.attachments || []),
|
||
...mediaPartAttachments.value,
|
||
])
|
||
const imageAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('image/')))
|
||
const videoAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('video/')))
|
||
const audioAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('audio/')))
|
||
const model3dAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('model/')))
|
||
const fileAttachments = computed(() => attachments.value.filter(a =>
|
||
!a.contentType?.startsWith('image/')
|
||
&& !a.contentType?.startsWith('video/')
|
||
&& !a.contentType?.startsWith('audio/')
|
||
&& !a.contentType?.startsWith('model/')
|
||
))
|
||
|
||
// 增量加载图片/视频/音频附件的鉴权 blob URL(watch 覆盖首次 + 后续变化)
|
||
watch(imageAttachments, (atts) => {
|
||
if (atts.length > 0) loadAllImages(atts)
|
||
}, { immediate: true })
|
||
watch(videoAttachments, (atts) => {
|
||
if (atts.length > 0) loadAllVideos(atts)
|
||
}, { immediate: true })
|
||
watch(audioAttachments, (atts) => {
|
||
if (atts.length > 0) loadAllAudios(atts)
|
||
}, { immediate: true })
|
||
// 3D models also need the auth-blob loader — <model-viewer src> doesn't carry
|
||
// the Authorization header any more than <img>/<audio> do. The heavy
|
||
// @google/model-viewer Web Component is lazy-loaded here (only when a bubble
|
||
// actually has a 3D attachment) instead of eagerly in main.ts.
|
||
watch(model3dAttachments, (atts) => {
|
||
if (atts.length > 0) {
|
||
ensureModelViewer()
|
||
loadAllModels(atts)
|
||
}
|
||
}, { immediate: true })
|
||
|
||
// --- 时间 ---
|
||
const formattedTime = computed(() => {
|
||
const createTime = props.message.createTime
|
||
if (!createTime) return ''
|
||
|
||
const date = new Date(createTime)
|
||
if (Number.isNaN(date.getTime())) return ''
|
||
|
||
const now = new Date()
|
||
|
||
const sameDay = (a: Date, b: Date) =>
|
||
a.getFullYear() === b.getFullYear() &&
|
||
a.getMonth() === b.getMonth() &&
|
||
a.getDate() === b.getDate()
|
||
|
||
const currentLocale = locale.value
|
||
|
||
const time = date.toLocaleTimeString(currentLocale, {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
|
||
if (sameDay(date, now)) return time
|
||
|
||
const yesterday = new Date(now)
|
||
yesterday.setDate(now.getDate() - 1)
|
||
|
||
if (sameDay(date, yesterday)) {
|
||
return `${t('security.activity.yesterday')} ${time}`
|
||
}
|
||
|
||
return date.toLocaleString(currentLocale, {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
})
|
||
|
||
// Reply model attribution: shows which model produced the assistant message.
|
||
// Empty for streaming (server only emits runtimeModel after persistence) and
|
||
// historical messages prior to MessageVO carrying the field.
|
||
const replyModel = computed(() => props.message.runtimeModel || '')
|
||
const replyModelTitle = computed(() => {
|
||
const provider = props.message.runtimeProvider
|
||
const base = t('chat.replyModel', { model: replyModel.value })
|
||
return provider ? `${base} (${provider})` : base
|
||
})
|
||
|
||
// Multimodal routing badge: rendered only when a sidecar actually fired this
|
||
// turn (strategy=sidecar, sidecarModel populated). Skipped for the legacy
|
||
// "primary handled it natively" case so non-routed turns stay clean.
|
||
const routingBadge = computed(() => {
|
||
const r = props.message.metadata?.routing
|
||
if (!r || r.strategy !== 'sidecar' || !r.sidecarModel) return null
|
||
// Count routed attachments by required modality. We summarize as "1 image"
|
||
// / "2 images" rather than naming each file to keep the chip compact.
|
||
const required = r.requiredModalities || []
|
||
const kind = required.includes('VISION')
|
||
? t('chat.routing.kind.image')
|
||
: required.includes('VIDEO')
|
||
? t('chat.routing.kind.video')
|
||
: t('chat.routing.kind.media')
|
||
const label = `${r.sidecarModel} (${kind})`
|
||
const tooltip = t('chat.routing.tooltip', {
|
||
primary: replyModel.value || '?',
|
||
sidecar: r.sidecarModel,
|
||
sidecarProvider: r.sidecarProvider || '',
|
||
kind,
|
||
})
|
||
return { label, tooltip }
|
||
})
|
||
|
||
const formatFileSize = (size: number) => {
|
||
if (size < 1024) return `${size} B`
|
||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||
}
|
||
|
||
// openAttachment 已由 useAuthenticatedAttachment 的 openImage / downloadFile 替代
|
||
|
||
// --- 执行过程面板 ---
|
||
const executionExpanded = ref(false)
|
||
|
||
// --- 分段式渲染(Claude Code 风格) ---
|
||
const parsedMetadata = computed(() => {
|
||
const raw = props.message.metadata
|
||
if (!raw) return {} as any
|
||
if (typeof raw === 'string') {
|
||
try {
|
||
let parsed = JSON.parse(raw)
|
||
// 处理双重 JSON 编码(DB 中 metadata 是字符串,Jackson 序列化时可能再次转义)
|
||
if (typeof parsed === 'string') {
|
||
try { parsed = JSON.parse(parsed) } catch { /* ignore */ }
|
||
}
|
||
return parsed
|
||
} catch { return {} }
|
||
}
|
||
return raw
|
||
})
|
||
|
||
/** Per-message override of the "full reasoning" preference (the collapse banner). */
|
||
const earlyThinkingExpanded = ref(false)
|
||
|
||
/**
|
||
* Apply the "full reasoning" preference. When off, only the reasoning span
|
||
* that produced the answer survives — the last one in the timeline. Everything
|
||
* is still persisted and still exported by the trajectory endpoint; this is
|
||
* purely how much of it the bubble shows. A running span is never dropped, so
|
||
* a live turn still shows the model thinking as it goes.
|
||
*
|
||
* Whatever this hides is announced by the banner above the timeline and can be
|
||
* expanded in place. Dropping spans with no trace is indistinguishable from
|
||
* losing them — the reader sees reasoning that was there mid-turn simply gone,
|
||
* and a preference stuck in the off state has no symptom to follow back.
|
||
*/
|
||
function applyThinkingDetail(segs: MessageSegment[]): MessageSegment[] {
|
||
if (thinkingFull.value || earlyThinkingExpanded.value) return segs
|
||
const keepIdx = segs.map((s, i) => (s.type === 'thinking' ? i : -1))
|
||
.filter(i => i >= 0)
|
||
.pop()
|
||
if (keepIdx === undefined) return segs
|
||
return segs.filter((s, i) =>
|
||
s.type !== 'thinking' || i === keepIdx || s.status === 'running')
|
||
}
|
||
|
||
/** How many reasoning spans the preference is currently hiding on this message. */
|
||
const hiddenThinkingCount = computed(() => {
|
||
if (thinkingFull.value || earlyThinkingExpanded.value) return 0
|
||
const all = (parsedMetadata.value?.segments as MessageSegment[] | undefined) || []
|
||
const total = all.filter(s => s.type === 'thinking').length
|
||
const shown = segments.value.filter(s => s.type === 'thinking').length
|
||
return Math.max(0, total - shown)
|
||
})
|
||
|
||
const segments = computed<MessageSegment[]>(() => {
|
||
if (props.message.role !== 'assistant') return []
|
||
const meta = parsedMetadata.value
|
||
|
||
// 优先:使用 metadata.segments(流式时由前端写入,历史时由后端持久化)
|
||
if (meta?.segments && Array.isArray(meta.segments) && meta.segments.length > 0
|
||
&& typeof meta.segments[0] === 'object' && meta.segments[0]?.type) {
|
||
const segs = [...meta.segments] as MessageSegment[]
|
||
|
||
// 补充:如果后端 segments 没有 thinking 但 contentParts 有(非原生 thinking 模型)
|
||
const hasThinking = segs.some(s => s.type === 'thinking')
|
||
if (!hasThinking) {
|
||
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking')
|
||
if (thinkingPart?.text) {
|
||
// Tag with iterationIndex=0 so groupedIterations puts it in the FIRST
|
||
// iteration's thinking bucket instead of the default-zero bucket
|
||
// colliding with later iteration content. Without this, the fallback
|
||
// thinking renders below the answer for any conversation that has
|
||
// multi-iteration segments tagged elsewhere.
|
||
//
|
||
// seq=-1 keeps it ahead of every producer-numbered segment so the
|
||
// sort below still applies to the array it was injected into. This
|
||
// reconstruction has no real emission position — the thinking came
|
||
// from contentParts, not from the timeline — and leading the turn is
|
||
// the only defensible placement for it.
|
||
const firstIter = segs.find(s => typeof s.iterationIndex === 'number')?.iterationIndex ?? 0
|
||
segs.unshift({ id: 'th-fb', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text, iterationIndex: firstIter, seq: -1 })
|
||
}
|
||
}
|
||
|
||
// 去重 tool_call segment。优先用 LLM 提供的 toolCallId —— 它端到端稳定
|
||
// (live 流与持久化两侧都带,见 useChat handleToolCallStarted / 后端
|
||
// accumulator),既能正确识别"同一次调用被 live+reload 渲染两遍"(两侧
|
||
// toolArgs 序列化可能有空白/键序差异,用 toolName::toolArgs 会漏判 → 重复
|
||
// 显示,issue #521),又不会把"同名同参的多次真实调用"(如重试 shell/python)
|
||
// 误合并成一次。仅当没有 toolCallId(历史/遗留 segment)时才退回
|
||
// toolName::toolArgs。
|
||
const seenToolCalls = new Set<string>()
|
||
const deduped = segs.filter(seg => {
|
||
if (seg.type !== 'tool_call') return true
|
||
const key = seg.toolCallId
|
||
? `id::${seg.toolCallId}`
|
||
: `na::${seg.toolName}::${seg.toolArgs || ''}`
|
||
if (seenToolCalls.has(key)) return false
|
||
seenToolCalls.add(key)
|
||
return true
|
||
})
|
||
segs.length = 0
|
||
segs.push(...deduped)
|
||
|
||
// 按发射序号排序。segments 携带生产端的单调 `seq`,排序对上面的去重
|
||
// 步骤是稳定的,且不会按类型搬运任何段落 —— 一段在工具观察之后产生的
|
||
// thinking 就留在观察之后,那才是模型真正产出它的位置。此前这里把"唯一
|
||
// 的 thinking 段"强行提到首个 content 之前,会把读完工具结果才得出的推理
|
||
// 显示在工具卡片上方,语义与实际发生顺序相反。
|
||
// 没有 `seq` 的走原数组顺序:live 段本就按事件顺序追加,历史消息也只有
|
||
// 数组顺序这一个信息源,无从重排。
|
||
if (segs.every(seg => typeof seg.seq === 'number')) {
|
||
segs.sort((a, b) => (a.seq as number) - (b.seq as number))
|
||
}
|
||
|
||
return applyThinkingDetail(segs)
|
||
}
|
||
|
||
// Fallback:从 toolCalls + contentParts 做 best-effort 重建(旧消息兼容)
|
||
// 注意:这会丢失事件交错顺序(所有 thinking 在前,所有 tool calls 在中,content 在后)
|
||
const segs: MessageSegment[] = []
|
||
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking')
|
||
if (thinkingPart?.text) {
|
||
segs.push({ id: 'th-0', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text })
|
||
}
|
||
const toolCalls = meta?.toolCalls || []
|
||
toolCalls.forEach((tc: ToolCallMeta, i: number) => {
|
||
segs.push({
|
||
id: `tc-${i}`, type: 'tool_call', status: 'completed',
|
||
toolName: tc.name, toolArgs: tc.arguments,
|
||
toolResult: tc.result, toolSuccess: tc.success,
|
||
})
|
||
})
|
||
if (props.message.content) {
|
||
segs.push({ id: 'ct-0', type: 'content', status: 'completed', text: props.message.content })
|
||
}
|
||
return segs
|
||
})
|
||
|
||
/**
|
||
* Use segmented rendering when there are multiple segments, OR when the turn
|
||
* contains a delegation segment. Delegations live in `segments` but not in
|
||
* `metadata.toolCalls`, so the fallback path (which only reads toolCalls)
|
||
* renders nothing for them — a single-step plan that delegates to a subagent
|
||
* would otherwise show the subagent call as completely invisible. Forcing
|
||
* segmented view here makes delegation surface as a timeline entry.
|
||
*/
|
||
const useSegmentedView = computed(() =>
|
||
segments.value.length > 1 ||
|
||
segments.value.some(s => s.type === 'tool_call' && (s.toolName || '').startsWith('→'))
|
||
)
|
||
|
||
/**
|
||
* Total token consumption for this assistant turn, rolled up the way a
|
||
* multi-agent orchestrator should report it: the parent message's own usage
|
||
* PLUS every delegated sub-agent's usage (depth-1 delegation segments and
|
||
* their nested children). Returns null when nothing is known yet.
|
||
*/
|
||
const tokenUsage = computed(() => {
|
||
const m = props.message
|
||
if (m.role !== 'assistant') return null
|
||
// Base usage comes from the message, which the backend already rolls
|
||
// delegated sub-agent tokens into (so live and reloaded values match and there
|
||
// is no double counting against the segment sum below).
|
||
const prompt = m.promptTokens || 0
|
||
const output = m.completionTokens || 0
|
||
if (prompt + output <= 0) return null
|
||
const cacheRead = m.cacheReadTokens || 0
|
||
const cacheWrite = m.cacheWriteTokens || 0
|
||
const reasoning = m.reasoningTokens || 0
|
||
// Provider accounting differs: the native Anthropic API reports input_tokens
|
||
// EXCLUDING the cache read/write segments (additive), while OpenAI-compatible
|
||
// and DashScope responses report prompt_tokens INCLUDING cached hits.
|
||
const additive = (m.runtimeProvider || '').toLowerCase().includes('anthropic')
|
||
const input = additive ? prompt + cacheRead + cacheWrite : prompt
|
||
const cacheMiss = Math.max(0, input - cacheRead - cacheWrite)
|
||
const reply = Math.max(0, output - reasoning)
|
||
const hitRate = input > 0 ? cacheRead / input : 0
|
||
const hasCacheData = cacheRead > 0 || cacheWrite > 0
|
||
const total = input + output
|
||
// Informational breakdown for the tooltip: how much of that total came from
|
||
// delegated sub-agents. Derived from the delegation segments, so it is present
|
||
// live and degrades to 0 after reload (the segments are not persisted).
|
||
let delegated = 0
|
||
const addNodes = (nodes?: DelegationNode[]) => {
|
||
if (!nodes) return
|
||
for (const n of nodes) {
|
||
delegated += (n.promptTokens || 0) + (n.completionTokens || 0)
|
||
addNodes(n.children)
|
||
}
|
||
}
|
||
for (const s of segments.value) {
|
||
if (s.type !== 'tool_call') continue
|
||
delegated += (s.delegPromptTokens || 0) + (s.delegCompletionTokens || 0)
|
||
addNodes(s.childTimeline?.children)
|
||
}
|
||
return {
|
||
input, output, total, delegated: Math.min(delegated, total),
|
||
cacheRead, cacheWrite, cacheMiss, reasoning, reply, hitRate, hasCacheData,
|
||
}
|
||
})
|
||
|
||
/** Width of a cache-bar segment as a percentage of total input tokens. */
|
||
function usageBarPct(part: number): string {
|
||
const u = tokenUsage.value
|
||
if (!u || u.input <= 0) return '0%'
|
||
return (part / u.input * 100).toFixed(2) + '%'
|
||
}
|
||
|
||
/** Compact token count, e.g. 67890 → "67.9k". */
|
||
function fmtTokens(n: number): string {
|
||
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
|
||
}
|
||
|
||
/**
|
||
* Group segments by iterationIndex so each ReAct iteration renders as its own
|
||
* thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket
|
||
* for legacy messages (no iterationIndex tagged) so historical conversations
|
||
* keep rendering as before. Ordering within a bucket is whatever the
|
||
* `segments` computed above settled on — emission order, by `seq`.
|
||
*/
|
||
const groupedIterations = computed(() => {
|
||
const segs = segments.value || []
|
||
const anyTagged = segs.some(s => typeof s.iterationIndex === 'number')
|
||
// Untagged (legacy / persisted-without-iterationIndex) messages render as a
|
||
// single bucket — but still in their ORIGINAL emission order, never split by
|
||
// type. Splitting into thinking/tool/content arrays was what made a tool box
|
||
// jump above or below its surrounding text depending on the message.
|
||
if (!anyTagged) {
|
||
return [{ key: 'all', index: 0, empty: segs.length === 0, items: segs }]
|
||
}
|
||
// Group by iteration for visual separation, but keep each bucket's segments
|
||
// in their original array order (segments[] is already in emission order, so
|
||
// a tool call stays exactly where the model emitted it relative to content).
|
||
const buckets = new Map<number, MessageSegment[]>()
|
||
for (const s of segs) {
|
||
const idx = s.iterationIndex ?? 0
|
||
if (!buckets.has(idx)) buckets.set(idx, [])
|
||
buckets.get(idx)!.push(s)
|
||
}
|
||
return [...buckets.entries()]
|
||
.sort(([a], [b]) => a - b)
|
||
.map(([index, items]) => ({
|
||
key: `iter-${index}`,
|
||
index,
|
||
empty: items.length === 0,
|
||
items,
|
||
}))
|
||
})
|
||
|
||
const toolCallsMeta = computed<ToolCallMeta[]>(() => {
|
||
return parsedMetadata.value?.toolCalls || []
|
||
})
|
||
|
||
/**
|
||
* True when the assistant turn was auto-truncated by the backend's
|
||
* thinking-only soft-cap and ended in INCOMPLETE.
|
||
* Surfaced as a banner with a "continue / regenerate" affordance so the
|
||
* user knows the answer ended early on purpose, not silently skipped.
|
||
*
|
||
* Reads `metadata.finishReason` set by the graph's FinalAnswerNode via
|
||
* the finish_reason GraphEvent → StreamDelta → accumulator pipeline.
|
||
*/
|
||
const isIncomplete = computed<boolean>(() => {
|
||
if (props.message.role !== 'assistant') return false
|
||
return parsedMetadata.value?.finishReason === 'incomplete'
|
||
})
|
||
|
||
/**
|
||
* True when the graph completed normally but {@code SourceEvidenceLedger}
|
||
* found unsupported references. The visible answer is full and persisted;
|
||
* the trailing "[证据不足] …" line just lists which file/class citations
|
||
* were never confirmed by an actual tool result. Without this banner the
|
||
* user often misreads that single line as a mid-answer cut.
|
||
*/
|
||
const isEvidenceInsufficient = computed<boolean>(() => {
|
||
if (props.message.role !== 'assistant') return false
|
||
return parsedMetadata.value?.finishReason === 'evidence_insufficient'
|
||
})
|
||
|
||
/**
|
||
* Backend runtime warnings for this turn (metadata.warnings) — e.g. the
|
||
* tool-call loop guard flagging repeated identical failures or a forced
|
||
* wrap-up. Strings arrive pre-localized from the backend; live streaming
|
||
* pushes them via the 'warning' SSE event and history reload reads the
|
||
* persisted metadata, so both paths converge here.
|
||
*/
|
||
const runtimeWarnings = computed<string[]>(() => {
|
||
if (props.message.role !== 'assistant') return []
|
||
const raw = parsedMetadata.value?.warnings
|
||
if (!Array.isArray(raw)) return []
|
||
return raw.filter((w: unknown): w is string => typeof w === 'string' && w.trim().length > 0)
|
||
})
|
||
|
||
/**
|
||
* Recovery-affordance payload from the graph's feedback_event. Populated
|
||
* for assistant turns that ended in a non-transient error (after the
|
||
* helper's TLS / IO retry loop has already given up). Shape mirrors
|
||
* GraphEventPublisher.feedback: { errorType, errorMessage, actions }.
|
||
*
|
||
* <p>Surfaces a card with buttons for each action: "retry" and
|
||
* "regenerate" both replay the last user message; "report" copies the
|
||
* error details for a bug report. The card sits right under the red
|
||
* "[错误] …" content so users see the recovery options inline rather
|
||
* than having to retype the whole prompt.
|
||
*/
|
||
interface FeedbackInfo {
|
||
errorType: string
|
||
errorMessage: string
|
||
actions: string[]
|
||
timestamp?: number
|
||
}
|
||
const feedbackInfo = computed<FeedbackInfo | undefined>(() => {
|
||
if (props.message.role !== 'assistant') return undefined
|
||
const raw = parsedMetadata.value?.feedbackEvent as FeedbackInfo | undefined
|
||
if (!raw || !Array.isArray(raw.actions) || raw.actions.length === 0) return undefined
|
||
return raw
|
||
})
|
||
|
||
function handleFeedbackAction(action: string) {
|
||
if (action === 'retry' || action === 'regenerate') {
|
||
emit('regenerate')
|
||
return
|
||
}
|
||
if (action === 'report') {
|
||
// Copy error details for a bug report. Lower-friction than a modal
|
||
// and works offline; users paste the result into wherever they file
|
||
// issues. Uses the clipboard helper with execCommand fallback for
|
||
// non-HTTPS contexts (e.g. internal IPs without TLS).
|
||
const lines = [
|
||
`Error type: ${feedbackInfo.value?.errorType || 'UNKNOWN'}`,
|
||
`Message: ${feedbackInfo.value?.errorMessage || ''}`,
|
||
`Conversation: ${(props.message as any).conversationId || ''}`,
|
||
`Message id: ${(props.message as any).id || ''}`,
|
||
`Timestamp: ${new Date(feedbackInfo.value?.timestamp || Date.now()).toISOString()}`,
|
||
].join('\n')
|
||
copyToClipboard(lines).then(() => {
|
||
mcToast.success(t('chat.feedback.reportCopied'))
|
||
}).catch(() => {
|
||
console.error('[feedback_event] copy failed:\n' + lines)
|
||
mcToast.error(t('chat.feedback.reportFailed'))
|
||
})
|
||
}
|
||
}
|
||
|
||
function feedbackActionLabel(action: string): string {
|
||
// Action labels go through i18n so the same data-driven button list
|
||
// renders correctly in zh-CN / en-US. Falls back to the raw action
|
||
// key if a future backend introduces a label we haven't translated.
|
||
const key = `chat.feedback.${action}`
|
||
const localized = t(key)
|
||
return localized === key ? action : localized
|
||
}
|
||
|
||
const browserActionsMeta = computed<BrowserAction[]>(() => {
|
||
return parsedMetadata.value?.browserActions || []
|
||
})
|
||
|
||
const planMeta = computed<PlanMeta | undefined>(() => {
|
||
return parsedMetadata.value?.plan
|
||
})
|
||
|
||
const PHASE_NAME_KEYS: Record<string, string> = {
|
||
reasoning: 'chat.phaseNames.reasoning',
|
||
action: 'chat.phaseNames.action',
|
||
planning: 'chat.phaseNames.planning',
|
||
summarizing: 'chat.phaseNames.summarizing',
|
||
awaiting_approval: 'chat.phaseNames.awaitingApproval',
|
||
executing: 'chat.phaseNames.executing',
|
||
replaying: 'chat.phaseNames.replaying',
|
||
resumed_execution: 'chat.phaseNames.resumed',
|
||
}
|
||
|
||
const currentPhaseName = computed(() => {
|
||
const phase = parsedMetadata.value?.currentPhase
|
||
return t(PHASE_NAME_KEYS[phase as string] || 'chat.phaseNames.processing')
|
||
})
|
||
|
||
// Live elapsed indicator for the phase-only window (LLM prefill / long
|
||
// reasoning before any tool call or content lands). A frozen "Reasoning"
|
||
// label with zero movement reads as a hang; a ticking clock + spinner shows
|
||
// the turn is alive. Resets whenever the backend reports a phase change.
|
||
const phaseSince = ref(Date.now())
|
||
const phaseNow = ref(Date.now())
|
||
let phaseTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
watch(() => parsedMetadata.value?.currentPhase, (p, old) => {
|
||
if (p !== old) {
|
||
phaseSince.value = Date.now()
|
||
phaseNow.value = Date.now()
|
||
}
|
||
})
|
||
|
||
watch(isGenerating, (gen) => {
|
||
if (gen) {
|
||
if (phaseTimer == null) phaseTimer = setInterval(() => { phaseNow.value = Date.now() }, 1000)
|
||
} else if (phaseTimer != null) {
|
||
clearInterval(phaseTimer)
|
||
phaseTimer = null
|
||
}
|
||
}, { immediate: true })
|
||
|
||
onBeforeUnmount(() => {
|
||
if (phaseTimer != null) {
|
||
clearInterval(phaseTimer)
|
||
phaseTimer = null
|
||
}
|
||
})
|
||
|
||
/**
|
||
* First-token wait state: the assistant bubble exists (placeholder created
|
||
* synchronously on send) but nothing renderable has arrived — no segments, no
|
||
* thinking, no content, no phase-driven execution panel. Without this the
|
||
* bubble sits blank for the whole context-prep + LLM-prefill window (multiple
|
||
* seconds) and reads as a hang; the placeholder yields automatically the
|
||
* moment any real block renders.
|
||
*/
|
||
const showPendingPlaceholder = computed(() =>
|
||
role.value === 'assistant'
|
||
&& isGenerating.value
|
||
&& segments.value.length === 0
|
||
&& !thinkingContent.value
|
||
&& !displayContent.value
|
||
&& !showExecutionPanel.value
|
||
&& !planMeta.value
|
||
)
|
||
|
||
const phaseElapsed = computed(() => {
|
||
// Only meaningful while waiting on the model with nothing else to show.
|
||
if (!isGenerating.value || toolCallsMeta.value.length) return ''
|
||
const sec = Math.floor((phaseNow.value - phaseSince.value) / 1000)
|
||
if (sec < 3) return ''
|
||
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
|
||
})
|
||
|
||
const truncateArgs = (args: string) => {
|
||
if (!args) return ''
|
||
const clean = args.replace(/\s+/g, ' ').trim()
|
||
return clean.length > 60 ? clean.slice(0, 60) + '...' : clean
|
||
}
|
||
|
||
// --- 审批面板 ---
|
||
const pendingApproval = computed(() => {
|
||
const approval = parsedMetadata.value?.pendingApproval
|
||
if (!approval || approval.status === 'expired') return null
|
||
return approval
|
||
})
|
||
|
||
const approvalSeverityClass = computed(() => {
|
||
const sev = pendingApproval.value?.maxSeverity?.toLowerCase()
|
||
if (!sev) return ''
|
||
return 'approval-severity-' + sev
|
||
})
|
||
|
||
const executionPhaseLabel = computed(() => {
|
||
if (pendingApproval.value?.status === 'pending_approval') {
|
||
return 'Waiting for approval'
|
||
}
|
||
if (pendingApproval.value?.status === 'approved') {
|
||
return 'Approved - Resuming'
|
||
}
|
||
if (planMeta.value) {
|
||
const done = planMeta.value.stepResults?.filter(r => r?.status === 'completed').length || 0
|
||
// Guard steps: a plan payload can arrive with steps undefined (mid-stream /
|
||
// malformed metadata). An unguarded .length here throws during render, which
|
||
// blanks the whole message subtree until a full remount (page refresh) — the
|
||
// "chat goes blank on switch, refresh fixes it" bug. Mirror the ?. used below.
|
||
return `Plan-Execute (${done}/${planMeta.value.steps?.length ?? 0})`
|
||
}
|
||
if (toolCallsMeta.value.length) {
|
||
const done = toolCallsMeta.value.filter(t => t.status === 'completed').length
|
||
return `Tool Calls (${done}/${toolCallsMeta.value.length})`
|
||
}
|
||
return currentPhaseName.value
|
||
})
|
||
|
||
const showExecutionPanel = computed(() => {
|
||
if (role.value !== 'assistant') return false
|
||
// The plan-step panel renders top-level outside this execution panel,
|
||
// so plan presence alone no longer keeps an (otherwise empty) panel open.
|
||
return toolCallsMeta.value.length > 0
|
||
|| (isGenerating.value && parsedMetadata.value?.currentPhase)
|
||
|| !!pendingApproval.value
|
||
})
|
||
|
||
// 自动展开执行面板(工具调用时、审批时或计划创建时)
|
||
watch(toolCallsMeta, (calls) => {
|
||
if (calls.length > 0 && isGenerating.value) {
|
||
executionExpanded.value = true
|
||
}
|
||
}, { deep: true })
|
||
|
||
watch(planMeta, (plan) => {
|
||
if (plan && plan.steps?.length > 0 && isGenerating.value) {
|
||
executionExpanded.value = true
|
||
}
|
||
})
|
||
|
||
watch(pendingApproval, (approval) => {
|
||
if (approval?.status === 'pending_approval') {
|
||
executionExpanded.value = true
|
||
}
|
||
})
|
||
|
||
// 生成结束后自动折叠(但审批等待中不折叠)
|
||
watch(isGenerating, (generating) => {
|
||
if (!generating && executionExpanded.value && !pendingApproval.value) {
|
||
executionExpanded.value = false
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 分段式渲染容器 */
|
||
.segments-view {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: 4px 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Iteration "no output" chip (interrupted iteration). */
|
||
.iter-empty-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
align-self: flex-start;
|
||
padding: 4px 10px;
|
||
margin: 4px 0;
|
||
font-size: 12px;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
background: var(--mc-bg-elevated, #f8fafc);
|
||
border: 1px dashed var(--mc-border, #e2e8f0);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
/* Inline informational banner shown when the backend trimmed a repetitive
|
||
tail off a content segment. Amber, not red — this is informational. */
|
||
.repetition-warning {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
margin: 6px 0 2px;
|
||
font-size: 12px;
|
||
color: #92400e;
|
||
background: rgba(245, 158, 11, 0.08);
|
||
border-left: 3px solid var(--mc-warning, #f59e0b);
|
||
border-radius: 4px;
|
||
}
|
||
.repetition-warning__text {
|
||
flex: 1;
|
||
}
|
||
.repetition-warning__meta {
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.pending-placeholder {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
align-self: flex-start;
|
||
padding: 8px 14px;
|
||
margin: 4px 0;
|
||
font-family: var(--mc-font-body, sans-serif);
|
||
font-size: var(--mc-text-sm, 13px);
|
||
color: var(--mc-text-secondary, #665245);
|
||
background: var(--mc-bg-muted, #f1e8df);
|
||
border-radius: var(--mc-radius-md, 12px);
|
||
}
|
||
|
||
.pending-placeholder__dots {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.pending-placeholder__dots i {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: var(--mc-radius-full, 9999px);
|
||
background: var(--mc-primary, #d96d46);
|
||
opacity: 0.35;
|
||
animation: pending-dot-pulse 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
.pending-placeholder__dots i:nth-child(2) { animation-delay: 0.2s; }
|
||
.pending-placeholder__dots i:nth-child(3) { animation-delay: 0.4s; }
|
||
|
||
@keyframes pending-dot-pulse {
|
||
0%, 60%, 100% { opacity: 0.35; transform: translateY(0); }
|
||
30% { opacity: 1; transform: translateY(-3px); }
|
||
}
|
||
|
||
.pending-placeholder__elapsed {
|
||
font-size: var(--mc-text-xs, 11px);
|
||
color: var(--mc-text-tertiary, #9b7d6c);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.superseded-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
align-self: flex-start;
|
||
padding: 5px 10px;
|
||
margin: 4px 0 2px;
|
||
font-size: 12px;
|
||
color: var(--mc-text-secondary, #64748b);
|
||
background: var(--mc-bg-elevated, #f8fafc);
|
||
border: 1px dashed var(--mc-border, #e2e8f0);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.superseded-toggle:hover {
|
||
color: var(--mc-text-primary, #0f172a);
|
||
border-color: var(--mc-primary, #2563eb);
|
||
}
|
||
|
||
.superseded-toggle__action {
|
||
color: var(--mc-primary, #2563eb);
|
||
}
|
||
|
||
.message-wrapper {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
width: 100%;
|
||
/* Cap at 920px on wide screens but never exceed the actual content column —
|
||
keeps the bubble container-relative so it narrows with the chat panel. */
|
||
max-width: min(920px, 100%);
|
||
min-width: 0;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.message-wrapper.user {
|
||
flex-direction: row-reverse;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.message-wrapper.assistant {
|
||
margin-right: auto;
|
||
}
|
||
|
||
/* 头像 */
|
||
.msg-avatar {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.assistant-avatar {
|
||
background: transparent;
|
||
}
|
||
|
||
.avatar-logo {
|
||
width: 30px;
|
||
height: 30px;
|
||
object-fit: contain;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.user-avatar {
|
||
background: linear-gradient(135deg, var(--mc-success), #3D7A3D);
|
||
color: white;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 消息体 */
|
||
.msg-body {
|
||
max-width: calc(100% - 44px);
|
||
min-width: 0;
|
||
}
|
||
|
||
.user-body {
|
||
align-items: flex-end;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* 气泡 */
|
||
.msg-bubble {
|
||
padding: 14px 16px;
|
||
border-radius: 16px;
|
||
font-size: 15px;
|
||
line-height: 1.7;
|
||
word-break: break-word;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.assistant-bubble {
|
||
background: none;
|
||
border: none;
|
||
border-radius: 0;
|
||
padding: 4px 0;
|
||
color: var(--mc-assistant-bubble-color, #1e293b);
|
||
}
|
||
|
||
.user-bubble {
|
||
background: var(--mc-user-bubble-bg, #D97757);
|
||
color: var(--mc-user-bubble-color, white);
|
||
border-radius: 18px 4px 18px 18px;
|
||
}
|
||
|
||
/* ==================== Thinking 面板 ==================== */
|
||
.thinking-section {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.thinking-toggle {
|
||
width: 100%;
|
||
border: 0;
|
||
background: var(--mc-thinking-bg, rgba(217, 119, 87, 0.06));
|
||
border-radius: 10px;
|
||
padding: 10px 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--mc-thinking-text, #475569);
|
||
cursor: pointer;
|
||
transition: background 0.15s ease;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.thinking-toggle:hover {
|
||
background: var(--mc-thinking-hover, rgba(217, 119, 87, 0.1));
|
||
}
|
||
|
||
.thinking-toggle__indicator {
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 6px;
|
||
background: var(--mc-thinking-icon-bg, rgba(217, 119, 87, 0.12));
|
||
color: var(--mc-primary, #D97757);
|
||
flex-shrink: 0;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.thinking-toggle__indicator.active {
|
||
animation: think-pulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes think-pulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.6; transform: scale(0.92); }
|
||
}
|
||
|
||
.thinking-toggle__label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
flex: 1;
|
||
text-align: left;
|
||
}
|
||
|
||
.thinking-toggle__duration {
|
||
font-size: 11px;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
font-weight: 400;
|
||
}
|
||
|
||
.thinking-toggle__arrow {
|
||
display: flex;
|
||
align-items: center;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.thinking-toggle__arrow.expanded {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
/* Thinking 内容折叠动画 */
|
||
.thinking-slide-enter-active,
|
||
.thinking-slide-leave-active {
|
||
transition: all 0.25s ease;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.thinking-slide-enter-from,
|
||
.thinking-slide-leave-to {
|
||
opacity: 0;
|
||
max-height: 0;
|
||
padding-top: 0;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.thinking-slide-enter-to,
|
||
.thinking-slide-leave-from {
|
||
opacity: 1;
|
||
max-height: 2000px;
|
||
}
|
||
|
||
.thinking-content {
|
||
padding: 10px 14px 6px;
|
||
color: var(--mc-text-secondary, #64748b);
|
||
font-size: 13px;
|
||
line-height: 1.65;
|
||
border-left: 2px solid var(--mc-thinking-border, rgba(217, 119, 87, 0.2));
|
||
margin-left: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.thinking-content :deep(*) {
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* ==================== 执行过程面板 ==================== */
|
||
.execution-section {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.execution-toggle {
|
||
width: 100%;
|
||
border: 0;
|
||
background: var(--mc-thinking-bg, rgba(217, 119, 87, 0.06));
|
||
border-radius: 10px;
|
||
padding: 8px 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--mc-thinking-text, #475569);
|
||
cursor: pointer;
|
||
transition: background 0.15s ease;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.execution-toggle:hover {
|
||
background: var(--mc-thinking-hover, rgba(217, 119, 87, 0.1));
|
||
}
|
||
|
||
.execution-toggle__indicator {
|
||
width: 22px;
|
||
height: 22px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 6px;
|
||
background: var(--mc-thinking-icon-bg, rgba(217, 119, 87, 0.12));
|
||
color: var(--mc-primary, #D97757);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.execution-toggle__indicator.active {
|
||
animation: think-pulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
.execution-toggle__label {
|
||
font-weight: 600;
|
||
flex: 1;
|
||
text-align: left;
|
||
}
|
||
|
||
.execution-toggle__count {
|
||
font-size: 11px;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
}
|
||
|
||
.execution-toggle__arrow {
|
||
display: flex;
|
||
align-items: center;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.execution-toggle__arrow.expanded {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.execution-content {
|
||
padding: 10px 14px 6px;
|
||
margin-top: 8px;
|
||
border-left: 2px solid rgba(217, 119, 87, 0.2);
|
||
margin-left: 12px;
|
||
}
|
||
|
||
.tool-calls {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.tool-call {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
background: var(--mc-bg-elevated, #f8fafc);
|
||
}
|
||
|
||
.tool-call--running {
|
||
background: rgba(217, 119, 87, 0.06);
|
||
}
|
||
|
||
.tool-call--awaiting {
|
||
background: rgba(245, 158, 11, 0.06);
|
||
}
|
||
|
||
.tool-call--error {
|
||
background: rgba(239, 68, 68, 0.06);
|
||
}
|
||
|
||
.tool-call__status {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.tc-icon--warning { color: var(--mc-warning, #f59e0b); }
|
||
.tc-icon--success { color: var(--mc-success, #10b981); }
|
||
.tc-icon--error { color: var(--mc-danger, #ef4444); }
|
||
|
||
.tool-call__name {
|
||
font-weight: 600;
|
||
color: var(--mc-text-primary, #1e293b);
|
||
}
|
||
|
||
.tool-call__args {
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
flex: 1;
|
||
}
|
||
|
||
/* plan-steps 样式已迁移到 PlanStepsPanel.vue 组件 */
|
||
|
||
.execution-empty {
|
||
font-size: 12px;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.spin {
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* ==================== parse_error card ==================== */
|
||
.parse-error-card {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
margin-bottom: 8px;
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--mc-warning, #f59e0b) 8%, var(--mc-bg-elevated, #f8fafc));
|
||
border: 1px solid color-mix(in srgb, var(--mc-warning, #f59e0b) 25%, transparent);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
color: var(--mc-text-secondary, #64748b);
|
||
}
|
||
|
||
.parse-error-card__icon {
|
||
flex-shrink: 0;
|
||
color: var(--mc-warning, #f59e0b);
|
||
margin-top: 1px;
|
||
}
|
||
|
||
.parse-error-card__text {
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* ==================== 审批面板 ==================== */
|
||
/* 极简审批状态(一行式) */
|
||
.approval-inline {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
color: var(--mc-text-secondary, #64748b);
|
||
background: var(--mc-bg-muted, #f9f7f5);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.approval-inline__icon {
|
||
color: var(--mc-warning, #f59e0b);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.approval-inline__text code {
|
||
font-size: 12px;
|
||
background: var(--mc-inline-code-bg, #f1f5f9);
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.approval-inline--approved {
|
||
color: var(--mc-success, #10b981);
|
||
}
|
||
.approval-inline--approved .approval-inline__icon {
|
||
color: var(--mc-success, #10b981);
|
||
}
|
||
|
||
.approval-inline--denied {
|
||
color: var(--mc-danger, #ef4444);
|
||
}
|
||
.approval-inline--denied .approval-inline__icon {
|
||
color: var(--mc-danger, #ef4444);
|
||
}
|
||
|
||
/* ==================== 操作栏 ==================== */
|
||
.msg-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: 4px 0 0 4px;
|
||
/* 始终占位,防止出现/消失时引起布局抖动 */
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.15s ease;
|
||
}
|
||
|
||
.msg-actions--visible {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.msg-actions--right {
|
||
justify-content: flex-end;
|
||
padding: 4px 4px 0 0;
|
||
}
|
||
|
||
.action-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.action-btn:hover {
|
||
background: var(--mc-bg-tertiary, rgba(0, 0, 0, 0.05));
|
||
color: var(--mc-text-secondary, #64748b);
|
||
}
|
||
|
||
.action-btn.copied {
|
||
color: #10b981;
|
||
}
|
||
.action-btn.tts-playing {
|
||
color: var(--mc-primary);
|
||
}
|
||
.tts-loading-icon {
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.action-time {
|
||
font-size: 11px;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
margin-left: 4px;
|
||
user-select: none;
|
||
}
|
||
|
||
.action-model {
|
||
font-size: 11px;
|
||
color: var(--mc-text-secondary, #64748b);
|
||
margin-left: 4px;
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
background: var(--mc-fill-2, rgba(100, 116, 139, 0.08));
|
||
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
|
||
user-select: text;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.action-tokens {
|
||
font-size: 11px;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
margin-left: 4px;
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
background: var(--mc-fill-2, rgba(100, 116, 139, 0.08));
|
||
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
|
||
user-select: text;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* The token chip is a popover trigger button — keep the chip look, add affordance. */
|
||
.usage-trigger {
|
||
border: none;
|
||
cursor: pointer;
|
||
line-height: inherit;
|
||
}
|
||
.usage-trigger:hover {
|
||
color: var(--mc-text-secondary, #64748b);
|
||
background: var(--mc-fill-3, rgba(100, 116, 139, 0.14));
|
||
}
|
||
|
||
.action-routing {
|
||
font-size: 11px;
|
||
color: var(--mc-primary, #d96d46);
|
||
margin-left: 4px;
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
background: var(--mc-primary-bg, rgba(217, 109, 70, 0.1));
|
||
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
|
||
user-select: text;
|
||
white-space: nowrap;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ==================== 主内容区域 ==================== */
|
||
.msg-content {
|
||
position: relative;
|
||
}
|
||
|
||
.msg-content.with-cursor {
|
||
display: inline;
|
||
}
|
||
|
||
/* 状态指示器 */
|
||
.stopped-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 0 2px;
|
||
font-size: 12px;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
}
|
||
|
||
/* 错误卡片 */
|
||
.error-card {
|
||
margin-top: 8px;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
background: var(--mc-danger-bg);
|
||
border: 1px solid color-mix(in srgb, var(--mc-danger) 25%, transparent);
|
||
font-size: 13px;
|
||
max-width: 480px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.error-card__header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.error-card__icon {
|
||
flex-shrink: 0;
|
||
color: var(--mc-danger);
|
||
}
|
||
|
||
.error-card__title {
|
||
font-weight: 600;
|
||
color: var(--mc-danger);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.error-card__description {
|
||
margin: 4px 0;
|
||
color: var(--mc-text-primary);
|
||
font-size: 13px;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.error-card__action {
|
||
margin: 4px 0 8px;
|
||
color: var(--mc-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.error-card__footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.error-card__code {
|
||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
|
||
font-size: 11px;
|
||
color: var(--mc-danger);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.error-card__retry {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 12px;
|
||
border-radius: 6px;
|
||
border: 1px solid color-mix(in srgb, var(--mc-danger) 30%, transparent);
|
||
background: color-mix(in srgb, var(--mc-danger) 8%, var(--mc-bg-elevated));
|
||
color: var(--mc-danger);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.error-card__retry:hover {
|
||
background: color-mix(in srgb, var(--mc-danger) 15%, var(--mc-bg-elevated));
|
||
border-color: color-mix(in srgb, var(--mc-danger) 50%, transparent);
|
||
}
|
||
|
||
/* ==================== 运行时警示条(循环守卫等 metadata.warnings) ==================== */
|
||
.runtime-warnings {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.runtime-warning-chip {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
background: color-mix(in srgb, var(--mc-warning, #d97706) 8%, var(--mc-bg-elevated));
|
||
border: 1px solid color-mix(in srgb, var(--mc-warning, #d97706) 25%, transparent);
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
max-width: 560px;
|
||
color: var(--mc-text-secondary);
|
||
}
|
||
|
||
.runtime-warning-chip__icon {
|
||
flex-shrink: 0;
|
||
margin-top: 2px;
|
||
font-size: 13px;
|
||
color: var(--mc-warning, #d97706);
|
||
}
|
||
|
||
.runtime-warning-chip__text {
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* ==================== INCOMPLETE 截断卡片(重复检测 / thinking-only 软上限) ==================== */
|
||
.incomplete-card {
|
||
margin-top: 8px;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--mc-warning, #d97706) 8%, var(--mc-bg-elevated));
|
||
border: 1px solid color-mix(in srgb, var(--mc-warning, #d97706) 30%, transparent);
|
||
font-size: 13px;
|
||
max-width: 480px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.incomplete-card__header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.incomplete-card__icon {
|
||
flex-shrink: 0;
|
||
color: var(--mc-warning, #d97706);
|
||
}
|
||
|
||
.incomplete-card__title {
|
||
font-weight: 600;
|
||
color: var(--mc-warning, #d97706);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.incomplete-card__description {
|
||
margin: 4px 0 8px;
|
||
color: var(--mc-text-primary);
|
||
font-size: 13px;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.incomplete-card__footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.incomplete-card__retry {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 12px;
|
||
border-radius: 6px;
|
||
border: 1px solid color-mix(in srgb, var(--mc-warning, #d97706) 35%, transparent);
|
||
background: color-mix(in srgb, var(--mc-warning, #d97706) 10%, var(--mc-bg-elevated));
|
||
color: var(--mc-warning, #d97706);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.incomplete-card__retry:hover {
|
||
background: color-mix(in srgb, var(--mc-warning, #d97706) 18%, var(--mc-bg-elevated));
|
||
border-color: color-mix(in srgb, var(--mc-warning, #d97706) 55%, transparent);
|
||
}
|
||
|
||
/* ==================== EVIDENCE_INSUFFICIENT 提示卡(info 调,非警告) ==================== */
|
||
.evidence-card {
|
||
margin-top: 8px;
|
||
padding: 10px 14px;
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--mc-info, #0891b2) 6%, var(--mc-bg-elevated));
|
||
border: 1px solid color-mix(in srgb, var(--mc-info, #0891b2) 25%, transparent);
|
||
font-size: 12.5px;
|
||
max-width: 480px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.evidence-card__header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.evidence-card__icon {
|
||
flex-shrink: 0;
|
||
color: var(--mc-info, #0891b2);
|
||
}
|
||
|
||
.evidence-card__title {
|
||
font-weight: 600;
|
||
color: var(--mc-info, #0891b2);
|
||
font-size: 13.5px;
|
||
}
|
||
|
||
.evidence-card__description {
|
||
margin: 4px 0 0;
|
||
color: var(--mc-text-primary);
|
||
font-size: 12.5px;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
/* ==================== feedback_event recovery card (ERROR_FALLBACK) ==================== */
|
||
.feedback-card {
|
||
margin-top: 8px;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--mc-danger, #dc2626) 8%, var(--mc-bg-elevated));
|
||
border: 1px solid color-mix(in srgb, var(--mc-danger, #dc2626) 30%, transparent);
|
||
font-size: 13px;
|
||
max-width: 480px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.feedback-card__header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.feedback-card__icon {
|
||
flex-shrink: 0;
|
||
color: var(--mc-danger, #dc2626);
|
||
}
|
||
|
||
.feedback-card__title {
|
||
font-weight: 600;
|
||
color: var(--mc-danger, #dc2626);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.feedback-card__description {
|
||
margin: 4px 0 8px;
|
||
color: var(--mc-text-primary);
|
||
font-size: 13px;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.feedback-card__actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.feedback-card__btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 12px;
|
||
border-radius: 6px;
|
||
border: 1px solid color-mix(in srgb, var(--mc-danger, #dc2626) 35%, transparent);
|
||
background: color-mix(in srgb, var(--mc-danger, #dc2626) 10%, var(--mc-bg-elevated));
|
||
color: var(--mc-danger, #dc2626);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.feedback-card__btn:hover {
|
||
background: color-mix(in srgb, var(--mc-danger, #dc2626) 18%, var(--mc-bg-elevated));
|
||
border-color: color-mix(in srgb, var(--mc-danger, #dc2626) 55%, transparent);
|
||
}
|
||
|
||
/* Report button is secondary action — muted neutral palette so the
|
||
primary "retry" stays visually emphasized. */
|
||
.feedback-card__btn--report {
|
||
border-color: var(--mc-border);
|
||
background: var(--mc-bg-elevated);
|
||
color: var(--mc-text-secondary);
|
||
}
|
||
|
||
.feedback-card__btn--report:hover {
|
||
background: var(--mc-bg-sunken);
|
||
border-color: var(--mc-border-strong, var(--mc-border));
|
||
color: var(--mc-text-primary);
|
||
}
|
||
|
||
/* ==================== 附件 ==================== */
|
||
.message-attachments {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.message-attachment-image {
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.message-attachment-image img {
|
||
max-width: 280px;
|
||
max-height: 200px;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.message-attachment-image__name {
|
||
display: block;
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
opacity: 0.76;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.message-attachment-video {
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.message-attachment-video video {
|
||
max-width: 400px;
|
||
max-height: 280px;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.message-attachment-video__name {
|
||
display: block;
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
opacity: 0.76;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.message-attachment-audio {
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.message-attachment-audio audio {
|
||
width: 100%;
|
||
max-width: 400px;
|
||
display: block;
|
||
}
|
||
|
||
.message-attachment-audio__name {
|
||
display: block;
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
opacity: 0.76;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.message-attachment-model3d {
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--bg-soft, #f5f5f5);
|
||
}
|
||
|
||
.message-attachment-model3d__viewer {
|
||
width: 100%;
|
||
max-width: 480px;
|
||
height: 360px;
|
||
display: block;
|
||
border-radius: 12px;
|
||
/* model-viewer renders nothing until the .glb finishes loading;
|
||
keep the box sized so layout doesn't jump. */
|
||
background: linear-gradient(135deg, #fafafa, #ececec);
|
||
}
|
||
|
||
.message-attachment-model3d__name {
|
||
display: block;
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
opacity: 0.76;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.message-attachment {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 8px 10px;
|
||
border-radius: 12px;
|
||
background: rgba(255, 255, 255, 0.14);
|
||
color: inherit;
|
||
text-decoration: none;
|
||
border: none;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
width: 100%;
|
||
}
|
||
|
||
.user-bubble .message-attachment {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.message-attachment__name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.message-attachment__meta {
|
||
flex-shrink: 0;
|
||
opacity: 0.76;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.message-attachment__icon {
|
||
flex-shrink: 0;
|
||
opacity: 0.76;
|
||
}
|
||
|
||
.message-attachment__download {
|
||
flex-shrink: 0;
|
||
opacity: 0.6;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.message-attachment__download:hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* ==================== Markdown 样式 ==================== */
|
||
.markdown-body :deep(p) {
|
||
margin: 0 0 10px;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.markdown-body :deep(p:last-child) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.markdown-body :deep(ul),
|
||
.markdown-body :deep(ol) {
|
||
margin: 10px 0;
|
||
padding-left: 24px;
|
||
}
|
||
|
||
.markdown-body :deep(ul) { list-style-type: disc; }
|
||
.markdown-body :deep(ol) { list-style-type: decimal; }
|
||
|
||
.markdown-body :deep(li) {
|
||
margin: 4px 0;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.markdown-body :deep(li > ul),
|
||
.markdown-body :deep(li > ol) {
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.markdown-body :deep(input[type="checkbox"]) {
|
||
margin-right: 8px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.markdown-body :deep(blockquote) {
|
||
margin: 14px 0;
|
||
padding: 12px 16px;
|
||
border-left: 4px solid var(--mc-primary, #D97757);
|
||
background: var(--mc-bg-elevated, #f8fafc);
|
||
border-radius: 0 8px 8px 0;
|
||
color: var(--mc-text-secondary, #64748b);
|
||
}
|
||
|
||
.markdown-body :deep(blockquote p) { margin: 0; }
|
||
|
||
.markdown-body :deep(table) {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 14px 0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.markdown-body :deep(th),
|
||
.markdown-body :deep(td) {
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--mc-border, #e2e8f0);
|
||
text-align: left;
|
||
}
|
||
|
||
.markdown-body :deep(th) {
|
||
background: var(--mc-bg-elevated, #f8fafc);
|
||
font-weight: 600;
|
||
color: var(--mc-text-primary, #1e293b);
|
||
}
|
||
|
||
.markdown-body :deep(tr:nth-child(even)) {
|
||
background: var(--mc-bg-sunken, #f1f5f9);
|
||
}
|
||
|
||
.markdown-body :deep(a) {
|
||
color: var(--mc-primary, #D97757);
|
||
text-decoration: none;
|
||
border-bottom: 1px solid transparent;
|
||
transition: border-color 0.15s ease;
|
||
}
|
||
|
||
.markdown-body :deep(a:hover) {
|
||
border-bottom-color: var(--mc-primary, #D97757);
|
||
}
|
||
|
||
.user-bubble .markdown-body :deep(a) {
|
||
color: var(--mc-user-bubble-color, white);
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.user-bubble .markdown-body :deep(a:hover) {
|
||
border-bottom-color: var(--mc-user-bubble-color, white);
|
||
}
|
||
|
||
.markdown-body :deep(hr) {
|
||
border: none;
|
||
border-top: 1px solid var(--mc-border, #e2e8f0);
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.markdown-body :deep(h1),
|
||
.markdown-body :deep(h2),
|
||
.markdown-body :deep(h3),
|
||
.markdown-body :deep(h4),
|
||
.markdown-body :deep(h5),
|
||
.markdown-body :deep(h6) {
|
||
margin: 20px 0 12px;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
color: var(--mc-text-primary, #1e293b);
|
||
}
|
||
|
||
.markdown-body :deep(h1) { font-size: 1.5em; }
|
||
.markdown-body :deep(h2) { font-size: 1.3em; }
|
||
.markdown-body :deep(h3) { font-size: 1.15em; }
|
||
.markdown-body :deep(h4) { font-size: 1em; }
|
||
|
||
/* 代码块 */
|
||
.markdown-body :deep(pre) {
|
||
background: var(--mc-code-bg, #1e293b);
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
overflow-x: auto;
|
||
margin: 14px 0;
|
||
}
|
||
|
||
.markdown-body :deep(code) {
|
||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.markdown-body :deep(pre code) {
|
||
color: #e2e8f0;
|
||
background: transparent;
|
||
padding: 0;
|
||
}
|
||
|
||
.markdown-body :deep(:not(pre) > code) {
|
||
background: var(--mc-inline-code-bg, #f1f5f9);
|
||
color: var(--mc-inline-code-color, #ef4444);
|
||
padding: 2px 6px;
|
||
border-radius: 6px;
|
||
font-size: 0.92em;
|
||
}
|
||
|
||
/* Code-block CSS lives globally in main.css now (.markdown-body .code-block*)
|
||
so the rules apply consistently across MessageBubble, AgentContext, and
|
||
any future markdown-body context, and don't depend on Vue's per-component
|
||
scope hash. Keep this comment as a breadcrumb so future edits don't get
|
||
re-added here by reflex. */
|
||
|
||
/* ===== Mermaid block ===== */
|
||
.markdown-body :deep(.mermaid-block) {
|
||
margin: 14px 0;
|
||
border-radius: 12px;
|
||
background: var(--mc-mermaid-bg, #f8fafc);
|
||
border: 1px solid var(--mc-mermaid-border, #e2e8f0);
|
||
overflow: hidden;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__header) {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
height: 38px;
|
||
padding: 0 14px;
|
||
background: var(--mc-code-header-bg);
|
||
border-bottom: 1px solid var(--mc-mermaid-border, #e2e8f0);
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
color: var(--mc-code-lang-color);
|
||
/* Prevent the header label/buttons from being swept into a text selection
|
||
that starts in the surrounding markdown — the highlighted-grey selection
|
||
band would otherwise extend across the whole header row. */
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__lang) {
|
||
font-weight: 500;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__actions) {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__download) {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 8px;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 6px;
|
||
color: var(--mc-code-copy-color);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: background 0.15s ease, color 0.15s ease;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__download:hover) {
|
||
background: var(--mc-code-copy-hover-bg);
|
||
color: var(--mc-code-copy-hover-color);
|
||
}
|
||
/* Pin EVERY icon inside the header to 14×14. Without this, DOMPurify can
|
||
normalise away the `width="14" height="14"` attrs from the markdown HTML,
|
||
leaving the SVG to fall back to the UA default 300×150. The button is
|
||
inline-flex so it grows to fit the icon, and `:hover` then paints a
|
||
gigantic grey rectangle (which is what user issue #67's follow-up screen-
|
||
shot showed). Same defence-in-depth as `.code-block__header svg`. */
|
||
.markdown-body :deep(.mermaid-block__header svg) {
|
||
width: 14px !important;
|
||
height: 14px !important;
|
||
flex-shrink: 0;
|
||
display: inline-block;
|
||
vertical-align: middle;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__header > *) {
|
||
flex-shrink: 0;
|
||
min-width: 0;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__body) {
|
||
padding: 16px;
|
||
text-align: center;
|
||
overflow-x: auto;
|
||
/* Reserve a stable height so the box doesn't collapse to 0px before the
|
||
SVG paints — keeps layout stable across the streaming cache-miss →
|
||
render cycle. */
|
||
min-height: 96px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__body svg) {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
.markdown-body :deep(.mermaid-block.mermaid-error .mermaid-block__body) {
|
||
background: #fef2f2;
|
||
color: #b91c1c;
|
||
text-align: left;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
font-size: 12px;
|
||
white-space: pre-wrap;
|
||
display: block;
|
||
}
|
||
/* Streaming placeholder: three pulsing dots inside the empty body. The dots
|
||
render the same DOM string on every v-html update (stable innerHTML) so
|
||
the box stops "shaking" during streaming. Once the async render fires
|
||
after stream end, this gets replaced with the actual SVG. */
|
||
.markdown-body :deep(.mermaid-block__loader) {
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__loader-dot) {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--mc-mermaid-border, #cbd5e1);
|
||
animation: mc-mermaid-pulse 1.4s ease-in-out infinite;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__loader-dot:nth-child(2)) {
|
||
animation-delay: 0.2s;
|
||
}
|
||
.markdown-body :deep(.mermaid-block__loader-dot:nth-child(3)) {
|
||
animation-delay: 0.4s;
|
||
}
|
||
@keyframes mc-mermaid-pulse {
|
||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
|
||
40% { opacity: 1; transform: scale(1); }
|
||
}
|
||
.markdown-body :deep(.mermaid-block__download.is-flash) {
|
||
background: var(--mc-warning-bg, rgba(255, 159, 67, 0.15));
|
||
color: var(--mc-warning, #f59e0b);
|
||
}
|
||
|
||
/* ===== KaTeX inline / block ===== */
|
||
.markdown-body :deep(.katex-inline) {
|
||
font-size: 1em;
|
||
}
|
||
.markdown-body :deep(.katex-block) {
|
||
display: block;
|
||
margin: 12px 0;
|
||
text-align: center;
|
||
overflow-x: auto;
|
||
}
|
||
.markdown-body :deep(.katex-error) {
|
||
color: #b91c1c;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
font-size: 0.92em;
|
||
}
|
||
|
||
.markdown-body :deep(img) {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border-radius: 8px;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.markdown-body :deep(del),
|
||
.markdown-body :deep(s) {
|
||
text-decoration: line-through;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.markdown-body :deep(strong),
|
||
.markdown-body :deep(b) {
|
||
font-weight: 600;
|
||
color: var(--mc-text-primary, #1e293b);
|
||
}
|
||
|
||
/* ===== 移动端适配 ===== */
|
||
@media (max-width: 768px) {
|
||
.message-wrapper {
|
||
max-width: 100%;
|
||
gap: 8px;
|
||
}
|
||
|
||
.msg-body {
|
||
max-width: calc(100% - 40px);
|
||
}
|
||
|
||
.msg-avatar {
|
||
width: 28px;
|
||
height: 28px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.error-card {
|
||
max-width: 100%;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<!-- Unscoped: the usage popover renders into <body> via Element Plus popper,
|
||
so scoped selectors can't reach it. Classes are mc-usage-* prefixed. -->
|
||
<style>
|
||
.mc-usage-popover.el-popover {
|
||
padding: 12px 14px;
|
||
}
|
||
.mc-usage-panel {
|
||
font-size: 12px;
|
||
color: var(--mc-text-secondary, #64748b);
|
||
}
|
||
.mc-usage-header {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.mc-usage-title {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
color: var(--mc-text-primary, #1e293b);
|
||
flex: 1;
|
||
}
|
||
.mc-usage-total-label {
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
}
|
||
.mc-usage-total {
|
||
font-weight: 700;
|
||
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
|
||
color: var(--mc-text-primary, #1e293b);
|
||
}
|
||
.mc-usage-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 2px 0;
|
||
}
|
||
.mc-usage-row-main .mc-usage-label {
|
||
color: var(--mc-text-primary, #1e293b);
|
||
font-weight: 500;
|
||
}
|
||
.mc-usage-row-sub {
|
||
padding-left: 14px;
|
||
}
|
||
.mc-usage-label {
|
||
flex: 1;
|
||
}
|
||
.mc-usage-label-indent {
|
||
padding-left: 14px;
|
||
}
|
||
.mc-usage-value {
|
||
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
|
||
color: var(--mc-text-primary, #1e293b);
|
||
}
|
||
.mc-usage-divider {
|
||
border-top: 1px dashed var(--mc-border-2, #e2e8f0);
|
||
margin: 6px 0;
|
||
}
|
||
.mc-usage-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 2px;
|
||
flex: none;
|
||
display: inline-block;
|
||
}
|
||
.mc-dot-input { background: #3b82f6; }
|
||
.mc-dot-output { background: #a855f7; }
|
||
.mc-dot-hit { background: #10b981; }
|
||
.mc-dot-miss { background: #f43f5e; }
|
||
.mc-dot-write { background: #eab308; }
|
||
.mc-usage-hit-icon {
|
||
flex: none;
|
||
font-size: 11px;
|
||
}
|
||
.mc-usage-hit-rate {
|
||
color: #10b981;
|
||
font-weight: 700;
|
||
}
|
||
.mc-usage-bar {
|
||
display: flex;
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
background: var(--mc-fill-2, rgba(100, 116, 139, 0.08));
|
||
margin: 6px 0 6px;
|
||
}
|
||
.mc-usage-bar i {
|
||
display: block;
|
||
height: 100%;
|
||
}
|
||
.mc-bar-hit { background: #10b981; }
|
||
.mc-bar-write { background: #eab308; }
|
||
.mc-bar-miss { background: #f43f5e; }
|
||
.mc-usage-legend {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 11px;
|
||
color: var(--mc-text-tertiary, #94a3b8);
|
||
}
|
||
.mc-usage-legend span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
</style>
|