left0ver's picture
Publish com.left0ver.agentcade 0.1.0
ad88834 verified
Raw History Blame Contribute Delete
65.4 kB
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agentcade</title>
<link rel="icon" href="assets/logo.svg">
<style>
:root {
color-scheme: light dark;
--bg: #ffffff; --panel: #f7f7f8; --border: #e4e4e7; --text: #1c1c1f; --muted: #777783;
--hover: #f1f1f3; --selected: #e8f0ff; --accent: #2f62e8; --error: #c9302c; --ok: #19874a;
--user: #2f62e8; --main-tool: #7446d4; --game-tool: #0c8585; --a2a: #a86400; --reply: #19874a; --device: #b0326b;
--bubble: #edf2ff; --bubble-text: #14213d; --shadow: 0 6px 24px rgb(15 23 42 / .08);
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #141416; --panel: #1a1a1d; --border: #2a2a2f; --text: #e6e6ea; --muted: #8b8b97;
--hover: #202024; --selected: #1c2740; --accent: #7ea4ff; --error: #ff6f66; --ok: #4dc480;
--user: #7ea4ff; --main-tool: #b294ff; --game-tool: #4fcfcf; --a2a: #e0a340; --reply: #4dc480; --device: #f28ab5;
--bubble: #222e4a; --bubble-text: #e8eeff; --shadow: 0 6px 24px rgb(0 0 0 / .4);
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { height: 100%; margin: 0; }
body {
display: flex; flex-direction: column; background: var(--bg); color: var(--text);
font: 13px/20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif;
}
button { font: inherit; color: inherit; }
.top {
display: flex; align-items: center; gap: 16px; height: 52px; padding: 0 18px; flex: none;
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: center; gap: 9px; font-size: 14px; font-weight: 650; }
.logo { flex: none; width: 26px; height: 26px; }
.avatar { flex: none; width: 26px; height: 26px; border-radius: 50%; }
.tabs { display: flex; align-self: stretch; gap: 2px; margin-left: 10px; }
.tabs button {
position: relative; padding: 0 12px; border: 0; background: none; color: var(--muted);
font-size: 14px; cursor: pointer;
}
.tabs button:hover { color: var(--text); }
.tabs button[aria-selected="true"] { color: var(--accent); font-weight: 600; }
.tabs button[aria-selected="true"]::after {
content: ""; position: absolute; left: 12px; right: 12px; bottom: -1px; height: 2px;
border-radius: 2px 2px 0 0; background: var(--accent);
}
.status { display: flex; align-items: center; gap: 6px; color: var(--muted); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.status.live .dot { background: var(--ok); }
.status.down .dot { background: var(--error); }
.counts { color: var(--muted); font-variant-numeric: tabular-nums; }
.spacer { flex: 1; }
.button {
height: 28px; padding: 0 11px; border: 1px solid var(--border); border-radius: 7px;
background: var(--bg); cursor: pointer;
}
.button:hover { background: var(--hover); }
a.button { display: inline-flex; align-items: center; color: inherit; text-decoration: none; }
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.subbar {
display: flex; align-items: center; gap: 10px; height: 42px; padding: 0 12px; flex: none;
border-bottom: 1px solid var(--border); background: var(--panel);
}
.subbar input {
width: 240px; height: 28px; padding: 0 9px; border: 1px solid var(--border); border-radius: 7px;
background: var(--bg); color: inherit; font: inherit;
}
.split { flex: 1; display: flex; min-height: 0; }
.ledger {
position: relative; flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column;
}
.columns, .row {
display: grid; grid-template-columns: 52px 136px minmax(0, 1fr) 168px 104px; align-items: center;
}
.columns {
flex: none; height: 28px; padding: 0 12px; color: var(--muted); font-size: 12px;
border-bottom: 1px solid var(--border);
}
.columns span:last-child, .c-time { text-align: right; }
.scroll { flex: 1; overflow-y: auto; padding-bottom: 48px; }
.empty { padding: 56px 16px; text-align: center; color: var(--muted); }
.group-head {
display: flex; align-items: center; gap: 8px; width: 100%; height: 30px; padding: 0 12px;
border: 0; border-top: 1px solid var(--border); border-left: 3px solid var(--accent);
background: var(--panel); text-align: left; cursor: pointer;
position: sticky; top: 0; z-index: 1;
}
.group:first-child .group-head { border-top: 0; }
.group.between .group-head, .group.orphan .group-head { border-left-color: var(--border); }
.chevron { width: 10px; color: var(--muted); transition: transform .12s; }
.group.collapsed .chevron { transform: rotate(-90deg); }
.group.collapsed .group-body { display: none; }
.group-title { font-weight: 600; }
.group-time, .group-state, .group-text { color: var(--muted); font-variant-numeric: tabular-nums; }
.group-text { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.group:not(.collapsed) .group-text { visibility: hidden; }
.group-state { margin-left: auto; white-space: nowrap; }
.divider {
padding: 6px 12px; text-align: center; font-size: 12px; color: var(--muted);
border-top: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
}
.row { min-height: 30px; padding: 0 12px; cursor: pointer; outline: none; }
.row:hover { background: var(--hover); }
.row.selected { background: var(--selected); box-shadow: inset 2px 0 0 var(--accent); }
.c-index { color: var(--muted); font: 12px/20px var(--mono); }
.badge {
display: inline-block; padding: 0 6px; border-radius: 4px; font-size: 11px; line-height: 18px;
white-space: nowrap; color: var(--kind);
background: color-mix(in srgb, var(--kind) 13%, transparent);
}
.kind-user { --kind: var(--user); }
.kind-reply { --kind: var(--reply); }
.kind-device { --kind: var(--device); }
.kind-main-tool { --kind: var(--main-tool); }
.kind-game-tool { --kind: var(--game-tool); }
.kind-a2a-out, .kind-a2a-in { --kind: var(--a2a); }
.c-summary { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.c-kind { position: relative; align-self: stretch; display: flex; align-items: center; }
.depth-1 .c-kind { padding-left: 18px; }
.depth-1 .c-kind::before {
content: ""; position: absolute; left: 6px; top: 0; bottom: 0; border-left: 1px solid var(--border);
}
.name { font-family: var(--mono); font-weight: 600; }
.args, .dim, .arrow { color: var(--muted); }
.args { font-family: var(--mono); }
.err { color: var(--error); }
.c-time { color: var(--muted); font: 12px/20px var(--mono); white-space: nowrap; }
.c-line { position: relative; align-self: stretch; margin: 0 12px; }
.c-line::before {
content: ""; position: absolute; left: 0; right: 0; top: 50%; border-top: 1px solid var(--border);
}
.bar {
position: absolute; top: 50%; height: 6px; min-width: 2px; margin-top: -3px; border-radius: 2px;
background: var(--kind);
}
.bar.point { width: 2px; height: 10px; margin-top: -5px; }
.bar.open { opacity: .45; }
.c-time.failed { color: var(--error); }
.row.fresh { animation: fresh 1.6s ease-out; }
@keyframes fresh { from { background: color-mix(in srgb, var(--accent) 22%, transparent); } }
.running { color: var(--accent); }
.running::before {
content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 6px;
border-radius: 50%; background: currentColor; animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .25; } }
@media (prefers-reduced-motion: reduce) { .running::before, .row.fresh { animation: none; } }
.tail {
position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); height: 28px;
padding: 0 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg);
box-shadow: 0 2px 10px rgb(0 0 0 / .14); cursor: pointer;
}
.inspector {
flex: none; width: 38%; min-width: 340px; max-width: 720px; overflow-y: auto;
border-left: 1px solid var(--border); background: var(--panel);
}
.inspector .placeholder { padding: 56px 16px; text-align: center; color: var(--muted); }
.i-head { display: flex; align-items: center; gap: 8px; padding: 12px 16px 4px; }
.i-title { font-weight: 600; overflow-wrap: anywhere; }
.i-status { margin-left: auto; white-space: nowrap; color: var(--muted); }
.i-status.error { color: var(--error); }
.facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 14px;
margin: 0; padding: 8px 16px 12px; }
.facts dt { color: var(--muted); }
.facts dd { margin: 0; font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
.section { border-top: 1px solid var(--border); padding: 10px 16px 12px; }
.section h3 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); }
.block {
margin: 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg); font: 12px/19px var(--mono); white-space: pre-wrap;
overflow-wrap: anywhere;
}
.block.text { font-family: inherit; font-size: 13px; }
.block.error { color: var(--error); }
.j-entry, .j-node > .j-children { padding-left: 14px; }
.j-node > summary { cursor: pointer; }
.j-key { color: var(--main-tool); }
.j-string { color: var(--ok); }
.j-number, .j-boolean { color: var(--accent); }
.j-null, .j-count { color: var(--muted); }
.more { margin-left: 6px; padding: 0 4px; border: 0; background: none; color: var(--accent);
cursor: pointer; font: inherit; }
.links button {
display: flex; align-items: center; gap: 8px; width: 100%; padding: 3px 0; border: 0;
background: none; text-align: left; cursor: pointer;
}
.links button > span:last-child { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.links button:hover > span:last-child { color: var(--accent); }
.links .dim { flex: none; width: 64px; font: 12px/20px var(--mono); }
.drawings { display: flex; flex-wrap: wrap; gap: 12px; }
.drawings figure { margin: 0; }
.drawings figcaption { margin-top: 4px; font-size: 12px; color: var(--muted); }
.drawing { display: block; width: 200px; height: 200px; border-radius: 8px; background: #000; }
.drawing polyline { fill: none; stroke: #fff; stroke-linecap: round; stroke-linejoin: round; }
.raw > summary { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--muted); }
.raw .more { float: right; margin: 0; }
.raw .block { margin-top: 8px; }
@media (max-width: 1200px) {
.columns, .row { grid-template-columns: 52px 136px minmax(0, 1fr) 104px; }
.columns .line-head, .c-line { display: none; }
}
@media (max-width: 900px) {
.split { flex-direction: column; }
.inspector { width: auto; min-width: 0; max-width: none; height: 46%;
border-left: 0; border-top: 1px solid var(--border); }
}
/* 对话视图 */
.chat { position: relative; }
.chat-scroll { flex: 1; overflow-y: auto; }
.thread {
display: flex; flex-direction: column; max-width: 800px; min-height: 100%; margin: 0 auto;
padding: 24px 24px 128px; font-size: 15px; line-height: 24px;
}
.hello { margin: auto; padding: 24px; text-align: center; color: var(--muted); font-size: 14px; }
.hello .avatar { width: 64px; height: 64px; margin-bottom: 14px; }
.hello h2 { margin: 0 0 6px; font-size: 19px; font-weight: 600; color: var(--text); }
.hello p { margin: 0; }
.note { align-self: center; margin: 18px 0 4px; font-size: 12px; line-height: 18px; color: var(--muted); }
.meta {
display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--muted);
font-variant-numeric: tabular-nums;
}
.msg { display: flex; gap: 12px; margin-top: 20px; }
.msg.user { justify-content: flex-end; }
.msg.user .body {
display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 0;
max-width: min(78%, 560px);
}
.bubble {
max-width: 100%; padding: 9px 15px; border-radius: 18px 5px 18px 18px;
background: var(--bubble); color: var(--bubble-text); white-space: pre-wrap; overflow-wrap: anywhere;
}
.msg .avatar { width: 36px; height: 36px; }
.msg.bot .avatar { margin-top: -1px; }
.msg.user .avatar { margin-top: 3px; }
.msg.bot .body { flex: 1; min-width: 0; }
.who { display: flex; align-items: center; gap: 8px; min-height: 18px; margin-bottom: 2px; font-size: 13px; line-height: 18px; }
.who b { font-weight: 600; }
.tag {
padding: 0 7px; border-radius: 9px; font-size: 11px; line-height: 18px; white-space: nowrap;
color: var(--game-tool); background: color-mix(in srgb, var(--game-tool) 13%, transparent);
}
.flow { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
.say { white-space: pre-wrap; overflow-wrap: anywhere; }
.say.failed .text { color: var(--muted); }
.say .state { margin-left: 8px; font-size: 12px; color: var(--error); }
.eq, .typing { display: none; align-items: center; gap: 2px; height: 12px; vertical-align: middle; }
.eq { margin-left: 6px; color: var(--ok); }
.say.speaking .eq, .say.pending .typing { display: inline-flex; }
.typing { gap: 4px; color: var(--muted); }
.typing i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: pulse 1.1s ease-in-out infinite; }
.typing i:nth-child(2) { animation-delay: .18s; }
.typing i:nth-child(3) { animation-delay: .36s; }
.eq i, .wave i { width: 3px; border-radius: 2px; background: currentColor; }
.eq i { height: 4px; animation: wave .9s ease-in-out infinite; }
.eq i:nth-child(2), .wave i:nth-child(2) { animation-delay: .15s; }
.eq i:nth-child(3), .wave i:nth-child(3) { animation-delay: .3s; }
.wave i:nth-child(4) { animation-delay: .45s; }
@keyframes wave { 0%, 100% { height: 4px; } 50% { height: 12px; } }
.flow > .steps { margin: 2px 0; }
.steps {
max-width: 100%; border: 1px solid var(--border); border-radius: 12px;
background: var(--panel); font-size: 13px; line-height: 20px; overflow: hidden;
}
.steps-head {
display: flex; align-items: center; gap: 8px; width: 100%; min-height: 34px; padding: 0 12px;
border: 0; background: none; text-align: left; cursor: pointer;
}
.steps-head:hover { background: var(--hover); }
.steps-title { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--muted); }
.steps-title b { font: 500 12px/20px var(--mono); color: var(--text); }
.steps-time { margin-left: auto; padding-left: 14px; font: 12px/20px var(--mono); color: var(--muted); white-space: nowrap; }
.steps .chevron { transform: rotate(-90deg); }
.steps.open .chevron { transform: none; }
.steps-list { display: none; padding: 4px; border-top: 1px solid var(--border); }
.steps.open .steps-list { display: block; }
.step {
display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap: 8px; align-items: center;
padding: 4px 8px;
}
.step .pip { justify-self: center; width: 7px; height: 7px; border-radius: 50%; background: var(--kind); }
.step.running .pip { animation: pulse 1.1s ease-in-out infinite; }
.step .what { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.step .took { font: 12px/20px var(--mono); color: var(--muted); }
.step.error .took { color: var(--error); }
.mark { flex: none; width: 14px; height: 14px; }
.mark.ok { color: var(--ok); }
.mark.error { color: var(--error); }
.mark.cancelled, .mark.lost { color: var(--muted); }
.mark.running {
border: 2px solid color-mix(in srgb, var(--accent) 25%, transparent); border-top-color: var(--accent);
border-radius: 50%; animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.act {
display: flex; align-items: center; gap: 6px; min-height: 34px; margin: 2px 0; padding: 0 12px;
border: 1px solid var(--border); border-radius: 12px; background: var(--panel);
font-size: 13px; line-height: 20px; color: var(--muted);
}
.act b { font-weight: 600; color: var(--text); }
.face polyline { fill: none; stroke: #fff; stroke-linecap: round; stroke-linejoin: round; }
.avatar.nod { animation: nod .9s ease-in-out; }
.avatar.shake { animation: shake .9s ease-in-out; }
.msg.bot:hover .avatar.nod { animation: nod-again .9s ease-in-out; }
.msg.bot:hover .avatar.shake { animation: shake-again .9s ease-in-out; }
@keyframes nod { 20%, 60% { transform: translateY(4px) rotate(4deg); } 40%, 80% { transform: none; } }
@keyframes nod-again { 20%, 60% { transform: translateY(4px) rotate(4deg); } 40%, 80% { transform: none; } }
@keyframes shake { 15%, 55% { transform: translateX(-4px) rotate(-7deg); } 35%, 75% { transform: translateX(4px) rotate(7deg); } }
@keyframes shake-again { 15%, 55% { transform: translateX(-4px) rotate(-7deg); } 35%, 75% { transform: translateX(4px) rotate(7deg); } }
.sketch { margin: 4px 0; }
.sketch .drawing { width: 220px; height: 220px; border-radius: 18px; box-shadow: var(--shadow); }
.sketch figcaption { margin-top: 6px; font-size: 12px; line-height: 18px; color: var(--muted); }
.dock {
position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: center;
padding: 36px 16px 20px; pointer-events: none;
background: linear-gradient(to top, var(--bg) 52%, transparent);
}
.voice {
display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 18px 0 15px;
border: 1px solid var(--border); border-radius: 20px; background: var(--bg); box-shadow: var(--shadow);
color: var(--muted); font-size: 13px; pointer-events: auto; transition: color .2s, border-color .2s;
}
.wave { display: flex; align-items: center; gap: 3px; width: 21px; height: 14px; }
.wave i { height: 4px; }
.voice.hearing { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.voice.speaking { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, var(--border)); }
.voice.hearing .wave i, .voice.speaking .wave i { animation: wave .9s ease-in-out infinite; }
.voice.thinking { color: var(--text); }
.voice.thinking .wave i { width: 4px; border-radius: 50%; animation: pulse 1.1s ease-in-out infinite; }
.voice.offline .wave i { opacity: .4; }
.chat .tail { bottom: 76px; }
@media (max-width: 640px) {
.top { gap: 10px; padding: 0 12px; }
.brand b, .counts { display: none; }
.thread { padding: 16px 14px 120px; }
}
@media (prefers-reduced-motion: reduce) {
.eq i, .wave i, .typing i, .mark.running, .step.running .pip, .avatar { animation: none !important; }
}
</style>
</head>
<body>
<svg hidden>
<symbol id="mark-ok" viewBox="0 0 14 14">
<path d="M3 7.4l2.6 2.6L11 4.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="mark-error" viewBox="0 0 14 14">
<path d="M7 3.2v4.6M7 10.4v.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</symbol>
<symbol id="mark-stop" viewBox="0 0 14 14">
<path d="M4 7h6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</symbol>
</svg>
<header class="top">
<div class="brand"><img class="logo" src="assets/logo.svg" alt=""><b>Agentcade</b></div>
<nav class="tabs" role="tablist">
<button id="tab-chat" type="button" role="tab" aria-controls="view-chat" aria-selected="true">对话</button>
<button id="tab-trajectory" type="button" role="tab" aria-controls="view-trajectory" aria-selected="false">轨迹</button>
</nav>
<div class="spacer"></div>
<div class="counts" id="counts"></div>
<div class="status" id="status"><i class="dot"></i><span>连接中</span></div>
<button class="button" id="clear" type="button">清空</button>
<a class="button" href="settings">设置</a>
</header>
<main class="view chat" id="view-chat" role="tabpanel" aria-labelledby="tab-chat">
<div class="chat-scroll" id="chat-scroll">
<div class="thread" id="thread">
<div class="hello" id="hello">
<img class="avatar" src="assets/avatar.svg" alt="">
<h2>对小懒说句话试试</h2>
<p>你们的对话会实时出现在这里,包括它用了哪些工具。</p>
</div>
</div>
</div>
<button class="tail" id="chat-tail" type="button" hidden>↓ 回到末尾</button>
<div class="dock"><div class="voice offline" id="voice"><span class="wave"><i></i><i></i><i></i><i></i></span><span>连接中</span></div></div>
</main>
<main class="view" id="view-trajectory" role="tabpanel" aria-labelledby="tab-trajectory" hidden>
<div class="subbar">
<input id="search" type="search" placeholder="搜索记录" aria-label="搜索记录">
<button class="button" id="collapse" type="button">收起所有轮次</button>
</div>
<div class="split">
<section class="ledger">
<div class="columns"><span>#</span><span>类型</span><span>内容</span><span class="line-head">时间线</span><span>时间</span></div>
<div class="scroll" id="scroll">
<div class="empty" id="empty">等待用户说话…</div>
</div>
<button class="tail" id="tail" type="button" hidden>↓ 回到末尾</button>
</section>
<aside class="inspector" id="inspector"></aside>
</div>
</main>
<script>
const $ = (id) => document.getElementById(id);
const KINDS = {
user: "用户", reply: "主 Agent · 回复", "main-tool": "主 Agent · 工具", "game-tool": "游戏 Agent · 工具",
"a2a-out": "A2A →", "a2a-in": "A2A ←", device: "设备执行",
};
const STATUS = { running: "运行中", ok: "已完成", error: "失败", cancelled: "已取消", lost: "中断" };
const outcome = (payload) => (payload.ok ? "ok" : payload.cancelled ? "cancelled" : "error");
const LIMIT = 3000;
const rowOf = new WeakMap();
const state = {
runId: null, cursor: "", turnCount: 0, rowCount: 0, groups: [], rows: [], selected: null, follow: true,
collapsed: false,
turns: new Map(), calls: new Map(), requests: new Map(), games: new Map(),
};
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
const clock = (seconds, precise) => {
const date = new Date(seconds * 1000);
const text = date.toLocaleTimeString("zh-CN", { hour12: false });
return precise ? `${text}.${String(date.getMilliseconds()).padStart(3, "0")}` : text;
};
const millis = (value) => `${Math.round(value).toLocaleString("en-US")} ms`;
const short = (text, limit = 80) => (text.length > limit ? `${text.slice(0, limit)}…` : text);
const gameName = (id) => state.games.get(id) || id;
const actorName = (actor) => ({ user: "用户", main: "主 Agent" })[actor] || `${gameName(actor)}(${actor})`;
function argumentsText(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return `(${short(JSON.stringify(value) ?? "")})`;
return `(${Object.entries(value).map(([key, item]) => `${key}=${short(JSON.stringify(item), 48)}`).join(", ")})`;
}
function resultText(result) {
if (result === null || result === undefined) return "";
if (typeof result !== "object") return String(result);
if (Array.isArray(result)) return `${result.length} 项`;
if (result.message) return result.message;
if (Array.isArray(result.messages) && result.messages.length) return result.messages.join(" ");
const parts = [];
if (result.delivered) parts.push("已送达");
if (result.outcome) parts.push(result.outcome);
if (result.phase) parts.push(result.phase);
return parts.length ? parts.join(" · ") : short(JSON.stringify(result));
}
function exchangeText(data) {
if (data.cancelled) return [["已取消", "dim"]];
if (data.error) return [["失败 · ", "err"], [data.error, "err"]];
const result = data.result || {};
const parts = [data.final ? "最后一条" : "中间回复"];
if (result.session === "ended") parts.push("本局结束");
return [[parts.join(" · "), "dim"], [result.text ? ` · ${result.text}` : "", ""]];
}
function summary(row) {
const data = row.data;
if (row.kind === "user" || row.kind === "reply") return [[data.text, ""]];
if (row.kind === "device") {
const output = data.output;
const parts = [output.text, output.paths?.length ? "绘制图案" : "", output.expression,
output.gesture, output.clear ? "清屏" : "", output.progress_seconds ? "显示进度" : ""].filter(Boolean);
return [[parts.join(" · "), ""], [data.error ? ` · ${data.error}` : "", "err"]];
}
if (row.kind === "a2a-in") return exchangeText(data);
if (row.kind === "a2a-out") {
const request = data.request || {};
const parts = [data.game_name, request.kind];
return [[parts.join(" · "), ""], [request.text ? ` · “${request.text}”` : "", "dim"]];
}
const parts = row.kind === "game-tool" ? [[`${gameName(row.actor)} · `, "dim"]] : [];
parts.push([data.tool, "name"], [argumentsText(data.arguments), "args"]);
if (row.status === "ok") parts.push([" → ", "arrow"], [resultText(data.result), ""]);
if (row.status === "error") parts.push([" → ", "arrow"], [data.error, "err"]);
if (row.status === "cancelled") parts.push([" → ", "arrow"], ["已取消", "dim"]);
return parts;
}
function title(row) {
const data = row.data;
if (row.kind === "user") return "用户输入";
if (row.kind === "reply") return "主 Agent 回复";
if (row.kind === "device") return `${actorName(row.actor)} · 设备执行`;
if (row.kind === "a2a-out") return `${data.game_name} · ${data.request?.kind ?? ""}`;
if (row.kind === "a2a-in") {
return data.cancelled ? "请求已取消" : data.error ? "请求失败" : data.final ? "最后一条回复" : "中间回复";
}
return row.kind === "game-tool" ? `${gameName(row.actor)} · ${data.tool}` : data.tool;
}
function timeCell(row) {
if (row.status === "running") return ["运行中", "c-time running"];
if (row.status === "lost") return ["中断", "c-time"];
if (row.duration !== null) return [millis(row.duration), row.status === "error" ? "c-time failed" : "c-time"];
if (row.offset !== null) return [`+${millis(row.offset)}`, "c-time"];
// 用户输入一行显示从开始说话到识别完成的耗时,发生时刻已经在轮次标题上。
if (row.kind === "user" && row.data.spoken_at) return [millis((row.time - row.data.spoken_at) * 1000), "c-time"];
return [clock(row.time), "c-time"];
}
function addGroup(kind, time) {
const group = { kind, time, rows: [], index: kind === "turn" ? ++state.turnCount : null };
group.node = el("section", `group ${kind}`);
const head = el("button", "group-head");
head.type = "button";
const label = { turn: `第 ${group.index} 轮`, between: "轮次之间", orphan: "更早的轮次" }[kind];
group.state = el("span", "group-state");
group.text = el("span", "group-text");
head.append(el("span", "chevron", "▾"), el("span", "group-title", label),
el("span", "group-time", clock(time)), group.text, group.state);
head.addEventListener("click", () => group.node.classList.toggle("collapsed"));
group.body = el("div", "group-body");
group.node.append(head, group.body);
if (state.collapsed) group.node.classList.add("collapsed");
$("empty").hidden = true;
$("scroll").append(group.node);
state.groups.push(group);
return group;
}
function groupFor(event) {
if (event.turn_id) {
let group = state.turns.get(event.turn_id);
if (!group) {
group = addGroup(event.type === "user.input" ? "turn" : "orphan", event.time);
state.turns.set(event.turn_id, group);
}
return group;
}
const last = state.groups[state.groups.length - 1];
return last && last.kind === "between" ? last : addGroup("between", event.time);
}
function addRow(event, spec) {
const group = spec.group || groupFor(event);
const row = {
kind: spec.kind, depth: spec.depth || 0, status: spec.status || "none", data: spec.data,
duration: null, offset: spec.offset ?? null, time: event.time, actor: event.actor,
id: spec.id ?? null, parent: spec.parent ?? null,
events: [event], group, index: ++state.rowCount,
};
// 刚发生的记录短暂高亮,方便看到较晚返回、插入到前面轮次的结果。
const fresh = Date.now() / 1000 - event.time < 3 ? " fresh" : "";
row.node = el("div", `row kind-${row.kind} depth-${row.depth}${fresh}`);
row.node.tabIndex = -1;
rowOf.set(row.node, row);
const kind = el("span", "c-kind");
kind.append(el("span", "badge", KINDS[row.kind]));
row.summary = el("span", "c-summary");
row.timing = el("span", "c-time");
row.bar = el("i", "bar");
const line = el("span", "c-line");
line.append(row.bar);
row.node.append(el("span", "c-index", `#${row.index}`), kind, row.summary, line, row.timing);
row.node.addEventListener("click", () => select(row));
group.rows.push(row);
state.rows.push(row);
group.body.append(row.node);
paint(row);
trim();
if (row.parent && state.selected?.id === row.parent) inspect(state.selected);
if (group === state.groups[state.groups.length - 1]) followTail();
return row;
}
function followTail() {
// 回放大量记录时每帧只滚动一次,避免逐条触发布局计算。
if (state.scrolling) return;
state.scrolling = true;
requestAnimationFrame(() => {
state.scrolling = false;
if (state.follow) $("scroll").scrollTop = $("scroll").scrollHeight;
});
}
function filter(rows) {
const query = $("search").value.trim().toLowerCase();
for (const row of rows) row.node.hidden = Boolean(query) && !row.text.includes(query);
for (const group of state.groups) {
group.node.hidden = Boolean(query) && group.rows.every((row) => row.node.hidden);
}
}
function paint(row) {
row.summary.replaceChildren(...summary(row).map(([text, name]) => el("span", name, text)));
row.summary.title = row.summary.textContent;
row.text = `${row.summary.textContent} ${JSON.stringify(row.events)}`.toLowerCase();
filter([row]);
[row.timing.textContent, row.timing.className] = timeCell(row);
const running = row.group.rows.filter((item) => item.status === "running").length;
// 一轮的耗时从用户说完算到这一轮最后一条记录。
const ended = Math.max(...row.group.rows.map((item) => item.events[item.events.length - 1].time));
const elapsed = row.group.kind === "turn" && row.group.rows.length > 1
? ` · ${millis((ended - row.group.time) * 1000)}` : "";
row.group.state.textContent = running ? "运行中" : `${row.group.rows.length} 条${elapsed}`;
row.group.state.className = running ? "group-state running" : "group-state";
timeline(row.group, ended);
if (row.kind === "user") row.group.text.textContent = row.data.text;
const turns = state.groups.filter((group) => group.kind === "turn").length;
$("counts").textContent = `${turns} 轮 · ${state.rows.length} 条记录`;
if (state.selected === row) inspect(row);
}
function timeline(group, ended) {
// 每一行在本轮时间范围内的位置:调用和请求画成时间段,其余记录画成时间点。
const span = Math.max(ended - group.time, 0.001);
for (const row of group.rows) {
const start = Math.max(row.time - group.time, 0);
const spanned = ["main-tool", "game-tool", "a2a-out"].includes(row.kind);
const end = row.duration === null ? ended - group.time : start + row.duration / 1000;
row.bar.className = spanned ? (row.duration === null ? "bar open" : "bar") : "bar point";
row.bar.style.left = `${Math.min(start / span, 1) * 100}%`;
row.bar.style.width = spanned ? `${Math.max(Math.min(end, span) - start, 0) / span * 100}%` : "";
}
}
const handlers = {
"device.output"(event) {
addRow(event, { kind: "device", depth: event.parent_id ? 1 : 0, parent: event.parent_id, data: event.payload,
status: event.payload.completed ? "ok" : outcome(event.payload) });
},
"user.input"(event) {
addRow(event, { kind: "user", data: event.payload });
},
"agent.reply"(event) {
const group = groupFor(event);
addRow(event, {
kind: "reply", group, data: event.payload,
offset: group.kind === "turn" ? (event.time - group.time) * 1000 : null,
});
},
"tool.call"(event) {
const row = addRow(event, {
kind: event.actor === "main" ? "main-tool" : "game-tool", depth: event.parent_id ? 1 : 0,
status: "running", data: { ...event.payload }, id: event.payload.call_id, parent: event.parent_id,
});
state.calls.set(event.payload.call_id, row);
},
"tool.result"(event) {
const row = state.calls.get(event.payload.call_id);
if (!row) return;
row.events.push(event);
row.status = outcome(event.payload);
row.duration = event.payload.duration_ms;
Object.assign(row.data, {
result: event.payload.result, error: event.payload.error, cancelled: event.payload.cancelled,
});
paint(row);
},
"a2a.request"(event) {
state.games.set(event.payload.game_id, event.payload.game_name);
const row = addRow(event, {
kind: "a2a-out", depth: event.parent_id ? 1 : 0, status: "running", data: { ...event.payload },
id: event.payload.request_id, parent: event.parent_id,
});
state.requests.set(event.payload.request_id, row);
},
"a2a.update"(event) {
const origin = state.requests.get(event.payload.request_id);
addRow(event, {
kind: "a2a-in", depth: origin ? origin.depth : 1, group: origin?.group,
parent: event.payload.request_id,
offset: origin ? (event.time - origin.time) * 1000 : null,
data: { ...event.payload, final: false, game_name: origin?.data.game_name },
});
},
"a2a.result"(event) {
const origin = state.requests.get(event.payload.request_id);
if (origin) {
origin.events.push(event);
origin.status = outcome(event.payload);
origin.duration = event.payload.duration_ms;
paint(origin);
}
addRow(event, {
kind: "a2a-in", depth: origin ? origin.depth : 1, group: origin?.group,
parent: event.payload.request_id,
status: event.payload.ok ? "none" : outcome(event.payload), offset: event.payload.duration_ms,
data: { ...event.payload, final: true, game_name: origin?.data.game_name },
});
},
};
// 对话视图:同一批事件按聊天的样子显示。小懒的一次回应是一条消息:名字下面依次是用到的工具、
// 动作和画面、说的话,按发生的先后排列。
const chat = {
items: [], current: null, block: null, entries: new Map(), running: new Set(), unspoken: [],
waiting: new Map(), hearing: "", online: false, follow: true, game: "",
};
const SVG = "http://www.w3.org/2000/svg";
// 小懒的头像,以及头像里那块圆形屏幕的位置和底色(按头像文件 512 的画布算),表情画在这块屏幕上。
const AVATAR = { file: "assets/avatar.svg", x: 256, y: 258.8, r: 109, screen: "#14141C" };
const seconds = (value) => `${value.toFixed(1)} 秒`;
function symbol(id, className) {
const node = document.createElementNS(SVG, "svg");
node.setAttribute("class", className);
const use = document.createElementNS(SVG, "use");
use.setAttribute("href", `#${id}`);
node.append(use);
return node;
}
function chatFollow() {
if (chat.scrolling) return;
chat.scrolling = true;
requestAnimationFrame(() => {
chat.scrolling = false;
const view = $("chat-scroll");
chat.height = view.scrollHeight;
if (chat.follow) view.scrollTop = view.scrollHeight;
});
}
function chatAppend(node, time) {
$("hello").hidden = true;
if (!chat.items.length) {
const started = new Date(time * 1000).toLocaleString("zh-CN", {
month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false,
});
const note = el("div", "note", started);
$("thread").append(note);
chat.items.push(note);
}
$("thread").append(node);
chat.items.push(node);
while (chat.items.length > 300) chat.items.shift().remove();
chatFollow();
}
function userMessage(event) {
const node = el("div", "msg user");
const body = el("div", "body");
body.append(el("div", "bubble", event.payload.text), el("div", "meta", clock(event.time)));
node.append(body, Object.assign(el("img", "avatar"), { src: "assets/user.svg", alt: "" }));
chat.waiting.set(event.turn_id, event.time);
Object.assign(chat, { current: null, block: null });
chatAppend(node, event.time);
}
function botMessage(event) {
// 用户说话之前,小懒这边的内容都接在同一条消息里。
if (chat.current) return chat.current;
const node = el("div", "msg bot");
const message = {
node, tag: el("span", "tag"), used: el("span"), flow: el("div", "flow"),
avatar: Object.assign(el("img", "avatar"), { src: AVATAR.file, alt: "" }),
};
message.tag.hidden = true;
const meta = el("span", "meta");
meta.append(el("span", "", clock(event.time)), message.used);
const who = el("div", "who");
who.append(el("b", "", "小懒"), message.tag, meta);
const body = el("div", "body");
body.append(who, message.flow);
node.append(message.avatar, body);
chatAppend(node, event.time);
Object.assign(chat, { current: message, block: null });
return message;
}
function flowAppend(event, node) {
const message = botMessage(event);
message.flow.append(node);
chatFollow();
return message;
}
function fromGame(message, name) {
if (!name) return;
message.tag.textContent = name;
message.tag.hidden = false;
}
function say(event, spoken) {
const node = el("div", "say");
const item = {
node, chars: [...event.payload.text.trim()], body: el("span", "text"), mark: el("span", "state"),
};
item.text = item.chars.join("");
const typing = el("span", "typing");
typing.append(el("i"), el("i"), el("i"));
const playing = el("span", "eq");
playing.append(el("i"), el("i"), el("i"));
node.append(item.body, typing, playing, item.mark);
const message = flowAppend(event, node);
if (event.payload.role === "game_agent") fromGame(message, chat.game);
const asked = chat.waiting.get(event.turn_id);
if (asked !== undefined) {
if (!message.used.textContent) message.used.textContent = `用时 ${seconds(event.time - asked)}`;
chat.waiting.delete(event.turn_id);
}
if (spoken) {
finishSay(item, true);
} else {
// 每句话都等播报结束的记录来收尾,这样回看时不会把同一句话当成两条。
item.time = event.time;
chat.unspoken.push(item);
while (chat.unspoken.length > 20) finishSay(chat.unspoken.shift(), true);
// 文字等到开始播报时再跟着语音逐步显示;很久以前的记录直接显示全文。
if (Date.now() / 1000 - event.time > 30) item.body.textContent = item.text;
else node.classList.add("pending");
}
voice();
}
function startSay(event) {
const text = event.payload.text.trim();
const item = chat.unspoken.find((entry) => entry.text === text && !entry.started);
if (!item) return;
item.started = event.time;
const duration = Math.max(event.payload.duration_ms / 1000, 0.2);
item.until = item.started + duration + 3;
if (Date.now() / 1000 < item.until) item.node.classList.add("speaking");
item.node.classList.remove("pending");
const step = () => {
if (item.done) return;
const progress = (Date.now() / 1000 - item.started) / duration;
item.body.textContent = item.chars.slice(0, Math.ceil(item.chars.length * Math.min(progress, 1))).join("");
chatFollow();
if (progress < 1) item.frame = requestAnimationFrame(step);
};
step();
voice();
}
function finishSay(item, ok) {
item.done = true;
cancelAnimationFrame(item.frame);
item.body.textContent = item.text;
item.node.classList.remove("pending", "speaking");
if (!ok) {
item.node.classList.add("failed");
item.mark.textContent = "没有播出";
}
}
function spokenOutput(event) {
// 播报结束时设备会记录一条带文字的输出,用它把对应的那句话标成已经说完。
const text = event.payload.output.text.trim();
const index = chat.unspoken.findIndex((item) => item.text === text);
if (index < 0) {
if (event.payload.completed) say({ ...event, payload: { text, role: "assistant" } }, true);
return;
}
finishSay(chat.unspoken.splice(index, 1)[0], Boolean(event.payload.completed));
voice();
}
function stepBlock(event) {
const message = botMessage(event);
// 同一轮连着发生的步骤收在一个块里,中间说过话或做过动作之后另起一块。
if (chat.block?.turn === event.turn_id && chat.block.node === message.flow.lastElementChild) return chat.block;
const node = el("div", "steps");
const head = el("button", "steps-head");
head.type = "button";
const block = {
turn: event.turn_id, node, entries: [], started: event.time, ended: event.time,
holder: el("span", "mark"), title: el("span", "steps-title"), time: el("span", "steps-time"),
list: el("div", "steps-list"),
};
head.append(block.holder, block.title, block.time, el("span", "chevron", "▾"));
head.addEventListener("click", () => {
node.classList.toggle("open");
chatFollow();
});
node.append(head, block.list);
flowAppend(event, node);
chat.block = block;
return block;
}
function addStep(event, spec) {
const block = stepBlock(event);
const node = el("div", "step");
const entry = {
block, node, result: "", duration: null, what: el("span", "what"), took: el("span", "took"), ...spec,
};
node.append(el("i", "pip"), entry.what, entry.took);
block.entries.push(entry);
block.list.append(node);
block.ended = Math.max(block.ended, event.time);
if (spec.id) chat.entries.set(spec.id, entry);
paintStep(entry);
}
function finishStep(id, event, result) {
const entry = chat.entries.get(id);
if (!entry) return;
Object.assign(entry, { status: outcome(event.payload), duration: event.payload.duration_ms, result });
entry.block.ended = Math.max(entry.block.ended, event.time);
paintStep(entry);
}
function paintStep(entry) {
entry.node.className = `step kind-${entry.kind} ${entry.status}`;
const parts = [];
if (entry.who) parts.push(el("span", "dim", `${entry.who} · `));
parts.push(el("span", entry.tool ? "name" : "", entry.name));
if (entry.detail) parts.push(el("span", entry.tool ? "args" : "dim", entry.tool ? entry.detail : ` · ${entry.detail}`));
if (entry.result) {
parts.push(el("span", "arrow", " → "), el("span", entry.status === "error" ? "err" : "", entry.result));
}
entry.what.replaceChildren(...parts);
entry.what.title = entry.what.textContent;
entry.took.textContent = entry.status === "running" || entry.duration === null ? "" : millis(entry.duration);
chat.running[entry.status === "running" ? "add" : "delete"](entry);
paintBlock(entry.block);
voice();
}
function paintBlock(block) {
const running = block.entries.filter((entry) => entry.status === "running");
const status = running.length ? "running"
: block.entries.some((entry) => entry.status === "error") ? "error"
: block.entries.every((entry) => ["cancelled", "lost"].includes(entry.status)) ? "cancelled" : "ok";
block.status = status;
const icon = { ok: "mark-ok", error: "mark-error", cancelled: "mark-stop" }[status];
block.holder.replaceWith(block.holder = icon ? symbol(icon, `mark ${status}`) : el("span", "mark running"));
if (running.length) {
block.title.replaceChildren(el("span", "", running[running.length - 1].busy));
} else {
// 标题列出这几步用到的工具,没有工具时列出做了什么。
const tools = block.entries.filter((entry) => entry.tool);
const names = [...new Set((tools.length ? tools : block.entries).map((entry) => entry.name))];
block.title.replaceChildren(el("b", "", names.join(" · ")));
}
blockTime(block);
}
function blockTime(block) {
const elapsed = (block.status === "running" ? Date.now() / 1000 : block.ended) - block.started;
block.time.textContent = elapsed >= 0.05 ? seconds(elapsed) : "";
}
function brief(value) {
const text = argumentsText(value);
return text === "()" ? "" : text;
}
function face(hex) {
// 头像里有一块圆形屏幕:把机器人当时显示的表情画在这块屏幕上,盖住原来的眼睛。
const strokes = decodeVector(hex);
if (!strokes) return null;
const points = strokes.flatMap((stroke) => stroke.points);
const [xs, ys] = [points.map((point) => point[0]), points.map((point) => point[1])];
const [left, top] = [Math.min(...xs), Math.min(...ys)];
const [width, height] = [Math.max(...xs) - left, Math.max(...ys) - top];
// 表情缩放到占屏幕的七成多,线条加粗到小尺寸下也看得清。
const scale = AVATAR.r * 1.5 / Math.max(width, height, 1);
const node = document.createElementNS(SVG, "svg");
node.setAttribute("viewBox", "0 0 512 512");
node.setAttribute("class", "avatar face");
const picture = document.createElementNS(SVG, "image");
picture.setAttribute("href", AVATAR.file);
picture.setAttribute("width", 512);
picture.setAttribute("height", 512);
const screen = document.createElementNS(SVG, "circle");
for (const [name, value] of [["cx", AVATAR.x], ["cy", AVATAR.y], ["r", AVATAR.r], ["fill", AVATAR.screen]]) {
screen.setAttribute(name, value);
}
const group = document.createElementNS(SVG, "g");
group.setAttribute("transform", `translate(${AVATAR.x} ${AVATAR.y}) scale(${scale}) `
+ `translate(${-(left + width / 2)} ${-(top + height / 2)})`);
for (const stroke of strokes) {
const line = document.createElementNS(SVG, "polyline");
const dots = stroke.points.length === 1 ? [stroke.points[0], stroke.points[0]] : stroke.points;
line.setAttribute("points", dots.map((point) => point.join(",")).join(" "));
line.setAttribute("stroke-width", Math.max(stroke.width, 20 / scale));
group.append(line);
}
node.append(picture, screen, group);
return node;
}
function reaction(event) {
// 这条消息的头像换上机器人当时的表情并做一次点头或摇头,正文里用一个小框说明它做了什么。
const output = event.payload.output;
const done = event.payload.completed;
const parts = [
// 没有对应说法的新动作和新表情直接显示它的名字,页面不用跟着改。
{ nod: "点了点头", shake_head: "摇了摇头" }[output.gesture] || output.gesture,
{ happy: "很开心", sorry: "有点抱歉" }[output.expression] || output.expression,
].filter(Boolean);
const node = el("div", "act");
node.append(el("b", "", "小懒"), el("span", "", parts.join(",") + (done ? "" : "(没有做成)")));
const message = flowAppend(event, node);
if (!done) return;
const avatar = (event.payload.face && face(event.payload.face)) || message.avatar;
if (output.gesture) avatar.classList.add({ nod: "nod", shake_head: "shake" }[output.gesture]);
message.avatar.replaceWith(avatar);
message.avatar = avatar;
}
function sketch(event) {
const paths = event.payload.output.paths;
const figure = drawing(paths[paths.length - 1], `小懒在屏幕上画了一幅画,共 ${paths.length} 笔`);
if (!figure) return;
figure.className = "sketch";
flowAppend(event, figure);
}
const chatHandlers = {
"user.input"(event) {
chat.hearing = "";
userMessage(event);
voice();
},
"agent.reply"(event) {
say(event, false);
},
"speech.start"(event) {
startSay(event);
},
"tool.call"(event) {
const main = event.actor === "main";
addStep(event, {
id: event.payload.call_id, kind: main ? "main-tool" : "game-tool", tool: true, status: "running",
who: main ? "" : gameName(event.actor), name: event.payload.tool, detail: brief(event.payload.arguments),
busy: `正在调用 ${event.payload.tool}`,
});
},
"tool.result"(event) {
const payload = event.payload;
finishStep(payload.call_id, event,
payload.ok ? resultText(payload.result) : payload.cancelled ? "已取消" : payload.error);
},
"a2a.request"(event) {
const payload = event.payload;
chat.game = payload.game_name;
fromGame(botMessage(event), payload.game_name);
addStep(event, {
id: payload.request_id, kind: "a2a-out", status: "running", name: `交给${payload.game_name}`,
detail: { start: "开始游戏", message: "用户说的话", end: "结束游戏" }[payload.request?.kind],
busy: `正在等${payload.game_name}回复`,
});
},
"a2a.result"(event) {
const payload = event.payload;
finishStep(payload.request_id, event, payload.ok ? "" : payload.cancelled ? "已取消" : payload.error);
},
"device.output"(event) {
const payload = event.payload;
const output = payload.output;
if (output.text) return spokenOutput(event);
if (output.expression || output.gesture) return reaction(event);
// 游戏展示题目时带着完整的作画步骤;说完话把屏幕恢复成图案的记录由主 Agent 这边发出。
if (output.paths?.length && event.actor !== "main" && payload.completed) return sketch(event);
// 说完话之后恢复画面属于收尾,成功时只在轨迹里记录。
const name = output.progress_seconds ? "屏幕显示进度" : output.paths?.length ? "屏幕显示图案" : "屏幕恢复待机";
if (payload.completed && !output.progress_seconds) return;
addStep(event, {
kind: "device", status: payload.completed ? "ok" : outcome(payload), name,
result: payload.completed || payload.cancelled ? "" : payload.error,
});
},
};
function voice() {
const now = Date.now() / 1000;
// 一轮迟迟没有回复时不一直显示在想,可能是这句话不需要回复。
const waiting = [...chat.waiting.values()].some((asked) => now - asked < 120);
const [name, label] = !chat.online ? ["offline", "没有连上应用"]
: chat.hearing ? ["hearing", `正在听:${short(chat.hearing, 28)}`]
: chat.unspoken.some((item) => now < item.until) ? ["speaking", "小懒正在说…"]
: waiting || chat.unspoken.some((item) => !item.started && now - item.time < 60) ? ["thinking", "小懒在想…"]
: chat.running.size ? ["thinking", "小懒在准备…"]
: ["listening", "小懒在听,你可以直接说话"];
$("voice").className = `voice ${name}`;
$("voice").lastElementChild.textContent = label;
}
function chatReset() {
for (const node of chat.items) node.remove();
for (const item of chat.unspoken) finishSay(item, true);
Object.assign(chat, { items: [], current: null, block: null, unspoken: [] });
for (const name of ["entries", "running", "waiting"]) chat[name].clear();
$("hello").hidden = false;
voice();
}
function chatRestarted() {
for (const entry of [...chat.running]) {
entry.status = "lost";
paintStep(entry);
}
for (const item of chat.unspoken) finishSay(item, true);
Object.assign(chat, { unspoken: [], hearing: "", current: null, block: null });
chat.waiting.clear();
if (chat.items.length) {
const node = el("div", "note", `应用已重新启动 · ${clock(Date.now() / 1000)}`);
$("thread").append(node);
chat.items.push(node);
chatFollow();
}
voice();
}
function show(view) {
for (const name of ["chat", "trajectory"]) {
$(`view-${name}`).hidden = name !== view;
$(`tab-${name}`).setAttribute("aria-selected", String(name === view));
}
state.view = view;
if (location.hash !== `#${view}`) history.replaceState(null, "", `#${view}`);
// 隐藏期间新增的内容没有滚动位置,切回来时回到末尾。
if (view === "chat" && chat.follow) $("chat-scroll").scrollTop = $("chat-scroll").scrollHeight;
if (view === "trajectory" && state.follow) $("scroll").scrollTop = $("scroll").scrollHeight;
}
function jsonNode(label, value, depth) {
const key = label === null ? [] : [el("span", "j-key", String(label)), el("span", "j-count", ": ")];
if (value !== null && typeof value === "object") {
const entries = Array.isArray(value) ? value.map((item, index) => [index, item]) : Object.entries(value);
const count = Array.isArray(value) ? `[${entries.length}]` : `{${entries.length}}`;
if (!entries.length) {
const leaf = el("div", "j-entry");
leaf.append(...key, el("span", "j-count", Array.isArray(value) ? "[]" : "{}"));
return leaf;
}
const node = el("details", "j-node");
node.open = depth < 2;
const head = el("summary");
head.append(...key, el("span", "j-count", count));
const children = el("div", "j-children");
children.append(...entries.map(([name, item]) => jsonNode(name, item, depth + 1)));
node.append(head, children);
return node;
}
const leaf = el("div", "j-entry");
leaf.append(...key);
if (typeof value === "string" && value.length > 240) {
const text = el("span", "j-string", JSON.stringify(`${value.slice(0, 240)}…`));
const more = el("button", "more", `展开(${value.length.toLocaleString("en-US")} 字)`);
more.type = "button";
more.addEventListener("click", () => {
text.textContent = JSON.stringify(value);
more.remove();
});
leaf.append(text, more);
} else {
leaf.append(el("span", `j-${value === null ? "null" : typeof value}`, JSON.stringify(value)));
}
return leaf;
}
function decodeVector(hex) {
// 与 agentcade/vector.py 的编码对应:版本、笔画数,每条笔画为宽度、点数和 16 位大端坐标。
if (typeof hex !== "string" || !/^(?:[0-9a-f]{2})+$/.test(hex)) return null;
const bytes = hex.match(/../g).map((pair) => parseInt(pair, 16));
if (bytes[0] !== 1) return null;
const strokes = [];
let offset = 2;
for (let index = 0; index < bytes[1]; index += 1) {
const [width, count] = [bytes[offset], bytes[offset + 1]];
offset += 2;
const points = [];
for (let point = 0; point < count; point += 1, offset += 4) {
points.push([bytes[offset] * 256 + bytes[offset + 1], bytes[offset + 2] * 256 + bytes[offset + 3]]);
}
strokes.push({ width, points });
}
return offset === bytes.length ? strokes : null;
}
function drawing(hex, caption) {
const strokes = decodeVector(hex);
if (!strokes) return null;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 412 412");
svg.setAttribute("class", "drawing");
for (const stroke of strokes) {
const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
// 只有一个点的笔画重复该点,圆头线帽会把它画成圆点。
const points = stroke.points.length === 1 ? [stroke.points[0], stroke.points[0]] : stroke.points;
line.setAttribute("points", points.map((point) => point.join(",")).join(" "));
line.setAttribute("stroke-width", stroke.width);
svg.append(line);
}
const figure = el("figure");
figure.append(svg, el("figcaption", "", caption));
return figure;
}
function drawings(outputs) {
const figures = [];
for (const output of outputs || []) {
if (output.paths?.length) {
figures.push(drawing(output.paths[output.paths.length - 1], `图案,共 ${output.paths.length} 笔,逐笔显示`));
}
if (output.restore_path) figures.push(drawing(output.restore_path, "反馈表情之后恢复显示的图案"));
}
const node = el("div", "drawings");
node.append(...figures.filter(Boolean));
return node.childElementCount ? node : null;
}
function related(row) {
const links = [];
const parent = row.parent && state.rows.find((item) => item.id === row.parent);
if (parent) links.push(["上级", parent]);
if (row.id) links.push(...state.rows.filter((item) => item.parent === row.id).map((item) => ["下级", item]));
if (!links.length) return null;
const node = el("div", "links");
for (const [relation, target] of links) {
const link = el("button", `kind-${target.kind}`);
link.type = "button";
link.append(el("span", "dim", `${relation} #${target.index}`), el("span", "badge", KINDS[target.kind]),
el("span", "", target.summary.textContent));
link.addEventListener("click", () => {
target.group.node.classList.remove("collapsed");
select(target);
});
node.append(link);
}
return node;
}
function section(heading, content) {
const node = el("div", "section");
node.append(el("h3", "", heading), content);
return node;
}
const jsonBlock = (value) => {
const block = el("div", "block");
block.append(jsonNode(null, value, 0));
return block;
};
function inspect(row) {
const panel = $("inspector");
const data = row.data;
const head = el("div", `i-head kind-${row.kind}`);
head.append(el("span", "badge", KINDS[row.kind]), el("span", "i-title", title(row)));
if (STATUS[row.status]) head.append(el("span", `i-status ${row.status}`, STATUS[row.status]));
const party = { "a2a-out": ["发往", data.game_name], "a2a-in": ["来自", data.game_name || "游戏 Agent"] };
const facts = [party[row.kind] || ["发起方", actorName(row.actor)], ["时间", clock(row.time, true)]];
if (row.group.index) facts.push(["轮次", `第 ${row.group.index} 轮`]);
if (row.duration !== null) facts.push(["耗时", millis(row.duration)]);
if (row.kind === "user" && data.spoken_at) {
facts.push(["开始说话", clock(data.spoken_at, true)]);
if (data.speech_ended_at) {
// 把这段时间拆成两部分:识别服务判定的说话时长,以及说完之后等到最终结果的时间。
facts.push(["说话时长", millis((data.speech_ended_at - data.spoken_at) * 1000)]);
if (data.audio_sent_until) {
// 说完之后识别服务又收到多少音频才给出结果,以及结果返回时音频发送落后多少。
facts.push(["断句等待", millis((data.audio_sent_until - data.speech_ended_at) * 1000)],
["音频发送延迟", millis((row.time - data.audio_sent_until) * 1000)]);
} else {
facts.push(["说完到识别完成", millis((row.time - data.speech_ended_at) * 1000)]);
}
}
facts.push(["说话到识别完成", millis((row.time - data.spoken_at) * 1000)]);
}
if (row.kind === "a2a-in" && row.offset !== null) facts.push(["距请求发出", millis(row.offset)]);
if (row.kind === "reply" && row.offset !== null) facts.push(["距用户说完", millis(row.offset)]);
for (const name of ["call_id", "request_id", "context_id"]) if (data[name]) facts.push([name, data[name]]);
const list = el("dl", "facts");
for (const [name, value] of facts) list.append(el("dt", "", name), el("dd", "", value));
const sections = [];
if (row.kind === "user") sections.push(section("ASR 识别结果", el("div", "block text", data.text)));
if (row.kind === "reply") sections.push(section("回复文字", el("div", "block text", data.text)));
if (row.kind === "main-tool" || row.kind === "game-tool") {
sections.push(section("参数", jsonBlock(data.arguments)));
if (row.status === "ok") sections.push(section("返回", jsonBlock(data.result)));
}
if (row.kind === "a2a-out") sections.push(section("请求", jsonBlock(data.request)));
if (row.kind === "a2a-in" && data.result) {
sections.push(section("回复文字", el("div", "block text", data.result.text)),
section("会话", el("div", "block text", data.result.session === "ended" ? "本局结束" : "继续")));
}
if (row.kind === "device") {
sections.push(section("设备操作", jsonBlock(data.output)));
const pictures = drawings([data.output]);
if (pictures) sections.push(section("屏幕图案", pictures));
}
if (data.error && !data.cancelled) sections.push(section("错误", el("div", "block error", data.error)));
const links = related(row);
if (links) sections.push(section("关联记录", links));
const raw = el("details", "raw");
const copy = el("button", "more", "复制");
copy.type = "button";
const text = JSON.stringify(row.events, null, 2);
copy.addEventListener("click", async (event) => {
event.preventDefault();
try {
await navigator.clipboard.writeText(text);
copy.textContent = "已复制";
} catch {
copy.textContent = "复制失败";
}
});
const rawHead = el("summary", "", "原始事件");
rawHead.append(copy);
raw.append(rawHead, el("pre", "block", text));
const rawSection = el("div", "section");
rawSection.append(raw);
panel.replaceChildren(head, list, ...sections, rawSection);
}
function select(row) {
state.selected?.node.classList.remove("selected");
state.selected = row;
if (!row) {
$("inspector").replaceChildren(el("div", "placeholder", "选择一条记录查看输入和输出"));
return;
}
row.node.classList.add("selected");
row.node.scrollIntoView({ block: "nearest" });
inspect(row);
}
function trim() {
// 页面只保留最近的记录,从最早的轮次开始整组移除。
while (state.rows.length > LIMIT && state.groups.length > 1) {
const group = state.groups.shift();
const removed = new Set(group.rows);
group.node.remove();
state.rows = state.rows.filter((row) => !removed.has(row));
for (const [id, item] of state.turns) if (item === group) state.turns.delete(id);
for (const name of ["calls", "requests"]) {
for (const [id, row] of state[name]) if (removed.has(row)) state[name].delete(id);
}
if (removed.has(state.selected)) select(null);
}
}
function reset() {
for (const group of state.groups) group.node.remove();
// 清空只移除页面上的记录,轮次和记录的编号在同一次运行内继续累加。
Object.assign(state, { groups: [], rows: [] });
for (const name of ["turns", "calls", "requests"]) state[name].clear();
$("empty").hidden = false;
$("counts").textContent = "";
select(null);
chatReset();
}
function restarted() {
// 上一次运行的进程已经退出,仍在运行的记录不会再收到结果。
for (const row of state.rows) {
if (row.status === "running") {
row.status = "lost";
paint(row);
}
}
if (state.rows.length) {
const node = el("div", "divider", `应用已重新启动 · ${clock(Date.now() / 1000)}`);
$("scroll").append(node);
state.groups.push({ kind: "divider", rows: [], node });
}
state.turnCount = 0;
chatRestarted();
}
function setStatus(name, text) {
$("status").className = `status ${name}`;
$("status").lastElementChild.textContent = text;
chat.online = name === "live";
voice();
}
function connect() {
const source = new EventSource(`events?after=${encodeURIComponent(state.cursor)}`);
source.addEventListener("open", () => setStatus("live", "已连接"));
source.addEventListener("error", () => {
setStatus("down", "已断开,正在重连");
// 应用退出后浏览器会停止自动重连,这里带着最后的事件 ID 重新建立连接。
if (source.readyState === EventSource.CLOSED) setTimeout(connect, 2000);
});
source.addEventListener("run", (message) => {
const { run_id: runId } = JSON.parse(message.data);
if (state.runId && state.runId !== runId) restarted();
state.runId = runId;
});
source.addEventListener("live", (message) => {
chat.hearing = JSON.parse(message.data).hearing || "";
voice();
});
source.addEventListener("message", (message) => {
state.cursor = message.lastEventId;
const event = JSON.parse(message.data);
handlers[event.type]?.(event);
chatHandlers[event.type]?.(event);
});
}
connect();
$("scroll").addEventListener("scroll", () => {
const view = $("scroll");
state.follow = view.scrollHeight - view.scrollTop - view.clientHeight < 32;
$("tail").hidden = state.follow;
});
$("tail").addEventListener("click", () => {
$("scroll").scrollTop = $("scroll").scrollHeight;
});
$("chat-scroll").addEventListener("scroll", () => {
const view = $("chat-scroll");
const away = view.scrollHeight - view.scrollTop - view.clientHeight >= 48;
// 滚到末尾之后内容又变长了,这时离末尾远不是用户往上翻,继续跟随。
if (chat.follow && away && view.scrollHeight > chat.height) return chatFollow();
chat.follow = !away;
$("chat-tail").hidden = chat.follow;
});
$("chat-tail").addEventListener("click", () => {
chat.follow = true;
chatFollow();
});
for (const name of ["chat", "trajectory"]) $(`tab-${name}`).addEventListener("click", () => show(name));
// 进行中的步骤每秒刷新已经用了多久,语音状态也跟着更新。
setInterval(() => {
for (const block of new Set([...chat.running].map((entry) => entry.block))) blockTime(block);
voice();
}, 1000);
$("clear").addEventListener("click", reset);
$("search").addEventListener("input", () => filter(state.rows));
$("collapse").addEventListener("click", () => {
state.collapsed = !state.collapsed;
$("collapse").textContent = state.collapsed ? "展开所有轮次" : "收起所有轮次";
for (const group of state.groups) group.node.classList.toggle("collapsed", state.collapsed);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && event.target !== $("search")) select(null);
// 在搜索框里输入时,方向键留给输入框自己使用。
if (event.target === $("search") || state.view !== "trajectory") return;
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
const rows = [...document.querySelectorAll(".group:not(.collapsed):not([hidden]) .row:not([hidden])")]
.map((node) => rowOf.get(node));
if (!rows.length) return;
event.preventDefault();
const current = rows.indexOf(state.selected);
const next = current < 0 ? rows.length - 1 : current + (event.key === "ArrowDown" ? 1 : -1);
select(rows[Math.max(0, Math.min(rows.length - 1, next))]);
});
select(null);
show(location.hash === "#trajectory" ? "trajectory" : "chat");
</script>
</body>
</html>