Download src/observability/web/index.html from left0ver/WatcherRobot-com.left0ver.agentcade: direct link, hf CLI and curl.
- Browser
- Download file 65.4 kB
-
https://huggingface.co/spaces/left0ver/WatcherRobot-com.left0ver.agentcade/resolve/main/src/observability/web/index.html
- Command line
-
hf download hf://spaces/left0ver/WatcherRobot-com.left0ver.agentcade/src/observability/web/index.html
-
curl -L -o index.html https://huggingface.co/spaces/left0ver/WatcherRobot-com.left0ver.agentcade/resolve/main/src/observability/web/index.html
65.4 kB
| <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 ; } | |
| 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 ; } | |
| } | |
| </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> | |