1717 lines
60 KiB
Vue
1717 lines
60 KiB
Vue
<template>
|
||
<div class="messages-area messages-area--stick" ref="scrollRef">
|
||
<div class="messages-flow" ref="contentRef">
|
||
<div
|
||
v-for="(msg, index) in filteredMessages"
|
||
:key="index"
|
||
class="message-block"
|
||
:class="{
|
||
'message-block--compact-user': getMessageVisibility(msg) === 'compact',
|
||
'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg),
|
||
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index)
|
||
}"
|
||
>
|
||
<div v-if="msg.role === 'user'" class="user-message" :class="{ 'user-message--compact': getMessageVisibility(msg) === 'compact', 'user-message--brief': isBriefCompactMessage(msg) }">
|
||
<div v-if="isBriefCompactMessage(msg)" class="compact-brief-line" role="separator">
|
||
<span class="compact-brief-text">{{ compactBriefLabel(msg) }}</span>
|
||
</div>
|
||
<template v-else>
|
||
<div class="message-header icon-label">
|
||
<span class="icon icon-sm" :style="iconStyleSafe(userHeaderIconKey(msg))" aria-hidden="true"></span>
|
||
<span>{{ userHeaderLabel(msg) }}</span>
|
||
</div>
|
||
<div class="message-text user-bubble-text">
|
||
<div
|
||
v-if="msg.content"
|
||
class="bubble-text"
|
||
v-html="renderUserMessageContent(msg.content)"
|
||
></div>
|
||
<div v-if="msg.images && msg.images.length" class="image-inline-row">
|
||
<div
|
||
class="image-thumbnail-wrapper"
|
||
v-for="(img, imgIndex) in msg.images"
|
||
:key="mediaPreviewKey(msg, img, imgIndex)"
|
||
>
|
||
<img
|
||
:src="getPreviewUrl(msg, img, 'image')"
|
||
:alt="formatImageName(img)"
|
||
class="image-thumbnail"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div v-if="msg.videos && msg.videos.length" class="image-inline-row video-inline-row">
|
||
<div
|
||
class="image-thumbnail-wrapper"
|
||
v-for="(video, videoIndex) in msg.videos"
|
||
:key="mediaPreviewKey(msg, video, videoIndex)"
|
||
>
|
||
<img
|
||
:src="getPreviewUrl(msg, video, 'video')"
|
||
:alt="formatImageName(video)"
|
||
class="image-thumbnail"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
|
||
<!-- 在 assistant 消息前显示 AI Assistant 头部 -->
|
||
<!-- 只有当前一条消息是 user 且当前消息有内容时才显示 -->
|
||
<div
|
||
v-if="
|
||
shouldShowAssistantHeader(index) &&
|
||
(hasRenderableAssistantActions(msg.actions || []) || msg.awaitingFirstContent)
|
||
"
|
||
class="message-header icon-label"
|
||
>
|
||
<span class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span>
|
||
<span>{{ aiAssistantName }}</span>
|
||
<span v-if="assistantWorkLabel(index)" class="assistant-work-status">{{
|
||
assistantWorkLabel(index)
|
||
}}</span>
|
||
</div>
|
||
<div
|
||
v-if="msg.awaitingFirstContent"
|
||
class="action-item streaming-content immediate-show assistant-generating-block"
|
||
>
|
||
<div class="text-output">
|
||
<div
|
||
class="text-content assistant-generating-placeholder"
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
<span
|
||
v-for="(letter, letterIndex) in getGeneratingLetters(msg)"
|
||
:key="letterIndex"
|
||
class="assistant-generating-letter"
|
||
:style="{ animationDelay: `${letterIndex * 0.08}s` }"
|
||
>
|
||
{{ letter }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template v-if="blockDisplayMode === 'minimal'">
|
||
<MinimalBlocks
|
||
v-if="hasRenderableAssistantActions(msg.actions || [])"
|
||
:actions="msg.actions || []"
|
||
:conversation-running="streamingMessage"
|
||
:is-latest-message="index === latestMessageIndex"
|
||
:icon-style="iconStyleSafe"
|
||
:get-tool-icon="getToolIcon"
|
||
:get-tool-status-text="getToolStatusText"
|
||
:get-tool-description="getToolDescription"
|
||
:format-search-topic="formatSearchTopic"
|
||
:format-search-time="formatSearchTime"
|
||
:format-search-domains="formatSearchDomains"
|
||
:render-markdown="renderMarkdown"
|
||
:register-thinking-ref="registerThinkingRef"
|
||
:handle-thinking-scroll="props.handleThinkingScroll"
|
||
@group-toggle="handleMinimalGroupToggle"
|
||
/>
|
||
</template>
|
||
<template v-else-if="stackedBlocksEnabled">
|
||
<template v-for="group in splitActionGroups(msg.actions || [], index)" :key="group.key">
|
||
<StackedBlocks
|
||
v-if="group.kind === 'stack'"
|
||
class="stacked-blocks-wrapper"
|
||
:actions="group.actions"
|
||
:expanded-blocks="expandedBlocks"
|
||
:conversation-running="streamingMessage"
|
||
:is-latest-message="index === latestMessageIndex"
|
||
:icon-style="iconStyleSafe"
|
||
:toggle-block="toggleBlock"
|
||
:register-thinking-ref="registerThinkingRef"
|
||
:handle-thinking-scroll="handleThinkingScroll"
|
||
:get-tool-animation-class="getToolAnimationClass"
|
||
:get-tool-icon="getToolIcon"
|
||
:get-tool-status-text="getToolStatusText"
|
||
:get-tool-description="getToolDescription"
|
||
:format-search-topic="formatSearchTopic"
|
||
:format-search-time="formatSearchTime"
|
||
:format-search-domains="formatSearchDomains"
|
||
/>
|
||
<div
|
||
v-else-if="isActionVisible(group.action)"
|
||
class="action-item"
|
||
:key="group.action?.id || `${index}-${group.actionIndex}`"
|
||
:class="{
|
||
'streaming-content': group.action?.streaming,
|
||
'completed-tool': group.action?.type === 'tool' && !group.action?.streaming,
|
||
'immediate-show':
|
||
group.action?.streaming ||
|
||
group.action?.type === 'text' ||
|
||
group.action?.type === 'thinking',
|
||
'thinking-finished': group.action?.type === 'thinking' && !group.action?.streaming,
|
||
'no-entry-animation': !streamingMessage || index !== latestMessageIndex
|
||
}"
|
||
>
|
||
<div
|
||
v-if="group.action?.type === 'thinking'"
|
||
class="collapsible-block thinking-block"
|
||
:class="{
|
||
expanded: expandedBlocks?.has(
|
||
group.action.blockId || `${index}-thinking-${group.actionIndex}`
|
||
)
|
||
}"
|
||
:data-block-id="group.action.blockId || `${index}-thinking-${group.actionIndex}`"
|
||
>
|
||
<div
|
||
class="collapsible-header"
|
||
@click="
|
||
toggleBlock(group.action.blockId || `${index}-thinking-${group.actionIndex}`)
|
||
"
|
||
>
|
||
<div class="arrow"></div>
|
||
<div class="status-icon">
|
||
<span
|
||
class="thinking-icon"
|
||
:class="{ 'thinking-animation': group.action.streaming }"
|
||
>
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('brain')"
|
||
aria-hidden="true"
|
||
></span>
|
||
</span>
|
||
</div>
|
||
<span class="status-text">{{
|
||
group.action.streaming ? '正在思考...' : '思考过程'
|
||
}}</span>
|
||
</div>
|
||
<div
|
||
class="collapsible-content"
|
||
:ref="
|
||
(el) =>
|
||
registerCollapseContent(
|
||
group.action.blockId || `${index}-thinking-${group.actionIndex}`,
|
||
el
|
||
)
|
||
"
|
||
>
|
||
<div
|
||
class="content-inner thinking-content"
|
||
:ref="
|
||
(el) =>
|
||
registerThinkingRef(
|
||
group.action.blockId || `${index}-thinking-${group.actionIndex}`,
|
||
el
|
||
)
|
||
"
|
||
@scroll="
|
||
handleThinkingScroll(
|
||
group.action.blockId || `${index}-thinking-${group.actionIndex}`,
|
||
$event
|
||
)
|
||
"
|
||
style="max-height: 240px; overflow-y: auto"
|
||
>
|
||
{{ group.action.content }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="group.action?.type === 'text'" class="text-output">
|
||
<div class="text-content" :class="{ 'streaming-text': group.action.streaming }">
|
||
<div
|
||
v-if="group.action.streaming"
|
||
v-html="renderMarkdown(group.action.content, true)"
|
||
></div>
|
||
<div v-else v-html="renderMarkdown(group.action.content, false)"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="
|
||
group.action?.type === 'system' && getSubAgentSystemNoticeLabel(group.action)
|
||
"
|
||
class="sub-agent-system-summary-line"
|
||
>
|
||
{{ getSubAgentSystemNoticeLabel(group.action) }}
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="group.action?.type === 'append_payload'"
|
||
class="append-placeholder"
|
||
:class="{ 'append-error': group.action.append?.success === false }"
|
||
>
|
||
<div class="append-placeholder-content">
|
||
<template v-if="group.action.append?.success !== false">
|
||
<div class="icon-label append-status">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('pencil')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span
|
||
>已写入
|
||
{{ group.action.append?.path || '目标文件' }}
|
||
的追加内容(内容已保存至文件)</span
|
||
>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="icon-label append-status append-error-text">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('x')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span
|
||
>向
|
||
{{ group.action.append?.path || '目标文件' }}
|
||
写入失败,内容已截获供后续修复。</span
|
||
>
|
||
</div>
|
||
</template>
|
||
<div class="append-meta" v-if="group.action.append">
|
||
<span
|
||
v-if="
|
||
group.action.append.lines !== null &&
|
||
group.action.append.lines !== undefined
|
||
"
|
||
>
|
||
· 行数 {{ group.action.append.lines }}
|
||
</span>
|
||
<span
|
||
v-if="
|
||
group.action.append.bytes !== null &&
|
||
group.action.append.bytes !== undefined
|
||
"
|
||
>
|
||
· 字节 {{ group.action.append.bytes }}
|
||
</span>
|
||
</div>
|
||
<div class="append-warning icon-label" v-if="group.action.append?.forced">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('triangleAlert')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span>未检测到结束标记,请根据提示继续补充。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="group.action?.type === 'append'"
|
||
class="append-placeholder"
|
||
:class="{ 'append-error': group.action.append?.success === false }"
|
||
>
|
||
<div class="append-placeholder-content">
|
||
<div class="icon-label append-status">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('pencil')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span>{{ group.action.append?.summary || '文件追加完成' }}</span>
|
||
</div>
|
||
<div class="append-meta" v-if="group.action.append">
|
||
<span>{{ group.action.append.path || '目标文件' }}</span>
|
||
<span v-if="group.action.append.lines"
|
||
>· 行数 {{ group.action.append.lines }}</span
|
||
>
|
||
<span v-if="group.action.append.bytes"
|
||
>· 字节 {{ group.action.append.bytes }}</span
|
||
>
|
||
</div>
|
||
<div class="append-warning icon-label" v-if="group.action.append?.forced">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('triangleAlert')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span>未检测到结束标记,请按提示继续补充。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<ToolAction
|
||
v-else-if="group.action?.type === 'tool'"
|
||
:action="group.action"
|
||
:expanded="
|
||
expandedBlocks?.has(
|
||
group.action.blockId || `${index}-tool-${group.actionIndex}`
|
||
)
|
||
"
|
||
:icon-style="iconStyleSafe"
|
||
:get-tool-animation-class="getToolAnimationClass"
|
||
:get-tool-icon="getToolIcon"
|
||
:get-tool-status-text="getToolStatusText"
|
||
:get-tool-description="getToolDescription"
|
||
:format-search-topic="formatSearchTopic"
|
||
:format-search-time="formatSearchTime"
|
||
:format-search-domains="formatSearchDomains"
|
||
:streaming-message="streamingMessage"
|
||
:register-collapse-content="registerCollapseContent"
|
||
:collapse-key="group.action.blockId || `${index}-tool-${group.actionIndex}`"
|
||
:block-id="group.action.blockId || `${index}-tool-${group.actionIndex}`"
|
||
@toggle="
|
||
toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)
|
||
"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<template
|
||
v-for="(action, actionIndex) in msg.actions || []"
|
||
:key="action.id || `${index}-${actionIndex}`"
|
||
>
|
||
<div
|
||
v-if="isActionVisible(action)"
|
||
class="action-item"
|
||
:class="{
|
||
'streaming-content': action.streaming,
|
||
'completed-tool': action.type === 'tool' && !action.streaming,
|
||
'immediate-show':
|
||
action.streaming || action.type === 'text' || action.type === 'thinking',
|
||
'thinking-finished': action.type === 'thinking' && !action.streaming,
|
||
'no-entry-animation': !streamingMessage || index !== latestMessageIndex
|
||
}"
|
||
>
|
||
<div
|
||
v-if="action.type === 'thinking'"
|
||
class="collapsible-block thinking-block"
|
||
:class="{
|
||
expanded: expandedBlocks?.has(
|
||
action.blockId || `${index}-thinking-${actionIndex}`
|
||
)
|
||
}"
|
||
:data-block-id="action.blockId || `${index}-thinking-${actionIndex}`"
|
||
>
|
||
<div
|
||
class="collapsible-header"
|
||
@click="toggleBlock(action.blockId || `${index}-thinking-${actionIndex}`)"
|
||
>
|
||
<div class="arrow"></div>
|
||
<div class="status-icon">
|
||
<span
|
||
class="thinking-icon"
|
||
:class="{ 'thinking-animation': action.streaming }"
|
||
>
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('brain')"
|
||
aria-hidden="true"
|
||
></span>
|
||
</span>
|
||
</div>
|
||
<span class="status-text">{{
|
||
action.streaming ? '正在思考...' : '思考过程'
|
||
}}</span>
|
||
</div>
|
||
<div
|
||
class="collapsible-content"
|
||
:ref="
|
||
(el) =>
|
||
registerCollapseContent(
|
||
action.blockId || `${index}-thinking-${actionIndex}`,
|
||
el
|
||
)
|
||
"
|
||
>
|
||
<div
|
||
class="content-inner thinking-content"
|
||
:ref="
|
||
(el) =>
|
||
registerThinkingRef(
|
||
action.blockId || `${index}-thinking-${actionIndex}`,
|
||
el
|
||
)
|
||
"
|
||
@scroll="
|
||
handleThinkingScroll(
|
||
action.blockId || `${index}-thinking-${actionIndex}`,
|
||
$event
|
||
)
|
||
"
|
||
style="max-height: 240px; overflow-y: auto"
|
||
>
|
||
{{ action.content }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="action.type === 'text'" class="text-output">
|
||
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
|
||
<div
|
||
v-if="action.streaming"
|
||
v-html="renderMarkdown(action.content, true)"
|
||
></div>
|
||
<div v-else v-html="renderMarkdown(action.content, false)"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="action.type === 'system' && getSubAgentSystemNoticeLabel(action)"
|
||
class="sub-agent-system-summary-line"
|
||
>
|
||
{{ getSubAgentSystemNoticeLabel(action) }}
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="action.type === 'append_payload'"
|
||
class="append-placeholder"
|
||
:class="{ 'append-error': action.append?.success === false }"
|
||
>
|
||
<div class="append-placeholder-content">
|
||
<template v-if="action.append?.success !== false">
|
||
<div class="icon-label append-status">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('pencil')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span
|
||
>已写入
|
||
{{ action.append?.path || '目标文件' }}
|
||
的追加内容(内容已保存至文件)</span
|
||
>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="icon-label append-status append-error-text">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('x')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span
|
||
>向
|
||
{{ action.append?.path || '目标文件' }}
|
||
写入失败,内容已截获供后续修复。</span
|
||
>
|
||
</div>
|
||
</template>
|
||
<div class="append-meta" v-if="action.append">
|
||
<span
|
||
v-if="action.append.lines !== null && action.append.lines !== undefined"
|
||
>
|
||
· 行数 {{ action.append.lines }}
|
||
</span>
|
||
<span
|
||
v-if="action.append.bytes !== null && action.append.bytes !== undefined"
|
||
>
|
||
· 字节 {{ action.append.bytes }}
|
||
</span>
|
||
</div>
|
||
<div class="append-warning icon-label" v-if="action.append?.forced">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('triangleAlert')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span>未检测到结束标记,请根据提示继续补充。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="action.type === 'append'"
|
||
class="append-placeholder"
|
||
:class="{ 'append-error': action.append?.success === false }"
|
||
>
|
||
<div class="append-placeholder-content">
|
||
<div class="icon-label append-status">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('pencil')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span>{{ action.append?.summary || '文件追加完成' }}</span>
|
||
</div>
|
||
<div class="append-meta" v-if="action.append">
|
||
<span>{{ action.append.path || '目标文件' }}</span>
|
||
<span v-if="action.append.lines">· 行数 {{ action.append.lines }}</span>
|
||
<span v-if="action.append.bytes">· 字节 {{ action.append.bytes }}</span>
|
||
</div>
|
||
<div class="append-warning icon-label" v-if="action.append?.forced">
|
||
<span
|
||
class="icon icon-sm"
|
||
:style="iconStyleSafe('triangleAlert')"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span>未检测到结束标记,请按提示继续补充。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<ToolAction
|
||
v-else-if="action.type === 'tool'"
|
||
:action="action"
|
||
:expanded="expandedBlocks?.has(action.blockId || `${index}-tool-${actionIndex}`)"
|
||
:icon-style="iconStyleSafe"
|
||
:get-tool-animation-class="getToolAnimationClass"
|
||
:get-tool-icon="getToolIcon"
|
||
:get-tool-status-text="getToolStatusText"
|
||
:get-tool-description="getToolDescription"
|
||
:format-search-topic="formatSearchTopic"
|
||
:format-search-time="formatSearchTime"
|
||
:format-search-domains="formatSearchDomains"
|
||
:streaming-message="streamingMessage"
|
||
:register-collapse-content="registerCollapseContent"
|
||
:collapse-key="action.blockId || `${index}-tool-${actionIndex}`"
|
||
:block-id="action.blockId || `${index}-tool-${actionIndex}`"
|
||
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="system-message"
|
||
@vue:mounted="
|
||
() =>
|
||
console.log('[ChatArea] 渲染系统消息:', {
|
||
index,
|
||
role: msg.role,
|
||
content: msg.content
|
||
})
|
||
"
|
||
>
|
||
<div
|
||
class="collapsible-block system-block"
|
||
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
|
||
:data-block-id="`system-${index}`"
|
||
>
|
||
<div class="collapsible-header" @click="toggleBlock(`system-${index}`)">
|
||
<div class="arrow"></div>
|
||
<div class="status-icon">
|
||
<span
|
||
class="tool-icon icon icon-md"
|
||
:style="iconStyleSafe('info')"
|
||
aria-hidden="true"
|
||
></span>
|
||
</div>
|
||
<span class="status-text">系统消息 (role: {{ msg.role }})</span>
|
||
</div>
|
||
<div
|
||
class="collapsible-content"
|
||
:ref="(el) => registerCollapseContent(`system-${index}`, el)"
|
||
>
|
||
<div class="content-inner">
|
||
{{ msg.content }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="messages-bottom-spacer" aria-hidden="true"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||
import { useStickToBottom } from 'vue-stick-to-bottom';
|
||
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
|
||
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||
import StackedBlocks from './StackedBlocks.vue';
|
||
import MinimalBlocks from './MinimalBlocks.vue';
|
||
import { usePersonalizationStore } from '@/stores/personalization';
|
||
import { getMessageVisibility, messageStartsWork } from '@/utils/messageVisibility';
|
||
|
||
const props = defineProps<{
|
||
messages: Array<any>;
|
||
iconStyle: (key: string, size?: string) => Record<string, string>;
|
||
expandedBlocks: Set<string>;
|
||
streamingMessage: boolean;
|
||
toggleBlock: (blockId: string) => void;
|
||
handleThinkingScroll: (blockId: string, event: Event) => void;
|
||
renderMarkdown: (content: string, isStreaming: boolean) => string;
|
||
getToolIcon: (tool: any) => string;
|
||
getToolStatusText: (tool: any) => string;
|
||
getToolAnimationClass: (tool: any) => string | Record<string, unknown>;
|
||
getToolDescription: (tool: any) => string;
|
||
formatSearchTopic: (filters: Record<string, any>) => string;
|
||
formatSearchTime: (filters: Record<string, any>) => string;
|
||
formatSearchDomains: (filters: Record<string, any>) => string;
|
||
}>();
|
||
const emit = defineEmits<{
|
||
(
|
||
event: 'stick-state-change',
|
||
payload: { isAtBottom: boolean; isNearBottom: boolean; escapedFromLock: boolean }
|
||
): void;
|
||
(event: 'user-scroll-intent', payload: { ts: number; delta: number; top: number }): void;
|
||
}>();
|
||
|
||
const personalization = usePersonalizationStore();
|
||
const blockDisplayMode = computed(() => {
|
||
return personalization.experiments.blockDisplayMode || 'stacked';
|
||
});
|
||
const compactMessageDisplay = computed(() => {
|
||
return (
|
||
personalization.form.compact_message_display ||
|
||
personalization.experiments.compactMessageDisplay ||
|
||
'full'
|
||
);
|
||
});
|
||
const stackedBlocksEnabled = computed(() => {
|
||
// 堆叠模式和极简模式都使用堆叠布局
|
||
return blockDisplayMode.value === 'stacked' || blockDisplayMode.value === 'minimal';
|
||
});
|
||
const aiAssistantName = computed(() => {
|
||
if (!personalization.form.use_custom_names) {
|
||
return 'AI Assistant';
|
||
}
|
||
return personalization.form.self_identify || 'AI Assistant';
|
||
});
|
||
const userName = computed(() => {
|
||
if (!personalization.form.use_custom_names) {
|
||
return '用户';
|
||
}
|
||
return personalization.form.user_name || '用户';
|
||
});
|
||
const isSystemAutoUserMessage = (message: any) => {
|
||
if (!message || message.role !== 'user') {
|
||
return false;
|
||
}
|
||
const meta = message.metadata || {};
|
||
return !!(
|
||
meta.is_auto_generated ||
|
||
meta.auto_message_type ||
|
||
meta.sub_agent_notice ||
|
||
meta.background_command_notice
|
||
);
|
||
};
|
||
const escapeUserHtml = (value: string): string =>
|
||
String(value || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.agents\/skills\/[^)\n]+\/SKILL\.md)\)/g;
|
||
|
||
function renderUserMessageContent(content: string): string {
|
||
const source = String(content || '');
|
||
USER_SKILL_LINK_RE.lastIndex = 0;
|
||
let html = '';
|
||
let cursor = 0;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = USER_SKILL_LINK_RE.exec(source))) {
|
||
html += escapeUserHtml(source.slice(cursor, match.index));
|
||
html += ` <span class="user-skill-link">${escapeUserHtml(match[1] || '')}</span> `;
|
||
cursor = match.index + match[0].length;
|
||
}
|
||
html += escapeUserHtml(source.slice(cursor));
|
||
return html;
|
||
}
|
||
const isHiddenUserMessage = (message: any) => {
|
||
if (!message || message.role !== 'user') {
|
||
return false;
|
||
}
|
||
return getMessageVisibility(message) === 'hidden';
|
||
};
|
||
const isEmptyAssistantMessage = (message: any) => {
|
||
if (!message || message.role !== 'assistant') {
|
||
return false;
|
||
}
|
||
const actions = Array.isArray(message.actions) ? message.actions : [];
|
||
const hasRenderable = actions.some((action) => isActionVisible(action));
|
||
if (hasRenderable) {
|
||
return false;
|
||
}
|
||
if (message.awaitingFirstContent) {
|
||
return false;
|
||
}
|
||
// 无可渲染 action 且不在等待首包、也不在流式中的 assistant 视为“空壳”并过滤。
|
||
const streaming = !!(message.streaming || message.currentStreamingType || message.streamingText || message.streamingThinking);
|
||
return !streaming;
|
||
};
|
||
|
||
const filteredMessages = computed(() => {
|
||
const source = props.messages || [];
|
||
const droppedRoleSystem = source.filter((m) => m && m.role === 'system');
|
||
if (droppedRoleSystem.length > 0) {
|
||
console.log('[DEBUG_SYSTEM][render] filteredMessages 过滤了 role=system 消息', {
|
||
count: droppedRoleSystem.length,
|
||
samples: droppedRoleSystem.slice(0, 3)
|
||
});
|
||
}
|
||
const result = source.filter((m) => {
|
||
if (m && m.metadata && m.metadata.system_injected_image) {
|
||
return false;
|
||
}
|
||
if (m?.role === 'system') {
|
||
return false;
|
||
}
|
||
if (isHiddenUserMessage(m)) {
|
||
return false;
|
||
}
|
||
if (isEmptyAssistantMessage(m)) {
|
||
userMDebug('ChatArea.filteredMessages:drop-empty-assistant', {
|
||
actions: Array.isArray(m?.actions) ? m.actions.map((a: any) => a?.type) : [],
|
||
awaitingFirstContent: !!m?.awaitingFirstContent
|
||
});
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
if (source.length !== result.length) {
|
||
userMDebug('ChatArea.filteredMessages:dropped', {
|
||
sourceLength: source.length,
|
||
resultLength: result.length,
|
||
dropped: source.length - result.length
|
||
});
|
||
}
|
||
return result;
|
||
});
|
||
const latestMessageIndex = computed(() => filteredMessages.value.length - 1);
|
||
const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/;
|
||
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[::]/;
|
||
const BG_RUN_COMMAND_DONE_LABEL_RE = /^(?:\[)?后台\s*run_command\s*完成(?:\])?$/;
|
||
const RENDERABLE_ACTION_TYPES = new Set([
|
||
'thinking',
|
||
'text',
|
||
'tool',
|
||
'append',
|
||
'append_payload',
|
||
'system'
|
||
]);
|
||
const nowMs = ref(Date.now());
|
||
let timerHandle: number | null = null;
|
||
const debugLoggedSystemActionKeys = new Set<string>();
|
||
const debugLoggedUnknownActionKeys = new Set<string>();
|
||
const CHAT_DEBUG_LOGS = false;
|
||
const userMDebug = (...args: any[]) => {
|
||
if (!CHAT_DEBUG_LOGS) {
|
||
return;
|
||
}
|
||
console.log('[USERMDEBUG]', ...args);
|
||
};
|
||
const DEFAULT_GENERATING_TEXT = '生成中…';
|
||
const {
|
||
scrollRef,
|
||
contentRef,
|
||
isAtBottom,
|
||
isNearBottom,
|
||
escapedFromLock,
|
||
scrollToBottom,
|
||
stopScroll
|
||
} = useStickToBottom({
|
||
resize: {
|
||
damping: 0.5,
|
||
stiffness: 0.28,
|
||
mass: 0.75
|
||
},
|
||
initial: 'instant'
|
||
});
|
||
const rootEl = scrollRef;
|
||
const { anchorBlockElement, stopAll: stopBlockExpansionAnchors } = useBlockExpansionAnchor(
|
||
scrollRef,
|
||
{ stopScroll }
|
||
);
|
||
const thinkingRefs = new Map<string, HTMLElement | null>();
|
||
let scrollbarResizeObserver: ResizeObserver | null = null;
|
||
let bounceTraceCount = 0;
|
||
const BOUNCE_TRACE_MAX = 240;
|
||
const bounceTraceLastTsByKey = new Map<string, number>();
|
||
let lastObservedTop = 0;
|
||
let lastObservedHeight = 0;
|
||
let lastUserDownScrollTs = 0;
|
||
let lastUserWheelUpTs = 0;
|
||
let lastProgrammaticHintTs = 0;
|
||
let lastProgrammaticHintSource = '';
|
||
let suppressUserIntentUntil = 0;
|
||
let isHandlingLargeGrowth = false;
|
||
let scrollListener: ((event: Event) => void) | null = null;
|
||
let wheelListener: ((event: WheelEvent) => void) | null = null;
|
||
let traceAttachLogged = false;
|
||
|
||
function isScrollBounceTraceEnabled() {
|
||
if (typeof window === 'undefined') return true;
|
||
try {
|
||
const explicit = (window as any).__SCROLL_BOUNCE_TRACE__;
|
||
if (explicit === false || explicit === '0') return false;
|
||
if (explicit === true || explicit === '1') return true;
|
||
const localFlag = window.localStorage?.getItem('scrollBounceTrace');
|
||
if (localFlag === '0' || localFlag === 'false') return false;
|
||
if (localFlag === '1' || localFlag === 'true') return true;
|
||
return true;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function getScrollMetrics(el: HTMLElement) {
|
||
const top = el.scrollTop;
|
||
const height = el.scrollHeight;
|
||
const client = el.clientHeight;
|
||
return {
|
||
top,
|
||
height,
|
||
client,
|
||
remain: height - top - client,
|
||
hasShowHtml: !!el.querySelector(
|
||
'show_html, show-html, .chat-inline-html, .chat-inline-card--html'
|
||
)
|
||
};
|
||
}
|
||
|
||
function bounceTraceLog(
|
||
event: string,
|
||
payload: Record<string, any> = {},
|
||
key = event,
|
||
throttleMs = 120
|
||
) {
|
||
if (!isScrollBounceTraceEnabled()) return;
|
||
if (bounceTraceCount >= BOUNCE_TRACE_MAX) return;
|
||
const now = Date.now();
|
||
const last = bounceTraceLastTsByKey.get(key) || 0;
|
||
if (throttleMs > 0 && now - last < throttleMs) return;
|
||
bounceTraceLastTsByKey.set(key, now);
|
||
bounceTraceCount += 1;
|
||
if (bounceTraceCount === BOUNCE_TRACE_MAX) {
|
||
console.warn('[SCROLL_BOUNCE_TRACE]', 'log-limit-reached', { max: BOUNCE_TRACE_MAX });
|
||
return;
|
||
}
|
||
console.log('[SCROLL_BOUNCE_TRACE]', event, payload);
|
||
}
|
||
|
||
function markProgrammaticHint(source: string) {
|
||
lastProgrammaticHintTs = Date.now();
|
||
lastProgrammaticHintSource = source;
|
||
}
|
||
|
||
function detachBounceListener() {
|
||
const el = scrollRef.value;
|
||
if (el && scrollListener) {
|
||
el.removeEventListener('scroll', scrollListener as EventListener);
|
||
}
|
||
if (el && wheelListener) {
|
||
el.removeEventListener('wheel', wheelListener as EventListener, true);
|
||
}
|
||
scrollListener = null;
|
||
wheelListener = null;
|
||
}
|
||
|
||
function attachBounceListener() {
|
||
const el = scrollRef.value;
|
||
if (!el) return;
|
||
if (scrollListener) {
|
||
el.removeEventListener('scroll', scrollListener as EventListener);
|
||
}
|
||
if (wheelListener) {
|
||
el.removeEventListener('wheel', wheelListener as EventListener, true);
|
||
}
|
||
lastObservedTop = el.scrollTop || 0;
|
||
lastObservedHeight = el.scrollHeight || 0;
|
||
wheelListener = (event: WheelEvent) => {
|
||
const target = scrollRef.value;
|
||
if (!target) return;
|
||
const trusted = (event as any).isTrusted === true;
|
||
const deltaY = Number(event.deltaY || 0);
|
||
if (!(trusted && deltaY < -1 && Date.now() > suppressUserIntentUntil)) {
|
||
return;
|
||
}
|
||
const now = Date.now();
|
||
lastUserWheelUpTs = now;
|
||
stopScroll();
|
||
emit('user-scroll-intent', {
|
||
ts: now,
|
||
delta: deltaY,
|
||
top: target.scrollTop || 0
|
||
});
|
||
bounceTraceLog(
|
||
'user-scroll-intent:wheel-up',
|
||
{ deltaY, top: target.scrollTop || 0, ...getScrollMetrics(target) },
|
||
'user-scroll-intent:wheel-up',
|
||
80
|
||
);
|
||
};
|
||
el.addEventListener('wheel', wheelListener, { passive: true, capture: true });
|
||
scrollListener = (event: Event) => {
|
||
const target = event.target as HTMLElement | null;
|
||
if (!target) return;
|
||
const top = target.scrollTop;
|
||
const delta = top - lastObservedTop;
|
||
const height = target.scrollHeight || 0;
|
||
const heightDelta = height - lastObservedHeight;
|
||
lastObservedTop = top;
|
||
lastObservedHeight = height;
|
||
|
||
// 大幅高度增长检测:当 scrollHeight 突增(内容大幅展开/渲染),
|
||
// 且用户没有主动脱离锁定(escapedFromLock=false)时,
|
||
// 用 instant 瞬间追底,绕过 spring 动画的延迟。
|
||
// 这解决了思考块展开、Markdown 表格渲染等场景下滚动跟不上内容增长的问题。
|
||
const largeGrowthThreshold = Math.max(200, target.clientHeight * 0.35 || 200);
|
||
if (
|
||
!isHandlingLargeGrowth &&
|
||
heightDelta > largeGrowthThreshold &&
|
||
!escapedFromLock.value
|
||
) {
|
||
isHandlingLargeGrowth = true;
|
||
markProgrammaticHint('ChatArea.largeGrowth');
|
||
suppressUserIntentUntil = Date.now() + 900;
|
||
scrollToBottom({ animation: 'instant', preserveScrollPosition: false });
|
||
bounceTraceLog(
|
||
'large-growth:instant-scroll',
|
||
{ heightDelta, threshold: largeGrowthThreshold, ...getScrollMetrics(target) },
|
||
'large-growth:instant-scroll',
|
||
120
|
||
);
|
||
setTimeout(() => {
|
||
isHandlingLargeGrowth = false;
|
||
}, 400);
|
||
}
|
||
|
||
const now = Date.now();
|
||
const trusted = (event as any).isTrusted === true;
|
||
const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140;
|
||
const likelyLayoutDriven = Math.abs(heightDelta) > 2;
|
||
const manualUpByWheel = delta < -3 && now - lastUserWheelUpTs <= 240;
|
||
const strongManualUp = delta < -18 && now > suppressUserIntentUntil && !closeToProgrammaticHint;
|
||
const shouldTreatAsUserIntent =
|
||
trusted &&
|
||
Math.abs(delta) > 5 &&
|
||
now > suppressUserIntentUntil &&
|
||
(!closeToProgrammaticHint || strongManualUp) &&
|
||
(delta < 0 ? strongManualUp || manualUpByWheel || !likelyLayoutDriven : !likelyLayoutDriven);
|
||
if (shouldTreatAsUserIntent) {
|
||
// 用户手动滚动时,立即中断 stick 引擎中可能仍在运行的动画滚动
|
||
stopScroll();
|
||
lastUserDownScrollTs = now;
|
||
emit('user-scroll-intent', {
|
||
ts: lastUserDownScrollTs,
|
||
delta,
|
||
top
|
||
});
|
||
bounceTraceLog(
|
||
'user-scroll-intent',
|
||
{
|
||
delta,
|
||
top,
|
||
ts: lastUserDownScrollTs,
|
||
closeToProgrammaticHint,
|
||
likelyLayoutDriven,
|
||
manualUpByWheel,
|
||
strongManualUp,
|
||
...getScrollMetrics(target)
|
||
},
|
||
'user-scroll-intent',
|
||
120
|
||
);
|
||
}
|
||
if (delta < -8) {
|
||
const reboundWindow = now - lastUserDownScrollTs <= 1400;
|
||
if (reboundWindow) {
|
||
const byProgrammatic = now - lastProgrammaticHintTs <= 1200;
|
||
bounceTraceLog(
|
||
'rebound:detected',
|
||
{
|
||
delta,
|
||
trusted,
|
||
likelySource: byProgrammatic
|
||
? lastProgrammaticHintSource || 'programmatic-unknown'
|
||
: 'unknown(internal-stick/browser-anchor)',
|
||
msSinceUserDown: now - lastUserDownScrollTs,
|
||
msSinceProgrammatic: now - lastProgrammaticHintTs,
|
||
...getScrollMetrics(target),
|
||
stickState: {
|
||
isAtBottom: !!isAtBottom.value,
|
||
isNearBottom: !!isNearBottom.value,
|
||
escapedFromLock: !!escapedFromLock.value
|
||
}
|
||
},
|
||
'rebound:detected',
|
||
0
|
||
);
|
||
}
|
||
} else {
|
||
bounceTraceLog(
|
||
'scroll:event',
|
||
{
|
||
delta,
|
||
trusted,
|
||
...getScrollMetrics(target),
|
||
stickState: {
|
||
isAtBottom: !!isAtBottom.value,
|
||
isNearBottom: !!isNearBottom.value,
|
||
escapedFromLock: !!escapedFromLock.value
|
||
}
|
||
},
|
||
'scroll:event',
|
||
180
|
||
);
|
||
}
|
||
};
|
||
el.addEventListener('scroll', scrollListener, { passive: true });
|
||
if (!traceAttachLogged) {
|
||
traceAttachLogged = true;
|
||
console.warn('[SCROLL_BOUNCE_TRACE]', 'listener-attached', {
|
||
hasElement: !!el,
|
||
className: el.className || ''
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateChatScrollbarWidth() {
|
||
const el = scrollRef.value;
|
||
if (!el) return;
|
||
const scrollbarWidth = Math.max(0, el.offsetWidth - el.clientWidth);
|
||
el.style.setProperty('--chat-scrollbar-width', `${scrollbarWidth}px`);
|
||
}
|
||
|
||
watch(
|
||
[isAtBottom, isNearBottom, escapedFromLock],
|
||
([atBottom, nearBottom, escaped]) => {
|
||
emit('stick-state-change', {
|
||
isAtBottom: !!atBottom,
|
||
isNearBottom: !!nearBottom,
|
||
escapedFromLock: !!escaped
|
||
});
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
function escapeBlockSelector(value: string) {
|
||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||
return CSS.escape(value);
|
||
}
|
||
return value.replace(/["\\]/g, '\\$&');
|
||
}
|
||
|
||
function elementLooksStreaming(el: HTMLElement) {
|
||
return (
|
||
el.classList.contains('processing') ||
|
||
el.classList.contains('running') ||
|
||
!!el.querySelector('.processing, .running, .thinking-animation')
|
||
);
|
||
}
|
||
|
||
const prevExpandedBlocks = ref(new Set<string>());
|
||
|
||
watch(
|
||
() => props.expandedBlocks,
|
||
(newSet, oldSet) => {
|
||
const prev = oldSet ?? prevExpandedBlocks.value;
|
||
const all = new Set([...Array.from(newSet), ...Array.from(prev)]);
|
||
const changed = new Set<string>();
|
||
for (const id of all) {
|
||
if (newSet.has(id) !== prev.has(id)) {
|
||
changed.add(id);
|
||
}
|
||
}
|
||
prevExpandedBlocks.value = new Set(newSet);
|
||
|
||
if (changed.size === 0) return;
|
||
const container = scrollRef.value;
|
||
if (!container) return;
|
||
|
||
for (const id of changed) {
|
||
const el = container.querySelector(
|
||
`[data-block-id="${escapeBlockSelector(id)}"]`
|
||
) as HTMLElement | null;
|
||
if (el) {
|
||
const isExpanding = newSet.has(id);
|
||
const streaming = elementLooksStreaming(el);
|
||
anchorBlockElement(el, id, {
|
||
direction: 'auto',
|
||
duration: streaming ? 5000 : 260,
|
||
extendOnGrowth: streaming,
|
||
phase: isExpanding ? 'expand' : 'collapse'
|
||
});
|
||
}
|
||
}
|
||
},
|
||
{ flush: 'post' }
|
||
);
|
||
|
||
function handleMinimalGroupToggle(payload: { groupId: string; expanded: boolean }) {
|
||
const { groupId, expanded } = payload;
|
||
const container = scrollRef.value;
|
||
if (!container) return;
|
||
const el = container.querySelector(
|
||
`[data-group-id="${escapeBlockSelector(groupId)}"]`
|
||
) as HTMLElement | null;
|
||
if (el) {
|
||
const streaming = elementLooksStreaming(el);
|
||
anchorBlockElement(el, groupId, {
|
||
direction: 'auto',
|
||
duration: streaming ? 5000 : 300,
|
||
extendOnGrowth: streaming,
|
||
phase: expanded ? 'expand' : 'collapse'
|
||
});
|
||
}
|
||
}
|
||
|
||
const registerCollapseContent = (key: string, el: Element | null) => {
|
||
if (!(el instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
const COLLAPSE_MAX_HEIGHT = 600;
|
||
requestAnimationFrame(() => {
|
||
const h = el.scrollHeight || el.offsetHeight || 0;
|
||
if (h > 0) {
|
||
el.style.setProperty('--collapse-max', `${Math.min(h, COLLAPSE_MAX_HEIGHT)}px`);
|
||
}
|
||
});
|
||
};
|
||
|
||
function registerThinkingRef(key: string, el: Element | null) {
|
||
if (el instanceof HTMLElement) {
|
||
thinkingRefs.set(key, el);
|
||
} else {
|
||
thinkingRefs.delete(key);
|
||
}
|
||
}
|
||
|
||
function getThinkingRef(key: string) {
|
||
return thinkingRefs.get(key) || null;
|
||
}
|
||
|
||
async function stickScrollToBottom(
|
||
options: {
|
||
behavior?: ScrollBehavior;
|
||
force?: boolean;
|
||
preserveScrollPosition?: boolean;
|
||
} = {}
|
||
) {
|
||
const el = scrollRef.value;
|
||
if (el) {
|
||
bounceTraceLog(
|
||
'programmatic:scrollToBottom:before',
|
||
{
|
||
source: 'ChatArea.stickScrollToBottom',
|
||
behavior: options.behavior || 'auto',
|
||
force: !!options.force,
|
||
preserveScrollPosition: !!options.preserveScrollPosition,
|
||
...getScrollMetrics(el)
|
||
},
|
||
'programmatic:scrollToBottom:before',
|
||
80
|
||
);
|
||
}
|
||
markProgrammaticHint('ChatArea.stickScrollToBottom');
|
||
// 点击“滚动到底部”按钮时会触发 smooth 动画滚动;期间会产生 trusted scroll 事件,
|
||
// 若不短暂抑制会被误判为“用户手动滚动”从而中断动画,表现为只滚动一点点。
|
||
if (options.force || options.behavior === 'smooth') {
|
||
suppressUserIntentUntil = Date.now() + 900;
|
||
}
|
||
return await scrollToBottom({
|
||
animation: options.behavior === 'smooth' ? 'smooth' : 'auto',
|
||
ignoreEscapes: !!options.force,
|
||
preserveScrollPosition: !!options.preserveScrollPosition,
|
||
duration: options.force ? 260 : 0
|
||
});
|
||
}
|
||
|
||
async function stickConditionalScrollToBottom(options: { force?: boolean } = {}) {
|
||
const el = scrollRef.value;
|
||
if (el) {
|
||
bounceTraceLog(
|
||
'programmatic:conditional:before',
|
||
{
|
||
source: 'ChatArea.stickConditionalScrollToBottom',
|
||
force: !!options.force,
|
||
...getScrollMetrics(el)
|
||
},
|
||
'programmatic:conditional:before',
|
||
100
|
||
);
|
||
}
|
||
markProgrammaticHint('ChatArea.stickConditionalScrollToBottom');
|
||
if (options.force) {
|
||
return await stickScrollToBottom({ force: true, preserveScrollPosition: false });
|
||
}
|
||
// conditional 场景由上层先判断“是否需要追底”,这里不要再使用 preserveScrollPosition,
|
||
// 否则一旦高度突增导致 escapedFromLock=true,后续会被卡在非底部。
|
||
return await stickScrollToBottom({ preserveScrollPosition: false });
|
||
}
|
||
|
||
function getStickState() {
|
||
return {
|
||
isAtBottom: !!isAtBottom.value,
|
||
isNearBottom: !!isNearBottom.value,
|
||
escapedFromLock: !!escapedFromLock.value
|
||
};
|
||
}
|
||
|
||
function iconStyleSafe(key: string, size?: string) {
|
||
if (typeof props.iconStyle === 'function') {
|
||
return props.iconStyle(key, size);
|
||
}
|
||
return {};
|
||
}
|
||
|
||
function normalizeMediaPath(input: any): string {
|
||
if (!input) return '';
|
||
if (typeof input === 'string') {
|
||
return input;
|
||
}
|
||
if (typeof input?.path === 'string') {
|
||
return input.path;
|
||
}
|
||
if (typeof input?.source_path === 'string') {
|
||
return input.source_path;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function formatImageName(input: any): string {
|
||
const path = normalizeMediaPath(input);
|
||
if (!path) {
|
||
if (typeof input?.name === 'string' && input.name) {
|
||
return input.name;
|
||
}
|
||
if (typeof input?.title === 'string' && input.title) {
|
||
return input.title;
|
||
}
|
||
if (typeof input?.media_id === 'string' && input.media_id) {
|
||
return input.media_id;
|
||
}
|
||
return '';
|
||
}
|
||
const parts = path.split(/[/\\]/);
|
||
return parts[parts.length - 1] || path;
|
||
}
|
||
|
||
function resolveMessageMediaRef(message: any, input: any, kind: 'image' | 'video'): any | null {
|
||
const refs = message?.media_refs || message?.metadata?.media_refs || [];
|
||
if (!Array.isArray(refs) || !refs.length) {
|
||
return null;
|
||
}
|
||
const mediaId = typeof input?.media_id === 'string' ? input.media_id : '';
|
||
if (mediaId) {
|
||
const matched = refs.find((item: any) => item?.media_id === mediaId);
|
||
if (matched) return matched;
|
||
}
|
||
const sourcePath = normalizeMediaPath(input);
|
||
if (sourcePath) {
|
||
const matched = refs.find(
|
||
(item: any) =>
|
||
item &&
|
||
item.kind === kind &&
|
||
typeof item.source_path === 'string' &&
|
||
item.source_path === sourcePath
|
||
);
|
||
if (matched) return matched;
|
||
}
|
||
const firstSameKind = refs.find((item: any) => item && item.kind === kind);
|
||
return firstSameKind || null;
|
||
}
|
||
|
||
function getPreviewUrl(message: any, input: any, kind: 'image' | 'video'): string {
|
||
const mediaRef = resolveMessageMediaRef(message, input, kind);
|
||
const mediaId = typeof mediaRef?.media_id === 'string' ? mediaRef.media_id : '';
|
||
if (mediaId) {
|
||
return `/api/conversations/media/${encodeURIComponent(mediaId)}`;
|
||
}
|
||
const path = normalizeMediaPath(input);
|
||
if (!path) return '';
|
||
return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
|
||
}
|
||
|
||
function mediaPreviewKey(message: any, input: any, index: number): string {
|
||
const mediaRef =
|
||
resolveMessageMediaRef(message, input, 'image') ||
|
||
resolveMessageMediaRef(message, input, 'video');
|
||
if (typeof mediaRef?.media_id === 'string' && mediaRef.media_id) {
|
||
return `${mediaRef.media_id}-${index}`;
|
||
}
|
||
const path = normalizeMediaPath(input);
|
||
if (path) return `${path}-${index}`;
|
||
return `media-${index}`;
|
||
}
|
||
|
||
function formatDurationMs(durationMs: number): string {
|
||
const totalSeconds = Math.max(0, Math.floor((durationMs || 0) / 1000));
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
if (hours > 0) {
|
||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||
}
|
||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||
}
|
||
|
||
function getSubAgentSystemNoticeLabel(action: any): string | null {
|
||
if (!action || action.type !== 'system') {
|
||
return null;
|
||
}
|
||
const content = typeof action.content === 'string' ? action.content.trim() : '';
|
||
const key = action?.id || `no-id-${content.slice(0, 32)}`;
|
||
if (!content) {
|
||
if (!debugLoggedSystemActionKeys.has(`${key}-empty`)) {
|
||
console.log('[DEBUG_SYSTEM][render] system action 内容为空', { action });
|
||
debugLoggedSystemActionKeys.add(`${key}-empty`);
|
||
}
|
||
return null;
|
||
}
|
||
if (
|
||
action.variant === 'sub_agent_done' &&
|
||
(SUB_AGENT_DONE_LABEL_RE.test(content) ||
|
||
BG_RUN_COMMAND_DONE_LABEL_RE.test(content))
|
||
) {
|
||
if (!debugLoggedSystemActionKeys.has(`${key}-variant-pass`)) {
|
||
console.log('[DEBUG_SYSTEM][render] 命中 variant 渲染', { action, label: content });
|
||
debugLoggedSystemActionKeys.add(`${key}-variant-pass`);
|
||
}
|
||
return content;
|
||
}
|
||
const m = content.match(SUB_AGENT_DONE_PREFIX_RE);
|
||
if (m && /已完成/.test(content)) {
|
||
if (!debugLoggedSystemActionKeys.has(`${key}-regex-pass`)) {
|
||
console.log('[DEBUG_SYSTEM][render] 命中 regex 渲染', {
|
||
action,
|
||
normalized: `子智能体${m[1]} 任务完成`
|
||
});
|
||
debugLoggedSystemActionKeys.add(`${key}-regex-pass`);
|
||
}
|
||
return `子智能体${m[1]} 任务完成`;
|
||
}
|
||
if (!debugLoggedSystemActionKeys.has(`${key}-blocked`)) {
|
||
console.log('[DEBUG_SYSTEM][render] system action 被渲染层拦截', {
|
||
action,
|
||
regex: SUB_AGENT_DONE_PREFIX_RE.toString(),
|
||
completed: /已完成/.test(content)
|
||
});
|
||
debugLoggedSystemActionKeys.add(`${key}-blocked`);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isActionVisible(action: any): boolean {
|
||
if (!action) {
|
||
return false;
|
||
}
|
||
const actionType = action.type;
|
||
if (!RENDERABLE_ACTION_TYPES.has(actionType)) {
|
||
const key = action?.id || `unknown-${actionType || 'none'}`;
|
||
if (!debugLoggedUnknownActionKeys.has(key)) {
|
||
console.log('[DEBUG_SYSTEM][render] 未知 action.type,已跳过渲染', {
|
||
actionType,
|
||
action
|
||
});
|
||
debugLoggedUnknownActionKeys.add(key);
|
||
}
|
||
return false;
|
||
}
|
||
if (action.type === 'tool') {
|
||
const tool = action?.tool || {};
|
||
const hasName = typeof tool.name === 'string' && tool.name.trim().length > 0;
|
||
const hasMessage = typeof tool.message === 'string' && tool.message.trim().length > 0;
|
||
const hasResult = tool.result !== null && tool.result !== undefined;
|
||
const hasArgs = tool.arguments && typeof tool.arguments === 'object' && Object.keys(tool.arguments).length > 0;
|
||
const visible = hasName || hasMessage || hasResult || hasArgs;
|
||
return visible;
|
||
}
|
||
if (action.type !== 'system') {
|
||
return true;
|
||
}
|
||
return !!getSubAgentSystemNoticeLabel(action);
|
||
}
|
||
|
||
function hasRenderableAssistantActions(actions: any[] = []): boolean {
|
||
return (actions || []).some((action) => isActionVisible(action));
|
||
}
|
||
|
||
function isSubAgentNoticeOnlyMessage(msg: any): boolean {
|
||
if (!msg || msg.role !== 'assistant') {
|
||
return false;
|
||
}
|
||
const visibleActions = (msg.actions || []).filter((action: any) => isActionVisible(action));
|
||
if (visibleActions.length !== 1) {
|
||
return false;
|
||
}
|
||
const onlyAction = visibleActions[0];
|
||
const yes = onlyAction?.type === 'system' && !!getSubAgentSystemNoticeLabel(onlyAction);
|
||
if (yes) {
|
||
userMDebug('ChatArea.isSubAgentNoticeOnlyMessage:hit', {
|
||
actionId: onlyAction?.id,
|
||
content: onlyAction?.content
|
||
});
|
||
}
|
||
return yes;
|
||
}
|
||
|
||
function isFollowedBySubAgentNotice(index: number): boolean {
|
||
const next = filteredMessages.value[index + 1];
|
||
const yes = !!next && isSubAgentNoticeOnlyMessage(next);
|
||
if (yes) {
|
||
userMDebug('ChatArea.isFollowedBySubAgentNotice:hit', {
|
||
index,
|
||
currentRole: filteredMessages.value[index]?.role,
|
||
nextRole: next?.role
|
||
});
|
||
}
|
||
return yes;
|
||
}
|
||
|
||
function assistantWorkLabel(index: number): string {
|
||
if (index <= 0) {
|
||
return '';
|
||
}
|
||
const workUser = findAssistantHeaderAnchorUser(index);
|
||
const timer = workUser?.metadata?.work_timer;
|
||
if (!timer || !timer.started_at) {
|
||
return '';
|
||
}
|
||
const startMs = Date.parse(timer.started_at);
|
||
if (!Number.isFinite(startMs)) {
|
||
return '';
|
||
}
|
||
const status = timer.status || 'working';
|
||
if (status === 'working') {
|
||
return `工作中 ${formatDurationMs(nowMs.value - startMs)}`;
|
||
}
|
||
const durationMs =
|
||
typeof timer.duration_ms === 'number'
|
||
? timer.duration_ms
|
||
: Math.max(
|
||
0,
|
||
(Number.isFinite(Date.parse(timer.finished_at || ''))
|
||
? Date.parse(timer.finished_at)
|
||
: nowMs.value) - startMs
|
||
);
|
||
return `工作完成 ${formatDurationMs(durationMs)}`;
|
||
}
|
||
|
||
function shouldShowAssistantHeader(index: number): boolean {
|
||
return !!findAssistantHeaderAnchorUser(index);
|
||
}
|
||
|
||
function findAssistantHeaderAnchorUser(index: number): any | null {
|
||
if (index <= 0) {
|
||
return null;
|
||
}
|
||
const prev = filteredMessages.value[index - 1];
|
||
if (!prev || prev.role !== 'user') {
|
||
return null;
|
||
}
|
||
// assistant 头部属于“紧挨着它上方的一组连续 user-side 消息”。
|
||
// 只有 starts_work=true 的消息才会开启新 work segment;运行期 guidance
|
||
// 只是当前 segment 内的补充,不会重置头部与计时器。
|
||
for (let i = index - 1; i >= 0; i -= 1) {
|
||
const item = filteredMessages.value[i];
|
||
if (!item || item.role !== 'user') {
|
||
break;
|
||
}
|
||
if (messageStartsWork(item)) {
|
||
return item;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function userHeaderSource(msg: any): string {
|
||
return String(msg?.metadata?.message_source || 'user')
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
function userHeaderLabel(msg: any): string {
|
||
const source = userHeaderSource(msg);
|
||
if (source === 'guidance') {
|
||
return '引导';
|
||
}
|
||
if (source === 'goal') {
|
||
return '目标';
|
||
}
|
||
if (source === 'goal_review') {
|
||
return '审核';
|
||
}
|
||
if (source === 'compression' || source === 'compression_handoff') {
|
||
return '压缩';
|
||
}
|
||
if (source === 'notify' || source === 'sub_agent' || source === 'background_command') {
|
||
return '通知';
|
||
}
|
||
return userName.value;
|
||
}
|
||
|
||
function userHeaderIconKey(msg: any): string {
|
||
const source = userHeaderSource(msg);
|
||
if (source === 'guidance') {
|
||
return 'navigation';
|
||
}
|
||
if (source === 'goal') {
|
||
return 'flag';
|
||
}
|
||
if (source === 'goal_review') {
|
||
return 'circleAlert';
|
||
}
|
||
if (source === 'compression' || source === 'compression_handoff') {
|
||
return 'layers';
|
||
}
|
||
if (source === 'notify' || source === 'sub_agent' || source === 'background_command') {
|
||
return 'bell';
|
||
}
|
||
return 'user';
|
||
}
|
||
|
||
// ---- 简略信息渲染(compact 消息可选显示形式)----
|
||
// 命中条件:当前消息为 compact 可见性,且用户在个人空间选择了“简略信息”。
|
||
function isBriefCompactMessage(msg: any): boolean {
|
||
if (!msg || msg.role !== 'user') {
|
||
return false;
|
||
}
|
||
if (compactMessageDisplay.value !== 'brief') {
|
||
return false;
|
||
}
|
||
return getMessageVisibility(msg) === 'compact';
|
||
}
|
||
|
||
// 简略信息只展示一行概要标签(不显示原始内容),按来源给出固定文案。
|
||
function compactBriefLabel(msg: any): string {
|
||
const source = userHeaderSource(msg);
|
||
switch (source) {
|
||
case 'goal_review':
|
||
return '目标审核完成';
|
||
case 'sub_agent':
|
||
return '后台子智能体完成';
|
||
case 'background_command':
|
||
return '后台指令完成';
|
||
case 'compression':
|
||
case 'compression_handoff':
|
||
return '对话压缩完成';
|
||
case 'goal':
|
||
return '目标进行中';
|
||
case 'guidance':
|
||
return '运行中引导';
|
||
case 'notify':
|
||
return '系统通知';
|
||
default:
|
||
return '系统消息';
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
attachBounceListener();
|
||
updateChatScrollbarWidth();
|
||
if (typeof ResizeObserver !== 'undefined' && scrollRef.value) {
|
||
scrollbarResizeObserver = new ResizeObserver(updateChatScrollbarWidth);
|
||
scrollbarResizeObserver.observe(scrollRef.value);
|
||
}
|
||
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
|
||
hasScrollRef: !!scrollRef.value
|
||
});
|
||
timerHandle = window.setInterval(() => {
|
||
nowMs.value = Date.now();
|
||
}, 1000);
|
||
});
|
||
|
||
watch(scrollRef, () => {
|
||
attachBounceListener();
|
||
updateChatScrollbarWidth();
|
||
if (scrollbarResizeObserver) {
|
||
scrollbarResizeObserver.disconnect();
|
||
scrollbarResizeObserver = null;
|
||
}
|
||
if (typeof ResizeObserver !== 'undefined' && scrollRef.value) {
|
||
scrollbarResizeObserver = new ResizeObserver(updateChatScrollbarWidth);
|
||
scrollbarResizeObserver.observe(scrollRef.value);
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
detachBounceListener();
|
||
stopBlockExpansionAnchors();
|
||
if (scrollbarResizeObserver) {
|
||
scrollbarResizeObserver.disconnect();
|
||
scrollbarResizeObserver = null;
|
||
}
|
||
if (timerHandle !== null) {
|
||
clearInterval(timerHandle);
|
||
timerHandle = null;
|
||
}
|
||
});
|
||
|
||
const isStackable = (action: any) =>
|
||
action && (action.type === 'thinking' || action.type === 'tool');
|
||
const isEmptyTextAction = (action: any) => {
|
||
if (!action || action.type !== 'text') {
|
||
return false;
|
||
}
|
||
const content = typeof action.content === 'string' ? action.content : '';
|
||
return !content.trim();
|
||
};
|
||
const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
|
||
const result: Array<
|
||
| { kind: 'stack'; actions: any[]; key: string }
|
||
| { kind: 'single'; action: any; actionIndex: number; key: string }
|
||
> = [];
|
||
let buffer: any[] = [];
|
||
|
||
// 调试:记录输入
|
||
if (CHAT_DEBUG_LOGS && actions.length > 0) {
|
||
console.log(`[splitActionGroups] 消息 ${messageIndex}:`, {
|
||
totalActions: actions.length,
|
||
actionTypes: actions.map((a) => a.type),
|
||
stackedBlocksEnabled: props.stackedBlocksEnabled
|
||
});
|
||
}
|
||
|
||
const flushBuffer = () => {
|
||
// 单个可堆叠块(thinking/tool)也走 stack 路径,避免 1↔2 块时在 single/stack
|
||
// 两套渲染路径之间整体切换导致 StackedBlocks 重新挂载(表现为外框瞬间塌成最扁再展开)。
|
||
// 单块时 StackedBlocks 内部 moreVisible=false,不显示「更多」头,外观由 CSS 对齐 single。
|
||
if (buffer.length >= 1) {
|
||
result.push({
|
||
kind: 'stack',
|
||
actions: buffer.slice(),
|
||
key: `stack-${messageIndex}-${result.length}`
|
||
});
|
||
}
|
||
buffer = [];
|
||
};
|
||
|
||
actions.forEach((action, idx) => {
|
||
if (isEmptyTextAction(action)) {
|
||
return;
|
||
}
|
||
if (isStackable(action)) {
|
||
buffer.push(action);
|
||
} else {
|
||
flushBuffer();
|
||
result.push({
|
||
kind: 'single',
|
||
action,
|
||
actionIndex: idx,
|
||
key: action.id || `single-${messageIndex}-${idx}`
|
||
});
|
||
}
|
||
});
|
||
flushBuffer();
|
||
|
||
// 调试:记录输出
|
||
if (CHAT_DEBUG_LOGS && actions.length > 0) {
|
||
console.log(`[splitActionGroups] 消息 ${messageIndex} 结果:`, {
|
||
totalGroups: result.length,
|
||
groups: result.map((g) => ({
|
||
kind: g.kind,
|
||
actionsCount: g.kind === 'stack' ? g.actions.length : 1,
|
||
actionType: g.kind === 'stack' ? g.actions[0]?.type : g.action?.type
|
||
}))
|
||
});
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
function getGeneratingLetters(message: any) {
|
||
const label =
|
||
typeof message?.generatingLabel === 'string' && message.generatingLabel.trim()
|
||
? message.generatingLabel.trim()
|
||
: DEFAULT_GENERATING_TEXT;
|
||
return Array.from(label);
|
||
}
|
||
|
||
defineExpose({
|
||
rootEl,
|
||
getThinkingRef,
|
||
isUsingStickToBottom: () => true,
|
||
getStickState,
|
||
stopStickScroll: stopScroll,
|
||
scrollToBottom: stickScrollToBottom,
|
||
conditionalStickToBottom: stickConditionalScrollToBottom
|
||
});
|
||
</script>
|