477 lines
22 KiB
Vue
477 lines
22 KiB
Vue
<template>
|
|
<div class="messages-area" ref="rootEl">
|
|
<div class="messages-flow">
|
|
<div v-for="(msg, index) in filteredMessages" :key="index" class="message-block">
|
|
<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>用户</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">
|
|
<span class="image-name" v-for="img in msg.images" :key="img">{{ formatImageName(img) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
|
|
<div class="message-header icon-label">
|
|
<span class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span>
|
|
<span>AI Assistant</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="stackedBlocksEnabled">
|
|
<template v-for="(group, groupIndex) 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"
|
|
/>
|
|
<div
|
|
v-else
|
|
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'" class="system-action">
|
|
<div class="system-action-content">
|
|
{{ group.action.content }}
|
|
</div>
|
|
</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"
|
|
: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>
|
|
<div
|
|
v-for="(action, actionIndex) in msg.actions || []"
|
|
:key="action.id || `${index}-${actionIndex}`"
|
|
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'" class="system-action">
|
|
<div class="system-action-content">
|
|
{{ action.content }}
|
|
</div>
|
|
</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"
|
|
:streaming-message="streamingMessage"
|
|
:register-collapse-content="registerCollapseContent"
|
|
:collapse-key="action.blockId || `${index}-tool-${actionIndex}`"
|
|
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div v-else class="system-message">
|
|
<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">系统消息</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, ref } from 'vue';
|
|
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
|
import StackedBlocks from './StackedBlocks.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;
|
|
}>();
|
|
|
|
const personalization = usePersonalizationStore();
|
|
const stackedBlocksEnabled = computed(() => personalization.experiments.stackedBlocksEnabled);
|
|
const filteredMessages = computed(() =>
|
|
(props.messages || []).filter(m => !(m && m.metadata && m.metadata.system_injected_image))
|
|
);
|
|
|
|
const DEFAULT_GENERATING_TEXT = '生成中…';
|
|
const rootEl = ref<HTMLElement | null>(null);
|
|
const thinkingRefs = new Map<string, HTMLElement | null>();
|
|
const registerCollapseContent = (key: string, el: Element | null) => {
|
|
if (!(el instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
requestAnimationFrame(() => {
|
|
const h = el.scrollHeight || el.offsetHeight || 0;
|
|
if (h > 0) {
|
|
el.style.setProperty('--collapse-max', `${h}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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const isStackable = (action: any) => action && (action.type === 'thinking' || action.type === 'tool');
|
|
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[] = [];
|
|
|
|
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 (isStackable(action)) {
|
|
buffer.push(action);
|
|
} else {
|
|
flushBuffer();
|
|
result.push({
|
|
kind: 'single',
|
|
action,
|
|
actionIndex: idx,
|
|
key: action.id || `single-${messageIndex}-${idx}`
|
|
});
|
|
}
|
|
});
|
|
flushBuffer();
|
|
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
|
|
});
|
|
</script>
|