486 lines
20 KiB
Vue
486 lines
20 KiB
Vue
<template>
|
||
<div
|
||
class="stacked-shell"
|
||
:class="{ 'stacked-shell--single': isSingle, 'stacked-shell--not-ready': !ready, 'stacked-shell--no-borders': hideBorders }"
|
||
ref="shell"
|
||
:style="{
|
||
height: `${shellHeight}px`,
|
||
paddingTop: moreVisible ? `${moreHeight}px` : '0px'
|
||
}"
|
||
>
|
||
<div
|
||
class="stacked-more-block"
|
||
ref="moreBlock"
|
||
:class="{ visible: moreVisible }"
|
||
:style="{ height: `${moreVisible ? moreHeight : 0}px` }"
|
||
@click="toggleMore"
|
||
>
|
||
<img class="more-icon" src="/static/icons/align-left.svg" alt="展开" />
|
||
<div class="more-copy">
|
||
<span class="more-title">{{ moreTitle }}</span>
|
||
<span class="more-desc">{{ moreDesc }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stacked-viewport" :style="{ height: `${viewportHeight}px` }">
|
||
<TransitionGroup
|
||
name="stacked"
|
||
tag="div"
|
||
class="stacked-inner"
|
||
ref="inner"
|
||
:style="{ transform: `translateY(${innerOffset}px)` }"
|
||
:appear="animated"
|
||
:css="animated"
|
||
>
|
||
<div
|
||
v-for="(action, idx) in stackableActions"
|
||
:key="blockKey(action, idx)"
|
||
class="stacked-item"
|
||
>
|
||
<div
|
||
v-if="action.type === 'thinking'"
|
||
class="collapsible-block thinking-block stacked-block"
|
||
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
|
||
:data-block-id="blockKey(action, idx)"
|
||
>
|
||
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
||
<div class="arrow"></div>
|
||
<div class="status-icon">
|
||
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">
|
||
<span class="icon icon-sm" :style="iconStyle('brain')" aria-hidden="true"></span>
|
||
</span>
|
||
</div>
|
||
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
|
||
</div>
|
||
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
|
||
<div
|
||
class="content-inner thinking-content"
|
||
:ref="(el) => registerThinking(blockKey(action, idx), el)"
|
||
@scroll="handleThinkingScrollInternal(blockKey(action, idx), $event)"
|
||
style="max-height: 240px; overflow-y: auto"
|
||
>
|
||
{{ action.content }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="action.type === 'tool'"
|
||
class="collapsible-block tool-block stacked-block"
|
||
:class="{
|
||
expanded: isExpanded(action, idx),
|
||
processing: isToolProcessing(action),
|
||
completed: isToolCompleted(action)
|
||
}"
|
||
:data-block-id="blockKey(action, idx)"
|
||
>
|
||
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
||
<div class="arrow"></div>
|
||
<div class="status-icon">
|
||
<span
|
||
class="tool-icon icon icon-md"
|
||
:class="getToolAnimationClass(action.tool)"
|
||
:style="iconStyle(getToolIcon(action.tool))"
|
||
aria-hidden="true"
|
||
></span>
|
||
</div>
|
||
<span class="status-text">{{ getToolStatusText(action.tool) }}</span>
|
||
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span>
|
||
</div>
|
||
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
|
||
<div class="content-inner">
|
||
<div
|
||
v-if="shouldUseEnhancedDisplay && renderToolResult(action)"
|
||
v-html="renderToolResult(action)"
|
||
></div>
|
||
<div
|
||
v-else-if="
|
||
!shouldUseEnhancedDisplay &&
|
||
action.tool?.name === 'web_search' &&
|
||
action.tool?.result
|
||
"
|
||
>
|
||
<div class="search-meta">
|
||
<div>
|
||
<strong>搜索内容:</strong
|
||
>{{ action.tool.result.query || action.tool.arguments?.query }}
|
||
</div>
|
||
<div>
|
||
<strong>主题:</strong
|
||
>{{ formatSearchTopic(action.tool.result.filters || {}) }}
|
||
</div>
|
||
<div>
|
||
<strong>时间范围:</strong
|
||
>{{ formatSearchTime(action.tool.result.filters || {}) }}
|
||
</div>
|
||
<div>
|
||
<strong>限定网站:</strong
|
||
>{{ formatSearchDomains(action.tool.result.filters || {}) }}
|
||
</div>
|
||
<div><strong>结果数量:</strong>{{ action.tool.result.total_results }}</div>
|
||
</div>
|
||
<div
|
||
v-if="action.tool.result.results && action.tool.result.results.length"
|
||
class="search-result-list"
|
||
>
|
||
<div
|
||
v-for="item in action.tool.result.results"
|
||
:key="item.url || item.index"
|
||
class="search-result-item"
|
||
>
|
||
<div class="search-result-title">{{ item.title || '无标题' }}</div>
|
||
<div class="search-result-url">
|
||
<a v-if="item.url" :href="item.url" target="_blank">{{ item.url }}</a
|
||
><span v-else>无可用链接</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="search-empty">未返回详细的搜索结果。</div>
|
||
</div>
|
||
<div v-else>
|
||
<pre>{{
|
||
JSON.stringify(action.tool?.result || action.tool?.arguments, null, 2)
|
||
}}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="isToolProcessing(action)" class="progress-indicator"></div>
|
||
</div>
|
||
</div>
|
||
</TransitionGroup>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||
import { usePersonalizationStore } from '@/stores/personalization';
|
||
import { renderEnhancedToolResult } from './actions/toolRenderers';
|
||
|
||
defineOptions({ name: 'StackedBlocks' });
|
||
|
||
const props = defineProps<{
|
||
actions: any[];
|
||
expandedBlocks: Set<string>;
|
||
conversationRunning?: boolean;
|
||
isLatestMessage?: boolean;
|
||
iconStyle: (key: string, size?: string) => Record<string, string>;
|
||
toggleBlock: (blockId: string) => void;
|
||
registerThinkingRef?: (key: string, el: Element | null) => void;
|
||
handleThinkingScroll?: (blockId: string, event: Event) => void;
|
||
getToolAnimationClass: (tool: any) => Record<string, unknown>;
|
||
getToolIcon: (tool: any) => string;
|
||
getToolStatusText: (tool: any) => string;
|
||
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: 'more-toggle',
|
||
payload: { element: HTMLElement; expanded: boolean; stackKey: string }
|
||
): void;
|
||
}>();
|
||
|
||
let stackInstanceCounter = 0;
|
||
const stackKey = `stacked-${++stackInstanceCounter}`;
|
||
|
||
// 初始化 personalization store
|
||
const personalizationStore = usePersonalizationStore();
|
||
|
||
const shouldUseEnhancedDisplay = computed(() => {
|
||
return personalizationStore.form.enhanced_tool_display;
|
||
});
|
||
|
||
const hideBorders = computed(() => personalizationStore.form.stacked_hide_borders);
|
||
|
||
const renderToolResult = (action: any) => {
|
||
return renderEnhancedToolResult(
|
||
action,
|
||
props.formatSearchTopic,
|
||
props.formatSearchTime,
|
||
props.formatSearchDomains
|
||
);
|
||
};
|
||
|
||
const VISIBLE_LIMIT = 6;
|
||
|
||
const shell = ref<HTMLElement | null>(null);
|
||
const inner = ref<any>(null);
|
||
const moreBlock = ref<any>(null);
|
||
|
||
const showAll = ref(false);
|
||
const shellHeight = ref(0);
|
||
const moreHeight = ref(0);
|
||
const innerOffset = ref(0);
|
||
const viewportHeight = ref(0);
|
||
// 首帧就绪标志:false 时禁用高度过渡(初始挂载/批量加载直接到位),首次 measure 后置 true。
|
||
const ready = ref(false);
|
||
const contentHeights = ref<Record<string, number>>({});
|
||
const COLLAPSE_MAX_HEIGHT = 600;
|
||
const HEADER_FALLBACK = 36; // 仅在 DOM 未挂载、读不到 header 时兜底
|
||
// content-inner 真实 DOM 引用,用于读取稳定的内容高度(不受 max-height 过渡影响)
|
||
const contentInnerEls = new Map<string, HTMLElement>();
|
||
|
||
const stackableActions = computed(() =>
|
||
(props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool'))
|
||
);
|
||
|
||
const hiddenCount = computed(() => Math.max(0, stackableActions.value.length - VISIBLE_LIMIT));
|
||
const moreVisible = computed(() => hiddenCount.value > 0 || showAll.value);
|
||
// 单块场景:外观需与原 single 路径(.collapsible-block 12px 圆角/透明底)一致,
|
||
// 由 .stacked-shell--single 还原,避免「单块也走堆叠组件」后单块外观变样。
|
||
const isSingle = computed(() => stackableActions.value.length === 1);
|
||
|
||
// 是否播放进场动画:仅「对话运行中 + 当前是最新消息」时播(新块逐个平滑进场)。
|
||
// 历史加载 / 对话重建 / 非最新消息:禁用 appear + enter 动画,块瞬间出现,
|
||
// 避免一次性挂载多个块时整个对话区剧烈高度跳动。
|
||
const animated = computed(() => !!props.conversationRunning && !!props.isLatestMessage);
|
||
const totalSteps = computed(() => stackableActions.value.length);
|
||
const moreTitle = computed(() => (showAll.value ? '已展开全部' : '更多'));
|
||
const moreDesc = computed(() =>
|
||
showAll.value ? `共 ${totalSteps.value} 个步骤` : `${hiddenCount.value} 个步骤折叠`
|
||
);
|
||
|
||
const blockKey = (action: any, idx: number) => action?.blockId || action?.id || `stacked-${idx}`;
|
||
|
||
const isExpanded = (action: any, idx: number) => props.expandedBlocks?.has(blockKey(action, idx));
|
||
const isExpandedById = (blockId: string) => props.expandedBlocks?.has(blockId);
|
||
const contentStyle = (blockId: string) => {
|
||
const h = Math.max(0, contentHeights.value[blockId] || 0);
|
||
return { maxHeight: isExpandedById(blockId) ? `${h}px` : '0px' };
|
||
};
|
||
|
||
const isToolProcessing = (action: any) => {
|
||
const status = action?.tool?.status;
|
||
return status === 'preparing' || status === 'running';
|
||
};
|
||
|
||
const isToolCompleted = (action: any) => action?.tool?.status === 'completed';
|
||
|
||
const toggleMore = () => {
|
||
showAll.value = !showAll.value;
|
||
scheduleMeasure();
|
||
const el = moreBlock.value;
|
||
if (el instanceof HTMLElement) {
|
||
emit('more-toggle', { element: el, expanded: showAll.value, stackKey });
|
||
}
|
||
};
|
||
|
||
const toggleBlock = (blockId: string) => {
|
||
if (typeof props.toggleBlock === 'function') {
|
||
props.toggleBlock(blockId);
|
||
scheduleMeasure();
|
||
}
|
||
};
|
||
|
||
const registerThinking = (key: string, el: Element | null) => {
|
||
// 记录 content-inner 真实引用用于高度测量,同时透传给上层(思考块自动滚动)
|
||
if (el instanceof HTMLElement) {
|
||
contentInnerEls.set(key, el);
|
||
} else {
|
||
contentInnerEls.delete(key);
|
||
}
|
||
if (typeof props.registerThinkingRef === 'function') {
|
||
props.registerThinkingRef(key, el);
|
||
}
|
||
};
|
||
|
||
const handleThinkingScrollInternal = (blockId: string, event: Event) => {
|
||
if (typeof props.handleThinkingScroll === 'function') {
|
||
props.handleThinkingScroll(blockId, event);
|
||
}
|
||
};
|
||
|
||
const resolveEl = (target: any): HTMLElement | null => {
|
||
if (!target) return null;
|
||
if (target instanceof HTMLElement) return target;
|
||
if (target.$el && target.$el instanceof HTMLElement) return target.$el;
|
||
return null;
|
||
};
|
||
|
||
const moreBaseHeight = () => {
|
||
const moreEl = resolveEl(moreBlock.value);
|
||
if (!moreEl) return 56;
|
||
const label = moreEl.querySelector('.more-title') as HTMLElement | null;
|
||
return Math.max(48, Math.ceil(label?.getBoundingClientRect().height || 56));
|
||
};
|
||
|
||
// 核心:测真实 DOM 的稳定值算每块高度,再算 shell/offset/viewport。
|
||
// 关键稳定性保证:
|
||
// - header.offsetHeight:表头固定高(min/max-height:36),展开/折叠都不变;
|
||
// - content-inner.offsetHeight:内容自然高度,即使父级 collapsible-content 的 max-height
|
||
// 正在 0↔目标 过渡(被 overflow:hidden 裁切),content-inner 自身 offsetHeight 仍是
|
||
// 稳定的完整值(裁切不影响子级布局高度)。
|
||
// 因此任何时刻读到的都是「目标态」高度,不是动画中间帧 → 写入 shellHeight/innerOffset 后由
|
||
// CSS transition 平滑(外框增高 / 传送带上移 / 展开收起),绝不抖动。
|
||
const measureAndCompute = () => {
|
||
const innerEl = resolveEl(inner.value);
|
||
const shellEl = resolveEl(shell.value);
|
||
if (!shellEl || !innerEl) return;
|
||
|
||
const children = Array.from(innerEl.children) as HTMLElement[];
|
||
const acts = stackableActions.value;
|
||
const heights: number[] = [];
|
||
const nextContentHeights: Record<string, number> = {};
|
||
|
||
children.forEach((el, idx) => {
|
||
const action = acts[idx];
|
||
const key = blockKey(action, idx);
|
||
const header = el.querySelector('.collapsible-header') as HTMLElement | null;
|
||
const innerC = el.querySelector('.content-inner') as HTMLElement | null;
|
||
const headerH = header ? Math.ceil(header.offsetHeight) : HEADER_FALLBACK;
|
||
// 展开后内容可见高度(封顶 600);content-inner 自身高度稳定(思考块被自己的
|
||
// max-height:240 限制,工具块为内容自然高),不受 collapsible-content 过渡影响。
|
||
const fullContent = innerC ? Math.min(Math.ceil(innerC.offsetHeight), COLLAPSE_MAX_HEIGHT) : 0;
|
||
nextContentHeights[key] = fullContent;
|
||
const expanded = isExpandedById(key);
|
||
// 分隔线:隐藏边线模式下统一为 0,否则最后一块无 border-bottom
|
||
const borderH = hideBorders.value ? 0 : (idx === children.length - 1 ? 0 : 1);
|
||
heights.push(headerH + (expanded ? fullContent : 0) + borderH);
|
||
});
|
||
|
||
contentHeights.value = nextContentHeights;
|
||
|
||
const sum = (arr: number[]) => (arr.length ? arr.reduce((a, b) => a + b, 0) : 0);
|
||
const totalHeight = sum(heights);
|
||
const hiddenHeight = sum(heights.slice(0, Math.max(0, heights.length - VISIBLE_LIMIT)));
|
||
const windowHeight = sum(heights.slice(-VISIBLE_LIMIT));
|
||
|
||
moreHeight.value = moreVisible.value ? moreBaseHeight() : 0;
|
||
const targetShell =
|
||
moreHeight.value + (showAll.value || !moreVisible.value ? totalHeight : windowHeight);
|
||
// shellHeight 需 +2 补偿 border-box(上下 border 各 1px),
|
||
// 确保内容区 ≥ viewport 高度,避免 overflow:hidden 裁切最后一块
|
||
shellHeight.value = targetShell + 2;
|
||
innerOffset.value = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
|
||
viewportHeight.value = Math.max(0, targetShell - moreHeight.value);
|
||
};
|
||
|
||
// 等 DOM 渲染稳定后测量一次(双帧:Vue patch + 浏览器布局完成后再读 offsetHeight)。
|
||
const scheduleMeasure = () => {
|
||
nextTick(() => {
|
||
requestAnimationFrame(() => {
|
||
measureAndCompute();
|
||
// 首次测量、高度直接到位后,再下一帧启用过渡;此后展开/收起/增长等变化才平滑,
|
||
// 而初始挂载/批量加载不会从 0 高度过渡跳动。
|
||
if (!ready.value) {
|
||
requestAnimationFrame(() => {
|
||
ready.value = true;
|
||
});
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
let roRaf: number | null = null;
|
||
|
||
const observeAll = () => {
|
||
if (!resizeObserver) return;
|
||
resizeObserver.disconnect();
|
||
// 只观察 content-inner(内容自然高度,流式增长会变)。不观察 collapsible-content /
|
||
// stacked-inner 等被 transition 的元素,避免「写 → 触发 RO → 再写」的回环抖动。
|
||
contentInnerEls.forEach((el) => resizeObserver!.observe(el));
|
||
};
|
||
|
||
onMounted(() => {
|
||
scheduleMeasure();
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
resizeObserver = new ResizeObserver(() => {
|
||
if (roRaf) cancelAnimationFrame(roRaf);
|
||
roRaf = requestAnimationFrame(() => measureAndCompute());
|
||
});
|
||
nextTick(() => observeAll());
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect();
|
||
resizeObserver = null;
|
||
}
|
||
if (roRaf) {
|
||
cancelAnimationFrame(roRaf);
|
||
roRaf = null;
|
||
}
|
||
});
|
||
|
||
watch(
|
||
() => {
|
||
const acts = stackableActions.value;
|
||
const firstId = acts[0]?.blockId || acts[0]?.id || '';
|
||
const lastId = acts[acts.length - 1]?.blockId || acts[acts.length - 1]?.id || '';
|
||
// 指纹:块数 + 首尾 + moreVisible + 每块内部状态/内容长度桶(追踪流式增长),
|
||
// 比直接 watch 数组引用更精准,避免父组件重渲染传新引用导致的过度触发。
|
||
const snap = acts
|
||
.map((a: any) => {
|
||
if (a?.type === 'tool') return `T:${a?.tool?.status || '?'}${a?.tool?.result ? 'R' : '-'}`;
|
||
if (a?.type === 'thinking')
|
||
return `H:${a?.streaming ? 'S' : '-'}${Math.floor((a?.content?.length || 0) / 48)}`;
|
||
return '?';
|
||
})
|
||
.join(',');
|
||
return `${acts.length}|${firstId}|${lastId}|${moreVisible.value}|${snap}`;
|
||
},
|
||
() => {
|
||
nextTick(() => {
|
||
observeAll();
|
||
scheduleMeasure();
|
||
});
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
|
||
() => scheduleMeasure()
|
||
);
|
||
|
||
watch(hideBorders, () => scheduleMeasure());
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 重构核心:shell 高度、viewport 高度、inner 位移三者用同一条 transition,使「外框增高」
|
||
「内部显示区裁切边界」「传送带上移/展开收起」完全同步过渡,时长/缓动与单块
|
||
collapsible-content(max-height 260ms) 对齐,互不打架。
|
||
替换了原先 JS 逐帧 lerp(animateMoreToggle) + 360ms runSync 轮询的方案,从根上去抖。
|
||
注意:viewport(显示区) 必须与 shell 同步过渡,否则收起瞬间 viewport 高度先跳到折叠后
|
||
终值,把还在收缩的下方块瞬间裁掉再重现,造成割裂。 */
|
||
.stacked-shell {
|
||
transition:
|
||
height 260ms cubic-bezier(0.4, 0, 0.2, 1),
|
||
padding-top 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
will-change: height;
|
||
}
|
||
.stacked-viewport {
|
||
transition: height 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
will-change: height;
|
||
}
|
||
.stacked-inner {
|
||
transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
will-change: transform;
|
||
}
|
||
/* 首帧未就绪(ready=false):禁用 shell/viewport/inner 的高度/位移过渡,让初始挂载 /
|
||
批量历史加载时高度直接到位,避免从 0 高度过渡造成整个对话区剧烈跳动。
|
||
首次 measure 完成后置 ready=true,此后展开/收起、流式增长、传送带等高度变化均平滑——
|
||
注意:展开/收起是用户主动交互,历史对话里也必须平滑,故用 ready(一次性) 而非 animated 控制。
|
||
块的 enter/appear 进场动画另由模板 :css/:appear="animated" 控制(仅运行中+最新才播)。
|
||
同时禁用内部 collapsible-content 的 max-height/opacity 过渡,否则历史加载时已展开块的
|
||
内容会从 opacity:0 淡入(表现为内部文字仍有动画)。 */
|
||
.stacked-shell--not-ready,
|
||
.stacked-shell--not-ready .stacked-viewport,
|
||
.stacked-shell--not-ready .stacked-inner,
|
||
.stacked-shell--not-ready .collapsible-content {
|
||
transition: none !important;
|
||
}
|
||
/* 单块场景:还原原 single 路径(.collapsible-block)的外观——12px 圆角 + 透明底,
|
||
而非多块外壳的 16px 圆角 + card 底。其余(1px 边框)两者本就一致。
|
||
这样「单块也走堆叠组件」后,单块视觉与改造前完全相同。 */
|
||
.stacked-shell--single {
|
||
border-radius: 12px;
|
||
background: transparent;
|
||
}
|
||
</style>
|