Compare commits

..

2 Commits

Author SHA1 Message Date
713659a644 feat: refine stacked blocks toggle and animations 2026-01-01 03:06:05 +08:00
93304bd2b8 chore: snapshot before stacked blocks demo 2026-01-01 01:09:24 +08:00
40 changed files with 3852 additions and 171 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
demo/stacked-blocks/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
demo/stacked-blocks/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
demo/stacked-blocks/3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
demo/stacked-blocks/4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
demo/stacked-blocks/5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
demo/stacked-blocks/6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -0,0 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>堆叠块拼接 Demo</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="panel controls-panel">
<div class="controls">
<button id="playLoop" class="primary">开始自动演示</button>
<button id="stepOnce">单次循环</button>
<button id="pauseLoop" disabled>暂停</button>
<button id="clearAll">清空</button>
<button id="toggleAll" disabled>展开全部</button>
</div>
<div class="hint">
· ≤6 块:下缘生长到新高度,思考默认展开,结束自动折叠。<br />
· >6 块:顶部出现“更多”,只保留最近 6 块,整体高度恒定 7 行;新增时整列上移,新块自底部露出再展开。
</div>
</div>
<div class="stack-wrapper">
<div id="stackShell" class="stack-shell empty">
<div id="stackInner" class="stack-inner"></div>
<div class="empty-hint">还没有块,点击上方按钮体验拼接动画。</div>
</div>
</div>
<div class="footer-note">demo/stacked-blocks · 原生 JS 静态示例</div>
</div>
<script src="script.js"></script>
</body>
</html>

View File

@ -0,0 +1,342 @@
(() => {
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();
})();

View File

@ -0,0 +1,514 @@
<!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>

View File

