343 lines
10 KiB
JavaScript
343 lines
10 KiB
JavaScript
(() => {
|
||
const MAX_VISIBLE = 6;
|
||
const blocks = [];
|
||
let showAll = false;
|
||
let idSeed = 1;
|
||
let running = false;
|
||
let timers = [];
|
||
|
||
const stackShell = document.getElementById('stackShell');
|
||
const stackInner = document.getElementById('stackInner');
|
||
const btnPlay = document.getElementById('playLoop');
|
||
const btnStep = document.getElementById('stepOnce');
|
||
const btnPause = document.getElementById('pauseLoop');
|
||
const btnClear = document.getElementById('clearAll');
|
||
const btnToggleAll = document.getElementById('toggleAll');
|
||
|
||
const samples = [
|
||
'评估用户意图,先汇总检索关键字,再推演候选工具。',
|
||
'正在调用 web_search,拼接 query 并去重关键词。',
|
||
'校验上下文有效性,准备输出草稿。',
|
||
'工具结果将写入下一个文本块,留意引用。',
|
||
'自动回溯最近 3 条工具调用,避免重复请求。'
|
||
];
|
||
|
||
const pick = () => samples[Math.floor(Math.random() * samples.length)];
|
||
|
||
const createBlock = type => ({
|
||
id: `${type}-${idSeed++}`,
|
||
type,
|
||
title: type === 'thinking' ? '思考过程' : '搜索完成',
|
||
detail: pick(),
|
||
streaming: true,
|
||
expanded: type === 'thinking',
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
const computeVisible = () => {
|
||
if (showAll || blocks.length <= MAX_VISIBLE) return [...blocks];
|
||
const hidden = blocks.length - MAX_VISIBLE;
|
||
return [{ id: `more-${hidden}`, type: 'more', hidden }, ...blocks.slice(-MAX_VISIBLE)];
|
||
};
|
||
|
||
const updateButtons = () => {
|
||
const overLimit = blocks.length > MAX_VISIBLE;
|
||
btnToggleAll.disabled = !overLimit;
|
||
btnToggleAll.textContent = showAll ? '收起到最近 6 个' : '展开全部';
|
||
};
|
||
|
||
const measurePositions = () => {
|
||
const map = {};
|
||
stackInner.querySelectorAll('[data-id]').forEach(el => {
|
||
map[el.dataset.id] = el.getBoundingClientRect();
|
||
});
|
||
return map;
|
||
};
|
||
|
||
const animateHeight = (prev, next) => {
|
||
if (prev === next) return;
|
||
stackShell.style.height = prev + 'px';
|
||
// force reflow
|
||
stackShell.getBoundingClientRect();
|
||
stackShell.style.transition = 'height 280ms cubic-bezier(0.25, 0.9, 0.3, 1)';
|
||
stackShell.style.height = next + 'px';
|
||
const clear = () => {
|
||
stackShell.style.transition = '';
|
||
stackShell.removeEventListener('transitionend', clear);
|
||
};
|
||
stackShell.addEventListener('transitionend', clear);
|
||
};
|
||
|
||
const applyFLIP = (prev, isGrowMode, addedId) => {
|
||
stackInner.querySelectorAll('[data-id]').forEach(el => {
|
||
const id = el.dataset.id;
|
||
const isMoreRow = id.startsWith('more');
|
||
const now = el.getBoundingClientRect();
|
||
const old = prev[id];
|
||
|
||
if (old) {
|
||
const dy = old.top - now.top;
|
||
if (Math.abs(dy) > 0.5) {
|
||
el.style.transform = `translateY(${dy}px)`;
|
||
el.style.transition = 'transform 220ms ease, opacity 220ms ease';
|
||
requestAnimationFrame(() => {
|
||
el.style.transform = 'translateY(0)';
|
||
});
|
||
el.addEventListener(
|
||
'transitionend',
|
||
() => {
|
||
el.style.transform = '';
|
||
el.style.transition = '';
|
||
},
|
||
{ once: true }
|
||
);
|
||
}
|
||
} else {
|
||
const usePeek = !showAll && blocks.length <= MAX_VISIBLE && id === addedId;
|
||
if (usePeek) {
|
||
el.classList.add('peek');
|
||
requestAnimationFrame(() => el.classList.remove('peek'));
|
||
} else if (isGrowMode || isMoreRow) {
|
||
el.classList.add('flow-enter');
|
||
requestAnimationFrame(() => el.classList.add('flow-enter-active'));
|
||
el.addEventListener(
|
||
'transitionend',
|
||
() => {
|
||
el.classList.remove('flow-enter', 'flow-enter-active');
|
||
},
|
||
{ once: true }
|
||
);
|
||
} else if (id === addedId) {
|
||
el.classList.add('peek');
|
||
requestAnimationFrame(() => el.classList.remove('peek'));
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const createBlockRow = block => {
|
||
const row = document.createElement('div');
|
||
row.className = `row block-row type-${block.type}`;
|
||
row.dataset.id = block.id;
|
||
|
||
const main = document.createElement('div');
|
||
main.className = 'row-main';
|
||
main.addEventListener('click', () => {
|
||
block.expanded = !block.expanded;
|
||
render();
|
||
});
|
||
|
||
const arrow = document.createElement('div');
|
||
arrow.className = `arrow ${block.expanded ? 'open' : ''}`;
|
||
arrow.textContent = '›';
|
||
|
||
const icon = document.createElement('div');
|
||
icon.className = 'row-icon';
|
||
icon.textContent = block.type === 'thinking' ? '🧠' : '🔍';
|
||
|
||
const text = document.createElement('div');
|
||
text.className = 'row-text';
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'row-title';
|
||
title.textContent = block.title;
|
||
|
||
const sub = document.createElement('div');
|
||
sub.className = 'row-sub';
|
||
if (block.type === 'thinking') {
|
||
sub.textContent = block.streaming ? '思考中 · 默认展开' : '思考完成 · 点击查看详情';
|
||
} else {
|
||
sub.textContent = block.streaming ? '工具调用中 · 运行后自动收起' : '工具完成 · 点击展开结果';
|
||
}
|
||
|
||
text.appendChild(title);
|
||
text.appendChild(sub);
|
||
|
||
const pill = document.createElement('div');
|
||
pill.className = `pill ${block.streaming ? '' : 'green'}`;
|
||
pill.textContent = block.streaming ? '进行中' : '已完成';
|
||
|
||
main.appendChild(arrow);
|
||
main.appendChild(icon);
|
||
main.appendChild(text);
|
||
main.appendChild(pill);
|
||
|
||
const detailWrap = document.createElement('div');
|
||
detailWrap.className = 'row-detail';
|
||
detailWrap.style.display = block.expanded ? 'block' : 'none';
|
||
|
||
const detailInner = document.createElement('div');
|
||
detailInner.className = 'row-detail-inner';
|
||
detailInner.textContent = block.detail;
|
||
detailWrap.appendChild(detailInner);
|
||
|
||
row.appendChild(main);
|
||
row.appendChild(detailWrap);
|
||
|
||
if (block.streaming) {
|
||
const progress = document.createElement('div');
|
||
progress.className = 'progress';
|
||
row.appendChild(progress);
|
||
}
|
||
|
||
return row;
|
||
};
|
||
|
||
const createMoreRow = (id, hidden) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'row more-row';
|
||
row.dataset.id = id;
|
||
row.addEventListener('click', () => {
|
||
showAll = true;
|
||
render();
|
||
});
|
||
|
||
const dot = document.createElement('div');
|
||
dot.className = 'more-dot';
|
||
dot.textContent = '···';
|
||
|
||
const text = document.createElement('div');
|
||
const title = document.createElement('div');
|
||
title.className = 'row-title';
|
||
title.textContent = '更多';
|
||
const tip = document.createElement('div');
|
||
tip.className = 'more-tip';
|
||
tip.textContent = `已折叠 ${hidden} 个更早的块,点击展开查看`;
|
||
text.appendChild(title);
|
||
text.appendChild(tip);
|
||
|
||
const btn = document.createElement('button');
|
||
btn.className = 'more-btn';
|
||
btn.textContent = '展开';
|
||
|
||
row.appendChild(dot);
|
||
row.appendChild(text);
|
||
row.appendChild(btn);
|
||
return row;
|
||
};
|
||
|
||
const render = (options = {}) => {
|
||
const prev = measurePositions();
|
||
const prevHeight = stackShell.getBoundingClientRect().height || 0;
|
||
const visible = computeVisible();
|
||
stackInner.innerHTML = '';
|
||
|
||
if (!visible.length) {
|
||
stackShell.classList.add('empty');
|
||
updateButtons();
|
||
animateHeight(prevHeight, 140);
|
||
return;
|
||
}
|
||
|
||
stackShell.classList.remove('empty');
|
||
|
||
visible.forEach(block => {
|
||
if (block.type === 'more') {
|
||
stackInner.appendChild(createMoreRow(block.id, block.hidden));
|
||
} else {
|
||
stackInner.appendChild(createBlockRow(block));
|
||
}
|
||
});
|
||
|
||
const isGrowMode = showAll || blocks.length <= MAX_VISIBLE;
|
||
requestAnimationFrame(() => applyFLIP(prev, isGrowMode, options.newlyAddedId));
|
||
updateButtons();
|
||
const nextHeight = stackInner.scrollHeight;
|
||
animateHeight(prevHeight || nextHeight, nextHeight);
|
||
};
|
||
|
||
const addBlock = type => {
|
||
const block = createBlock(type);
|
||
blocks.push(block);
|
||
render({ newlyAddedId: block.id });
|
||
|
||
// 返回 Promise,结束时机取决于类型
|
||
return new Promise(resolve => {
|
||
if (type === 'thinking') {
|
||
timers.push(
|
||
setTimeout(() => {
|
||
block.streaming = false;
|
||
block.detail += ' · 已产出结论,等待工具。';
|
||
render();
|
||
}, 1200)
|
||
);
|
||
timers.push(
|
||
setTimeout(() => {
|
||
block.expanded = false;
|
||
render();
|
||
resolve();
|
||
}, 2000)
|
||
);
|
||
} else {
|
||
timers.push(
|
||
setTimeout(() => {
|
||
block.streaming = false;
|
||
block.detail = '检索到 3 条结果,已写入后续回复。';
|
||
render();
|
||
resolve();
|
||
}, 1400)
|
||
);
|
||
}
|
||
});
|
||
};
|
||
|
||
const wait = ms => new Promise(r => timers.push(setTimeout(r, ms)));
|
||
|
||
const runCycle = async () => {
|
||
const thinkingDone = await addBlock('thinking');
|
||
await wait(200); // 给折叠留一帧
|
||
await addBlock('tool');
|
||
await wait(500); // 循环间隔
|
||
};
|
||
|
||
const playLoop = async () => {
|
||
if (running) return;
|
||
running = true;
|
||
btnPlay.disabled = true;
|
||
btnStep.disabled = true;
|
||
btnPause.disabled = false;
|
||
while (running) {
|
||
await runCycle();
|
||
}
|
||
btnPlay.disabled = false;
|
||
btnStep.disabled = false;
|
||
btnPause.disabled = true;
|
||
};
|
||
|
||
const stepOnce = async () => {
|
||
if (running) return;
|
||
btnStep.disabled = true;
|
||
await runCycle();
|
||
btnStep.disabled = false;
|
||
};
|
||
|
||
const pauseLoop = () => {
|
||
running = false;
|
||
};
|
||
|
||
const clearAll = () => {
|
||
timers.forEach(t => clearTimeout(t));
|
||
timers = [];
|
||
running = false;
|
||
blocks.length = 0;
|
||
showAll = false;
|
||
render();
|
||
btnPlay.disabled = false;
|
||
btnStep.disabled = false;
|
||
btnPause.disabled = true;
|
||
};
|
||
|
||
btnPlay.addEventListener('click', playLoop);
|
||
btnStep.addEventListener('click', stepOnce);
|
||
btnPause.addEventListener('click', pauseLoop);
|
||
btnClear.addEventListener('click', clearAll);
|
||
btnToggleAll.addEventListener('click', () => {
|
||
if (blocks.length <= MAX_VISIBLE) return;
|
||
showAll = !showAll;
|
||
render();
|
||
});
|
||
|
||
// 初始播放一轮示例,演示“先思考后工具”的顺序
|
||
stepOnce();
|
||
})();
|