agent-Specialization/demo/stacked-blocks/stack6.html

515 lines
17 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>≤6 块顺序动画 Demo</title>
<style>
:root {
--bg: #f6f1e6;
--card: rgba(255, 255, 255, 0.86);
--border: #e0d7c7;
--shadow: 0 16px 44px rgba(91, 74, 54, 0.12);
--text: #4b3e2f;
--text-muted: #9a8d7d;
--accent: #b98a59;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px;
background: radial-gradient(circle at 18% 18%, rgba(185, 138, 89, 0.08), transparent 28%), linear-gradient(180deg, #faf7f0, #f5f1e7);
font-family: "Inter", "PingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
}
.page { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
padding: 12px 16px;
}
.controls { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
button {
border: 1px solid var(--border);
background: #fff;
color: var(--text);
border-radius: 12px;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
transition: all 150ms ease;
box-shadow: 0 6px 18px rgba(91, 74, 54, 0.08);
}
button.primary { background: linear-gradient(135deg, #cfa976, #b98957); border-color: #c49556; color: #fffdf8; }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.55; cursor: not-allowed; box-shadow: none; }
.hint { color: var(--text-muted); font-size: 13px; }
.stack-shell {
position: relative;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--card);
box-shadow: var(--shadow);
overflow: hidden;
transition: height 280ms cubic-bezier(0.4, 0, 0.2, 1);
min-height: 0;
}
.stack-inner { width: 100%; }
.block {
background: transparent;
border-bottom: 1px solid var(--border);
}
.block:last-child { border-bottom: none; }
.collapsible-block {
margin: 0;
border: none;
border-radius: 0;
box-shadow: none;
position: relative;
overflow: hidden;
}
.collapsible-header {
padding: 14px 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
user-select: none;
background: rgba(255, 255, 255, 0.78);
transition: background-color 0.2s ease;
position: relative;
}
.arrow {
width: 20px; height: 20px; display: grid; place-items: center;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
color: var(--text-muted); font-size: 18px;
}
.collapsible-block.expanded .arrow { transform: rotate(90deg); }
.status-icon { width: 24px; height: 24px; display: grid; place-items: center; font-size: 18px; }
.status-text { font-size: 14px; font-weight: 600; color: var(--text); }
.tool-desc { color: var(--text-muted); font-size: 12px; margin-left: 6px; }
.collapsible-content {
max-height: 0;
overflow: hidden;
opacity: 0;
transition:
max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsible-block.expanded .collapsible-content {
max-height: 600px;
opacity: 1;
}
.content-inner {
padding: 16px 20px 24px 56px; /* 适度底部留白 */
font-size: 14px;
line-height: 1.6;
color: var(--text-muted);
white-space: pre-wrap; /* 保留换行以展示流式文本 */
}
.content-inner::after {
content: '';
display: block;
height: 12px; /* 确保末行与底部有稳定间距 */
}
.progress-indicator {
position: absolute; bottom: 0; left: 0; height: 2px;
background: var(--accent);
animation: progress-bar 2s ease-in-out infinite;
opacity: 1;
}
@keyframes progress-bar {
0% { width: 0%; left: 0%; }
50% { width: 40%; left: 30%; }
100% { width: 0%; left: 100%; }
}
.footer-note { color: var(--text-muted); font-size: 12px; text-align: right; }
</style>
</head>
<body>
<div class="page">
<div class="panel">
<div class="controls">
<button id="play" class="primary">开始演示 (0→6块)</button>
<button id="reset">重置</button>
</div>
<div class="hint">严格顺序:思考(展开→折叠) → 工具 → 思考 → 工具... 直至 6 块。动画曲线、展开/折叠与现有项目一致。</div>
</div>
<div class="stack-shell" id="shell">
<div class="stack-inner" id="inner"></div>
</div>
<div class="footer-note">demo/stacked-blocks/stack6.html · 单文件顺序动画示例≤6 块场景)</div>
</div>
<script>
const shell = document.getElementById('shell');
const inner = document.getElementById('inner');
const btnPlay = document.getElementById('play');
const btnReset = document.getElementById('reset');
let running = false;
let timers = [];
let idSeed = 1;
let shellHeight = 0; // 记录当前容器高度,跟随内容变化
const pick = () => {
const samples = [
'评估用户意图,列出检索关键词。',
'构造搜索查询,并准备调用工具。',
'生成中间结论,等待工具补充。',
'对结果做归因与去重。',
'落盘前再次校验上下文。'
];
return samples[Math.floor(Math.random() * samples.length)];
};
const createBlockEl = (type) => {
const block = document.createElement('div');
block.className = 'block';
block.dataset.id = `blk-${idSeed++}`;
const wrap = document.createElement('div');
wrap.className = 'collapsible-block ' + (type === 'thinking' ? 'thinking-block' : 'tool-block');
const header = document.createElement('div');
header.className = 'collapsible-header';
const arrow = document.createElement('div');
arrow.className = 'arrow';
arrow.textContent = '';
const icon = document.createElement('div');
icon.className = 'status-icon';
icon.textContent = type === 'thinking' ? '🧠' : '🔍';
const text = document.createElement('span');
text.className = 'status-text';
text.textContent = type === 'thinking' ? '思考过程' : '搜索完成';
const desc = document.createElement('span');
desc.className = 'tool-desc';
desc.textContent = type === 'thinking' ? '正在思考...' : '“RAG 模型 2024 2025 常用”';
header.appendChild(arrow);
header.appendChild(icon);
header.appendChild(text);
header.appendChild(desc);
const contentWrap = document.createElement('div');
contentWrap.className = 'collapsible-content';
const innerContent = document.createElement('div');
innerContent.className = 'content-inner';
innerContent.textContent = pick();
contentWrap.appendChild(innerContent);
wrap.appendChild(header);
wrap.appendChild(contentWrap);
block.appendChild(wrap);
return { block, wrap, arrow, contentWrap, desc, text, innerContent };
};
const animateHeight = (from, to) => {
if (from === to) return Promise.resolve();
return new Promise((resolve) => {
shell.style.height = from + 'px';
shell.getBoundingClientRect();
shell.style.height = to + 'px';
const onEnd = () => {
shell.removeEventListener('transitionend', onEnd);
resolve();
};
shell.addEventListener('transitionend', onEnd, { once: true });
});
};
const measureProjectedHeight = (overrides = {}) => {
const blocks = Array.from(inner.querySelectorAll('.block'));
return blocks.reduce((acc, b, idx) => {
const wrap = b.firstElementChild;
const header = b.querySelector('.collapsible-header');
const content = b.querySelector('.collapsible-content');
const progress = b.querySelector('.progress-indicator');
const id = b.dataset.id;
const expanded =
Object.prototype.hasOwnProperty.call(overrides, id)
? overrides[id]
: wrap?.classList.contains('expanded');
const headerH = header ? header.getBoundingClientRect().height : 0;
const contentH = content ? (expanded ? content.scrollHeight : 0) : 0;
const progressH = progress ? progress.getBoundingClientRect().height : 0;
const borderH = idx < blocks.length - 1 ? parseFloat(getComputedStyle(b).borderBottomWidth) || 0 : 0;
return acc + Math.ceil(headerH + contentH + progressH + borderH);
}, 0);
};
const capturePositions = () => {
const map = {};
inner.querySelectorAll('.block').forEach((b) => {
map[b.dataset.id] = { top: b.offsetTop, height: b.offsetHeight };
});
return map;
};
const playFLIP = (prevMap, nextMap) => {
if (!prevMap || !nextMap) return;
inner.querySelectorAll('.block').forEach((b) => {
const id = b.dataset.id;
const old = prevMap[id];
const next = nextMap[id];
if (!old || !next) return;
const dy = old.top - next.top;
if (Math.abs(dy) < 0.5) return;
b.style.transform = `translateY(${dy}px)`;
b.style.transition = 'transform 220ms ease';
requestAnimationFrame(() => {
b.style.transform = '';
});
b.addEventListener(
'transitionend',
() => {
b.style.transition = '';
},
{ once: true }
);
});
};
const updateHeightToContent = async (overrides = {}, prevPositions) => {
const target = Math.max(0, Math.ceil(measureProjectedHeight(overrides)));
const heightAnim = animateHeight(shellHeight, target);
shellHeight = target;
if (prevPositions) {
const nextMap = capturePositions();
playFLIP(prevPositions, nextMap);
}
await heightAnim;
};
const waitForContentTransition = (content) =>
new Promise((resolve) => {
if (!content) return resolve();
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
resolve();
}
}, 500);
const handler = (e) => {
if (e.target === content && e.propertyName === 'max-height') {
if (!settled) {
settled = true;
clearTimeout(timer);
content.removeEventListener('transitionend', handler);
resolve();
}
}
};
content.addEventListener('transitionend', handler);
});
const applyContentTransition = (content, toHeight, toOpacity) => {
if (!content) return;
const current = content.style.maxHeight;
if (!current || current === '0px') {
content.style.maxHeight = content.scrollHeight + 'px';
}
content.getBoundingClientRect(); // reflow
content.style.maxHeight = toHeight;
content.style.opacity = toOpacity;
};
const setExpandedState = (wrap, expanded) => {
const content = wrap.querySelector('.collapsible-content');
wrap.classList.toggle('expanded', expanded);
if (expanded) {
applyContentTransition(content, `${content.scrollHeight}px`, '1');
} else {
applyContentTransition(content, '0px', '0');
}
};
const toggleBlock = async (wrap) => {
const block = wrap.parentElement;
const id = block?.dataset.id;
const content = wrap.querySelector('.collapsible-content');
const prev = capturePositions();
const next = !wrap.classList.contains('expanded');
setExpandedState(wrap, next);
const overrides = id ? { [id]: next } : {};
await Promise.all([updateHeightToContent(overrides, prev), waitForContentTransition(content)]);
};
const addThinking = async () => {
const { block, wrap, arrow, contentWrap, desc } = createBlockEl('thinking');
wrap.dataset.type = 'thinking';
wrap.querySelector('.collapsible-header')?.addEventListener('click', () => toggleBlock(wrap));
// 初始:思考进行中,进度条
const progress = document.createElement('div');
progress.className = 'progress-indicator';
wrap.appendChild(progress);
const prev = capturePositions();
inner.appendChild(block);
setExpandedState(wrap, true);
const contentEl = wrap.querySelector('.content-inner');
if (contentEl) contentEl.textContent = '';
streamThinkingContent(contentEl, wrap);
await updateHeightToContent({ [block.dataset.id]: true }, prev);
// 思考结束
await wait(THINKING_DURATION);
desc.textContent = '思考完成,自动折叠';
wrap.removeChild(progress);
// 思考完成后稍等再折叠
await wait(THINKING_COLLAPSE_DELAY);
// 折叠时整体高度同步缩回
await collapseBlock(wrap);
};
const addTool = async () => {
const { block, wrap, desc } = createBlockEl('tool');
wrap.dataset.type = 'tool';
// 工具默认折叠,不做额外淡入,靠容器生长露出
wrap.querySelector('.collapsible-header')?.addEventListener('click', () => toggleBlock(wrap));
// 工具进行中进度条
const progress = document.createElement('div');
progress.className = 'progress-indicator';
wrap.appendChild(progress);
const prev = capturePositions();
inner.appendChild(block);
setExpandedState(wrap, false);
await updateHeightToContent({ [block.dataset.id]: false }, prev);
// 工具完成
await wait(TOOL_DURATION);
desc.textContent = '工具完成 · 结果已写入';
wrap.removeChild(progress);
};
const collapseBlock = async (wrap) => {
const block = wrap.parentElement;
const id = block?.dataset.id;
const content = wrap.querySelector('.collapsible-content');
const prev = capturePositions();
setExpandedState(wrap, false);
await Promise.all([updateHeightToContent(id ? { [id]: false } : {}, prev), waitForContentTransition(content)]);
};
const wait = (ms) => new Promise((r) => timers.push(setTimeout(r, ms)));
const THINKING_DURATION = 4800; // 进度条能跑 2-3 轮
const THINKING_COLLAPSE_DELAY = 200; // 与现有动画节奏相近的轻微缓冲
const TOOL_DURATION = 4200; // 工具执行展示时间
const STREAM_INTERVAL = 90;
const randomLetters = (len = 2) => {
const chars = 'abcdefghijklmnopqrstuvwxyz';
let out = '';
for (let i = 0; i < len; i++) {
out += chars[Math.floor(Math.random() * chars.length)];
}
return out;
};
const streamThinkingContent = (contentEl, wrap) => {
if (!contentEl) return;
let charInLine = 0;
const blockId = wrap.parentElement?.dataset.id;
const start = performance.now();
const contentWrap = wrap.querySelector('.collapsible-content');
const tick = () => {
if (!running) return;
const elapsed = performance.now() - start;
if (elapsed >= THINKING_DURATION - STREAM_INTERVAL) return;
const chunk = randomLetters(2);
chunk.split('').forEach((ch) => {
if (charInLine >= 10) {
contentEl.textContent += '\n';
charInLine = 0;
}
contentEl.textContent += ch;
charInLine += 1;
});
// 调整内容与容器高度
contentEl.style.maxHeight = contentEl.scrollHeight + 'px';
if (contentWrap) {
contentWrap.style.maxHeight = contentWrap.scrollHeight + 'px';
}
const overrides = blockId ? { [blockId]: true } : {};
updateHeightToContent(overrides, null);
const t = setTimeout(tick, STREAM_INTERVAL);
timers.push(t);
};
tick();
};
const runSequence = async () => {
const sequence = ['thinking', 'tool', 'thinking', 'tool', 'thinking', 'tool'];
for (const type of sequence) {
if (!running) break;
if (type === 'thinking') {
await addThinking();
} else {
await addTool();
}
await wait(300);
}
running = false;
btnPlay.disabled = false;
};
const reset = () => {
timers.forEach((t) => clearTimeout(t));
timers = [];
running = false;
inner.innerHTML = '';
shell.style.height = '0px';
shellHeight = 0;
btnPlay.disabled = false;
};
btnPlay.addEventListener('click', () => {
if (running) return;
reset();
running = true;
btnPlay.disabled = true;
runSequence();
});
btnReset.addEventListener('click', reset);
// 初始高度 0待首块生长露出
shell.style.height = '0px';
</script>
</body>
</html>