agent-Specialization/static/src/components/chat/ChatArea.vue

1717 lines
60 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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>