主要修复: 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>
415 lines
15 KiB
Vue
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>
|