{"css": "* { box-sizing: border-box; }\n:root {\n  --bg: #fafbfc; --panel: #fff; --border: #e1e4e8; --border-strong: #d0d7de;\n  --text: #1f2328; --muted: #6e7781; --accent: #6f42c1; --accent-bg: #f5f0ff;\n  --row-hover: #f6f8fa; --row-active: #ede7f8;\n  --ok: #1a7f37; --err: #cf222e; --warn: #9a6700;\n  --badge-bg: #eaeef2; --badge-text: #424a53;\n  --json-key: #953800; --json-string: #0a3069; --json-number: #0550ae;\n  --json-bool: #8250df; --json-null: #6e7781;\n}\n@media (prefers-color-scheme: dark) {\n  :root {\n    --bg: #0d1117; --panel: #161b22; --border: #30363d; --border-strong: #3d444d;\n    --text: #e6edf3; --muted: #8b949e; --accent: #d2a8ff; --accent-bg: #2d1b4f44;\n    --row-hover: #1c2128; --row-active: #2d1b4f88;\n    --ok: #3fb950; --err: #f85149; --warn: #d29922;\n    --badge-bg: #21262d; --badge-text: #8d96a0;\n    --json-key: #ffa657; --json-string: #a5d6ff; --json-number: #79c0ff;\n    --json-bool: #d2a8ff; --json-null: #8b949e;\n  }\n}\nhtml, body { height: 100%; }\nbody {\n  font: 13px/1.5 -apple-system, \"PingFang SC\", system-ui, sans-serif;\n  margin: 0; background: var(--bg); color: var(--text);\n  display: flex; flex-direction: column; height: 100vh; overflow: hidden;\n}\n.topbar {\n  display: flex; align-items: center; gap: 12px;\n  padding: 10px 16px; background: var(--panel);\n  border-bottom: 1px solid var(--border); flex-shrink: 0;\n}\n.topbar-icon { color: var(--accent); font-weight: 700; font-size: 16px; }\n.topbar-title { font-weight: 600; font-size: 14px; }\n.topbar-id {\n  color: var(--muted); font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n  font-size: 12px; cursor: pointer; padding: 2px 6px; border-radius: 4px;\n}\n.topbar-id:hover { background: var(--row-hover); }\n.topbar-meta { margin-left: auto; color: var(--muted); font-size: 12px; display: flex; gap: 16px; }\n.topbar-meta b { color: var(--text); }\n\n.main { display: grid; grid-template-columns: 380px 1fr; flex: 1; overflow: hidden; }\n\n.tree-pane { background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }\n.tree-search { padding: 8px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }\n.tree-search input {\n  width: 100%; padding: 6px 10px 6px 28px;\n  border: 1px solid var(--border-strong); border-radius: 6px;\n  background: var(--bg); color: var(--text); font: inherit;\n  background-image: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236e7781' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>\");\n  background-repeat: no-repeat; background-position: 8px center;\n}\n.tree-search input:focus { outline: none; border-color: var(--accent); }\n.tree-toolbar { padding: 6px 12px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; font-size: 11px; color: var(--muted); flex-shrink: 0; }\n.tree-toolbar button { background: none; border: none; color: var(--muted); cursor: pointer; padding: 2px 4px; font: inherit; }\n.tree-toolbar button:hover { color: var(--text); }\n.tree-toolbar button.active { color: var(--err); font-weight: 600; }\n.tree-toolbar button:disabled { color: var(--muted); opacity: 0.4; cursor: not-allowed; }\n.tree-toolbar button:disabled:hover { color: var(--muted); }\n.tree-list { flex: 1; overflow-y: auto; padding: 4px 0 12px; }\n.node-row {\n  display: flex; align-items: center; padding: 4px 8px 4px 0;\n  cursor: pointer; border-left: 2px solid transparent; user-select: none;\n  font-size: 12.5px; white-space: nowrap;\n}\n.node-row:hover { background: var(--row-hover); }\n.node-row.active { background: var(--row-active); border-left-color: var(--accent); }\n.node-toggle { width: 16px; flex-shrink: 0; text-align: center; color: var(--muted); font-size: 9px; transition: transform 0.1s; }\n.node-row.collapsed .node-toggle { transform: rotate(-90deg); }\n.node-row.leaf .node-toggle { visibility: hidden; }\n.node-icon { width: 18px; flex-shrink: 0; text-align: center; }\n.node-name { flex: 1; overflow: hidden; text-overflow: ellipsis; margin-right: 6px; }\n.node-row.status-error .node-name { color: var(--err); }\n.node-row.status-aborted .node-name { color: var(--warn); }\n.node-duration { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, monospace; flex-shrink: 0; }\n.node-tokens { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, monospace; flex-shrink: 0; margin-right: 8px; opacity: 0.85; }\n.node-children.hidden { display: none; }\n\n.dag-pane { border-top: 1px solid var(--border); padding: 10px 12px; flex-shrink: 0; background: var(--panel); max-height: 240px; overflow-y: auto; }\n.dag-title { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }\n.dag-grid { display: flex; flex-wrap: wrap; gap: 6px; }\n.dag-node { border: 1px solid var(--border-strong); background: var(--bg); padding: 4px 10px; border-radius: 14px; font-size: 11px; display: flex; gap: 6px; align-items: center; }\n.dag-node.has-err { border-color: var(--err); background: rgba(207,34,46,0.06); }\n.dag-node.has-abort { border-color: var(--warn); background: rgba(154,103,0,0.06); }\n.dag-node code { background: none; padding: 0; font-size: inherit; color: inherit; }\n.dag-node-count { color: var(--muted); font-family: ui-monospace, Menlo, monospace; }\n.dag-node.has-err .dag-node-count { color: var(--err); }\n.dag-node.has-abort .dag-node-count { color: var(--warn); }\n\n.detail-pane { display: flex; flex-direction: column; overflow: hidden; background: var(--panel); }\n.detail-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 14px; }\n.detail-header { padding: 12px 20px; border-bottom: 1px solid var(--border); flex-shrink: 0; }\n.detail-title { display: flex; align-items: baseline; gap: 10px; font-size: 16px; font-weight: 600; margin-bottom: 4px; }\n.detail-time { color: var(--muted); font-size: 12px; font-family: ui-monospace, Menlo, monospace; }\n.badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }\n.badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 14px; background: var(--badge-bg); color: var(--badge-text); font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; }\n.badge.badge-model { background: var(--accent-bg); color: var(--accent); }\n.badge.badge-error { background: rgba(207,34,46,0.12); color: var(--err); }\n.badge-key { color: var(--muted); }\n\n.detail-body { flex: 1; overflow-y: auto; padding: 16px 20px; }\n.section { border-top: 1px solid var(--border); margin-top: 12px; padding-top: 12px; }\n.section:first-child { border-top: none; margin-top: 0; padding-top: 0; }\n.section-title { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }\n.section-title .count { font-weight: 400; color: var(--muted); }\n.jump-bar { display: flex; gap: 12px; font-size: 12px; margin-bottom: 12px; color: var(--muted); padding-bottom: 12px; border-bottom: 1px solid var(--border); }\n.jump-bar a { color: var(--accent); text-decoration: none; cursor: pointer; }\n.jump-bar a:hover { text-decoration: underline; }\n\n.text-block { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; white-space: pre-wrap; word-wrap: break-word; font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; max-height: 600px; overflow-y: auto; }\n.text-block.error { background: rgba(207,34,46,0.06); border-color: var(--err); color: var(--err); }\n.text-block.warn { background: rgba(154,103,0,0.06); border-color: var(--warn); color: var(--warn); }\n.thinking-block { background: rgba(111,66,193,0.04); border: 1px solid rgba(111,66,193,0.2); border-radius: 6px; padding: 10px 12px; white-space: pre-wrap; word-wrap: break-word; font-size: 12.5px; max-height: 400px; overflow-y: auto; color: var(--muted); font-style: italic; }\n\n.json-tree { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; font-family: ui-monospace, \"SF Mono\", Menlo, monospace; font-size: 12px; max-height: 500px; overflow: auto; }\n.json-row { padding: 1px 0; display: flex; align-items: flex-start; }\n.json-line-num { width: 32px; text-align: right; color: var(--muted); opacity: 0.5; user-select: none; flex-shrink: 0; margin-right: 12px; font-size: 11px; }\n.json-content { flex: 1; min-width: 0; }\n.json-toggle { display: inline-block; width: 12px; text-align: center; cursor: pointer; color: var(--muted); user-select: none; }\n.json-key { color: var(--json-key); }\n.json-string { color: var(--json-string); }\n.json-number { color: var(--json-number); }\n.json-bool { color: var(--json-bool); }\n.json-null { color: var(--json-null); font-style: italic; }\n.json-bracket { color: var(--text); }\n.json-meta { color: var(--muted); font-size: 11px; margin-left: 6px; }\n.json-children.collapsed { display: none; }\n.json-indent { display: inline-block; width: 14px; }\n.json-string-content { white-space: pre-wrap; word-wrap: break-word; max-width: 100%; }\n\n.tag { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 10.5px; font-weight: 600; margin-left: 6px; text-transform: uppercase; letter-spacing: 0.04em; }\n.tag-error { background: rgba(207,34,46,0.12); color: var(--err); }\n.tag-warn { background: rgba(154,103,0,0.12); color: var(--warn); }\n.tag-purple { background: var(--accent-bg); color: var(--accent); }\n\n.tc-item { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 6px; font-size: 12px; }\n.tc-name { font-family: ui-monospace, Menlo, monospace; font-weight: 600; }\n.tc-args { margin-top: 6px; background: var(--panel); padding: 6px 8px; border-radius: 4px; font-size: 11.5px; white-space: pre-wrap; word-wrap: break-word; max-height: 200px; overflow-y: auto; color: var(--muted); }\n.fc-list { display: flex; flex-direction: column; gap: 4px; }\n.fc-list code { background: var(--bg); padding: 2px 6px; border-radius: 3px; }\n.skill-list { font-size: 12px; color: var(--muted); }\n.skill-list code { color: var(--text); background: var(--bg); padding: 1px 5px; border-radius: 3px; }\n.skill-list > div { margin: 2px 0; }\n.no-data { color: var(--muted); font-size: 12px; font-style: italic; padding: 8px 0; }\n", "js": "const TRACE_DATA = /*__DATA__*/null;\nconst DAG_STATS = /*__DAG__*/null;\n\nfunction fmtMs(ms) { if (ms == null) return \"\"; if (ms < 1000) return ms + \"ms\"; const s = ms/1000; if (s < 60) return s.toFixed(2)+\"s\"; const m = Math.floor(s/60); return m+\"m \"+Math.round(s-m*60)+\"s\"; }\nfunction fmtMoney(x) {\n  if (x && typeof x === \"object\") x = x.total ?? x.value ?? 0;\n  if (typeof x !== \"number\" || !x) return \"$0\";\n  if (x < 0.01) return \"$\"+x.toFixed(4);\n  return \"$\"+x.toFixed(3);\n}\nfunction fmtInt(n) { if (n == null) return \"0\"; return Number(n).toLocaleString(); }\nfunction fmtIso(ms) { if (!ms) return \"\"; const d = new Date(ms); const p=(n,w)=>String(n).padStart(w||2,\"0\"); return d.getFullYear()+\"-\"+p(d.getMonth()+1)+\"-\"+p(d.getDate())+\" \"+p(d.getHours())+\":\"+p(d.getMinutes())+\":\"+p(d.getSeconds())+\".\"+p(d.getMilliseconds(),3); }\n\nfunction setText(el, s) { el.textContent = s == null ? \"\" : String(s); }\nfunction el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }\nfunction span(cls, text) { return el(\"span\", cls, text); }\n\nconst NODE_INDEX = {};\n(function index(n) { NODE_INDEX[n.id] = n; (n.children || []).forEach(index); })(TRACE_DATA);\n\nfunction nodeStatusClass(n) { return \"status-\" + (n.status || \"ok\"); }\n\nfunction nodeTotalTokens(n) {\n  if (n.type === \"session\") return 0;\n  const d = n.data || {};\n  const u = d.usage || d.totalUsage;\n  if (!u) return 0;\n  return (u.input || 0) + (u.cacheRead || 0) + (u.output || 0);\n}\n\nfunction fmtTokens(n) {\n  if (!n || n < 1) return \"\";\n  if (n < 1000) return \"~\" + n + \" tok\";\n  if (n < 10000) return \"~\" + (n / 1000).toFixed(1) + \"k tok\";\n  return \"~\" + Math.round(n / 1000) + \"k tok\";\n}\n\nfunction renderNodeRow(n, depth) {\n  const hasChildren = (n.children && n.children.length > 0);\n  const statusIcon = n.status === \"error\" ? \"❌\" : (n.status === \"aborted\" ? \"⏹\" : (n.status === \"in_progress\" ? \"⏳\" : (n.icon || \"·\")));\n  const dur = n.duration_ms != null ? fmtMs(n.duration_ms) : \"\";\n  const totalTok = nodeTotalTokens(n);\n  const tokStr = totalTok >= 1000 ? fmtTokens(totalTok) : \"\";\n  const wrap = el(\"div\");\n  const row = el(\"div\", \"node-row \" + nodeStatusClass(n) + (hasChildren ? \"\" : \" leaf\"));\n  row.dataset.nodeId = n.id;\n  row.style.paddingLeft = (depth * 14 + 8) + \"px\";\n\n  const tog = el(\"span\", \"node-toggle\", \"▼\");\n  const ic = el(\"span\", \"node-icon\", statusIcon);\n  const nm = el(\"span\", \"node-name\", n.name || \"\");\n  nm.title = n.name || \"\";\n  const tk = el(\"span\", \"node-tokens\", tokStr);\n  const du = el(\"span\", \"node-duration\", dur);\n  row.appendChild(tog); row.appendChild(ic); row.appendChild(nm); row.appendChild(tk); row.appendChild(du);\n  wrap.appendChild(row);\n\n  if (hasChildren) {\n    const cwrap = el(\"div\", \"node-children\");\n    n.children.forEach(c => cwrap.appendChild(renderNodeRow(c, depth + 1)));\n    wrap.appendChild(cwrap);\n    tog.addEventListener(\"click\", (e) => {\n      e.stopPropagation();\n      row.classList.toggle(\"collapsed\");\n      cwrap.classList.toggle(\"hidden\");\n    });\n  }\n  row.addEventListener(\"click\", () => selectNode(n.id));\n  return wrap;\n}\n\nfunction selectNode(id) {\n  document.querySelectorAll(\".node-row.active\").forEach(r => r.classList.remove(\"active\"));\n  const row = document.querySelector('.node-row[data-node-id=\"' + CSS.escape(id) + '\"]');\n  if (row) { row.classList.add(\"active\"); row.scrollIntoView({block: \"nearest\"}); }\n  renderDetail(NODE_INDEX[id]);\n}\n\nfunction applySearch(q) {\n  filterState.query = (q || \"\").toLowerCase().trim();\n  applyFilters();\n}\n\n// 页面级过滤状态\nconst filterState = { query: \"\", onlyErrors: false };\n\nfunction isErrLike(n) { return n && (n.status === \"error\" || n.status === \"aborted\"); }\n\nfunction nodeMatchesQuery(n, q) {\n  if (!q) return true;\n  const hay = ((n.name||\"\") + \" \" + (n.data && n.data.toolName || \"\") + \" \" + (n.data && n.data.prompt || \"\") + \" \" + n.type).toLowerCase();\n  return hay.includes(q);\n}\n\n// 自底向上标记 keep：自身命中 OR 后代有命中\nfunction markKeep(n, q, onlyErrors) {\n  const selfHit = nodeMatchesQuery(n, q) && (!onlyErrors || isErrLike(n));\n  let childHit = false;\n  (n.children || []).forEach(c => { if (markKeep(c, q, onlyErrors)) childHit = true; });\n  const keep = selfHit || childHit;\n  n._keep = keep;\n  return keep;\n}\n\nfunction applyFilters() {\n  const q = filterState.query;\n  const onlyErr = filterState.onlyErrors;\n  // 无任何过滤时直接全显，省一次 DFS\n  if (!q && !onlyErr) {\n    document.querySelectorAll(\".node-row\").forEach(row => { row.style.display = \"\"; });\n    return;\n  }\n  markKeep(TRACE_DATA, q, onlyErr);\n  document.querySelectorAll(\".node-row\").forEach(row => {\n    const n = NODE_INDEX[row.dataset.nodeId];\n    row.style.display = (n && n._keep) ? \"\" : \"none\";\n  });\n}\n\nfunction hasAnyErrors(n) {\n  if (isErrLike(n)) return true;\n  return (n.children || []).some(hasAnyErrors);\n}\n\nlet __jsonLine = 0;\nfunction renderJsonRoot(value) {\n  __jsonLine = 0;\n  const root = el(\"div\", \"json-tree\");\n  renderJsonValue(value, root, 0, null);\n  return root;\n}\nfunction jsonRow(indent) {\n  __jsonLine += 1;\n  const row = el(\"div\", \"json-row\");\n  row.appendChild(el(\"span\", \"json-line-num\", String(__jsonLine)));\n  const content = el(\"span\", \"json-content\");\n  for (let i = 0; i < indent; i++) content.appendChild(el(\"span\", \"json-indent\"));\n  row.appendChild(content);\n  return {row, content};\n}\nfunction appendKey(content, key) {\n  if (key != null) {\n    content.appendChild(span(\"json-key\", key));\n    content.appendChild(document.createTextNode(\": \"));\n  }\n}\nfunction renderJsonValue(value, container, indent, key) {\n  if (value === null || value === undefined) {\n    const {row, content} = jsonRow(indent);\n    appendKey(content, key);\n    content.appendChild(span(\"json-null\", \"null\"));\n    container.appendChild(row); return;\n  }\n  if (typeof value === \"boolean\") {\n    const {row, content} = jsonRow(indent);\n    appendKey(content, key);\n    content.appendChild(span(\"json-bool\", String(value)));\n    container.appendChild(row); return;\n  }\n  if (typeof value === \"number\") {\n    const {row, content} = jsonRow(indent);\n    appendKey(content, key);\n    content.appendChild(span(\"json-number\", String(value)));\n    container.appendChild(row); return;\n  }\n  if (typeof value === \"string\") {\n    if (value.length > 120 || value.includes(\"\\n\")) {\n      const {row, content} = jsonRow(indent);\n      appendKey(content, key);\n      const tog = span(\"json-toggle\", \"▼\");\n      content.appendChild(tog);\n      content.appendChild(span(\"json-string\", '\"…\"'));\n      content.appendChild(span(\"json-meta\", value.length + \" chars\"));\n      container.appendChild(row);\n      const {row: row2, content: c2} = jsonRow(indent + 1);\n      const sw = el(\"span\", \"json-string\");\n      const inner = el(\"span\", \"json-string-content\");\n      setText(inner, '\"' + value + '\"');\n      sw.appendChild(inner);\n      c2.appendChild(sw);\n      container.appendChild(row2);\n      tog.addEventListener(\"click\", (e) => {\n        e.stopPropagation();\n        row2.style.display = row2.style.display === \"none\" ? \"\" : \"none\";\n        tog.textContent = row2.style.display === \"none\" ? \"▶\" : \"▼\";\n      });\n      return;\n    }\n    const {row, content} = jsonRow(indent);\n    appendKey(content, key);\n    const sw = span(\"json-string\", '\"' + value + '\"');\n    content.appendChild(sw);\n    container.appendChild(row); return;\n  }\n  if (Array.isArray(value)) {\n    if (value.length === 0) {\n      const {row, content} = jsonRow(indent);\n      appendKey(content, key);\n      content.appendChild(span(\"json-bracket\", \"[]\"));\n      container.appendChild(row); return;\n    }\n    const {row, content} = jsonRow(indent);\n    appendKey(content, key);\n    const tog = span(\"json-toggle\", \"▼\");\n    content.appendChild(tog);\n    content.appendChild(span(\"json-bracket\", \"[\"));\n    content.appendChild(span(\"json-meta\", \"Array(\" + value.length + \")\"));\n    container.appendChild(row);\n    const childWrap = el(\"div\", \"json-children\");\n    value.forEach((v, i) => renderJsonValue(v, childWrap, indent + 1, String(i)));\n    container.appendChild(childWrap);\n    const {row: r2, content: c2} = jsonRow(indent);\n    c2.appendChild(span(\"json-bracket\", \"]\"));\n    container.appendChild(r2);\n    tog.addEventListener(\"click\", (e) => {\n      e.stopPropagation();\n      childWrap.classList.toggle(\"collapsed\");\n      tog.textContent = childWrap.classList.contains(\"collapsed\") ? \"▶\" : \"▼\";\n    });\n    return;\n  }\n  if (typeof value === \"object\") {\n    const keys = Object.keys(value);\n    if (keys.length === 0) {\n      const {row, content} = jsonRow(indent);\n      appendKey(content, key);\n      content.appendChild(span(\"json-bracket\", \"{}\"));\n      container.appendChild(row); return;\n    }\n    const {row, content} = jsonRow(indent);\n    appendKey(content, key);\n    const tog = span(\"json-toggle\", \"▼\");\n    content.appendChild(tog);\n    content.appendChild(span(\"json-bracket\", \"{\"));\n    content.appendChild(span(\"json-meta\", \"{\" + keys.length + \" keys}\"));\n    container.appendChild(row);\n    const childWrap = el(\"div\", \"json-children\");\n    keys.forEach(k => renderJsonValue(value[k], childWrap, indent + 1, k));\n    container.appendChild(childWrap);\n    const {row: r2, content: c2} = jsonRow(indent);\n    c2.appendChild(span(\"json-bracket\", \"}\"));\n    container.appendChild(r2);\n    tog.addEventListener(\"click\", (e) => {\n      e.stopPropagation();\n      childWrap.classList.toggle(\"collapsed\");\n      tog.textContent = childWrap.classList.contains(\"collapsed\") ? \"▶\" : \"▼\";\n    });\n  }\n}\n\nfunction makeBadge(label, value, cls) {\n  const b = el(\"span\", \"badge\" + (cls ? \" \" + cls : \"\"));\n  if (label) {\n    b.appendChild(span(\"badge-key\", label + \":\"));\n    b.appendChild(document.createTextNode(\" \" + value));\n  } else {\n    b.textContent = value;\n  }\n  return b;\n}\n\nfunction renderThinkingList(changes) {\n  // 复用 model changes 的 fc-list 样式：一行一次切换\n  const wrap = el(\"div\", \"fc-list\");\n  changes.forEach((tc) => {\n    const row = el(\"div\");\n    const tsStr = fmtIso(tc.ts);\n    const prev = tc.previousLevel || \"?\";\n    const nxt = tc.level || \"?\";\n    setText(row, `${tsStr}  ${prev}  →  ${nxt}`);\n    wrap.appendChild(row);\n  });\n  return wrap;\n}\n\nfunction renderDetail(node) {\n  const pane = document.getElementById(\"detail-pane\");\n  pane.innerHTML = \"\";\n  if (!node) {\n    pane.appendChild(el(\"div\", \"detail-empty\", \"从左侧选择节点查看详情\"));\n    return;\n  }\n  const t = node.type;\n\n  // Header\n  const hdr = el(\"div\", \"detail-header\");\n  const titleRow = el(\"div\", \"detail-title\");\n  const titleSpan = el(\"span\", null, (node.icon || \"·\") + \" \" + (node.name || \"\"));\n  titleRow.appendChild(titleSpan);\n  const typeTag = span(\"tag tag-purple\", t);\n  titleRow.appendChild(typeTag);\n  if (node.status === \"error\") titleRow.appendChild(span(\"tag tag-error\", \"error\"));\n  else if (node.status === \"aborted\") titleRow.appendChild(span(\"tag tag-warn\", \"aborted\"));\n  hdr.appendChild(titleRow);\n  const time = el(\"div\", \"detail-time\", fmtIso(node.start) + (node.end ? \"  →  \" + fmtIso(node.end) : \"\"));\n  hdr.appendChild(time);\n\n  const badges = el(\"div\", \"badges\");\n  function addBadge(label, value, cls) { badges.appendChild(makeBadge(label, value, cls)); }\n\n  if (node.duration_ms != null) addBadge(\"Latency\", fmtMs(node.duration_ms));\n\n  if (t === \"turn\") {\n    const u = node.data.usage || {};\n    if (u.input || u.output) addBadge(null, fmtInt(u.input) + \" prompt → \" + fmtInt(u.output) + \" completion\");\n    if (u.cacheRead) addBadge(\"cacheRead\", fmtInt(u.cacheRead));\n    if (u.cost) addBadge(\"cost\", fmtMoney(u.cost));\n  } else if (t === \"step\") {\n    const d = node.data;\n    const llmDur = (d.llm_start && d.llm_end) ? (d.llm_end - d.llm_start) : null;\n    if (llmDur != null) addBadge(\"LLM\", fmtMs(llmDur));\n    const u = d.usage || {};\n    if (u.input != null) addBadge(null, fmtInt(u.input) + \" prompt → \" + fmtInt(u.output||0) + \" completion (Σ \" + fmtInt((u.input||0)+(u.cacheRead||0)+(u.output||0)) + \")\");\n    if (u.cacheRead) addBadge(\"cacheRead\", fmtInt(u.cacheRead));\n    if (u.cost) addBadge(\"cost\", fmtMoney(u.cost));\n    if (d.stopReason) addBadge(\"stop\", d.stopReason);\n    if (d.model) addBadge(null, d.model, \"badge-model\");\n  } else if (t === \"tool\") {\n    const d = node.data;\n    if (d.toolName) addBadge(\"tool\", d.toolName);\n    if (d.isError) addBadge(null, \"FAILED\", \"badge-error\");\n    if (d.resultTotalLength) addBadge(\"result\", fmtInt(d.resultTotalLength) + \" chars\");\n  } else if (t === \"interaction\") {\n    const u = node.data.totalUsage || {};\n    if (u.input || u.output) addBadge(null, fmtInt(u.input) + \" prompt → \" + fmtInt(u.output) + \" completion\");\n    if (u.cacheRead) addBadge(\"cacheRead\", fmtInt(u.cacheRead));\n    if (u.cost) addBadge(\"cost\", fmtMoney(u.cost));\n    if (node.data.model) addBadge(null, node.data.model, \"badge-model\");\n    if (node.data.slashCommand) addBadge(null, \"/\" + node.data.slashCommand, \"badge-model\");\n  } else if (t === \"subagent-result\") {\n    const d = node.data;\n    if (d.model) addBadge(null, d.model, \"badge-model\");\n    if (d.usage) {\n      addBadge(null, fmtInt(d.usage.input) + \" prompt → \" + fmtInt(d.usage.output) + \" completion\");\n      if (d.usage.cost) addBadge(\"cost\", fmtMoney(d.usage.cost));\n      if (d.usage.turns) addBadge(\"turns\", String(d.usage.turns));\n    }\n    if (d.exitCode != null && d.exitCode !== 0) addBadge(null, \"exit \" + d.exitCode, \"badge-error\");\n  } else if (t === \"session\") {\n    const u = node.data.totalUsage || {};\n    if (u.input || u.output) addBadge(null, fmtInt(u.input) + \" prompt → \" + fmtInt(u.output) + \" completion\");\n    if (u.cost) addBadge(\"total cost\", fmtMoney(u.cost));\n    addBadge(\"interactions\", String(node.data.interactionCount));\n  }\n  hdr.appendChild(badges);\n  pane.appendChild(hdr);\n\n  const body = el(\"div\", \"detail-body\");\n  pane.appendChild(body);\n\n  const jumps = [];\n  function section(id, label, contentEl, count) {\n    const sec = el(\"div\", \"section\"); sec.id = \"sec-\" + id;\n    const t = el(\"div\", \"section-title\");\n    t.appendChild(document.createTextNode(label));\n    if (count != null) { const c = el(\"span\", \"count\", \" (\" + count + \")\"); t.appendChild(c); }\n    sec.appendChild(t);\n    sec.appendChild(contentEl);\n    body.appendChild(sec);\n    jumps.push({id, label});\n  }\n  function textBlock(s, kind) {\n    // kind: true|\"error\" -> 红框, \"warn\" -> 黄框, 其他 -> 普通\n    let cls = \"text-block\";\n    if (kind === true || kind === \"error\") cls += \" error\";\n    else if (kind === \"warn\") cls += \" warn\";\n    const e = el(\"div\", cls);\n    setText(e, s || \"\"); return e;\n  }\n\n  if (t === \"session\") {\n    // model 切换历史\n    if (node.data.modelChanges && node.data.modelChanges.length) {\n      const wrap = el(\"div\", \"fc-list\");\n      node.data.modelChanges.forEach((mc) => {\n        const row = el(\"div\");\n        const tsStr = fmtIso(mc.ts);\n        const txt = mc.previousModel\n          ? `${tsStr}  ${mc.previousModel}  →  ${mc.model}` + (mc.source ? ` (${mc.source})` : \"\")\n          : `${tsStr}  → ${mc.model}` + (mc.source ? ` (${mc.source})` : \"\");\n        setText(row, txt);\n        wrap.appendChild(row);\n      });\n      section(\"models\", \"Model Changes\", wrap, node.data.modelChanges.length);\n    }\n    // thinking level 切换历史\n    if (node.data.thinkingChanges && node.data.thinkingChanges.length) {\n      section(\"thinking\", \"Thinking Changes\",\n        renderThinkingList(node.data.thinkingChanges),\n        node.data.thinkingChanges.length);\n    }\n    section(\"metadata\", \"Metadata\", renderJsonRoot({\n      sessionId: node.data.sessionId, cwd: node.data.cwd, model: node.data.model,\n      duration: fmtMs(node.duration_ms),\n      interactions: node.data.interactionCount,\n      totalUsage: node.data.totalUsage,\n    }));\n  } else if (t === \"interaction\") {\n    if (node.data.prompt) section(\"input\", \"User Input\", textBlock(node.data.prompt));\n    if (node.data.finalText) section(\"output\", \"Final Output\", textBlock(node.data.finalText));\n    if (node.data.filesChanged && node.data.filesChanged.length) {\n      const wrap = el(\"div\", \"fc-list\");\n      const seen = new Map();\n      node.data.filesChanged.forEach(f => {\n        if (!f.path) return;\n        if (!seen.has(f.path)) seen.set(f.path, {ops: new Set(), count: 0});\n        seen.get(f.path).ops.add(f.op);\n        seen.get(f.path).count += f.count || 1;\n      });\n      seen.forEach((v, p) => {\n        const row = el(\"div\");\n        const c = el(\"code\"); setText(c, p); row.appendChild(c);\n        row.appendChild(document.createTextNode(\" \"));\n        const m = el(\"span\"); m.style.color = \"var(--muted)\";\n        setText(m, [...v.ops].join(\"/\") + \" × \" + v.count);\n        row.appendChild(m);\n        wrap.appendChild(row);\n      });\n      section(\"files\", \"Files Changed\", wrap, seen.size);\n    }\n    if (node.data.skillsLoaded && node.data.skillsLoaded.length) {\n      const wrap = el(\"div\", \"skill-list\");\n      node.data.skillsLoaded.slice(0, 50).forEach(s => {\n        const row = el(\"div\");\n        const c = el(\"code\"); setText(c, s.name); row.appendChild(c);\n        if (s.scope) {\n          row.appendChild(document.createTextNode(\" \"));\n          row.appendChild(span(\"tag tag-purple\", s.scope));\n        }\n        if (s.description) { row.appendChild(document.createTextNode(\" — \" + s.description)); }\n        wrap.appendChild(row);\n      });\n      if (node.data.skillsLoaded.length > 50) {\n        const m = el(\"div\", \"no-data\", \"... 还有 \" + (node.data.skillsLoaded.length - 50) + \" 个\");\n        wrap.appendChild(m);\n      }\n      section(\"skills\", \"Skills Loaded\", wrap, node.data.skillsLoaded.length);\n    }\n    if (node.data.modelChanges && node.data.modelChanges.length) {\n      const wrap = el(\"div\", \"fc-list\");\n      node.data.modelChanges.forEach((mc) => {\n        const row = el(\"div\");\n        const tsStr = fmtIso(mc.ts);\n        const txt = mc.previousModel\n          ? `${tsStr}  ${mc.previousModel}  →  ${mc.model}` + (mc.source ? ` (${mc.source})` : \"\")\n          : `${tsStr}  → ${mc.model}` + (mc.source ? ` (${mc.source})` : \"\");\n        setText(row, txt);\n        wrap.appendChild(row);\n      });\n      section(\"models\", \"Model Changes\", wrap, node.data.modelChanges.length);\n    }\n    if (node.data.thinkingChanges && node.data.thinkingChanges.length) {\n      section(\"thinking\", \"Thinking Changes\",\n        renderThinkingList(node.data.thinkingChanges),\n        node.data.thinkingChanges.length);\n    }\n    section(\"metadata\", \"Metadata\", renderJsonRoot({\n      interactionId: node.data.interactionId,\n      systemPromptLength: node.data.systemPromptLength,\n      imagesCount: node.data.imagesCount,\n      slashCommand: node.data.slashCommand,\n      totalUsage: node.data.totalUsage,\n    }));\n  } else if (t === \"turn\") {\n    if (node.data.modelChanges && node.data.modelChanges.length) {\n      const wrap = el(\"div\", \"fc-list\");\n      node.data.modelChanges.forEach((mc) => {\n        const row = el(\"div\");\n        const tsStr = fmtIso(mc.ts);\n        const txt = mc.previousModel\n          ? `${tsStr}  ${mc.previousModel}  →  ${mc.model}`\n          : `${tsStr}  → ${mc.model}`;\n        setText(row, txt);\n        wrap.appendChild(row);\n      });\n      section(\"models\", \"Model Changes (this turn)\", wrap, node.data.modelChanges.length);\n    }\n    if (node.data.thinkingChanges && node.data.thinkingChanges.length) {\n      section(\"thinking\", \"Thinking Changes (this turn)\",\n        renderThinkingList(node.data.thinkingChanges),\n        node.data.thinkingChanges.length);\n    }\n    section(\"metadata\", \"Metadata\", renderJsonRoot({\n      turnIndex: node.data.turnIndex,\n      epoch: node.data.epoch,\n      interactionId: node.data.interactionId,\n      usage: node.data.usage,\n      childCount: (node.children || []).length,\n    }));\n  } else if (t === \"step\") {\n    const d = node.data;\n    if (d.errorMessage || d.stopReason === \"error\" || d.stopReason === \"aborted\") {\n      const isAborted = d.stopReason === \"aborted\";\n      const label = isAborted ? \"Aborted\" : \"Error\";\n      section(\"error\", label, textBlock(d.errorMessage || (\"(no message; stopReason=\" + d.stopReason + \")\"), isAborted ? \"warn\" : \"error\"));\n      if (d.diagnostics) section(\"diagnostics\", \"Diagnostics\", renderJsonRoot(d.diagnostics));\n    }\n    if (d.input) {\n      // input 是发给 LLM 的完整 payload (model + messages + tools)\n      const inputCount = d.input.messages ? `${d.input.messages.length} msgs` : \"\";\n      section(\"input\", \"Input\" + (inputCount ? ` (${inputCount})` : \"\"), renderJsonRoot(d.input));\n    }\n    if (d.thinking) {\n      const e = el(\"div\", \"thinking-block\"); setText(e, d.thinking);\n      section(\"thinking\", \"Thinking\", e);\n    } else if (d.thinkingRedacted) {\n      section(\"thinking\", \"Thinking\", textBlock(\"(已加密，模型推理了但内容不可读)\"));\n    }\n    if (d.text) section(\"output\", \"Output\", textBlock(d.text));\n    if (d.toolCalls && d.toolCalls.length) {\n      const wrap = el(\"div\");\n      d.toolCalls.forEach(tc => {\n        const item = el(\"div\", \"tc-item\");\n        const nm = el(\"div\", \"tc-name\"); setText(nm, tc.name); item.appendChild(nm);\n        const ar = el(\"div\", \"tc-args\");\n        let s = \"\"; try { s = JSON.stringify(tc.args, null, 2); } catch { s = String(tc.args); }\n        setText(ar, s); item.appendChild(ar);\n        wrap.appendChild(item);\n      });\n      section(\"tools\", \"Tool Calls\", wrap, d.toolCalls.length);\n    }\n    section(\"metadata\", \"Metadata\", renderJsonRoot({\n      stepIndex: d.stepIndex, turnIndex: d.turnIndex,\n      stopReason: d.stopReason,\n      // llm_status: 主 agent 是 HTTP 状态码；subagent-derived step 从子 trace 取（有则显示）\n      llm_status: d.llm_status,\n      usage: d.usage,\n      thinkingSource: d.thinkingSource,\n      thinkingDeltaCount: d.thinkingDeltaCount,\n      thinkingRedacted: d.thinkingRedacted,\n      subagentDerived: d._subagentDerived,\n    }));\n  } else if (t === \"tool\") {\n    const d = node.data;\n    section(\"input\", \"Args\", renderJsonRoot(d.args || {}));\n    if (d.subagent) section(\"subagent\", \"Subagent Detail\", renderJsonRoot(d.subagent));\n\n    // 解析 result：标准 pi tool result 形如 {content:[{type:\"text\",text:\"...\"}], details:{}}\n    let parsed = null;\n    if (d.resultPreview) { try { parsed = JSON.parse(d.resultPreview); } catch {} }\n    function extractText(p) {\n      if (!p) return null;\n      if (typeof p === \"string\") return p;\n      if (Array.isArray(p?.content)) {\n        return p.content.filter(x => x && (x.type === \"text\" || x.type === \"error_text\"))\n                        .map(x => x.text || \"\").filter(Boolean).join(\"\\n\");\n      }\n      return null;\n    }\n\n    // tool error：把可读的错误文本顶到 Error section 红框里\n    if (d.isError) {\n      const errText = extractText(parsed) || d.resultPreview || \"(no error message)\";\n      section(\"error\", \"Error\", textBlock(errText, true));\n    }\n\n    if (d.resultPreview) {\n      if (parsed && typeof parsed === \"object\") section(\"output\", \"Result\", renderJsonRoot(parsed));\n      else section(\"output\", \"Result\", textBlock(d.resultPreview, d.isError));\n    }\n    section(\"metadata\", \"Metadata\", renderJsonRoot({\n      toolName: d.toolName, toolCallId: d.toolCallId, isError: d.isError,\n      resultTotalLength: d.resultTotalLength, stepIndex: d.stepIndex,\n    }));\n  } else if (t === \"subagent-result\") {\n    const d = node.data;\n    if (d.task) section(\"input\", \"Task\", textBlock(d.task));\n    if (d.errorMessage) section(\"error\", \"Error\", textBlock(d.errorMessage, true));\n    if (d.finalOutput) section(\"output\", \"Final Output\", textBlock(d.finalOutput));\n    if (d.toolsUsed && d.toolsUsed.length) {\n      const counts = {}, errs = {};\n      d.toolsUsed.forEach(u => { const n = u.name || \"?\"; counts[n] = (counts[n]||0)+1; if (u.isError) errs[n] = (errs[n]||0)+1; });\n      const wrap = el(\"div\", \"fc-list\");\n      Object.entries(counts).sort((a,b)=>b[1]-a[1]).forEach(([n, c]) => {\n        const row = el(\"div\");\n        const code = el(\"code\"); setText(code, n); row.appendChild(code);\n        row.appendChild(document.createTextNode(\" × \" + c));\n        if (errs[n]) {\n          const e = el(\"span\"); e.style.color = \"var(--err)\";\n          setText(e, \" (\" + errs[n] + \" 错)\"); row.appendChild(e);\n        }\n        wrap.appendChild(row);\n      });\n      section(\"tools\", \"Tools Used\", wrap, d.toolsUsed.length);\n    }\n    // Child trace 链接：包到 section 里，避免破坏 .section:first-child 等 CSS 假设\n    if (d.childTraceId) {\n      const wrap = el(\"div\");\n      const btn = el(\"a\", \"tag tag-purple\");\n      btn.style.cursor = \"pointer\";\n      btn.style.display = \"inline-block\";\n      btn.style.textDecoration = \"none\";\n      setText(btn, \"↗ Open child trace\");\n      btn.addEventListener(\"click\", () => {\n        // 用 new URL 解析当前页 + 相对路径，避免手写 regex 漏掉 ?query/#fragment\n        // childTraceId 可能含特殊字符（例如 sessionId 里的冒号），encodeURIComponent 防御\n        try {\n          const childUrl = new URL(\"subagents/\" + encodeURIComponent(d.childTraceId) + \"/trace.html\", window.location.href);\n          window.open(childUrl.href, \"_blank\");\n        } catch {\n          /* ignore */\n        }\n      });\n      wrap.appendChild(btn);\n      section(\"childTrace\", \"Child Trace\", wrap);\n    }\n    section(\"metadata\", \"Metadata\", renderJsonRoot({\n      agent: d.agent, agentSource: d.agentSource, model: d.model,\n      exitCode: d.exitCode, stopReason: d.stopReason, step: d.step, usage: d.usage,\n      childTraceId: d.childTraceId,\n    }));\n  } else if (t === \"compact\") {\n    const d = node.data || {};\n    // compactionEntry 通常是字符串（Python 端 str() 序列化过），也可能是 dict\n    const entry = d.compactionEntry;\n    if (entry) {\n      if (typeof entry === \"string\") {\n        section(\"entry\", \"Compaction Entry\", textBlock(entry));\n      } else {\n        section(\"entry\", \"Compaction Entry\", renderJsonRoot(entry));\n      }\n    }\n    section(\"metadata\", \"Metadata\", renderJsonRoot({\n      turnIndex: d.turnIndex,\n      fromExtension: d.fromExtension,\n    }));\n  }\n\n  if (jumps.length > 1) {\n    const jb = el(\"div\", \"jump-bar\");\n    jb.appendChild(document.createTextNode(\"Jump to: \"));\n    jumps.forEach((j, i) => {\n      if (i > 0) jb.appendChild(document.createTextNode(\", \"));\n      const a = el(\"a\", null, j.label); a.dataset.target = \"sec-\" + j.id;\n      a.addEventListener(\"click\", () => {\n        const t = document.getElementById(a.dataset.target);\n        if (t) t.scrollIntoView({behavior: \"smooth\", block: \"start\"});\n      });\n      jb.appendChild(a);\n    });\n    body.insertBefore(jb, body.firstChild);\n  }\n}\n\nfunction renderDag() {\n  const e = document.getElementById(\"dag-grid\");\n  e.innerHTML = \"\";\n  DAG_STATS.sort((a,b) => b.total - a.total).forEach(s => {\n    const cls = \"dag-node\" + (s.err > 0 ? \" has-err\" : (s.abort > 0 ? \" has-abort\" : \"\"));\n    const node = el(\"div\", cls);\n    if (s.icon) { const i = el(\"span\"); setText(i, s.icon); node.appendChild(i); }\n    const c = el(\"code\"); setText(c, s.name); node.appendChild(c);\n    const cnt = el(\"span\", \"dag-node-count\");\n    // ok/total，再可选拼上 abort 数\n    let txt = s.ok + \"/\" + s.total;\n    if (s.abort > 0) txt += \" · \" + s.abort + \" ⏹\";\n    setText(cnt, txt);\n    node.appendChild(cnt);\n    e.appendChild(node);\n  });\n}\n\nwindow.addEventListener(\"DOMContentLoaded\", () => {\n  const treeEl = document.getElementById(\"tree-list\");\n  treeEl.appendChild(renderNodeRow(TRACE_DATA, 0));\n  renderDag();\n\n  document.getElementById(\"tree-search\").addEventListener(\"input\", (e) => applySearch(e.target.value));\n  document.getElementById(\"expand-all\").addEventListener(\"click\", () => {\n    document.querySelectorAll(\".node-children\").forEach(c => c.classList.remove(\"hidden\"));\n    document.querySelectorAll(\".node-row\").forEach(r => r.classList.remove(\"collapsed\"));\n  });\n  document.getElementById(\"collapse-all\").addEventListener(\"click\", () => {\n    document.querySelectorAll(\".node-children\").forEach((c, i) => { if (i > 0) c.classList.add(\"hidden\"); });\n    document.querySelectorAll(\".node-row\").forEach((r, i) => { if (i > 0 && !r.classList.contains(\"leaf\")) r.classList.add(\"collapsed\"); });\n  });\n\n  const onlyErrBtn = document.getElementById(\"only-errors\");\n  if (!hasAnyErrors(TRACE_DATA)) {\n    onlyErrBtn.disabled = true;\n    onlyErrBtn.title = \"此 trace 无错误节点\";\n  } else {\n    onlyErrBtn.addEventListener(\"click\", () => {\n      filterState.onlyErrors = !filterState.onlyErrors;\n      onlyErrBtn.classList.toggle(\"active\", filterState.onlyErrors);\n      // 打开时强制全展开，避免折叠住的错误节点被 display:none 掩盖\n      if (filterState.onlyErrors) {\n        document.querySelectorAll(\".node-children\").forEach(c => c.classList.remove(\"hidden\"));\n        document.querySelectorAll(\".node-row\").forEach(r => r.classList.remove(\"collapsed\"));\n      }\n      applyFilters();\n    });\n  }\n\n  const cid = document.getElementById(\"copy-id\");\n  cid.addEventListener(\"click\", (e) => {\n    const txt = e.target.textContent.trim();\n    if (navigator.clipboard) navigator.clipboard.writeText(txt);\n    const orig = e.target.textContent;\n    e.target.textContent = \"✓ copied\";\n    setTimeout(() => { e.target.textContent = orig; }, 1000);\n  });\n\n  const firstInter = TRACE_DATA.children && TRACE_DATA.children[0];\n  selectNode((firstInter || TRACE_DATA).id);\n});\n", "html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<title>Pi Trace · {session_name}</title>\n<style>{css}</style>\n</head>\n<body>\n<div class=\"topbar\">\n  <span class=\"topbar-icon\">⚡</span>\n  <span class=\"topbar-title\">Trace · pi-agent:</span>\n  <span class=\"topbar-id\" id=\"copy-id\" title=\"点击复制\">{session_name}</span>\n  <div class=\"topbar-meta\">\n    <span><b>{interaction_count}</b> interactions</span>\n    <span><b>{step_count}</b> llm-generations</span>\n    <span><b>{tool_count}</b> tool calls</span>\n    <span><b>{total_cost}</b> 总成本</span>\n    <span><b>{total_dur}</b> 总耗时</span>\n  </div>\n</div>\n<div class=\"main\">\n  <div class=\"tree-pane\">\n    <div class=\"tree-search\"><input type=\"text\" id=\"tree-search\" placeholder=\"搜索节点 (名称 / 工具 / prompt)\" /></div>\n    <div class=\"tree-toolbar\">\n      <button id=\"expand-all\">▼ 展开全部</button>\n      <button id=\"collapse-all\">▶ 折叠全部</button>\n      <button id=\"only-errors\">❌ 只看错误</button>\n    </div>\n    <div class=\"tree-list\" id=\"tree-list\"></div>\n    <div class=\"dag-pane\">\n      <div class=\"dag-title\">节点统计 (ok / total)</div>\n      <div class=\"dag-grid\" id=\"dag-grid\"></div>\n    </div>\n  </div>\n  <div class=\"detail-pane\" id=\"detail-pane\">\n    <div class=\"detail-empty\">从左侧选择节点查看详情</div>\n  </div>\n</div>\n<script>{js}</script>\n</body>\n</html>\n", "dash_css": "* { box-sizing: border-box; }\n:root {\n  --bg: #fafbfc; --panel: #fff; --border: #e1e4e8; --border-strong: #d0d7de;\n  --text: #1f2328; --muted: #6e7781; --accent: #6f42c1; --accent-bg: #f5f0ff;\n  --row-hover: #f6f8fa; --row-active: #ede7f8;\n  --ok: #1a7f37; --err: #cf222e; --warn: #9a6700;\n  --badge-bg: #eaeef2; --badge-text: #424a53;\n  /* dashboard 专用状态色：软化的琥珀（错误）/ 中性米色（ok），不用刺眼的红/绿 */\n  --dash-err-soft: #d4a373;      /* 温暖的琥珀，暗示\"有事发生但别慌\" */\n  --dash-abort-soft: #b5c99a;    /* 淡橄榄，中断 */\n  --dash-ok-soft: #d0d7de;       /* 与 border-strong 同调，几乎不抢眼 */\n}\n@media (prefers-color-scheme: dark) {\n  :root {\n    --bg: #0d1117; --panel: #161b22; --border: #30363d; --border-strong: #3d444d;\n    --text: #e6edf3; --muted: #8b949e; --accent: #d2a8ff; --accent-bg: #2d1b4f44;\n    --row-hover: #1c2128; --row-active: #2d1b4f88;\n    --ok: #3fb950; --err: #f85149; --warn: #d29922;\n    --badge-bg: #21262d; --badge-text: #8d96a0;\n    --dash-err-soft: #c17c4e;\n    --dash-abort-soft: #7ea057;\n    --dash-ok-soft: #3d444d;\n  }\n}\nhtml, body { margin: 0; font: 13px/1.5 -apple-system, \"PingFang SC\", system-ui, sans-serif; background: var(--bg); color: var(--text); }\n\n.dash-topbar {\n  display: flex; align-items: center; gap: 12px;\n  padding: 10px 20px; background: var(--panel);\n  border-bottom: 1px solid var(--border);\n}\n.topbar-icon { color: var(--accent); font-weight: 700; font-size: 16px; }\n.topbar-title { font-weight: 600; font-size: 14px; }\n.dash-search { margin-left: auto; flex: 0 1 320px; }\n.dash-search input {\n  width: 100%; padding: 6px 10px 6px 28px;\n  border: 1px solid var(--border-strong); border-radius: 6px;\n  background: var(--bg); color: var(--text); font: inherit;\n  background-image: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236e7781' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>\");\n  background-repeat: no-repeat; background-position: 8px center;\n}\n.dash-search input:focus { outline: none; border-color: var(--accent); }\n\n.dash-kpi { padding: 16px 20px 4px; display: flex; flex-direction: column; gap: 8px; }\n.kpi-row { display: flex; align-items: stretch; gap: 12px; }\n.kpi-label { display: flex; align-items: center; width: 60px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }\n.kpi-card {\n  flex: 0 0 160px; background: var(--panel); border: 1px solid var(--border);\n  border-radius: 8px; padding: 10px 14px; display: flex; flex-direction: column; gap: 2px;\n}\n.kpi-card small { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }\n.kpi-card strong { color: var(--text); font-size: 18px; font-weight: 600; font-family: ui-monospace, \"SF Mono\", Menlo, monospace; }\n\n.dash-chips { padding: 12px 20px 6px; display: flex; gap: 6px; flex-wrap: wrap; }\n.chip {\n  padding: 3px 10px; border-radius: 12px; border: 1px solid var(--border-strong);\n  background: var(--panel); color: var(--muted); font-size: 12px; cursor: pointer; user-select: none;\n}\n.chip:hover { color: var(--text); border-color: var(--accent); }\n.chip.active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); font-weight: 600; }\n.chip-count { color: var(--muted); font-size: 10.5px; margin-left: 3px; opacity: 0.7; }\n.chip.active .chip-count { color: var(--accent); opacity: 0.7; }\n\n.dash-table-wrap { padding: 6px 20px 24px; }\n.dash-table { width: 100%; border-collapse: collapse; font-size: 12.5px; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; }\n.dash-table thead { background: var(--bg); }\n.dash-table thead tr:first-child th:first-child { border-top-left-radius: 8px; }\n.dash-table thead tr:first-child th:last-child { border-top-right-radius: 8px; }\n.dash-table th {\n  text-align: left; padding: 8px 10px; color: var(--muted); font-weight: 600;\n  text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px;\n  border-bottom: 1px solid var(--border); white-space: nowrap;\n}\n.dash-table th.num, .dash-table td.num { text-align: right; font-family: ui-monospace, Menlo, monospace; }\n.dash-table th.sortable { cursor: pointer; user-select: none; }\n.dash-table th.sortable:hover { color: var(--text); }\n.sort-ind { color: var(--muted); font-size: 10px; opacity: 0.5; margin-left: 2px; }\n.dash-table th.sortable[data-dir=\"asc\"] .sort-ind,\n.dash-table th.sortable[data-dir=\"desc\"] .sort-ind { opacity: 1; color: var(--accent); }\n\n.dash-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }\n.dash-table tbody tr:last-child td { border-bottom: none; }\n.dash-table tbody tr:hover { background: var(--row-hover); }\n\n.col-status { width: 6px; min-width: 6px; padding: 0 !important; }\n.dash-table th.col-status, .dash-table td.col-status { width: 6px; min-width: 6px; padding: 0 !important; background: transparent; }\n.dash-table td.col-status::before { content: \"\\00a0\"; display: inline-block; width: 6px; }\n.dash-table tr.has-err td.col-status { background: var(--dash-err-soft); }\n.dash-table tr.has-abort td.col-status { background: var(--dash-abort-soft); }\n.dash-table tr.all-ok td.col-status { background: var(--dash-ok-soft); }\n\n.dash-table td.col-time { color: var(--muted); font-family: ui-monospace, Menlo, monospace; white-space: nowrap; }\n.dash-table td.col-id a { color: var(--accent); text-decoration: none; font-family: ui-monospace, Menlo, monospace; }\n.dash-table td.col-id a:hover { text-decoration: underline; }\n.dash-table td.col-prompt { color: var(--text); max-width: 460px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.dash-table td.col-prompt.empty { color: var(--muted); font-style: italic; }\n.dash-table td.col-cwd { color: var(--muted); font-family: ui-monospace, Menlo, monospace; white-space: nowrap; }\n.dash-table td.col-scale { color: var(--muted); font-family: ui-monospace, Menlo, monospace; white-space: nowrap; }\n.dash-table td.col-scale .scale-sep { opacity: 0.4; margin: 0 4px; }\n\n.dash-empty { padding: 60px 20px; text-align: center; color: var(--muted); }\n", "dash_js": "const SUMMARIES = /*__SUMMARIES__*/[];\n\nconst state = {\n  query: \"\",\n  project: null,       // null = All\n  sortKey: \"startedAt\",\n  sortDir: \"desc\",     // asc | desc\n};\n\nfunction fmtDate(ms) {\n  if (!ms) return \"\";\n  const d = new Date(ms);\n  const p = (n, w) => String(n).padStart(w || 2, \"0\");\n  return d.getFullYear() + \"-\" + p(d.getMonth() + 1) + \"-\" + p(d.getDate()) + \" \" + p(d.getHours()) + \":\" + p(d.getMinutes());\n}\nfunction fmtMs(ms) {\n  if (!ms) return \"\";\n  if (ms < 1000) return ms + \"ms\";\n  const s = Math.floor(ms / 1000);\n  if (s < 60) return s + \"s\";\n  const m = Math.floor(s / 60);\n  if (m < 60) return m + \"m \" + (s - m * 60) + \"s\";\n  const h = Math.floor(m / 60);\n  return h + \"h \" + (m - h * 60) + \"m\";\n}\nfunction fmtTokens(n) {\n  if (!n) return \"0\";\n  if (n < 1000) return String(n);\n  if (n < 10000) return (n / 1000).toFixed(1) + \"k\";\n  if (n < 1000000) return Math.round(n / 1000) + \"k\";\n  return (n / 1000000).toFixed(1) + \"M\";\n}\nfunction fmtMoney(x) {\n  if (!x) return \"$0\";\n  if (x < 0.01) return \"$\" + x.toFixed(4);\n  return \"$\" + x.toFixed(3);\n}\nfunction el(tag, cls, text) {\n  const e = document.createElement(tag);\n  if (cls) e.className = cls;\n  if (text != null) e.textContent = text;\n  return e;\n}\nfunction statusClass(s) {\n  if (s.errorCount > 0) return \"has-err\";\n  if (s.abortedCount > 0) return \"has-abort\";\n  return \"all-ok\";\n}\nfunction totalTokens(s) { return (s.totalInput || 0) + (s.totalOutput || 0) + (s.totalCacheRead || 0); }\n\n// session dir 名格式: <timestamp>_<uuid>，展示 uuid 前 8 位比时间前 8 位更能区分\nfunction shortId(id) {\n  if (!id) return \"\";\n  const i = id.indexOf(\"_\");\n  return (i >= 0 ? id.slice(i + 1, i + 9) : id.slice(0, 8));\n}\n\n// —— 项目 chip：只列 ≥ 3 的项目，其余归 Others\nfunction buildChips() {\n  const counts = {};\n  SUMMARIES.forEach(s => { const k = s.cwdName || \"(unknown)\"; counts[k] = (counts[k] || 0) + 1; });\n  const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);\n  const shown = entries.filter(([, n]) => n >= 3);\n  const otherCount = entries.filter(([, n]) => n < 3).reduce((a, [, n]) => a + n, 0);\n\n  const wrap = document.getElementById(\"chips\");\n  const mk = (label, key, count) => {\n    const c = el(\"span\", \"chip\");\n    c.dataset.project = key == null ? \"\" : key;\n    c.appendChild(document.createTextNode(label));\n    if (count != null) c.appendChild(el(\"span\", \"chip-count\", \"· \" + count));\n    if (state.project === key) c.classList.add(\"active\");\n    c.addEventListener(\"click\", () => {\n      state.project = state.project === key ? null : key;\n      // 用一个具有 hasOthers 的 key 作为特殊值 \"__others__\"\n      render();\n    });\n    return c;\n  };\n  wrap.innerHTML = \"\";\n  wrap.appendChild(mk(\"All\", null, SUMMARIES.length));\n  shown.forEach(([k, n]) => wrap.appendChild(mk(k, k, n)));\n  if (otherCount > 0) wrap.appendChild(mk(\"Others\", \"__others__\", otherCount));\n}\n\nfunction matchProject(s) {\n  if (state.project == null) return true;\n  const name = s.cwdName || \"(unknown)\";\n  if (state.project === \"__others__\") {\n    // Others = 出现次数 < 3 的项目\n    const counts = {};\n    SUMMARIES.forEach(x => { const k = x.cwdName || \"(unknown)\"; counts[k] = (counts[k] || 0) + 1; });\n    return counts[name] < 3;\n  }\n  return name === state.project;\n}\n\nfunction matchQuery(s) {\n  const q = state.query;\n  if (!q) return true;\n  const hay = ((s.firstPrompt || \"\") + \" \" + (s.id || \"\") + \" \" + (s.cwdName || \"\")).toLowerCase();\n  return hay.includes(q);\n}\n\nfunction sortKeyValue(s, key) {\n  if (key === \"tokens\") return totalTokens(s);\n  if (key === \"cost\") return s.totalCost || 0;\n  if (key === \"dur\") return s.durationMs || 0;\n  if (key === \"id\") return s.id || \"\";\n  if (key === \"cwdName\") return s.cwdName || \"\";\n  return s.startedAt || 0;\n}\n\nfunction renderRow(s) {\n  const tr = el(\"tr\", statusClass(s));\n  tr.appendChild(el(\"td\", \"col-status\"));\n  tr.appendChild(el(\"td\", \"col-time\", fmtDate(s.startedAt)));\n\n  const idTd = el(\"td\", \"col-id\");\n  const a = el(\"a\", null, shortId(s.id));\n  a.href = \"./\" + s.id + \"/trace.html\";\n  a.title = s.id;\n  idTd.appendChild(a);\n  tr.appendChild(idTd);\n\n  const prompt = s.firstPrompt || \"\";\n  const promptTd = el(\"td\", \"col-prompt\" + (prompt ? \"\" : \" empty\"), prompt || \"(no prompt)\");\n  promptTd.title = prompt;\n  tr.appendChild(promptTd);\n\n  tr.appendChild(el(\"td\", \"col-cwd\", s.cwdName || \"(unknown)\"));\n\n  const scaleTd = el(\"td\", \"col-scale\");\n  scaleTd.appendChild(document.createTextNode(s.interactionCount || 0));\n  scaleTd.appendChild(el(\"span\", \"scale-sep\", \"·\"));\n  scaleTd.appendChild(document.createTextNode(s.turnCount || 0));\n  scaleTd.appendChild(el(\"span\", \"scale-sep\", \"·\"));\n  scaleTd.appendChild(document.createTextNode(s.toolCount || 0));\n  tr.appendChild(scaleTd);\n\n  tr.appendChild(el(\"td\", \"num\", fmtTokens(totalTokens(s))));\n  tr.appendChild(el(\"td\", \"num\", fmtMoney(s.totalCost)));\n  tr.appendChild(el(\"td\", \"num\", fmtMs(s.durationMs)));\n  return tr;\n}\n\nfunction render() {\n  // 更新 chips 激活态\n  document.querySelectorAll(\"#chips .chip\").forEach(c => {\n    const key = c.dataset.project === \"\" ? null : c.dataset.project;\n    c.classList.toggle(\"active\", key === state.project);\n  });\n\n  // 排序指示\n  document.querySelectorAll(\".dash-table th.sortable\").forEach(th => {\n    if (th.dataset.sort === state.sortKey) {\n      th.dataset.dir = state.sortDir;\n      const ind = th.querySelector(\".sort-ind\");\n      if (ind) ind.textContent = state.sortDir === \"asc\" ? \"↑\" : \"↓\";\n    } else {\n      delete th.dataset.dir;\n      const ind = th.querySelector(\".sort-ind\");\n      if (ind) ind.textContent = \"↕\";\n    }\n  });\n\n  let rows = SUMMARIES.filter(s => matchProject(s) && matchQuery(s));\n  rows.sort((a, b) => {\n    const av = sortKeyValue(a, state.sortKey);\n    const bv = sortKeyValue(b, state.sortKey);\n    if (av < bv) return state.sortDir === \"asc\" ? -1 : 1;\n    if (av > bv) return state.sortDir === \"asc\" ? 1 : -1;\n    return 0;\n  });\n\n  const tbody = document.getElementById(\"dash-tbody\");\n  tbody.innerHTML = \"\";\n  rows.forEach(s => tbody.appendChild(renderRow(s)));\n\n  document.getElementById(\"dash-empty\").style.display = rows.length ? \"none\" : \"\";\n}\n\nwindow.addEventListener(\"DOMContentLoaded\", () => {\n  buildChips();\n\n  document.getElementById(\"dash-search\").addEventListener(\"input\", (e) => {\n    state.query = e.target.value.toLowerCase().trim();\n    render();\n  });\n\n  document.querySelectorAll(\".dash-table th.sortable\").forEach(th => {\n    th.addEventListener(\"click\", () => {\n      const k = th.dataset.sort;\n      if (state.sortKey === k) {\n        state.sortDir = state.sortDir === \"asc\" ? \"desc\" : \"asc\";\n      } else {\n        state.sortKey = k;\n        // 数值列默认 desc，字母列默认 asc\n        state.sortDir = th.classList.contains(\"num\") ? \"desc\" : \"asc\";\n      }\n      render();\n    });\n  });\n\n  render();\n});\n", "dash_html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<title>Pi Trace · Dashboard</title>\n<style>{css}</style>\n</head>\n<body>\n<div class=\"dash-topbar\">\n  <span class=\"topbar-icon\">⚡</span>\n  <span class=\"topbar-title\">Pi Trace · Dashboard</span>\n  <div class=\"dash-search\"><input type=\"text\" id=\"dash-search\" placeholder=\"搜索 prompt / session / 项目\" /></div>\n</div>\n<div class=\"dash-kpi\">\n  <div class=\"kpi-row\">\n    <div class=\"kpi-label\">本周</div>\n    <div class=\"kpi-card\"><small>Sessions</small><strong>{week_sessions}</strong></div>\n    <div class=\"kpi-card\"><small>Prompts</small><strong>{week_prompts}</strong></div>\n    <div class=\"kpi-card\"><small>Tool Errors</small><strong>{week_tool_errs}</strong></div>\n    <div class=\"kpi-card\"><small>Tokens</small><strong>{week_tokens}</strong></div>\n    <div class=\"kpi-card\"><small>Cost</small><strong>{week_cost}</strong></div>\n    <div class=\"kpi-card\"><small>Duration</small><strong>{week_dur}</strong></div>\n  </div>\n  <div class=\"kpi-row\">\n    <div class=\"kpi-label\">全局</div>\n    <div class=\"kpi-card\"><small>Sessions</small><strong>{all_sessions}</strong></div>\n    <div class=\"kpi-card\"><small>Prompts</small><strong>{all_prompts}</strong></div>\n    <div class=\"kpi-card\"><small>Tool Errors</small><strong>{all_tool_errs}</strong></div>\n    <div class=\"kpi-card\"><small>Tokens</small><strong>{all_tokens}</strong></div>\n    <div class=\"kpi-card\"><small>Cost</small><strong>{all_cost}</strong></div>\n  </div>\n</div>\n<div class=\"dash-chips\" id=\"chips\"></div>\n<div class=\"dash-table-wrap\">\n  <table class=\"dash-table\" id=\"dash-table\">\n    <thead>\n      <tr>\n        <th class=\"col-status\"></th>\n        <th class=\"sortable\" data-sort=\"startedAt\" data-dir=\"desc\">时间 <span class=\"sort-ind\">↓</span></th>\n        <th class=\"sortable\" data-sort=\"id\">Session <span class=\"sort-ind\">↕</span></th>\n        <th>提问</th>\n        <th class=\"sortable\" data-sort=\"cwdName\">项目 <span class=\"sort-ind\">↕</span></th>\n        <th title=\"Prompts · Turns · Tools\">Prompts · Turns · Tools</th>\n        <th class=\"sortable num\" data-sort=\"tokens\">Tokens <span class=\"sort-ind\">↕</span></th>\n        <th class=\"sortable num\" data-sort=\"cost\">Cost <span class=\"sort-ind\">↕</span></th>\n        <th class=\"sortable num\" data-sort=\"dur\">时长 <span class=\"sort-ind\">↕</span></th>\n      </tr>\n    </thead>\n    <tbody id=\"dash-tbody\"></tbody>\n  </table>\n  <div class=\"dash-empty\" id=\"dash-empty\" style=\"display:none\">没有匹配的 session</div>\n</div>\n<script>{js}</script>\n</body>\n</html>\n"}