feat: add minimal tool reel animation

This commit is contained in:
JOJO 2026-06-07 14:59:44 +08:00
parent a1dd97d5f1
commit 4bf0733907
2 changed files with 813 additions and 11 deletions

View 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>

View File

@ -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;