feat: add minimal tool reel animation
This commit is contained in:
parent
a1dd97d5f1
commit
4bf0733907
443
static/demo/minimal-tool-roller-demo.html
Normal file
443
static/demo/minimal-tool-roller-demo.html
Normal file
@ -0,0 +1,443 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>极简模式 · 并行工具转轮动画 Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-base: #faf9f5;
|
||||
--surface-card: #f0ece3;
|
||||
--surface-muted: #e7e1d5;
|
||||
--surface-raised: #fffdf8;
|
||||
--text-primary: #25231f;
|
||||
--text-secondary: #6c665d;
|
||||
--text-muted: #91887a;
|
||||
--border-subtle: #ddd5c8;
|
||||
--accent: #b9654f;
|
||||
--state-running: #8b7567;
|
||||
--state-done: #47735e;
|
||||
--shadow-panel: 0 18px 52px rgba(53, 45, 35, .14);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
linear-gradient(180deg, var(--surface-base), var(--surface-muted));
|
||||
color: var(--text-primary);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
.stage {
|
||||
width: min(760px, calc(100vw - 40px));
|
||||
padding: 34px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 24px;
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -.03em;
|
||||
}
|
||||
|
||||
.desc {
|
||||
max-width: 620px;
|
||||
margin: 0 0 28px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.minimal-line {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 46px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--state-running);
|
||||
transform-origin: center;
|
||||
animation: breathe 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot.done {
|
||||
background: var(--state-done);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(.72); opacity: .55; }
|
||||
50% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.intent {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-window {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-window::before,
|
||||
.tool-window::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
height: 7px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tool-window::before {
|
||||
top: 0;
|
||||
background: linear-gradient(180deg, var(--surface-card), transparent);
|
||||
}
|
||||
|
||||
.tool-window::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(0deg, var(--surface-card), transparent);
|
||||
}
|
||||
|
||||
.tool-item,
|
||||
.typing-text,
|
||||
.done-text {
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typing-text,
|
||||
.done-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.typing-text::after {
|
||||
content: "";
|
||||
width: 1px;
|
||||
height: 15px;
|
||||
margin-left: 2px;
|
||||
background: var(--accent);
|
||||
animation: caret .75s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes caret {
|
||||
0%, 45% { opacity: 1; }
|
||||
46%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.tool-reel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tool-reel.rolling {
|
||||
transition: transform 520ms cubic-bezier(.22, .9, .25, 1);
|
||||
}
|
||||
|
||||
.tool-reel.settle {
|
||||
transition: transform 170ms cubic-bezier(.2, .72, .26, 1);
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-card);
|
||||
color: var(--text-primary);
|
||||
font: 650 13px/1 var(--sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--surface-raised);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.notes {
|
||||
margin: 18px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="stage">
|
||||
<p class="eyebrow">Minimal Mode Motion Test</p>
|
||||
<h1>并行工具调用摘要行 · 转轮动画</h1>
|
||||
<p class="desc">
|
||||
演示逻辑:并行批次开始后,第一个工具先按打字机显示;随后摘要行在本批次工具之间做纵向转轮循环,直到批次结束后停在最终状态。
|
||||
</p>
|
||||
|
||||
<section class="minimal-line" aria-label="极简模式摘要行">
|
||||
<span id="dot" class="dot" aria-hidden="true"></span>
|
||||
<div class="summary">
|
||||
<span id="intent" class="intent">正在并行调用</span>
|
||||
<span id="toolWindow" class="tool-window" aria-live="polite"></span>
|
||||
</div>
|
||||
<span id="badge" class="badge">准备中</span>
|
||||
</section>
|
||||
|
||||
<div class="controls">
|
||||
<button id="startBtn" class="primary" type="button">播放并行调用</button>
|
||||
<button id="finishBtn" type="button" disabled>结束批次</button>
|
||||
<button id="resetBtn" type="button">重置</button>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li>工具列表是一整列,窗口只露出当前命中的一项;所有工具同时向上滚动。</li>
|
||||
<li>每项停留约 1.45 秒;落位时只轻微多冲一点,再回弹,模拟克制的“刹不住车”顿挫。</li>
|
||||
<li>第一个工具保留打字机式出现,避免开头突兀。</li>
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const tools = [
|
||||
"search_query · 检索相关资料",
|
||||
"read_file · 读取项目片段",
|
||||
"run_command · 执行最小验证",
|
||||
"browser_preview · 检查本地界面"
|
||||
];
|
||||
|
||||
const toolWindow = document.getElementById("toolWindow");
|
||||
const badge = document.getElementById("badge");
|
||||
const dot = document.getElementById("dot");
|
||||
const startBtn = document.getElementById("startBtn");
|
||||
const finishBtn = document.getElementById("finishBtn");
|
||||
const resetBtn = document.getElementById("resetBtn");
|
||||
|
||||
let typingTimer = null;
|
||||
let wheelTimer = null;
|
||||
let settleTimer = null;
|
||||
let index = 0;
|
||||
let running = false;
|
||||
const itemHeight = 26;
|
||||
|
||||
function clearTimers() {
|
||||
window.clearInterval(typingTimer);
|
||||
window.clearInterval(wheelTimer);
|
||||
window.clearTimeout(settleTimer);
|
||||
typingTimer = null;
|
||||
wheelTimer = null;
|
||||
settleTimer = null;
|
||||
}
|
||||
|
||||
function clearToolWindow() {
|
||||
toolWindow.replaceChildren();
|
||||
}
|
||||
|
||||
function setTextNode(text, className) {
|
||||
clearToolWindow();
|
||||
const node = document.createElement("span");
|
||||
node.className = className;
|
||||
node.textContent = text;
|
||||
toolWindow.appendChild(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildReel() {
|
||||
clearToolWindow();
|
||||
const reel = document.createElement("div");
|
||||
reel.id = "toolReel";
|
||||
reel.className = "tool-reel";
|
||||
|
||||
[...tools, tools[0]].forEach((tool) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "tool-item";
|
||||
item.textContent = tool;
|
||||
reel.appendChild(item);
|
||||
});
|
||||
|
||||
toolWindow.appendChild(reel);
|
||||
index = 0;
|
||||
reel.style.transform = "translateY(0)";
|
||||
return reel;
|
||||
}
|
||||
|
||||
function typeFirstTool(done) {
|
||||
const text = tools[0];
|
||||
let cursor = 0;
|
||||
const typingNode = setTextNode("", "typing-text");
|
||||
badge.textContent = "1 / " + tools.length;
|
||||
|
||||
typingTimer = window.setInterval(() => {
|
||||
cursor += 1;
|
||||
typingNode.textContent = text.slice(0, cursor);
|
||||
|
||||
if (cursor >= text.length) {
|
||||
window.clearInterval(typingTimer);
|
||||
typingTimer = null;
|
||||
window.setTimeout(done, 520);
|
||||
}
|
||||
}, 34);
|
||||
}
|
||||
|
||||
function spinToNext() {
|
||||
const reel = document.getElementById("toolReel");
|
||||
if (!reel) return;
|
||||
|
||||
index = (index + 1) % tools.length;
|
||||
badge.textContent = `${index + 1} / ${tools.length}`;
|
||||
|
||||
const visualIndex = index === 0 ? tools.length : index;
|
||||
const target = -visualIndex * itemHeight;
|
||||
const overshoot = target - 2;
|
||||
|
||||
reel.className = "tool-reel rolling";
|
||||
reel.style.transform = `translateY(${overshoot}px)`;
|
||||
|
||||
settleTimer = window.setTimeout(() => {
|
||||
reel.className = "tool-reel settle";
|
||||
reel.style.transform = `translateY(${target}px)`;
|
||||
|
||||
if (index === 0) {
|
||||
settleTimer = window.setTimeout(() => {
|
||||
reel.className = "tool-reel";
|
||||
reel.style.transform = "translateY(0)";
|
||||
}, 175);
|
||||
}
|
||||
}, 520);
|
||||
}
|
||||
|
||||
function startWheel() {
|
||||
buildReel();
|
||||
wheelTimer = window.setInterval(spinToNext, 1450);
|
||||
}
|
||||
|
||||
function startDemo() {
|
||||
if (running) return;
|
||||
clearTimers();
|
||||
running = true;
|
||||
index = 0;
|
||||
dot.classList.remove("done");
|
||||
startBtn.disabled = true;
|
||||
finishBtn.disabled = false;
|
||||
badge.textContent = "启动中";
|
||||
typeFirstTool(startWheel);
|
||||
}
|
||||
|
||||
function finishDemo() {
|
||||
if (!running) return;
|
||||
clearTimers();
|
||||
running = false;
|
||||
setTextNode("并行工具调用完成", "done-text");
|
||||
badge.textContent = "完成";
|
||||
dot.classList.add("done");
|
||||
startBtn.disabled = false;
|
||||
finishBtn.disabled = true;
|
||||
}
|
||||
|
||||
function resetDemo() {
|
||||
clearTimers();
|
||||
running = false;
|
||||
index = 0;
|
||||
setTextNode("等待并行工具批次", "done-text");
|
||||
badge.textContent = "准备中";
|
||||
dot.classList.remove("done");
|
||||
startBtn.disabled = false;
|
||||
finishBtn.disabled = true;
|
||||
}
|
||||
|
||||
startBtn.addEventListener("click", startDemo);
|
||||
finishBtn.addEventListener("click", finishDemo);
|
||||
resetBtn.addEventListener("click", resetDemo);
|
||||
resetDemo();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -12,15 +12,35 @@
|
||||
>
|
||||
<div class="summary-content-wrapper">
|
||||
<span class="summary-preview">
|
||||
<template v-if="isSummaryRunning(group.actions, group.id)">
|
||||
<template v-if="shouldAnimateSummary(group.actions, group.id)">
|
||||
<span
|
||||
v-for="(char, idx) in getAnimatedSummaryChars(getSummaryPreview(group.actions))"
|
||||
:key="`${group.id}-${idx}`"
|
||||
class="summary-char"
|
||||
:style="{ animationDelay: `${(idx + 1) * 0.12}s` }"
|
||||
v-if="shouldShowToolReel(group.actions, group.id)"
|
||||
class="summary-tool-reel-window"
|
||||
>
|
||||
{{ char === ' ' ? '\u00A0' : char }}
|
||||
<span
|
||||
class="summary-tool-reel-track"
|
||||
:class="getToolReelPhase(group.id)"
|
||||
:style="getToolReelTrackStyle(group.id)"
|
||||
>
|
||||
<span
|
||||
v-for="(item, idx) in getToolReelDisplayItems(group.actions, group.id)"
|
||||
:key="`${group.id}-tool-reel-${idx}-${item}`"
|
||||
class="summary-tool-reel-item"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<template v-else>
|
||||
<span
|
||||
v-for="(char, idx) in getAnimatedSummaryChars(getSummaryPreview(group.actions))"
|
||||
:key="`${group.id}-${idx}`"
|
||||
class="summary-char"
|
||||
:style="{ animationDelay: `${(idx + 1) * 0.12}s` }"
|
||||
>
|
||||
{{ char === ' ' ? '\u00A0' : char }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getSummaryPreview(group.actions) }}
|
||||
@ -125,7 +145,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, Component } from 'vue';
|
||||
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount, Component } from 'vue';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
import { renderEnhancedToolResult } from './actions/toolRenderers';
|
||||
import { getRandomLoader } from './loaders/index';
|
||||
@ -139,6 +159,10 @@ interface Action {
|
||||
tool?: {
|
||||
name?: string;
|
||||
intent?: string;
|
||||
intent_rendered?: string;
|
||||
intent_full?: string;
|
||||
status?: string;
|
||||
display_name?: string;
|
||||
arguments?: any;
|
||||
result?: any;
|
||||
};
|
||||
@ -181,6 +205,26 @@ const expandedGroups = ref(new Set<string>());
|
||||
const thinkingRefs = new Map<string, HTMLElement>();
|
||||
const summaryLoaders = new Map<string, Component>();
|
||||
const SUMMARY_PREVIEW_MAX_CHARS = 25;
|
||||
const TOOL_REEL_ITEM_HEIGHT = 26;
|
||||
const TOOL_REEL_INTERVAL_MS = 1450;
|
||||
const TOOL_REEL_ROLL_MS = 520;
|
||||
const TOOL_REEL_SETTLE_MS = 170;
|
||||
const TOOL_REEL_OVERSHOOT_PX = 2;
|
||||
|
||||
type ToolReelPhase = 'idle' | 'rolling' | 'settle';
|
||||
|
||||
interface ToolReelState {
|
||||
index: number;
|
||||
offsetPx: number;
|
||||
phase: ToolReelPhase;
|
||||
signature: string;
|
||||
items: string[];
|
||||
completing?: boolean;
|
||||
}
|
||||
|
||||
const toolReelStates = reactive<Record<string, ToolReelState>>({});
|
||||
const toolReelIntervals = new Map<string, number>();
|
||||
const toolReelTimeouts = new Map<string, number[]>();
|
||||
|
||||
const truncateSummaryPreview = (raw: string) => {
|
||||
const text = typeof raw === 'string' ? raw : '';
|
||||
@ -192,6 +236,251 @@ const truncateSummaryPreview = (raw: string) => {
|
||||
|
||||
const getAnimatedSummaryChars = (text: string) => Array.from(text || '');
|
||||
|
||||
const getFirstLine = (raw: string) => {
|
||||
const text = typeof raw === 'string' ? raw : '';
|
||||
const firstLineEnd = text.indexOf('\n');
|
||||
return firstLineEnd > 0 ? text.substring(0, firstLineEnd) : text;
|
||||
};
|
||||
|
||||
const getToolSummaryText = (action: Action) => {
|
||||
const tool = action.tool;
|
||||
if (!tool) return '';
|
||||
|
||||
const intentEnabled = personalizationStore.form.tool_intent_enabled;
|
||||
const intentText = tool.intent_rendered || tool.intent_full || '';
|
||||
|
||||
if (intentEnabled && intentText) {
|
||||
return truncateSummaryPreview(getFirstLine(intentText));
|
||||
}
|
||||
|
||||
if (tool.status === 'preparing') {
|
||||
return `准备调用 ${tool.name || '工具'}...`;
|
||||
}
|
||||
if (tool.status === 'running') {
|
||||
return `正在调用 ${tool.name || '工具'}...`;
|
||||
}
|
||||
if (tool.status === 'completed') {
|
||||
return tool.display_name || tool.name || '工具执行完成';
|
||||
}
|
||||
return action.streaming ? '正在执行工具...' : tool.display_name || tool.name || '执行工具';
|
||||
};
|
||||
|
||||
const isActiveToolAction = (action: Action) => {
|
||||
if (action.type !== 'tool') return false;
|
||||
if (action.streaming) return true;
|
||||
|
||||
const status = String(action.tool?.status || '').toLowerCase();
|
||||
return [
|
||||
'hinted',
|
||||
'preparing',
|
||||
'running',
|
||||
'pending',
|
||||
'pending_approval',
|
||||
'awaiting_approval',
|
||||
'awaiting_user_answer'
|
||||
].includes(status);
|
||||
};
|
||||
|
||||
const getLatestToolSegment = (actions: Action[]) => {
|
||||
let lastToolIndex = -1;
|
||||
for (let i = actions.length - 1; i >= 0; i--) {
|
||||
if (actions[i].type === 'tool') {
|
||||
lastToolIndex = i;
|
||||
break;
|
||||
}
|
||||
if (actions[i].type === 'thinking') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastToolIndex < 0) return [];
|
||||
|
||||
let startIndex = lastToolIndex;
|
||||
while (startIndex > 0 && actions[startIndex - 1].type === 'tool') {
|
||||
startIndex--;
|
||||
}
|
||||
|
||||
return actions.slice(startIndex, lastToolIndex + 1);
|
||||
};
|
||||
|
||||
const getLatestActiveToolSegment = (actions: Action[]) => {
|
||||
const segment = getLatestToolSegment(actions);
|
||||
return segment.some(isActiveToolAction) ? segment : [];
|
||||
};
|
||||
|
||||
const getToolReelItems = (actions: Action[]) =>
|
||||
getLatestActiveToolSegment(actions)
|
||||
.map(getToolSummaryText)
|
||||
.filter((item) => item.trim().length > 0);
|
||||
|
||||
const getToolReelDisplayItems = (actions: Action[], groupId: string) => {
|
||||
const completingItems = toolReelStates[groupId]?.completing
|
||||
? toolReelStates[groupId].items
|
||||
: null;
|
||||
if (completingItems?.length) {
|
||||
return completingItems;
|
||||
}
|
||||
|
||||
const items = getToolReelItems(actions);
|
||||
return items.length > 0 ? [...items, items[0]] : [];
|
||||
};
|
||||
|
||||
const shouldShowToolReel = (actions: Action[], groupId: string) =>
|
||||
!!toolReelStates[groupId]?.completing ||
|
||||
(isSummaryRunning(actions, groupId) && getToolReelItems(actions).length > 1);
|
||||
|
||||
const shouldAnimateSummary = (actions: Action[], groupId: string) =>
|
||||
isSummaryRunning(actions, groupId) || !!toolReelStates[groupId]?.completing;
|
||||
|
||||
const getToolReelPhase = (groupId: string) => toolReelStates[groupId]?.phase || 'idle';
|
||||
|
||||
const getToolReelTrackStyle = (groupId: string) => ({
|
||||
transform: `translateY(${toolReelStates[groupId]?.offsetPx || 0}px)`
|
||||
});
|
||||
|
||||
const clearToolReelTimers = (groupId: string) => {
|
||||
const interval = toolReelIntervals.get(groupId);
|
||||
if (interval) {
|
||||
window.clearInterval(interval);
|
||||
toolReelIntervals.delete(groupId);
|
||||
}
|
||||
|
||||
const timeouts = toolReelTimeouts.get(groupId) || [];
|
||||
timeouts.forEach((timeout) => window.clearTimeout(timeout));
|
||||
toolReelTimeouts.delete(groupId);
|
||||
};
|
||||
|
||||
const pushToolReelTimeout = (groupId: string, timeout: number) => {
|
||||
const timeouts = toolReelTimeouts.get(groupId) || [];
|
||||
timeouts.push(timeout);
|
||||
toolReelTimeouts.set(groupId, timeouts);
|
||||
};
|
||||
|
||||
const spinToolReel = (groupId: string, itemCount: number) => {
|
||||
const state = toolReelStates[groupId];
|
||||
if (!state || state.completing || itemCount < 2) return;
|
||||
|
||||
const nextIndex = (state.index + 1) % itemCount;
|
||||
const visualIndex = nextIndex === 0 ? itemCount : nextIndex;
|
||||
const targetOffset = -visualIndex * TOOL_REEL_ITEM_HEIGHT;
|
||||
|
||||
state.phase = 'rolling';
|
||||
state.offsetPx = targetOffset - TOOL_REEL_OVERSHOOT_PX;
|
||||
|
||||
const settleTimeout = window.setTimeout(() => {
|
||||
state.phase = 'settle';
|
||||
state.offsetPx = targetOffset;
|
||||
state.index = nextIndex;
|
||||
|
||||
if (nextIndex === 0) {
|
||||
const resetTimeout = window.setTimeout(() => {
|
||||
state.phase = 'idle';
|
||||
state.offsetPx = 0;
|
||||
}, TOOL_REEL_SETTLE_MS);
|
||||
pushToolReelTimeout(groupId, resetTimeout);
|
||||
}
|
||||
}, TOOL_REEL_ROLL_MS);
|
||||
pushToolReelTimeout(groupId, settleTimeout);
|
||||
};
|
||||
|
||||
const finishToolReel = (groupId: string, actions: Action[]) => {
|
||||
const state = toolReelStates[groupId];
|
||||
if (!state || state.completing) return;
|
||||
|
||||
const items =
|
||||
state.items.length > 1
|
||||
? state.items
|
||||
: getLatestToolSegment(actions)
|
||||
.map(getToolSummaryText)
|
||||
.filter((item) => item.trim().length > 0);
|
||||
|
||||
if (items.length < 2) {
|
||||
clearToolReelTimers(groupId);
|
||||
delete toolReelStates[groupId];
|
||||
return;
|
||||
}
|
||||
|
||||
clearToolReelTimers(groupId);
|
||||
state.items = items;
|
||||
state.signature = items.join('\u0001');
|
||||
state.completing = true;
|
||||
|
||||
const finalIndex = items.length - 1;
|
||||
const normalizedOffset = -state.index * TOOL_REEL_ITEM_HEIGHT;
|
||||
const targetOffset = -finalIndex * TOOL_REEL_ITEM_HEIGHT;
|
||||
|
||||
state.phase = 'idle';
|
||||
state.offsetPx = normalizedOffset;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
state.phase = 'rolling';
|
||||
state.offsetPx = targetOffset - TOOL_REEL_OVERSHOOT_PX;
|
||||
|
||||
const settleTimeout = window.setTimeout(() => {
|
||||
state.phase = 'settle';
|
||||
state.offsetPx = targetOffset;
|
||||
state.index = finalIndex;
|
||||
}, TOOL_REEL_ROLL_MS);
|
||||
pushToolReelTimeout(groupId, settleTimeout);
|
||||
|
||||
const cleanupTimeout = window.setTimeout(() => {
|
||||
clearToolReelTimers(groupId);
|
||||
delete toolReelStates[groupId];
|
||||
}, TOOL_REEL_ROLL_MS + TOOL_REEL_SETTLE_MS + 260);
|
||||
pushToolReelTimeout(groupId, cleanupTimeout);
|
||||
});
|
||||
};
|
||||
|
||||
const syncToolReels = () => {
|
||||
const activeGroups = new Set<string>();
|
||||
|
||||
blockGroups.value.forEach((group) => {
|
||||
if (group.type !== 'summary' || !group.actions || !shouldShowToolReel(group.actions, group.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = getToolReelItems(group.actions);
|
||||
const signature = items.join('\u0001');
|
||||
activeGroups.add(group.id);
|
||||
|
||||
if (!toolReelStates[group.id] || toolReelStates[group.id].signature !== signature) {
|
||||
clearToolReelTimers(group.id);
|
||||
toolReelStates[group.id] = {
|
||||
index: 0,
|
||||
offsetPx: 0,
|
||||
phase: 'idle',
|
||||
signature,
|
||||
items
|
||||
};
|
||||
} else {
|
||||
toolReelStates[group.id].items = items;
|
||||
}
|
||||
|
||||
if (!toolReelIntervals.has(group.id)) {
|
||||
const interval = window.setInterval(
|
||||
() => spinToolReel(group.id, items.length),
|
||||
TOOL_REEL_INTERVAL_MS
|
||||
);
|
||||
toolReelIntervals.set(group.id, interval);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(toolReelStates).forEach((groupId) => {
|
||||
if (!activeGroups.has(groupId)) {
|
||||
const group = blockGroups.value.find((item) => item.id === groupId);
|
||||
if (group?.type === 'summary' && group.actions && !toolReelStates[groupId].completing) {
|
||||
activeGroups.add(groupId);
|
||||
finishToolReel(groupId, group.actions);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!toolReelStates[groupId].completing) {
|
||||
clearToolReelTimers(groupId);
|
||||
delete toolReelStates[groupId];
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 获取摘要组的加载动画组件(每次action类型切换时随机一次)
|
||||
const getSummaryLoader = (groupId: string) => {
|
||||
const group = blockGroups.value.find((g) => g.id === groupId);
|
||||
@ -342,9 +631,11 @@ const getSummaryPreview = (actions: Action[]) => {
|
||||
return `正在调用 ${tool.name || '工具'}...`;
|
||||
}
|
||||
if (tool.status === 'completed') {
|
||||
return '工具执行完成';
|
||||
return truncateSummaryPreview(tool.display_name || tool.name || '工具执行完成');
|
||||
}
|
||||
return currentStep.streaming ? '正在执行工具...' : '工具执行完成';
|
||||
return currentStep.streaming
|
||||
? '正在执行工具...'
|
||||
: truncateSummaryPreview(tool.display_name || tool.name || '工具执行完成');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@ -533,10 +824,21 @@ const handleScroll = (blockId: string, event: Event) => {
|
||||
watch(
|
||||
() => props.actions,
|
||||
() => {
|
||||
// 简化:不需要动态更新高度
|
||||
syncToolReels();
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.conversationRunning, props.isLatestMessage],
|
||||
() => {
|
||||
syncToolReels();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Object.keys(toolReelStates).forEach(clearToolReelTimers);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -596,6 +898,63 @@ watch(
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.summary-tool-reel-window {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: min(100%, 36em);
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.summary-tool-reel-window::before,
|
||||
.summary-tool-reel-window::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
height: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.summary-tool-reel-window::before {
|
||||
top: 0;
|
||||
background: linear-gradient(180deg, var(--surface-base), transparent);
|
||||
}
|
||||
|
||||
.summary-tool-reel-window::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(0deg, var(--surface-base), transparent);
|
||||
}
|
||||
|
||||
.summary-tool-reel-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.summary-tool-reel-track.rolling {
|
||||
transition: transform 520ms cubic-bezier(0.22, 0.9, 0.25, 1);
|
||||
}
|
||||
|
||||
.summary-tool-reel-track.settle {
|
||||
transition: transform 170ms cubic-bezier(0.2, 0.72, 0.26, 1);
|
||||
}
|
||||
|
||||
.summary-tool-reel-item {
|
||||
display: block;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary-status-icon {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user