/** * — a self-contained Web Component that replays an * Agent's ReAct loop step by step. Drop it into any MkDocs page: * * * * Data format: see extras/agent-lab/SCHEMA.md. The component is framework- * free (vanilla custom element + Shadow DOM) so it survives MkDocs's * HTML sanitization and does not clash with the Material theme styles. */ (function () { const TEMPLATE = document.createElement('template'); TEMPLATE.innerHTML = ` `; const TYPE_LABEL = { thought: '思考', action: '行动', observation: '观察', answer: '答案', }; class AgentTrajectory extends HTMLElement { constructor() { super(); const root = this.attachShadow({ mode: 'open' }); root.appendChild(TEMPLATE.content.cloneNode(true)); this._shown = 0; this._timer = null; } connectedCallback() { this._applyTheme(); this._wire(); const src = this.getAttribute('src'); if (!src) { this._fail('未指定 src 属性'); return; } this._load(src); // Re-apply theme if the document changes light/dark. new MutationObserver(() => this._applyTheme()) .observe(document.documentElement, { attributes: true, attributeFilter: ['data-md-color-scheme', 'data-theme'] }); } _applyTheme() { const scheme = document.documentElement.getAttribute('data-md-color-scheme'); this.setAttribute('data-theme', scheme === 'slate' ? 'dark' : 'light'); } _wire() { const $ = (id) => this.shadowRoot.getElementById(id); $('btn-play').addEventListener('click', () => this._togglePlay()); $('btn-next').addEventListener('click', () => this._step()); $('btn-reset').addEventListener('click', () => this._reset()); } async _load(src) { this.shadowRoot.getElementById('loading').hidden = false; try { const res = await fetch(src); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); this._render(data); } catch (e) { this._fail(`无法加载轨迹:${e.message}`); } } _fail(msg) { const el = this.shadowRoot.getElementById('loading'); el.hidden = false; el.textContent = msg; } _render(data) { const $ = (id) => this.shadowRoot.getElementById(id); $('loading').hidden = true; $('t-title').textContent = data.title || data.experiment || 'Agent 轨迹'; $('t-model').textContent = '🤖 ' + (data.model || 'unknown'); $('t-outcome').textContent = '结果:' + this._outcomeLabel(data.outcome); $('t-iter').textContent = (data.steps || []).length + ' 步'; $('t-task').innerHTML = data.task ? `任务:${this._escape(data.task)}` : ''; const ol = $('timeline'); ol.innerHTML = ''; (data.steps || []).forEach((s, i) => { const li = document.createElement('li'); li.className = 'step'; li.dataset.type = s.type; li.dataset.index = i; const head = document.createElement('div'); head.className = 'head'; const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = TYPE_LABEL[s.type] || s.type; const iter = document.createElement('span'); iter.className = 'iter'; iter.textContent = `第 ${s.iteration} 轮迭代`; head.append(badge, iter); if (s.tool) { const t = document.createElement('span'); t.className = 'tool'; t.textContent = '🔧 ' + s.tool; head.appendChild(t); } li.appendChild(head); if (s.content != null) { const body = document.createElement('div'); body.className = 'body'; body.textContent = s.content; li.appendChild(body); if (s.content.length > 220) this._makeCollapsible(body); } if (s.args != null) { const args = document.createElement('div'); args.className = 'args'; args.textContent = 'args: ' + this._prettify(s.args); li.appendChild(args); } ol.appendChild(li); }); this._wrapEl = this.shadowRoot.querySelector('.wrap'); this._wrapEl.hidden = false; this._steps = ol.children; this._total = this._steps.length; this._shown = 0; this._update(); } _makeCollapsible(body) { body.classList.add('collapsed'); const toggle = document.createElement('span'); toggle.className = 'toggle'; toggle.textContent = '展开 ▾'; toggle.addEventListener('click', () => { const collapsed = body.classList.toggle('collapsed'); toggle.textContent = collapsed ? '展开 ▾' : '收起 ▴'; }); body.parentElement.insertBefore(toggle, body.nextSibling); } _togglePlay() { if (this._timer) { clearInterval(this._timer); this._timer = null; this.shadowRoot.getElementById('btn-play').textContent = '▶ 自动播放'; } else { this.shadowRoot.getElementById('btn-play').textContent = '⏸ 暂停'; this._timer = setInterval(() => { if (this._shown >= this._total) { clearInterval(this._timer); this._timer = null; this.shadowRoot.getElementById('btn-play').textContent = '▶ 自动播放'; return; } this._step(); }, 1200); } } _step() { if (this._shown >= this._total) return; this._shown++; this._update(); } _reset() { if (this._timer) { clearInterval(this._timer); this._timer = null; this.shadowRoot.getElementById('btn-play').textContent = '▶ 自动播放'; } this._shown = 0; this._update(); } _update() { for (let i = 0; i < this._steps.length; i++) { this._steps[i].classList.toggle('shown', i < this._shown); } const bar = this.shadowRoot.getElementById('bar'); const counter = this.shadowRoot.getElementById('counter'); const pct = this._total ? (this._shown / this._total) * 100 : 0; bar.style.width = pct + '%'; counter.textContent = `${this._shown} / ${this._total}`; if (this._shown > 0) { const last = this._steps[this._shown - 1]; last.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } _outcomeLabel(o) { return ({ success: '✅ 成功', failure: '❌ 失败', loop: '🔁 死循环', timeout: '⏱️ 超时' })[o] || (o || '未知'); } _escape(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } _prettify(v) { try { return typeof v === 'string' ? v : JSON.stringify(v, null, 2); } catch { return String(v); } } } customElements.define('agent-trajectory', AgentTrajectory); })();