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

1331 lines
47 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--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg),
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index)
}"
>
<div v-if="msg.role === 'user'" class="user-message">
<div class="message-header icon-label">
<span class="icon icon-sm" :style="iconStyleSafe('user')" aria-hidden="true"></span>
<span>{{ userName }}</span>
</div>
<div class="message-text user-bubble-text">
<div v-if="msg.content" class="bubble-text">{{ msg.content }}</div>
<div v-if="msg.images && msg.images.length" class="image-inline-row">
<div class="image-thumbnail-wrapper" v-for="img in msg.images" :key="img">
<img
:src="getPreviewUrl(img)"
: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 in msg.videos" :key="video">
<img
:src="getPreviewUrl(video)"
:alt="formatImageName(video)"
class="image-thumbnail"
/>
</div>
</div>
</div>
</div>
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
<!-- assistant 消息前显示 AI Assistant 头部 -->
<!-- 只有当前一条消息是 user 且当前消息有内容时才显示 -->
<div
v-if="
index > 0 &&
filteredMessages[index - 1].role === 'user' &&
(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"
/>
</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"
: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
}"
>
<div
v-if="group.action?.type === 'thinking'"
class="collapsible-block thinking-block"
:class="{
expanded: expandedBlocks?.has(
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}`"
@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
}"
>
<div
v-if="action.type === 'thinking'"
class="collapsible-block thinking-block"
:class="{
expanded: expandedBlocks?.has(
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}`"
@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}`) }"
>
<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>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useStickToBottom } from 'vue-stick-to-bottom';
import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue';
import { usePersonalizationStore } from '@/stores/personalization';
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 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 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));
return !hasRenderable && !message.awaitingFirstContent;
};
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 (isSystemAutoUserMessage(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.68,
stiffness: 0.11,
mass: 1.05
},
initial: 'instant'
});
const rootEl = scrollRef;
const thinkingRefs = new Map<string, HTMLElement | 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 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;
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 || ''
});
}
}
watch(
[isAtBottom, isNearBottom, escapedFromLock],
([atBottom, nearBottom, escaped]) => {
emit('stick-state-change', {
isAtBottom: !!atBottom,
isNearBottom: !!nearBottom,
escapedFromLock: !!escaped
});
},
{ immediate: true }
);
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 formatImageName(path: string): string {
if (!path) return '';
const parts = path.split(/[/\\]/);
return parts[parts.length - 1] || path;
}
function getPreviewUrl(path: string): string {
if (!path) return '';
return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
}
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 !== '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 prev = filteredMessages.value[index - 1];
if (!prev || prev.role !== 'user') {
return '';
}
const timer = prev?.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)}`;
}
onMounted(() => {
attachBounceListener();
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
hasScrollRef: !!scrollRef.value
});
timerHandle = window.setInterval(() => {
nowMs.value = Date.now();
}, 1000);
});
watch(scrollRef, () => {
attachBounceListener();
});
onBeforeUnmount(() => {
detachBounceListener();
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 = () => {
if (buffer.length >= 2) {
result.push({
kind: 'stack',
actions: buffer.slice(),
key: `stack-${messageIndex}-${result.length}`
});
} else if (buffer.length === 1) {
const single = buffer[0];
result.push({
kind: 'single',
action: single,
actionIndex: actions.indexOf(single),
key: single.id || `single-${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>