agent-Specialization/static/src/components/chat/ChatArea.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>