@ -0,0 +1,841 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>≥7 块传送带 Demo</title>
<style>
:root {
--bg: #f6f1e6;
--card: rgba(255, 255, 255, 0.86);
--card-solid: #ffffff;
--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),
padding-top 280ms cubic-bezier(0.4, 0, 0.2, 1);
min-height: 0;
padding-top: 0;
}
.stack-inner { width: 100%; position: relative; z-index: 1; }
.more-block {
position: absolute;
inset: 0 0 auto 0;
background: var(--card-solid);
border-bottom: 0 solid var(--border);
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
height: 0;
opacity: 0;
overflow: hidden;
z-index: 20;
cursor: pointer;
transition:
height 280ms cubic-bezier(0.4,0,0.2,1),
padding 280ms cubic-bezier(0.4,0,0.2,1),
border-bottom-width 280ms cubic-bezier(0.4,0,0.2,1),
opacity 200ms ease;
}
.more-block.visible {
opacity: 1;
padding: 14px 16px;
border-bottom-width: 1px;
}
.more-label { font-weight: 700; color: var(--text); }
.more-desc { color: var(--text-muted); font-size: 12px; }
.more-expand { margin-left: auto; font-size: 12px; color: var(--accent); cursor: pointer; user-select: none; }
.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→10块)</button>
<button id="reset">重置</button>
<span class="hint">前 6 块动画与 stack6 完全一致;第 7 块起触发“更多”+传送带。</span>
</div>
</div>
<div class="stack-shell" id="shell">
<div class="more-block" id="moreBlock">
<span class="more-label">⋯ 更多</span>
<span class="more-desc" id="moreDesc">0 条折叠</span>
</div>
<div class="stack-inner" id="inner"></div>
</div>
<div class="footer-note">demo/stacked-blocks/stack7.html · 7+ 块传送带示例</div>
</div>
<script>
const shell = document.getElementById('shell');
const inner = document.getElementById('inner');
const moreBlock = document.getElementById('moreBlock');
const moreDesc = document.getElementById('moreDesc');
const btnPlay = document.getElementById('play');
const btnReset = document.getElementById('reset');
let running = false;
let timers = [];
let idSeed = 1;
let shellHeight = 0;
let blocks = [];
let showAll = false;
let toggling = false;
// 为了便于快速查看 demo前 6 个阶段缩短时长(参考 stack6
const THINKING_DURATION = 2400; // 原 4800
const THINKING_COLLAPSE_DELAY = 120; // 原 200
const TOOL_DURATION = 2000; // 原 4200
const STREAM_INTERVAL = 90;
const VISIBLE = 6;
const pick = () => {
const samples = [
'评估用户意图,列出检索关键词。',
'构造搜索查询,并准备调用工具。',
'生成中间结论,等待工具补充。',
'对结果做归因与去重。',
'落盘前再次校验上下文。'
];
return samples[Math.floor(Math.random() * samples.length)];
};
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 wait = (ms) => new Promise((r) => timers.push(setTimeout(r, ms)));
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);
header.addEventListener('click', () => toggleBlock(wrap));
return { block, wrap, contentWrap, innerContent, desc };
};
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 measureBlockHeight = (block, isLast) => {
const header = block.querySelector('.collapsible-header');
const content = block.querySelector('.collapsible-content');
const progress = block.querySelector('.progress-indicator');
const wrap = block.firstElementChild;
const expanded = wrap?.classList.contains('expanded');
const headerH = header ? Math.ceil(header.getBoundingClientRect().height) : 0;
const contentH = content && expanded ? Math.ceil(content.scrollHeight) : 0;
const progressH = progress ? Math.ceil(progress.getBoundingClientRect().height) : 0;
const borderH = !isLast ? parseFloat(getComputedStyle(block).borderBottomWidth) || 0 : 0;
return headerH + contentH + progressH + borderH;
};
const measureListHeight = (listEls) => {
return listEls.reduce((acc, b, idx) => acc + measureBlockHeight(b, idx === listEls.length - 1), 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 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();
content.style.maxHeight = toHeight;
content.style.opacity = toOpacity;
};
const setExpandedState = (wrap, expanded) => {
const content = wrap.querySelector('.collapsible-content');
wrap.classList.toggle('expanded', expanded);
if (content) {
const target = expanded ? content.scrollHeight : 0;
content.style.maxHeight = target + 'px';
content.style.opacity = expanded ? '1' : '0';
}
};
const normalizeBlockState = (block) => {
const wrap = block?.firstElementChild;
if (!wrap) return;
const content = wrap.querySelector('.collapsible-content');
const expanded = wrap.classList.contains('expanded');
if (content) {
content.style.maxHeight = expanded ? content.scrollHeight + 'px' : '0px';
content.style.opacity = expanded ? '1' : '0';
}
block.style.transition = '';
block.style.transform = '';
};
const toggleBlock = async (wrap) => {
const content = wrap.querySelector('.collapsible-content');
const prev = capturePositions();
const next = !wrap.classList.contains('expanded');
setExpandedState(wrap, next);
const nextPos = capturePositions();
await Promise.all([updateHeight(prev, nextPos), waitForContentTransition(content)]);
};
const streamThinkingContent = (contentEl, wrap) => {
if (!contentEl) return;
let charInLine = 0;
const start = performance.now();
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;
});
const contentWrap = wrap.querySelector('.collapsible-content');
contentEl.style.maxHeight = contentEl.scrollHeight + 'px';
if (contentWrap) contentWrap.style.maxHeight = contentWrap.scrollHeight + 'px';
updateHeight();
const t = setTimeout(tick, STREAM_INTERVAL);
timers.push(t);
};
tick();
};
const updateMore = (hiddenCount, refHeight, forceVisible = false, labelOverride = null) => {
const shouldShow = hiddenCount > 0 || forceVisible;
if (shouldShow) {
moreBlock.classList.add('visible');
moreBlock.style.height = refHeight + 'px';
shell.style.paddingTop = refHeight + 'px';
moreDesc.textContent = labelOverride ?? `${hiddenCount} 条折叠`;
} else {
moreBlock.classList.remove('visible');
moreBlock.style.height = '0px';
shell.style.paddingTop = '0px';
moreDesc.textContent = '无折叠';
}
};
const updateHeight = async (prevPositions, nextPositions) => {
const children = Array.from(inner.children);
const visibleH = measureListHeight(children);
const moreH = moreBlock.classList.contains('visible') ? parseFloat(moreBlock.style.height) || 0 : 0;
const target = Math.max(0, moreH + visibleH);
const anim = animateHeight(shellHeight, target);
shellHeight = target;
if (prevPositions && nextPositions) {
playFLIP(prevPositions, nextPositions);
}
await anim;
};
const conveyorShift = async (firstHeight, firstOverflow) => {
const items = Array.from(inner.children);
if (!items.length) return;
const targetShell = shellHeight + (firstOverflow ? firstHeight : 0);
if (firstOverflow) {
moreBlock.classList.add('visible');
moreBlock.style.height = firstHeight + 'px';
moreDesc.textContent = `${blocks.length - VISIBLE} 条折叠`;
shell.style.paddingTop = firstHeight + 'px';
}
// 传送带上移
const movePromise = new Promise((resolve) => {
let done = 0;
items.forEach((el) => {
el.style.transition = 'transform 260ms cubic-bezier(0.4,0,0.2,1)';
el.style.transform = `translateY(-${firstHeight}px)`;
el.addEventListener(
'transitionend',
() => {
done += 1;
if (done === items.length) resolve();
},
{ once: true }
);
});
requestAnimationFrame(() => {});
});
const heightPromise = animateHeight(shellHeight, targetShell).then(() => {
shellHeight = targetShell;
});
await Promise.all([movePromise, heightPromise]);
// 清理
const first = inner.firstElementChild;
if (first) inner.removeChild(first);
Array.from(inner.children).forEach((el) => {
el.style.transition = '';
el.style.transform = '';
});
};
// 展开抽屉动画:把折叠的块从顶部“吐”出来,现有 6 个块整体下移
const expandDrawer = async (hiddenBlocks, visibleBlocks) => {
if (!hiddenBlocks.length) return render({ mode: 'collapse' }); // nothing hidden, fallback
// 先放入 DOM 便于测量
inner.innerHTML = '';
const full = [...hiddenBlocks, ...visibleBlocks];
full.forEach((b) => {
normalizeBlockState(b);
b.style.transition = 'none';
b.style.transform = '';
b.style.opacity = '1';
inner.appendChild(b);
});
const totalH = measureListHeight(full);
const ref = full[0] || null;
const refH = ref ? measureBlockHeight(ref) : 0;
const firstVisible = visibleBlocks[0] || null;
const hiddenH = firstVisible ? firstVisible.offsetTop : measureListHeight(hiddenBlocks); // 精确到分割线
// 顶部展开块保持占位
updateMore(hiddenBlocks.length, refH, true, '已展开全部');
// 初始状态:整体向上移 hiddenH使当前 6 个保持原位,隐藏块在遮挡上方
full.forEach((b, idx) => {
b.style.transition = 'none';
b.style.transform = `translateY(-${hiddenH}px)`;
if (idx < hiddenBlocks.length) b.style.opacity = '0';
});
inner.getBoundingClientRect(); // reflow
// 整体下滑,隐藏块同步显现(无级联,整体运动)
full.forEach((b, idx) => {
b.style.transition = `transform 260ms cubic-bezier(0.4,0,0.2,1), opacity 220ms ease`;
});
const targetH = refH + totalH;
const heightAnim = animateHeight(shellHeight, targetH).then(() => {
shellHeight = targetH;
});
requestAnimationFrame(() => {
full.forEach((b, idx) => {
b.style.transform = '';
if (idx < hiddenBlocks.length) b.style.opacity = '1';
});
});
// 等待动画结束
await Promise.all([heightAnim, wait(300)]);
// 清理内联样式,恢复自然流
full.forEach((b) => {
b.style.transition = '';
b.style.transform = '';
b.style.opacity = '';
});
};
// 折叠抽屉动画:把隐藏块一起“收回”到顶部
const collapseDrawer = async (hiddenBlocks, visibleBlocks) => {
if (!hiddenBlocks.length) {
// 没有需要折叠的,直接常规渲染
inner.innerHTML = '';
visibleBlocks.forEach((b) => {
normalizeBlockState(b);
inner.appendChild(b);
});
const ref = visibleBlocks[0] || blocks[0] || null;
const refH = ref ? measureBlockHeight(ref) : 0;
updateMore(0, refH, false, null);
await updateHeight();
return;
}
inner.innerHTML = '';
const full = [...hiddenBlocks, ...visibleBlocks];
full.forEach((b) => {
normalizeBlockState(b);
b.style.transition = 'none';
b.style.transform = '';
b.style.opacity = '1';
inner.appendChild(b);
});
const ref = full[0] || null;
const refH = ref ? measureBlockHeight(ref) : 0;
const firstVisible = visibleBlocks[0] || null;
const hiddenH = firstVisible ? firstVisible.offsetTop : measureListHeight(hiddenBlocks);
const visibleH = measureListHeight(visibleBlocks);
// 确保“更多”占位存在,显示折叠数量
updateMore(hiddenBlocks.length, refH, true, `${hiddenBlocks.length} 条折叠`);
inner.getBoundingClientRect(); // reflow
// 统一动画:整体向上移 hiddenH隐藏块淡出
full.forEach((b, idx) => {
b.style.transition = `transform 260ms cubic-bezier(0.4,0,0.2,1)`;
});
const targetHeight = refH + visibleH;
const heightAnim = animateHeight(shellHeight, targetHeight).then(() => {
shellHeight = targetHeight;
});
requestAnimationFrame(() => {
full.forEach((b) => {
b.style.transform = `translateY(-${hiddenH}px)`;
});
});
await Promise.all([heightAnim, wait(300)]);
// 移除隐藏块,恢复最近 VISIBLE
inner.innerHTML = '';
visibleBlocks.forEach((b) => {
b.style.transition = '';
b.style.transform = '';
inner.appendChild(b);
});
updateMore(hiddenBlocks.length, refH, false, null);
await updateHeight();
};
const render = async (options = {}) => {
const { mode } = options; // mode: 'expand' | 'collapse' | undefined
const prevPositions = capturePositions();
const hiddenRaw = Math.max(0, blocks.length - VISIBLE);
const visibleBlocks = showAll ? blocks : blocks.slice(-VISIBLE);
// 展开动画:折叠块逐个“吐”出
if (mode === 'expand') {
const hiddenBlocks = blocks.slice(0, -VISIBLE);
const visibleList = blocks.slice(-VISIBLE);
await expandDrawer(hiddenBlocks, visibleList);
return;
}
// 收起动画:整体上收
if (mode === 'collapse') {
const hiddenBlocks = blocks.slice(0, -VISIBLE);
const visibleList = blocks.slice(-VISIBLE);
await collapseDrawer(hiddenBlocks, visibleList);
return;
}
// 折叠或常规重渲染
inner.innerHTML = '';
visibleBlocks.forEach((b) => {
normalizeBlockState(b);
b.style.transition = '';
b.style.transform = '';
inner.appendChild(b);
});
const ref = visibleBlocks[0] || blocks[0] || null;
const refH = ref ? measureBlockHeight(ref) : 0;
const hiddenCount = showAll ? 0 : hiddenRaw;
const forceVisible = showAll; // 展开后依然保留“展开块”占位,避免顶部跳变
const label = showAll ? '已展开全部' : null;
updateMore(hiddenCount, refH, forceVisible, label);
const nextPositions = capturePositions();
await updateHeight(prevPositions, nextPositions);
};
const addThinking = async () => {
const { block, wrap, innerContent, desc } = createBlockEl('thinking');
wrap.dataset.type = 'thinking';
const progress = document.createElement('div');
progress.className = 'progress-indicator';
wrap.appendChild(progress);
if (innerContent) innerContent.textContent = '';
const willOverflow = !showAll && blocks.length >= VISIBLE;
const hiddenBefore = Math.max(0, blocks.length - VISIBLE);
blocks.push(block);
if (willOverflow) {
inner.appendChild(block);
const first = inner.firstElementChild;
const firstH = first ? measureBlockHeight(first) : measureBlockHeight(block);
const firstOverflow = hiddenBefore === 0;
setExpandedState(wrap, true);
streamThinkingContent(innerContent, wrap);
await conveyorShift(firstH, firstOverflow);
// 保留最近 VISIBLE
inner.innerHTML = '';
blocks.slice(-VISIBLE).forEach((b) => inner.appendChild(b));
const ref = inner.firstElementChild || block;
const refH = ref ? measureBlockHeight(ref) : firstH;
updateMore(blocks.length - VISIBLE, refH);
await updateHeight();
} else {
const prev = capturePositions();
inner.appendChild(block);
setExpandedState(wrap, true);
streamThinkingContent(innerContent, wrap);
const next = capturePositions();
await updateHeight(prev, next);
}
await wait(THINKING_DURATION);
desc.textContent = '思考完成,自动折叠';
wrap.removeChild(progress);
await wait(THINKING_COLLAPSE_DELAY);
setExpandedState(wrap, false);
await updateHeight();
if (showAll) {
const ref = blocks[0] || null;
const refH = ref ? measureBlockHeight(ref) : 0;
updateMore(Math.max(0, blocks.length - VISIBLE), refH, true, '已展开全部');
}
};
const addTool = async () => {
const { block, wrap, desc } = createBlockEl('tool');
wrap.dataset.type = 'tool';
setExpandedState(wrap, false);
const progress = document.createElement('div');
progress.className = 'progress-indicator';
wrap.appendChild(progress);
const willOverflow = !showAll && blocks.length >= VISIBLE;
const hiddenBefore = Math.max(0, blocks.length - VISIBLE);
blocks.push(block);
if (willOverflow) {
inner.appendChild(block);
const first = inner.firstElementChild;
const firstH = first ? measureBlockHeight(first) : measureBlockHeight(block);
const firstOverflow = hiddenBefore === 0;
await conveyorShift(firstH, firstOverflow);
inner.innerHTML = '';
blocks.slice(-VISIBLE).forEach((b) => inner.appendChild(b));
const ref = inner.firstElementChild || block;
const refH = ref ? measureBlockHeight(ref) : firstH;
updateMore(blocks.length - VISIBLE, refH);
await updateHeight();
} else {
const prev = capturePositions();
inner.appendChild(block);
const next = capturePositions();
await updateHeight(prev, next);
}
await wait(TOOL_DURATION);
desc.textContent = '工具完成 · 结果已写入';
wrap.removeChild(progress);
await updateHeight();
if (showAll) {
const ref = blocks[0] || null;
const refH = ref ? measureBlockHeight(ref) : 0;
updateMore(Math.max(0, blocks.length - VISIBLE), refH, true, '已展开全部');
}
};
const runSequence = async () => {
const sequence = ['thinking', 'tool', 'thinking', 'tool', '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;
blocks = [];
shellHeight = 0;
showAll = false;
toggling = false;
inner.innerHTML = '';
moreBlock.classList.remove('visible');
moreBlock.style.height = '0px';
shell.style.height = '0px';
btnPlay.disabled = false;
moreDesc.textContent = '无折叠';
};
moreBlock.addEventListener('click', async () => {
if (!blocks.length || toggling) return;
const hiddenCount = Math.max(0, blocks.length - VISIBLE);
if (hiddenCount === 0 && !showAll) return; // 无折叠内容
toggling = true;
const targetShowAll = !showAll;
showAll = targetShowAll;
await render({ mode: targetShowAll ? 'expand' : 'collapse' });
toggling = false;
});
btnPlay.addEventListener('click', () => {
if (running) return;
reset();
running = true;
btnPlay.disabled = true;
runSequence();
});
btnReset.addEventListener('click', reset);
shell.style.height = '0px';
</script>
</body>
</html>

View File

@ -0,0 +1,324 @@
:root {
--paper: #f7f3ea;
--card: #fdfbf6;
--ink: #5b4a36;
--muted: #9a8d7d;
--line: #e9e1d6;
--shadow: 0 18px 48px rgba(91, 74, 54, 0.12);
--accent: #b98a59;
--accent-weak: rgba(185, 138, 89, 0.18);
--green: #4c9f70;
--peek-clip: inset(42% 0 0 0 round 0 0 18px 18px);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 20px;
background: radial-gradient(circle at 20% 18%, rgba(185, 138, 89, 0.08), transparent 28%),
radial-gradient(circle at 86% 12%, rgba(76, 159, 112, 0.08), transparent 30%),
linear-gradient(180deg, #faf7f0, #f5f1e7);
font-family: "Inter", "PingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--ink);
}
.page {
max-width: 1080px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
}
.panel {
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 14px 16px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-start;
}
button {
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
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;
box-shadow: 0 8px 22px rgba(185, 138, 89, 0.28);
}
button:active {
transform: translateY(1px);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
.hint {
color: var(--muted);
font-size: 13px;
margin-top: 6px;
line-height: 1.4;
}
.stack-wrapper {
position: relative;
}
.stack-shell {
position: relative;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--card);
box-shadow: var(--shadow);
overflow: hidden;
transition: height 280ms cubic-bezier(0.25, 0.9, 0.3, 1), box-shadow 180ms ease;
min-height: 140px;
}
.stack-shell.empty {
display: grid;
place-items: center;
}
.stack-shell:not(.empty) .empty-hint {
display: none;
}
.stack-inner {
width: 100%;
}
.empty-hint {
color: var(--muted);
font-size: 14px;
padding: 18px 0;
text-align: center;
}
.row {
background: transparent;
}
.block-row {
padding: 16px 22px;
display: flex;
flex-direction: column;
gap: 8px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0.48));
}
.block-row:last-child {
border-bottom: none;
}
.row-main {
display: grid;
grid-template-columns: 22px 40px 1fr auto;
align-items: center;
gap: 12px;
cursor: pointer;
}
.arrow {
width: 18px;
height: 18px;
display: grid;
place-items: center;
color: var(--muted);
transition: transform 240ms ease;
font-size: 18px;
}
.arrow.open {
transform: rotate(90deg);
color: var(--accent);
}
.row-icon {
width: 36px;
height: 36px;
border-radius: 12px;
background: #f2ede4;
display: grid;
place-items: center;
color: var(--accent);
font-size: 18px;
box-shadow: inset 0 0 0 1px rgba(91, 74, 54, 0.06);
}
.row-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.row-title {
font-weight: 650;
color: var(--ink);
}
.row-sub {
font-size: 13px;
color: var(--muted);
}
.pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--accent-weak);
background: rgba(185, 138, 89, 0.12);
color: var(--accent);
white-space: nowrap;
}
.pill.green {
border-color: rgba(76, 159, 112, 0.16);
background: rgba(76, 159, 112, 0.12);
color: var(--green);
}
.row-detail {
padding-left: 64px;
padding-right: 4px;
color: var(--ink);
line-height: 1.6;
font-size: 14px;
}
.row-detail-inner {
background: #fffdfa;
border: 1px dashed var(--line);
border-radius: 12px;
padding: 10px 12px;
}
.progress {
height: 3px;
border-radius: 999px;
background: rgba(185, 138, 89, 0.16);
overflow: hidden;
position: relative;
margin-left: 64px;
margin-right: 10px;
}
.progress::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(185, 138, 89, 0.05), rgba(185, 138, 89, 0.8), rgba(185, 138, 89, 0.05));
animation: shimmer 1.3s linear infinite;
}
@keyframes shimmer {
from {
transform: translateX(-40%);
}
to {
transform: translateX(40%);
}
}
.more-row {
background: linear-gradient(180deg, #f0ebe1, #e6dece);
color: #4a3f30;
padding: 14px 18px;
border-bottom: 1px solid rgba(74, 63, 48, 0.08);
display: grid;
grid-template-columns: 32px 1fr auto;
align-items: center;
gap: 10px;
cursor: pointer;
}
.more-dot {
width: 32px;
height: 32px;
border-radius: 12px;
background: rgba(74, 63, 48, 0.12);
display: grid;
place-items: center;
font-weight: 800;
letter-spacing: 2px;
}
.more-tip {
font-size: 13px;
color: rgba(74, 63, 48, 0.75);
}
.more-btn {
border: 1px solid rgba(74, 63, 48, 0.2);
background: rgba(255, 255, 255, 0.9);
color: #4a3f30;
border-radius: 10px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
transition: all 120ms ease;
}
.more-btn:hover {
background: #fff;
box-shadow: 0 6px 14px rgba(74, 63, 48, 0.12);
}
.flow-enter {
max-height: 0;
opacity: 0;
transform: translateY(18px);
}
.flow-enter-active {
max-height: 420px;
opacity: 1;
transform: translateY(0);
transition: max-height 260ms cubic-bezier(0.25, 0.9, 0.3, 1), opacity 220ms ease, transform 260ms ease;
}
.peek {
clip-path: var(--peek-clip);
transform: translateY(18px);
opacity: 0.65;
transition: clip-path 260ms ease, transform 260ms ease, opacity 220ms ease;
}
.block-row,
.more-row {
transition: transform 220ms ease, opacity 220ms ease;
}
.footer-note {
color: var(--muted);
font-size: 12px;
text-align: right;
margin-top: 6px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

112
static/demo/style/1.html Normal file
View File

@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
:root {
--bg-body: #F3F4F6;
--bg-sidebar: #FFFFFF;
--bg-card: #FFFFFF;
--border-color: #E5E7EB;
--text-primary: #111827;
--text-secondary: #6B7280;
--accent: #2563EB; /* 商务蓝 */
--accent-bg: #EFF6FF;
}
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg-body); color: var(--text-primary); display: flex; height: 100vh; overflow: hidden; }
/* Sidebar */
.sidebar { width: 280px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; padding: 20px; }
.logo { font-weight: 700; font-size: 18px; color: var(--text-primary); margin-bottom: 30px; display: flex; align-items: center; gap: 10px; }
.logo span { background: var(--accent); color: white; padding: 4px 8px; border-radius: 6px; font-size: 12px; }
.nav-item { padding: 10px 12px; margin-bottom: 4px; border-radius: 6px; color: var(--text-secondary); font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 10px;}
.nav-item.active { background: var(--accent-bg); color: var(--accent); font-weight: 500; }
.nav-item:hover:not(.active) { background: #F9FAFB; }
/* Main Content */
.main { flex: 1; display: flex; flex-direction: column; padding: 0 40px; overflow-y: auto; align-items: center; }
.chat-container { max-width: 800px; width: 100%; padding-top: 40px; padding-bottom: 100px; }
/* User Message */
.user-msg { background: transparent; padding: 16px; font-size: 16px; border-bottom: 1px solid var(--border-color); margin-bottom: 30px; }
/* AI Message */
.ai-msg { margin-bottom: 30px; }
.ai-text { margin-bottom: 20px; line-height: 1.6; color: var(--text-primary); }
/* Action Cards (Search/File) */
.step-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px; /* 较小的圆角 */
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05); /* 极轻微的阴影 */
}
.icon-box { width: 32px; height: 32px; border-radius: 6px; background: var(--bg-body); display: flex; align-items: center; justify-content: center; color: var(--text-secondary); }
/* Input Area */
.input-wrapper { position: fixed; bottom: 30px; width: 100%; max-width: 800px; }
.input-box {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
}
.send-btn { width: 32px; height: 32px; background: var(--text-primary); border-radius: 50%; border: none; }
</style>
<title>Light Theme Demo</title>
</head>
<body>
<div class="sidebar">
<div class="logo">AI Agent <span>v5.3</span></div>
<div class="nav-item active">📁 项目文件</div>
<div class="nav-item">⚙️ 管理面板</div>
<div style="margin-top:20px; font-size:12px; color:#9CA3AF; font-weight:600;">TEMP</div>
<div class="nav-item" style="padding-left: 0;">└ 📄 user_upload</div>
</div>
<div class="main">
<div class="chat-container">
<div class="user-msg">
搜索一下原神目前卡池是什么,写在一个文件里。
</div>
<div class="ai-msg">
<div class="ai-text">我来帮您搜索原神当前卡池信息并写入文件。</div>
<div class="step-card">
<div class="icon-box">🔍</div>
<div style="flex:1">搜索完成 <span style="color:var(--text-secondary)">"原神当前卡池 2024年12月"</span></div>
</div>
<div class="step-card">
<div class="icon-box">🔍</div>
<div style="flex:1">搜索完成 <span style="color:var(--text-secondary)">"原神 5.3版本卡池"</span></div>
</div>
<div class="step-card">
<div class="icon-box">📄</div>
<div style="flex:1">文件创建成功 <span style="font-family:monospace; background:#F3F4F6; padding:2px 6px; border-radius:4px;">genshin_banner.txt</span></div>
</div>
</div>
<div class="input-wrapper">
<div class="input-box">
<span>输入消息... (Ctrl+Enter 发送)</span>
<button class="send-btn"></button>
</div>
</div>
</div>
</div>
</body>
</html>

