agent-Specialization/static/src/components/chat/StackedBlocks.vue
JOJO 43409c523e fix: 移除错误的对话切换跳转逻辑并修复工具执行返回值问题
主要修复:
1. 移除前端"取消跳转到正在运行的对话"的错误逻辑
   - 删除 switchConversation 中的任务检查和确认提示
   - 删除 createNewConversation 中的跳转回运行对话逻辑
   - 删除 loadConversation 中对未定义变量 hasActiveTask 的引用

2. 修复后端工具执行返回值问题
   - 修复 execute_tool_calls 在用户停止时返回 None 的 bug
   - 确保所有返回路径都返回包含 stopped 和 last_tool_call_time 的字典

3. 其他改进
   - 添加代码复制功能 (handleCopyCodeClick)
   - 移除 FocusPanel 相关代码
   - 更新个性化配置 (enhanced_tool_display)
   - 样式和主题优化

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 17:42:07 +08:00

415 lines
15 KiB
Vue

<template>
<div
class="stacked-shell"
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
>
<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 }"
>
<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)
}"
>
<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-if="action.tool?.name === 'run_python' && action.tool?.result">
<div class="code-block">
<div class="code-label">代码:</div>
<pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments?.code }}</code></pre>
</div>
<div v-if="action.tool.result.output" class="output-block">
<div class="output-label">输出:</div>
<pre>{{ action.tool.result.output }}</pre>
</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>;
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;
}>();
// 初始化 personalization store
const personalizationStore = usePersonalizationStore();
const shouldUseEnhancedDisplay = computed(() => {
return personalizationStore.form.enhanced_tool_display;
});
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);
const contentHeights = ref<Record<string, number>>({});
const ANIMATION_SYNC_MS = 360;
const MORE_ANIMATION_MS = 300;
let syncRaf: number | null = null;
let syncUntil = 0;
const isMoreAnimating = ref(false);
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);
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 = () => {
animateMoreToggle(!showAll.value);
};
const toggleBlock = (blockId: string) => {
if (typeof props.toggleBlock === 'function') {
props.toggleBlock(blockId);
nextTick(() => runSync());
}
};
const registerThinking = (key: string, el: Element | null) => {
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));
};
const setShellMetrics = () => {
const innerEl = resolveEl(inner.value);
const shellEl = resolveEl(shell.value);
if (!shellEl || !innerEl) return;
const children = Array.from(innerEl.children) as HTMLElement[];
const heights: number[] = [];
const nextContentHeights: Record<string, number> = {};
children.forEach((el, idx) => {
const action = stackableActions.value[idx];
const key = blockKey(action, idx);
const content = el.querySelector('.collapsible-content') as HTMLElement | null;
const contentHeight = content ? Math.ceil(content.scrollHeight) : 0;
nextContentHeights[key] = contentHeight;
// 使用当前真实高度而非「展开/折叠」状态推导值,避免在折叠动画过程中出现高度骤减导致的闪烁
const liveHeight = Math.ceil(el.getBoundingClientRect().height || el.offsetHeight || 0);
heights.push(liveHeight);
});
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);
const targetOffset = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
shellHeight.value = targetShell;
innerOffset.value = targetOffset;
viewportHeight.value = Math.max(0, targetShell - moreHeight.value);
};
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const animateMoreToggle = (nextShowAll: boolean) => {
if (isMoreAnimating.value) return;
// 停掉其他同步,避免冲突
if (syncRaf) {
cancelAnimationFrame(syncRaf);
syncRaf = null;
}
const innerEl = resolveEl(inner.value);
const shellEl = resolveEl(shell.value);
if (!innerEl || !shellEl) {
showAll.value = nextShowAll;
nextTick(() => runSync());
return;
}
// 先计算当前状态
setShellMetrics();
const start = {
shell: shellHeight.value,
offset: innerOffset.value,
viewport: viewportHeight.value,
more: moreHeight.value
};
// 切换到目标状态并计算目标值
showAll.value = nextShowAll;
setShellMetrics();
const target = {
shell: shellHeight.value,
offset: innerOffset.value,
viewport: viewportHeight.value,
more: moreHeight.value
};
// 回到起点数值,准备动画
shellHeight.value = start.shell;
innerOffset.value = start.offset;
viewportHeight.value = start.viewport;
moreHeight.value = start.more;
isMoreAnimating.value = true;
const startedAt = performance.now();
const step = () => {
const now = performance.now();
const t = Math.min(1, (now - startedAt) / MORE_ANIMATION_MS);
const eased = easeOutCubic(t);
shellHeight.value = lerp(start.shell, target.shell, eased);
innerOffset.value = lerp(start.offset, target.offset, eased);
viewportHeight.value = lerp(start.viewport, target.viewport, eased);
moreHeight.value = lerp(start.more, target.more, eased);
if (t < 1) {
requestAnimationFrame(step);
} else {
isMoreAnimating.value = false;
shellHeight.value = target.shell;
innerOffset.value = target.offset;
viewportHeight.value = target.viewport;
moreHeight.value = target.more;
setShellMetrics();
}
};
requestAnimationFrame(step);
};
const runSync = () => {
if (isMoreAnimating.value) return;
if (syncRaf) {
cancelAnimationFrame(syncRaf);
syncRaf = null;
}
syncUntil = performance.now() + ANIMATION_SYNC_MS;
const step = () => {
setShellMetrics();
if (performance.now() < syncUntil) {
syncRaf = requestAnimationFrame(step);
} else {
syncRaf = null;
}
};
syncRaf = requestAnimationFrame(step);
};
let resizeObserver: ResizeObserver | null = null;
let heightRaf: number | null = null;
onMounted(() => {
const innerEl = resolveEl(inner.value);
setShellMetrics();
resizeObserver = new ResizeObserver(() => {
if (heightRaf) {
cancelAnimationFrame(heightRaf);
}
heightRaf = requestAnimationFrame(() => {
setShellMetrics();
});
});
if (innerEl) {
resizeObserver.observe(innerEl);
}
});
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (heightRaf) {
cancelAnimationFrame(heightRaf);
heightRaf = null;
}
if (syncRaf) {
cancelAnimationFrame(syncRaf);
syncRaf = null;
}
});
watch([stackableActions, moreVisible], () => {
nextTick(() => runSync());
});
watch(
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
() => {
nextTick(() => runSync());
}
);
</script>