111
static/demo/style/2.html Normal file
View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
:root {
--bg-body: #0D1117; /* GitHub Dark Dimmed */
--bg-sidebar: #161B22;
--bg-card: #21262D;
--border-color: #30363D;
--text-primary: #C9D1D9;
--text-secondary: #8B949E;
--accent: #2EA043; /* 极客绿 */
--accent-hover: #3FB950;
}
body { margin: 0; font-family: "SF Mono", "Consolas", "Roboto Mono", sans-serif; /* 代码字体风格 */ background: var(--bg-body); color: var(--text-primary); display: flex; height: 100vh; overflow: hidden; }
.sidebar { width: 280px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; padding: 20px; }
.logo { font-weight: 700; font-size: 16px; color: var(--text-primary); margin-bottom: 30px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--border-color); padding-bottom: 20px;}
.status-dot { width: 8px; height: 8px; background: var(--accent); border-radius: 50%; box-shadow: 0 0 8px var(--accent); }
.nav-item { padding: 10px 12px; margin-bottom: 4px; border-radius: 4px; color: var(--text-secondary); font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: 0.2s;}
.nav-item.active { background: rgba(46, 160, 67, 0.15); color: #FFF; border-left: 3px solid var(--accent); }
.nav-item:hover:not(.active) { color: #FFF; background: var(--bg-card); }
.main { flex: 1; display: flex; flex-direction: column; padding: 0 40px; overflow-y: auto; align-items: center; }
.chat-container { max-width: 800px; width: 100%; padding-top: 40px; padding-bottom: 100px; }
.user-msg { background: var(--bg-card); border-radius: 8px; padding: 16px; font-size: 15px; border: 1px solid var(--border-color); margin-bottom: 30px; }
.ai-msg { margin-bottom: 30px; }
.ai-text { margin-bottom: 20px; line-height: 1.6; color: var(--text-primary); }
.step-card {
background: #161B22;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 14px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
transition: border 0.2s;
}
.step-card:hover { border-color: var(--text-secondary); }
.icon-box { color: var(--accent); font-family: monospace; }
.input-wrapper { position: fixed; bottom: 30px; width: 100%; max-width: 800px; }
.input-box {
background: var(--bg-sidebar);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
}
.send-btn { width: 30px; height: 30px; background: var(--accent); border-radius: 4px; border: none; cursor: pointer; }
</style>
<title>Dark Theme Demo</title>
</head>
<body>
<div class="sidebar">
<div class="logo">
<div class="status-dot"></div>
AI_AGENT_SYSTEM v5.3
</div>
<div class="nav-item active">~/project_files</div>
<div class="nav-item">~/monitoring</div>
<div style="margin-top:20px; font-size:11px; color:#484F58; text-transform:uppercase; letter-spacing:1px;">Directory</div>
<div class="nav-item">📁 temp</div>
<div class="nav-item">📄 user_upload</div>
</div>
<div class="main">
<div class="chat-container">
<div class="user-msg">
> 搜索一下原神目前卡池是什么,写在一个文件里
</div>
<div class="ai-msg">
<div class="ai-text">正在执行任务序列...</div>
<div class="step-card">
<div class="icon-box">[SEARCH]</div>
<div style="flex:1">Query: "原神当前卡池 2024年12月"</div>
</div>
<div class="step-card">
<div class="icon-box">[SEARCH]</div>
<div style="flex:1">Query: "原神 5.3版本卡池"</div>
</div>
<div class="step-card">
<div class="icon-box">[WRITE]</div>
<div style="flex:1">Created: <span style="color:var(--accent)">./temp/genshin_banner.txt</span></div>
</div>
</div>
<div class="input-wrapper">
<div class="input-box">
<span>_ 输入指令...</span>
<button class="send-btn"></button>
</div>
</div>
</div>
</div>
</body>
</html>

177
static/demo/style/3.html Normal file
View File

@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
:root {
--glass-bg: rgba(255, 255, 255, 0.65);
--glass-border: rgba(255, 255, 255, 0.5);
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
--text-primary: #2D3748;
--text-secondary: #718096;
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
body {
margin: 0;
font-family: -apple-system, system-ui, sans-serif;
/* 弥散光感背景 */
background: radial-gradient(circle at 10% 20%, rgb(239, 246, 255) 0%, rgb(219, 228, 255) 90%);
background-attachment: fixed;
color: var(--text-primary);
display: flex;
height: 100vh;
overflow: hidden;
}
/* 增加一个背景装饰球 */
body::before {
content: '';
position: absolute;
top: -10%;
left: -10%;
width: 50%;
height: 50%;
background: radial-gradient(circle, rgba(169, 139, 254, 0.4) 0%, rgba(255,255,255,0) 70%);
z-index: -1;
}
/* Sidebar - 悬浮的玻璃侧栏 */
.sidebar {
width: 260px;
margin: 16px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
display: flex; flex-direction: column; padding: 24px;
}
.logo {
font-weight: 800; font-size: 20px;
background: var(--primary-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent;
margin-bottom: 40px;
}
.nav-item {
padding: 12px 16px; margin-bottom: 8px; border-radius: 16px;
color: var(--text-secondary); font-size: 14px; cursor: pointer; font-weight: 500;
transition: all 0.3s ease;
}
.nav-item.active {
background: white;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
color: #5B4BC4;
}
.nav-item:hover:not(.active) { background: rgba(255,255,255,0.5); }
.main { flex: 1; display: flex; flex-direction: column; padding: 0 20px; overflow-y: auto; align-items: center; position: relative; }
.chat-container { max-width: 760px; width: 100%; padding-top: 40px; padding-bottom: 120px; }
/* 对话气泡 */
.user-msg {
background: white;
align-self: flex-end;
padding: 16px 24px;
border-radius: 20px 20px 4px 20px; /* 不规则圆角 */
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
margin-bottom: 40px;
margin-left: auto;
max-width: 80%;
font-size: 15px;
}
.ai-msg { margin-bottom: 30px; }
/* 步骤卡片 - 玻璃条 */
.step-card {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(8px);
border: 1px solid white;
border-radius: 16px;
padding: 16px;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 16px;
font-size: 14px;
box-shadow: 0 2px 10px rgba(0,0,0,0.02);
}
.icon-circle {
width: 36px; height: 36px; border-radius: 12px;
background: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%);
display: flex; align-items: center; justify-content: center; color: white;
}
/* 输入框 - 悬浮胶囊 */
.input-wrapper { position: fixed; bottom: 30px; width: 100%; max-width: 760px; z-index: 10; }
.input-box {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(16px);
border: 1px solid white;
border-radius: 100px; /* 胶囊形状 */
padding: 12px 12px 12px 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
}
.send-btn {
width: 44px; height: 44px;
background: var(--primary-gradient);
border-radius: 50%; border: none;
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.4);
cursor: pointer;
transition: transform 0.2s;
}
.send-btn:hover { transform: scale(1.05); }
</style>
<title>Creative Glass Theme</title>
</head>
<body>
<div class="sidebar">
<div class="logo">AI Agent ✨</div>
<div class="nav-item active">Project Files</div>
<div class="nav-item">Dashboard</div>
<div style="flex:1"></div>
<div class="nav-item">Settings</div>
</div>
<div class="main">
<div class="chat-container">
<div class="user-msg">
搜索一下原神目前卡池是什么,写在一个文件里。
</div>
<div class="ai-msg">
<div class="ai-text" style="padding-left: 8px;">好的,我已经为您整理了卡池信息。</div>
<div class="step-card">
<div class="icon-circle">🔍</div>
<div style="flex:1">Web Search: <b>Genshin Impact Banners</b></div>
</div>
<div class="step-card">
<div class="icon-circle">⚙️</div>
<div style="flex:1">Processing Data...</div>
</div>
<div class="step-card">
<div class="icon-circle">📂</div>
<div style="flex:1">File Saved: <b style="color:#6B46C1">banner_info.txt</b></div>
</div>
</div>
<div class="input-wrapper">
<div class="input-box">
<span>Ask me anything...</span>
<button class="send-btn"></button>
</div>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

View File

@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 8V4H8" />
<rect width="16" height="12" x="4" y="8" rx="2" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M15 13v2" />
<path d="M9 13v2" />
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>

After

Width:  |  Height:  |  Size: 491 B

View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 5h16" />
<path d="M4 12h16" />
<path d="M4 19h16" />
</svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11zm7.318-19.539l-10.94 10.939"/></svg>

After

Width:  |  Height:  |  Size: 372 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 7c0-2 1.6-3.6 3.6-3.6h13c2 0 3.6 1.6 3.6 3.6v10c0 2-1.6 3.6-3.6 3.6h-6.2L12.4 25l0.7-4.4H9.6c-2 0-3.6-1.6-3.6-3.6V7Z" />
<path d="M10.4 11h11.2" />
<path d="M10.4 15h7.2" />
</svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="m21 21l-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3m.08 4h.01"/></svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@ -35,6 +35,154 @@
</div>
</div>
</div>
<template v-if="stackedBlocksEnabled">
<template v-for="(group, groupIndex) in splitActionGroups(msg.actions || [], index)" :key="group.key">
<StackedBlocks
v-if="group.kind === 'stack'"
class="stacked-blocks-wrapper"
:actions="group.actions"
:expanded-blocks="expandedBlocks"
:icon-style="iconStyleSafe"
:toggle-block="toggleBlock"
:register-thinking-ref="registerThinkingRef"
:handle-thinking-scroll="handleThinkingScroll"
:get-tool-animation-class="getToolAnimationClass"
:get-tool-icon="getToolIcon"
:get-tool-status-text="getToolStatusText"
:get-tool-description="getToolDescription"
:format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime"
/>
<div
v-else
class="action-item"
:key="group.action?.id || `${index}-${group.actionIndex}`"
:class="{
'streaming-content': group.action?.streaming,
'completed-tool': group.action?.type === 'tool' && !group.action?.streaming,
'immediate-show':
group.action?.streaming || group.action?.type === 'text' || group.action?.type === 'thinking',
'thinking-finished': group.action?.type === 'thinking' && !group.action?.streaming
}"
>
<div
v-if="group.action?.type === 'thinking'"
class="collapsible-block thinking-block"
:class="{ expanded: expandedBlocks?.has(group.action.blockId || `${index}-thinking-${group.actionIndex}`) }"
>
<div class="collapsible-header" @click="toggleBlock(group.action.blockId || `${index}-thinking-${group.actionIndex}`)">
<div class="arrow"></div>
<div class="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': group.action.streaming }">
<span class="icon icon-sm" :style="iconStyleSafe('brain')" aria-hidden="true"></span>
</span>
</div>
<span class="status-text">{{ group.action.streaming ? '正在思考...' : '思考过程' }}</span>
</div>
<div class="collapsible-content">
<div
class="content-inner thinking-content"
:ref="el => registerThinkingRef(group.action.blockId || `${index}-thinking-${group.actionIndex}`, el)"
@scroll="
handleThinkingScroll(
group.action.blockId || `${index}-thinking-${group.actionIndex}`,
$event
)
"
style="max-height: 240px; overflow-y: auto;"
>
{{ group.action.content }}
</div>
</div>
</div>
<div v-else-if="group.action?.type === 'text'" class="text-output">
<div class="text-content" :class="{ 'streaming-text': group.action.streaming }">
<div v-if="group.action.streaming" v-html="renderMarkdown(group.action.content, true)"></div>
<div v-else v-html="renderMarkdown(group.action.content, false)"></div>
</div>
</div>
<div v-else-if="group.action?.type === 'system'" class="system-action">
<div class="system-action-content">
{{ group.action.content }}
</div>
</div>
<div
v-else-if="group.action?.type === 'append_payload'"
class="append-placeholder"
:class="{ 'append-error': group.action.append?.success === false }"
>
<div class="append-placeholder-content">
<template v-if="group.action.append?.success !== false">
<div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
<span>已写入 {{ group.action.append?.path || '目标文件' }} 的追加内容内容已保存至文件</span>
</div>
</template>
<template v-else>
<div class="icon-label append-status append-error-text">
<span class="icon icon-sm" :style="iconStyleSafe('x')" aria-hidden="true"></span>
<span> {{ group.action.append?.path || '目标文件' }} 写入失败内容已截获供后续修复</span>
</div>
</template>
<div class="append-meta" v-if="group.action.append">
<span v-if="group.action.append.lines !== null && group.action.append.lines !== undefined">
· 行数 {{ group.action.append.lines }}
</span>
<span v-if="group.action.append.bytes !== null && group.action.append.bytes !== undefined">
· 字节 {{ group.action.append.bytes }}
</span>
</div>
<div class="append-warning icon-label" v-if="group.action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
<span>未检测到结束标记请根据提示继续补充</span>
</div>
</div>
</div>
<div
v-else-if="group.action?.type === 'append'"
class="append-placeholder"
:class="{ 'append-error': group.action.append?.success === false }"
>
<div class="append-placeholder-content">
<div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
<span>{{ group.action.append?.summary || '文件追加完成' }}</span>
</div>
<div class="append-meta" v-if="group.action.append">
<span>{{ group.action.append.path || '目标文件' }}</span>
<span v-if="group.action.append.lines">· 行数 {{ group.action.append.lines }}</span>
<span v-if="group.action.append.bytes">· 字节 {{ group.action.append.bytes }}</span>
</div>
<div class="append-warning icon-label" v-if="group.action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
<span>未检测到结束标记请按提示继续补充</span>
</div>
</div>
</div>
<ToolAction
v-else-if="group.action?.type === 'tool'"
:action="group.action"
:expanded="expandedBlocks?.has(group.action.blockId || `${index}-tool-${group.actionIndex}`)"
:icon-style="iconStyleSafe"
:get-tool-animation-class="getToolAnimationClass"
:get-tool-icon="getToolIcon"
:get-tool-status-text="getToolStatusText"
:get-tool-description="getToolDescription"
:format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime"
:streaming-message="streamingMessage"
@toggle="toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)"
/>
</div>
</template>
</template>
<template v-else>
<div
v-for="(action, actionIndex) in msg.actions || []"
:key="action.id || `${index}-${actionIndex}`"
@ -75,7 +223,6 @@
{{ action.content }}
</div>
</div>
<div v-if="action.streaming" class="progress-indicator"></div>
</div>
<div v-else-if="action.type === 'text'" class="text-output">
@ -161,6 +308,7 @@
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
/>
</div>
</template>
</div>
<div v-else class="system-message">
<div class="collapsible-block system-block" :class="{ expanded: expandedBlocks?.has(`system-${index}`) }">
@ -184,8 +332,10 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue';
import { usePersonalizationStore } from '@/stores/personalization';
const props = defineProps<{
messages: Array<any>;
@ -203,6 +353,9 @@ const props = defineProps<{
formatSearchTime: (filters: Record<string, any>) => string;
}>();
const personalization = usePersonalizationStore();
const stackedBlocksEnabled = computed(() => personalization.experiments.stackedBlocksEnabled);
const DEFAULT_GENERATING_TEXT = '生成中…';
const rootEl = ref<HTMLElement | null>(null);
const thinkingRefs = new Map<string, HTMLElement | null>();
@ -226,6 +379,50 @@ function iconStyleSafe(key: string, size?: string) {
return {};
}
const isStackable = (action: any) => action && (action.type === 'thinking' || action.type === 'tool');
const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
const result: Array<
| { kind: 'stack'; actions: any[]; key: string }
| { kind: 'single'; action: any; actionIndex: number; key: string }
> = [];
let buffer: any[] = [];
const flushBuffer = () => {
if (buffer.length >= 2) {
result.push({
kind: 'stack',
actions: buffer.slice(),
key: `stack-${messageIndex}-${result.length}`
});
} else if (buffer.length === 1) {
const single = buffer[0];
result.push({
kind: 'single',
action: single,
actionIndex: actions.indexOf(single),
key: single.id || `single-${messageIndex}-${result.length}`
});
}
buffer = [];
};
actions.forEach((action, idx) => {
if (isStackable(action)) {
buffer.push(action);
} else {
flushBuffer();
result.push({
kind: 'single',
action,
actionIndex: idx,
key: action.id || `single-${messageIndex}-${idx}`
});
}
});
flushBuffer();
return result;
};
function getGeneratingLetters(message: any) {
const label =
typeof message?.generatingLabel === 'string' && message.generatingLabel.trim()

View File

@ -0,0 +1,302 @@
<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` }">
<div class="stacked-inner" ref="inner" :style="{ transform: `translateY(${innerOffset}px)` }">
<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="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>{{ 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>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
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;
}>();
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 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 = () => {
showAll.value = !showAll.value;
nextTick(() => {
setShellMetrics();
});
};
const toggleBlock = (blockId: string) => {
if (typeof props.toggleBlock === 'function') {
props.toggleBlock(blockId);
nextTick(() => {
setShellMetrics();
});
}
};
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 = (expandedOverride: Record<string, boolean> = {}) => {
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 header = el.querySelector('.collapsible-header') as HTMLElement | null;
const content = el.querySelector('.collapsible-content') as HTMLElement | null;
const progress = el.querySelector('.progress-indicator') as HTMLElement | null;
const expanded = typeof expandedOverride[key] === 'boolean' ? expandedOverride[key] : isExpanded(action, idx);
const headerH = header ? header.getBoundingClientRect().height : 0;
const contentHeight = content ? Math.ceil(content.scrollHeight) : 0;
nextContentHeights[key] = contentHeight;
const contentH = expanded ? contentHeight : 0;
const progressH = progress ? progress.getBoundingClientRect().height : 0;
const borderH = idx < children.length - 1 ? parseFloat(getComputedStyle(el).borderBottomWidth) || 0 : 0;
heights.push(Math.ceil(headerH + contentH + progressH + borderH));
});
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);
};
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;
}
});
watch([stackableActions, moreVisible], () => {
nextTick(() => {
setShellMetrics();
});
});
watch(
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
() => {
nextTick(() => setShellMetrics());
}
);
</script>

View File

@ -207,6 +207,29 @@
</button>
</div>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">堆叠块显示</span>
<p class="field-desc">使用新版堆叠动画展示思考/工具块超过 6 条自动收纳为更多默认开启</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="experiments.stackedBlocksEnabled"
@change="handleStackedBlocksToggle($event)"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>在对话区使用堆叠动画可随时切换回传统列表</span>
</label>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">自动生成对话标题</span>
@ -218,6 +241,15 @@
:checked="form.auto_generate_title"
@change="personalization.updateField({ key: 'auto_generate_title', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>使用快速模型为新对话生成含 emoji 的简短标题</span>
</label>
</div>
@ -295,6 +327,45 @@
</div>
</div>
</section>
<section v-else-if="activeTab === 'theme'" key="theme" class="personal-page theme-page">
<div class="theme-section">
<div class="theme-header">
<div>
<h3>界面主题</h3>
<p>在浅色深色和 Claude 经典之间一键切换会立即应用并保存在本地</p>
</div>
<div class="theme-badge">Beta</div>
</div>
<div class="theme-grid">
<button
v-for="option in themeOptions"
:key="option.id"
type="button"
class="theme-card"
:class="{ active: activeTheme === option.id }"
@click.prevent="applyThemeOption(option.id)"
>
<div class="theme-card-head">
<div class="theme-dot-row">
<span
v-for="(color, idx) in option.swatches"
:key="`${option.id}-${idx}`"
class="theme-dot"
:style="{ background: color }"
aria-hidden="true"
></span>
</div>
<span class="theme-check" v-if="activeTheme === option.id"></span>
</div>
<div class="theme-card-body">
<h4>{{ option.label }}</h4>
<p>{{ option.desc }}</p>
</div>
</button>
</div>
<p class="theme-note">虚拟显示器保持原样仅主界面侧边栏与个人空间配色随主题改变</p>
</div>
</section>
<section v-else-if="activeTab === 'experiments'" key="experiments" class="personal-page experiment-page">
<div class="experiment-hero">
<div class="experiment-visual" aria-hidden="true">
@ -381,6 +452,8 @@ import { ref, computed } from 'vue';
import { storeToRefs } from 'pinia';
import { usePersonalizationStore } from '@/stores/personalization';
import { useResourceStore } from '@/stores/resource';
import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme';
defineOptions({ name: 'PersonalizationDrawer' });
@ -406,10 +479,11 @@ const {
const baseTabs = [
{ id: 'preferences', label: '个性化设置' },
{ id: 'behavior', label: '模型行为' },
{ id: 'theme', label: '主题切换', description: '浅色 / 深色 / Claude' },
{ id: 'experiments', label: '实验功能', description: 'Liquid Glass' }
] as const;
type PersonalTab = 'preferences' | 'behavior' | 'experiments' | 'admin-monitor';
type PersonalTab = 'preferences' | 'behavior' | 'theme' | 'experiments' | 'admin-monitor';
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
@ -427,7 +501,6 @@ const swipeState = ref<{ startY: number; active: boolean }>({ startY: 0, active:
type RunModeValue = 'fast' | 'thinking' | 'deep' | null;
const runModeOptions: Array<{ id: string; label: string; desc: string; value: RunModeValue; badge?: string }> = [
{ id: 'auto', label: '跟随系统', desc: '沿用工作区默认设置', value: null },
{ id: 'fast', label: '快速模式', desc: '追求响应速度,跳过思考模型', value: 'fast' },
{ id: 'thinking', label: '思考模式', desc: '首轮回复会先输出思考过程', value: 'thinking', badge: '推荐' },
{ id: 'deep', label: '深度思考', desc: '整轮对话都使用思考模型', value: 'deep' }
@ -520,10 +593,48 @@ const handleLiquidGlassToggle = (event: Event) => {
personalization.setLiquidGlassExperimentEnabled(!!target?.checked);
};
const handleStackedBlocksToggle = (event: Event) => {
const target = event.target as HTMLInputElement | null;
personalization.setStackedBlocksEnabled(!!target?.checked);
};
const openAdminPanel = () => {
window.open('/admin/monitor', '_blank', 'noopener');
personalization.closeDrawer();
};
// ===== =====
import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme';
const { setTheme, loadTheme } = useTheme();
const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [
{
id: 'claude',
label: 'Claude 经典',
desc: '仿 Claude 的米色质感,柔和高对比',
swatches: ['#eeece2', '#f7f3ea', '#da7756']
},
{
id: 'light',
label: '浅灰 · 明亮',
desc: '浅灰 + 白,清晰对比适合日间工作',
swatches: ['#f4f5f7', '#ffffff', '#4f8bff']
},
{
id: 'dark',
label: '深灰 · 夜间',
desc: '深灰 + 黑,低亮度并保持彩色点缀',
swatches: ['#0f1115', '#1d2230', '#5ad1c9']
}
];
const activeTheme = ref<ThemeKey>(loadTheme());
const applyThemeOption = (theme: ThemeKey) => {
activeTheme.value = theme;
setTheme(theme);
};
</script>
<style scoped>

View File

@ -4,9 +4,11 @@ import App from './App.vue';
import 'katex/dist/katex.min.css';
import 'prismjs/themes/prism.css';
import './styles/index.scss';
import { installTheme } from './utils/theme';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
installTheme();
app.mount('#app');

View File

@ -23,6 +23,7 @@ interface LiquidGlassPosition {
interface ExperimentState {
liquidGlassEnabled: boolean;
liquidGlassPosition: LiquidGlassPosition | null;
stackedBlocksEnabled: boolean;
}
interface PersonalizationState {
@ -65,7 +66,8 @@ const defaultForm = (): PersonalForm => ({
const defaultExperimentState = (): ExperimentState => ({
liquidGlassEnabled: false,
liquidGlassPosition: null
liquidGlassPosition: null,
stackedBlocksEnabled: true
});
const isValidPosition = (value: any): value is LiquidGlassPosition => {
@ -91,7 +93,9 @@ const loadExperimentState = (): ExperimentState => {
const parsed = JSON.parse(raw);
return {
liquidGlassEnabled: Boolean(parsed?.liquidGlassEnabled),
liquidGlassPosition: isValidPosition(parsed?.liquidGlassPosition) ? parsed?.liquidGlassPosition : null
liquidGlassPosition: isValidPosition(parsed?.liquidGlassPosition) ? parsed?.liquidGlassPosition : null,
stackedBlocksEnabled:
typeof parsed?.stackedBlocksEnabled === 'boolean' ? parsed.stackedBlocksEnabled : defaultExperimentState().stackedBlocksEnabled
};
} catch (error) {
console.warn('无法读取实验功能设置:', error);
@ -452,6 +456,16 @@ export const usePersonalizationStore = defineStore('personalization', {
toggleLiquidGlassExperiment() {
this.setLiquidGlassExperimentEnabled(!this.experiments.liquidGlassEnabled);
},
setStackedBlocksEnabled(enabled: boolean) {
this.experiments = {
...this.experiments,
stackedBlocksEnabled: !!enabled
};
this.persistExperiments();
},
toggleStackedBlocks() {
this.setStackedBlocksEnabled(!this.experiments.stackedBlocksEnabled);
},
updateLiquidGlassPosition(position: LiquidGlassPosition | null) {
this.experiments = {
...this.experiments,

View File

@ -13,6 +13,11 @@ body {
-webkit-font-smoothing: antialiased;
}
body[data-theme='dark'] {
background: var(--claude-bg);
color: var(--claude-text);
}
/* Global icon utility */
.icon {
--icon-size: 1em;

View File

@ -1,6 +1,7 @@
/* CSS variables + shared design tokens */
:root {
color-scheme: light;
--app-viewport: 100vh;
--app-bottom-inset: env(safe-area-inset-bottom, 0px);
--claude-bg: #eeece2;
@ -8,8 +9,10 @@
--claude-left-rail: #f7f3ea;
--claude-sidebar: rgba(255, 255, 255, 0.68);
--claude-border: rgba(118, 103, 84, 0.25);
--claude-border-strong: rgba(118, 103, 84, 0.35);
--claude-text: #3d3929;
--claude-text-secondary: #7f7766;
--claude-text-tertiary: #a59a86;
--claude-muted: rgba(121, 109, 94, 0.4);
--claude-accent: #da7756;
--claude-accent-strong: #bd5d3a;
@ -21,4 +24,149 @@
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
--claude-success: #76b086;
--claude-warning: #d99845;
/* Theme-neutral surfaces */
--theme-surface-card: #fffaf4;
--theme-surface-strong: #ffffff;
--theme-surface-soft: rgba(255, 255, 255, 0.92);
--theme-surface-muted: rgba(255, 255, 255, 0.85);
--theme-overlay-scrim: rgba(33, 24, 14, 0.55);
--theme-shadow-strong: 0 28px 60px rgba(38, 28, 18, 0.25);
--theme-shadow-soft: 0 12px 24px rgba(38, 28, 18, 0.12);
--theme-shadow-mid: 0 20px 45px rgba(16, 24, 40, 0.08);
--theme-control-border: rgba(118, 103, 84, 0.25);
--theme-control-border-strong: rgba(118, 103, 84, 0.35);
--theme-switch-track: #d7d1c5;
--theme-chip-bg: rgba(118, 103, 84, 0.08);
--theme-chip-border: rgba(118, 103, 84, 0.2);
--theme-badge-bg: rgba(118, 103, 84, 0.12);
--theme-tab-active: rgba(189, 93, 58, 0.12);
--theme-mobile-menu: rgba(255, 255, 255, 0.35);
--theme-mobile-menu-shadow: 0 12px 30px rgba(38, 28, 18, 0.15);
--theme-card-border-strong: rgba(118, 103, 84, 0.25);
}
:root[data-theme='claude'] {
color-scheme: light;
--claude-bg: #eeece2;
--claude-panel: rgba(255, 255, 255, 0.82);
--claude-left-rail: #f7f3ea;
--claude-sidebar: rgba(255, 255, 255, 0.68);
--claude-border: rgba(118, 103, 84, 0.25);
--claude-border-strong: rgba(118, 103, 84, 0.35);
--claude-text: #3d3929;
--claude-text-secondary: #7f7766;
--claude-text-tertiary: #a59a86;
--claude-muted: rgba(121, 109, 94, 0.4);
--claude-accent: #da7756;
--claude-accent-strong: #bd5d3a;
--claude-deep: #f2a93b;
--claude-deep-strong: #d07a14;
--claude-highlight: rgba(218, 119, 86, 0.14);
--claude-button-hover: #c76541;
--claude-button-active: #a95331;
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
--claude-success: #76b086;
--claude-warning: #d99845;
--theme-surface-card: #fffaf4;
--theme-surface-strong: #ffffff;
--theme-surface-soft: rgba(255, 255, 255, 0.92);
--theme-surface-muted: rgba(255, 255, 255, 0.85);
--theme-overlay-scrim: rgba(33, 24, 14, 0.55);
--theme-shadow-strong: 0 28px 60px rgba(38, 28, 18, 0.25);
--theme-shadow-soft: 0 12px 24px rgba(38, 28, 18, 0.12);
--theme-shadow-mid: 0 20px 45px rgba(16, 24, 40, 0.08);
--theme-control-border: rgba(118, 103, 84, 0.25);
--theme-control-border-strong: rgba(118, 103, 84, 0.35);
--theme-switch-track: #d7d1c5;
--theme-chip-bg: rgba(118, 103, 84, 0.08);
--theme-chip-border: rgba(118, 103, 84, 0.2);
--theme-badge-bg: rgba(118, 103, 84, 0.12);
--theme-tab-active: rgba(189, 93, 58, 0.12);
--theme-mobile-menu: rgba(255, 255, 255, 0.35);
--theme-mobile-menu-shadow: 0 12px 30px rgba(38, 28, 18, 0.15);
--theme-card-border-strong: rgba(118, 103, 84, 0.25);
}
:root[data-theme='light'] {
color-scheme: light;
--claude-bg: #f5f5f4;
--claude-panel: rgba(255, 255, 255, 0.97);
--claude-left-rail: #faf9f7;
--claude-sidebar: rgba(255, 255, 255, 0.94);
--claude-border: rgba(26, 27, 30, 0.12);
--claude-border-strong: rgba(26, 27, 30, 0.2);
--claude-text: #1b1c1f;
--claude-text-secondary: #3c3f46;
--claude-text-tertiary: #5a5f69;
--claude-muted: rgba(27, 28, 31, 0.28);
--claude-accent: #4a4f58;
--claude-accent-strong: #2f343d;
--claude-deep: #6b6f78;
--claude-deep-strong: #4d515a;
--claude-highlight: rgba(74, 79, 88, 0.12);
--claude-button-hover: #3d424b;
--claude-button-active: #2f343c;
--claude-shadow: 0 16px 40px rgba(27, 28, 31, 0.12);
--claude-success: #3f6f4f;
--claude-warning: #7a6a3a;
--theme-surface-card: #ffffff;
--theme-surface-strong: #ffffff;
--theme-surface-soft: rgba(255, 255, 255, 0.98);
--theme-surface-muted: rgba(255, 255, 255, 0.94);
--theme-overlay-scrim: rgba(17, 16, 14, 0.45);
--theme-shadow-strong: 0 32px 72px rgba(17, 16, 14, 0.18);
--theme-shadow-soft: 0 18px 42px rgba(17, 16, 14, 0.12);
--theme-shadow-mid: 0 20px 45px rgba(17, 16, 14, 0.1);
--theme-control-border: rgba(26, 27, 30, 0.12);
--theme-control-border-strong: rgba(26, 27, 30, 0.2);
--theme-switch-track: #dededd;
--theme-chip-bg: rgba(74, 79, 88, 0.08);
--theme-chip-border: rgba(74, 79, 88, 0.18);
--theme-badge-bg: rgba(74, 79, 88, 0.14);
--theme-tab-active: rgba(74, 79, 88, 0.12);
--theme-mobile-menu: rgba(255, 255, 255, 0.88);
--theme-mobile-menu-shadow: 0 14px 32px rgba(17, 16, 14, 0.16);
--theme-card-border-strong: rgba(26, 27, 30, 0.18);
}
:root[data-theme='dark'] {
color-scheme: dark;
--claude-bg: #08090d;
--claude-panel: rgba(16, 17, 23, 0.92);
--claude-left-rail: #0b0c11;
--claude-sidebar: rgba(13, 14, 19, 0.94);
--claude-border: rgba(255, 255, 255, 0.08);
--claude-border-strong: rgba(255, 255, 255, 0.14);
--claude-text: #f8f9fc;
--claude-text-secondary: #d7dbe8;
--claude-text-tertiary: #aeb6c7;
--claude-muted: rgba(248, 249, 252, 0.22);
--claude-accent: #62e0d2;
--claude-accent-strong: #3bb8aa;
--claude-deep: #8cc7ff;
--claude-deep-strong: #5b9fe6;
--claude-highlight: rgba(98, 224, 210, 0.2);
--claude-button-hover: #4ac8ba;
--claude-button-active: #37aa9e;
--claude-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
--claude-success: #82e8b3;
--claude-warning: #f0c76a;
--theme-surface-card: #0f1117;
--theme-surface-strong: #141823;
--theme-surface-soft: rgba(20, 24, 35, 0.95);
--theme-surface-muted: rgba(16, 19, 28, 0.92);
--theme-overlay-scrim: rgba(0, 0, 0, 0.72);
--theme-shadow-strong: 0 32px 80px rgba(0, 0, 0, 0.7);
--theme-shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.55);
--theme-shadow-mid: 0 22px 50px rgba(0, 0, 0, 0.5);
--theme-control-border: rgba(255, 255, 255, 0.1);
--theme-control-border-strong: rgba(255, 255, 255, 0.16);
--theme-switch-track: #262b34;
--theme-chip-bg: rgba(98, 224, 210, 0.14);
--theme-chip-border: rgba(98, 224, 210, 0.26);
--theme-badge-bg: rgba(98, 224, 210, 0.18);
--theme-tab-active: rgba(98, 224, 210, 0.16);
--theme-mobile-menu: rgba(16, 19, 28, 0.78);
--theme-mobile-menu-shadow: 0 18px 40px rgba(0, 0, 0, 0.62);
--theme-card-border-strong: rgba(255, 255, 255, 0.16);
}

View File

@ -265,7 +265,9 @@
max-height: 0;
overflow: hidden;
opacity: 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transition:
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.26s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsible-block.expanded .collapsible-content {
@ -279,6 +281,11 @@
font-size: 14px;
line-height: 1.6;
color: var(--claude-text-secondary);
scrollbar-width: none;
}
.content-inner::-webkit-scrollbar {
display: none;
}
.action-item {
@ -299,6 +306,131 @@
animation: none;
}
.stacked-blocks-wrapper {
margin: 12px 0 8px;
}
.stacked-shell {
position: relative;
border: 1px solid var(--claude-border);
border-radius: 16px;
background: var(--claude-card);
box-shadow: var(--claude-shadow);
overflow: hidden;
transition:
height 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
padding-top 280ms cubic-bezier(0.25, 0.9, 0.3, 1);
min-height: 0;
}
.stacked-inner {
position: relative;
width: 100%;
transition: transform 280ms cubic-bezier(0.25, 0.9, 0.3, 1);
}
.stacked-viewport {
overflow: hidden;
position: relative;
width: 100%;
}
.stacked-item {
border-bottom: 1px solid var(--claude-border);
}
.stacked-item:last-child {
border-bottom: none;
}
.stacked-block {
margin: 0;
border: none;
border-radius: 0;
box-shadow: none;
background: transparent;
}
.stacked-more-block {
position: absolute;
inset: 0 0 auto 0;
background: var(--claude-card);
border-bottom: 0 solid var(--claude-border);
display: flex;
align-items: center;
gap: 10px;
padding: 0 22px;
height: 0;
opacity: 0;
overflow: hidden;
cursor: pointer;
z-index: 2;
transition:
height 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
padding 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
border-bottom-width 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
opacity 160ms ease;
}
.stacked-more-block.visible {
opacity: 1;
padding: 14px 22px;
border-bottom-width: 1px;
}
.more-icon {
width: 18px;
height: 18px;
display: inline-block;
object-fit: contain;
}
.more-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.more-title {
font-weight: 700;
color: var(--claude-text);
}
.more-desc {
color: var(--claude-text-secondary);
font-size: 12px;
}
.stacked-enter-from {
opacity: 0;
transform: translateY(14px);
}
.stacked-enter-active {
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
}
.stacked-leave-active {
position: absolute;
width: 100%;
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.stacked-leave-to {
opacity: 0;
transform: translateY(-12px);
}
.stacked-move {
transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1);
}
.stacked-block .collapsible-content {
transition:
max-height 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
opacity 220ms ease;
}
.progress-indicator {
position: absolute;
bottom: 0;

View File

@ -1,7 +1,7 @@
.personal-page-overlay {
position: fixed;
inset: 0;
background: rgba(33, 24, 14, 0.55);
background: var(--theme-overlay-scrim);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
@ -16,10 +16,10 @@
width: min(96vw, 1020px);
height: calc(100vh - 40px);
max-height: 760px;
background: #fffaf4;
background: var(--theme-surface-card);
border-radius: 24px;
border: 1px solid rgba(118, 103, 84, 0.25);
box-shadow: 0 28px 60px rgba(38, 28, 18, 0.25);
border: 1px solid var(--theme-control-border);
box-shadow: var(--theme-shadow-strong);
padding: 40px;
text-align: left;
color: var(--claude-text);
@ -76,8 +76,8 @@
align-self: flex-start;
padding: 8px 16px;
border-radius: 999px;
border: 1px solid rgba(118, 103, 84, 0.35);
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-control-border-strong);
background: var(--theme-surface-soft);
color: var(--claude-text);
font-size: 13px;
font-weight: 600;
@ -86,8 +86,8 @@
}
.personal-page-logout:hover {
background: #fff;
box-shadow: 0 12px 24px rgba(38, 28, 18, 0.12);
background: var(--theme-surface-strong);
box-shadow: var(--theme-shadow-soft);
}
.personalization-body {
@ -107,8 +107,8 @@
gap: 12px;
padding: 16px 18px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(118, 103, 84, 0.2);
background: var(--theme-surface-muted);
border: 1px solid var(--theme-control-border);
}
.toggle-text {
@ -141,7 +141,7 @@
.switch-slider {
position: absolute;
inset: 0;
background-color: #d7d1c5;
background-color: var(--theme-switch-track);
border-radius: 30px;
transition: background-color 0.2s ease;
}
@ -170,6 +170,81 @@
margin-bottom: 18px;
}
.toggle-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--theme-control-border);
background: var(--theme-surface-muted);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
position: relative;
}
.toggle-row:hover {
border-color: var(--theme-control-border);
box-shadow: none;
background: var(--theme-surface-muted);
}
.toggle-row:focus-within {
border-color: var(--theme-control-border);
box-shadow: none;
}
.toggle-row input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggle-row .fancy-check {
width: 28px;
height: 28px;
border-radius: 6px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: none;
transition: opacity 0.2s ease;
}
.toggle-row .fancy-check svg {
width: 22px;
height: 22px;
overflow: visible;
}
.fancy-path {
fill: none;
stroke: var(--claude-text-secondary);
stroke-width: 5;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke-dasharray 0.5s ease, stroke-dashoffset 0.5s ease, stroke 0.2s ease;
stroke-dasharray: 241 9999999;
stroke-dashoffset: 0;
}
.toggle-row input:checked + .fancy-check {
background: transparent;
box-shadow: none;
}
.toggle-row input:checked + .fancy-check .fancy-path {
stroke: var(--claude-accent);
stroke-dasharray: 70.5096664428711 9999999;
stroke-dashoffset: -262.2723388671875;
}
.toggle-row span {
color: var(--claude-text);
font-weight: 500;
}
.personalization-layout {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
@ -186,8 +261,8 @@
gap: 12px;
padding: 20px;
border-radius: 20px;
border: 1px solid rgba(118, 103, 84, 0.2);
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-control-border);
background: var(--theme-surface-soft);
align-self: flex-start;
height: auto;
max-height: none;
@ -217,9 +292,9 @@
}
.personal-tab-button.active {
background: rgba(189, 93, 58, 0.12);
background: var(--theme-tab-active);
color: var(--claude-accent);
box-shadow: 0 10px 24px rgba(189, 93, 58, 0.15);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
}
.personal-tab-button:focus-visible {
@ -239,8 +314,8 @@
position: relative;
overflow-y: auto;
border-radius: 24px;
border: 1px solid rgba(118, 103, 84, 0.18);
background: rgba(255, 255, 255, 0.98);
border: 1px solid var(--theme-control-border);
background: var(--theme-surface-soft);
min-height: 0;
flex: 1 1 auto;
height: 100%;
@ -279,10 +354,10 @@
display: flex;
flex-direction: column;
gap: 24px;
background: rgba(255, 255, 255, 0.85);
background: var(--theme-surface-muted);
border-radius: 20px;
padding: 24px;
border: 1px solid rgba(118, 103, 84, 0.18);
border: 1px solid var(--theme-control-border);
}
.behavior-field {
@ -310,9 +385,9 @@
display: flex;
flex-direction: column;
gap: 28px;
background: rgba(255, 255, 255, 0.95);
background: var(--theme-surface-soft);
border-radius: 24px;
border: 1px solid rgba(118, 103, 84, 0.18);
border: 1px solid var(--theme-control-border);
padding: 28px 30px;
}
@ -407,10 +482,10 @@
align-items: center;
justify-content: space-between;
border-radius: 22px;
border: 1px solid rgba(118, 103, 84, 0.2);
border: 1px solid var(--theme-control-border);
padding: 20px 24px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 20px 45px rgba(16, 24, 40, 0.08);
background: var(--theme-surface-muted);
box-shadow: var(--theme-shadow-mid);
}
.experiment-toggle-info h4 {
@ -430,8 +505,8 @@
.experiment-note {
border-radius: 18px;
padding: 18px 22px;
background: rgba(255, 255, 255, 0.85);
border: 1px dashed rgba(118, 103, 84, 0.3);
background: var(--theme-surface-muted);
border: 1px dashed var(--theme-control-border-strong);
}
.experiment-note p {
@ -601,9 +676,9 @@
}
.run-mode-card {
border: 1px solid rgba(118, 103, 84, 0.2);
border: 1px solid var(--theme-control-border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.95);
background: var(--theme-surface-soft);
padding: 16px;
text-align: left;
cursor: pointer;
@ -616,14 +691,14 @@
}
.run-mode-card:hover {
border-color: rgba(118, 103, 84, 0.4);
border-color: var(--claude-accent);
transform: translateY(-1px);
}
.run-mode-card.active {
border-color: var(--claude-accent);
background: rgba(118, 103, 84, 0.08);
box-shadow: 0 8px 20px rgba(118, 103, 84, 0.18);
background: var(--theme-tab-active);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
}
.run-mode-card-header {
@ -643,7 +718,7 @@
font-size: 12px;
color: var(--claude-accent);
font-weight: 600;
background: rgba(118, 103, 84, 0.12);
background: var(--theme-badge-bg);
border-radius: 999px;
padding: 2px 8px;
}
@ -663,10 +738,10 @@
.thinking-presets button {
flex: 1;
min-width: 100px;
border: 1px solid rgba(118, 103, 84, 0.2);
border: 1px solid var(--theme-control-border);
border-radius: 12px;
padding: 10px 12px;
background: #fff;
background: var(--theme-surface-strong);
display: flex;
flex-direction: column;
align-items: center;
@ -678,7 +753,7 @@
.thinking-presets button.active {
border-color: var(--claude-accent);
color: var(--claude-accent);
background: rgba(118, 103, 84, 0.08);
background: var(--theme-tab-active);
}
.thinking-presets button small {
@ -704,7 +779,7 @@
width: 120px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(118, 103, 84, 0.3);
border: 1px solid var(--theme-control-border-strong);
}
.thinking-hint {
@ -737,8 +812,8 @@
gap: 6px;
padding: 8px 12px;
border-radius: 12px;
background: rgba(118, 103, 84, 0.08);
border: 1px solid rgba(118, 103, 84, 0.2);
background: var(--theme-chip-bg);
border: 1px solid var(--theme-chip-border);
font-size: 13px;
}
@ -752,6 +827,128 @@
gap: 16px;
}
/* ========================================= */
/* 主题切换 */
/* ========================================= */
.theme-page {
display: flex;
flex-direction: column;
gap: 18px;
background: var(--theme-surface-soft);
border-radius: 20px;
border: 1px solid var(--theme-control-border);
padding: 22px 24px 26px;
box-shadow: var(--theme-shadow-mid);
}
.theme-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.theme-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.theme-header h3 {
margin: 0 0 6px;
}
.theme-header p {
margin: 0;
color: var(--claude-text-secondary);
}
.theme-badge {
padding: 6px 12px;
border-radius: 12px;
background: var(--theme-badge-bg);
color: var(--claude-accent);
font-weight: 700;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.theme-card {
border: 1px solid var(--theme-control-border);
border-radius: 16px;
padding: 14px 16px;
background: var(--theme-surface-strong);
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.theme-card:hover {
border-color: var(--claude-accent);
box-shadow: var(--theme-shadow-soft);
transform: translateY(-1px);
}
.theme-card.active {
border-color: var(--claude-accent);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
background: var(--theme-tab-active);
}
.theme-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.theme-dot-row {
display: inline-flex;
gap: 6px;
align-items: center;
}
.theme-dot {
width: 18px;
height: 18px;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.theme-check {
font-weight: 700;
color: var(--claude-accent);
font-size: 16px;
}
.theme-card-body h4 {
margin: 0 0 4px;
font-size: 16px;
}
.theme-card-body p {
margin: 0;
color: var(--claude-text-secondary);
line-height: 1.5;
}
.theme-note {
margin: 0;
color: var(--claude-text-secondary);
font-size: 13px;
}
/* ========================================= */
/* 移动端面板入口 */
/* ========================================= */
@ -815,9 +1012,9 @@
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.35);
background: var(--theme-mobile-menu);
backdrop-filter: blur(10px);
box-shadow: 0 12px 30px rgba(38, 28, 18, 0.15);
box-shadow: var(--theme-mobile-menu-shadow);
align-self: flex-start;
}
@ -878,7 +1075,7 @@
.mobile-panel-overlay {
position: fixed;
inset: 0;
background: rgba(16, 11, 7, 0.45);
background: var(--theme-overlay-scrim);
backdrop-filter: blur(6px);
z-index: 2000;
display: flex;
@ -1009,7 +1206,7 @@
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(33, 24, 14, 0.5);
background: var(--theme-overlay-scrim);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
@ -1022,11 +1219,11 @@
.confirm-modal {
width: min(360px, 100%);
background: var(--claude-bg);
border: 1px solid var(--claude-border-strong, rgba(118,103,84,0.35));
background: var(--theme-surface-card);
border: 1px solid var(--theme-control-border-strong);
border-radius: 18px;
padding: 24px;
box-shadow: 0 18px 40px rgba(61, 57, 41, 0.22);
box-shadow: var(--theme-shadow-soft);
transition: transform 0.25s ease, opacity 0.25s ease;
}
@ -1049,7 +1246,7 @@
}
.confirm-button {
border: 1px solid var(--claude-border-strong, rgba(118,103,84,0.35));
border: 1px solid var(--theme-control-border-strong);
background: transparent;
color: var(--claude-text);
border-radius: 10px;
@ -1115,8 +1312,8 @@
.personal-section {
flex: 1 1 0;
min-width: 240px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(118, 103, 84, 0.25);
background: var(--theme-surface-soft);
border: 1px solid var(--theme-control-border);
border-radius: 18px;
padding: 16px 18px;
display: flex;
@ -1157,7 +1354,7 @@
}
.personal-section.personal-considerations .consideration-item {
background: rgba(255, 255, 255, 0.95);
background: var(--theme-surface-strong);
}
@media (max-width: 1024px) {
@ -1185,8 +1382,8 @@
width: 100%;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(118, 103, 84, 0.4);
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--theme-control-border-strong);
background: var(--theme-surface-soft);
font-size: 14px;
}
@ -1213,8 +1410,8 @@
.tone-preset-buttons button {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(118, 103, 84, 0.4);
background: #fff;
border: 1px solid var(--theme-control-border);
background: var(--theme-surface-strong);
font-size: 13px;
cursor: pointer;
}
@ -1266,8 +1463,8 @@
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px dashed rgba(118, 103, 84, 0.5);
background: rgba(255, 255, 255, 0.9);
border: 1px dashed var(--theme-control-border-strong);
background: var(--theme-surface-soft);
cursor: grab;
}

45
static/src/utils/theme.ts Normal file
View File

@ -0,0 +1,45 @@
import { onMounted } from 'vue';
type ThemeKey = 'claude' | 'light' | 'dark';
const THEME_STORAGE_KEY = 'agents_ui_theme';
const applyTheme = (theme: ThemeKey) => {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
document.body.setAttribute('data-theme', theme);
};
const loadTheme = (): ThemeKey => {
if (typeof window === 'undefined') return 'claude';
const saved = window.localStorage.getItem(THEME_STORAGE_KEY) as ThemeKey | null;
if (saved === 'light' || saved === 'dark' || saved === 'claude') return saved;
return 'claude';
};
const persistTheme = (theme: ThemeKey) => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
};
export const installTheme = () => {
const theme = loadTheme();
applyTheme(theme);
};
export const useTheme = () => {
const setTheme = (theme: ThemeKey) => {
applyTheme(theme);
persistTheme(theme);
};
const restore = () => applyTheme(loadTheme());
onMounted(() => {
restore();
});
return { setTheme, restore, loadTheme };
};
export type { ThemeKey };