@obov/mext 0.5.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/approval.d.ts +33 -0
- package/dist/approval.js +36 -0
- package/dist/approval.js.map +1 -0
- package/dist/audit.d.ts +123 -0
- package/dist/audit.js +221 -0
- package/dist/audit.js.map +1 -0
- package/dist/build-cli.d.ts +1 -0
- package/dist/build-cli.js.map +1 -1
- package/dist/build.d.ts +2 -0
- package/dist/build.js +46 -5
- package/dist/build.js.map +1 -1
- package/dist/caller.d.ts +53 -0
- package/dist/caller.js +51 -0
- package/dist/caller.js.map +1 -0
- package/dist/cli/args.js +1 -2
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/build.js +26 -4
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/dev-audit.d.ts +25 -0
- package/dist/cli/dev-audit.js +84 -0
- package/dist/cli/dev-audit.js.map +1 -0
- package/dist/cli/dev-lock.d.ts +3 -1
- package/dist/cli/dev-lock.js +12 -5
- package/dist/cli/dev-lock.js.map +1 -1
- package/dist/cli/dev-server.d.ts +9 -0
- package/dist/cli/dev-server.js +27 -3
- package/dist/cli/dev-server.js.map +1 -1
- package/dist/cli/dev-tap.d.ts +20 -0
- package/dist/cli/dev-tap.js +70 -0
- package/dist/cli/dev-tap.js.map +1 -0
- package/dist/cli/dev.d.ts +6 -0
- package/dist/cli/dev.js +20 -8
- package/dist/cli/dev.js.map +1 -1
- package/dist/cli/help.js +2 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect.d.ts +7 -0
- package/dist/cli/inspect.js +699 -0
- package/dist/cli/inspect.js.map +1 -0
- package/dist/cli/main.js +10 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/media.d.ts +19 -0
- package/dist/cli/media.js +77 -0
- package/dist/cli/media.js.map +1 -0
- package/dist/cli/output.d.ts +1 -1
- package/dist/cli/output.js +3 -2
- package/dist/cli/output.js.map +1 -1
- package/dist/cli/run.js +33 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/serve.d.ts +10 -0
- package/dist/cli/serve.js +114 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/cli/types.d.ts +9 -0
- package/dist/cli/types.js +77 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/client/index.d.ts +11 -0
- package/dist/client/index.js +7 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/manifest.js +6 -2
- package/dist/client/manifest.js.map +1 -1
- package/dist/client/runner.js +40 -3
- package/dist/client/runner.js.map +1 -1
- package/dist/client/typed.d.ts +56 -0
- package/dist/client/typed.js +526 -0
- package/dist/client/typed.js.map +1 -0
- package/dist/client/types.d.ts +225 -0
- package/dist/client/types.js +2 -0
- package/dist/client/types.js.map +1 -0
- package/dist/command.d.ts +61 -5
- package/dist/command.js +42 -1
- package/dist/command.js.map +1 -1
- package/dist/config.d.ts +59 -10
- package/dist/config.js +67 -7
- package/dist/config.js.map +1 -1
- package/dist/content.d.ts +65 -0
- package/dist/content.js +99 -0
- package/dist/content.js.map +1 -0
- package/dist/errors.d.ts +21 -3
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/expose.d.ts +9 -0
- package/dist/expose.js +52 -0
- package/dist/expose.js.map +1 -0
- package/dist/index.d.ts +18 -7
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/inspect/agent.d.ts +104 -0
- package/dist/inspect/agent.js +125 -0
- package/dist/inspect/agent.js.map +1 -0
- package/dist/inspect/audit.d.ts +46 -0
- package/dist/inspect/audit.js +133 -0
- package/dist/inspect/audit.js.map +1 -0
- package/dist/inspect/compare.d.ts +12 -0
- package/dist/inspect/compare.js +31 -0
- package/dist/inspect/compare.js.map +1 -0
- package/dist/inspect/index.d.ts +17 -0
- package/dist/inspect/index.js +12 -0
- package/dist/inspect/index.js.map +1 -0
- package/dist/inspect/inspector.d.ts +168 -0
- package/dist/inspect/inspector.js +392 -0
- package/dist/inspect/inspector.js.map +1 -0
- package/dist/inspect/profiles.d.ts +95 -0
- package/dist/inspect/profiles.js +235 -0
- package/dist/inspect/profiles.js.map +1 -0
- package/dist/inspect/scenario.d.ts +126 -0
- package/dist/inspect/scenario.js +555 -0
- package/dist/inspect/scenario.js.map +1 -0
- package/dist/inspect/ui-server.d.ts +31 -0
- package/dist/inspect/ui-server.js +378 -0
- package/dist/inspect/ui-server.js.map +1 -0
- package/dist/inspect/ui.html +917 -0
- package/dist/instrumentation.d.ts +13 -2
- package/dist/instrumentation.js +11 -1
- package/dist/instrumentation.js.map +1 -1
- package/dist/invoke.d.ts +36 -5
- package/dist/invoke.js +162 -21
- package/dist/invoke.js.map +1 -1
- package/dist/manifest.d.ts +19 -3
- package/dist/manifest.js +5 -1
- package/dist/manifest.js.map +1 -1
- package/dist/mcp/constants.d.ts +7 -0
- package/dist/mcp/constants.js +7 -0
- package/dist/mcp/constants.js.map +1 -1
- package/dist/mcp/dispatch.d.ts +10 -4
- package/dist/mcp/dispatch.js +24 -7
- package/dist/mcp/dispatch.js.map +1 -1
- package/dist/mcp/help.d.ts +2 -0
- package/dist/mcp/help.js +6 -1
- package/dist/mcp/help.js.map +1 -1
- package/dist/mcp/host.d.ts +14 -0
- package/dist/mcp/host.js +48 -0
- package/dist/mcp/host.js.map +1 -0
- package/dist/mcp/http.d.ts +38 -7
- package/dist/mcp/http.js +199 -23
- package/dist/mcp/http.js.map +1 -1
- package/dist/mcp/index.d.ts +2 -1
- package/dist/mcp/index.js +2 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server.d.ts +12 -1
- package/dist/mcp/server.js +63 -17
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/stdio.d.ts +4 -0
- package/dist/mcp/stdio.js +3 -0
- package/dist/mcp/stdio.js.map +1 -1
- package/dist/mcp/tasks.d.ts +3 -1
- package/dist/mcp/tasks.js +7 -4
- package/dist/mcp/tasks.js.map +1 -1
- package/dist/principal.d.ts +22 -0
- package/dist/principal.js +29 -0
- package/dist/principal.js.map +1 -1
- package/dist/registry.d.ts +5 -0
- package/dist/registry.js +21 -0
- package/dist/registry.js.map +1 -1
- package/dist/remote/client.d.ts +26 -1
- package/dist/remote/client.js +180 -55
- package/dist/remote/client.js.map +1 -1
- package/dist/remote/index.d.ts +1 -1
- package/dist/remote/index.js.map +1 -1
- package/dist/remote/oauth.d.ts +1 -1
- package/dist/remote/oauth.js +1 -1
- package/dist/remote/oauth.js.map +1 -1
- package/dist/route.d.ts +40 -2
- package/dist/route.js +128 -8
- package/dist/route.js.map +1 -1
- package/dist/start.js +9 -4
- package/dist/start.js.map +1 -1
- package/dist/suggest.js +1 -1
- package/dist/suggest.js.map +1 -1
- package/dist/typegen.d.ts +27 -0
- package/dist/typegen.js +62 -0
- package/dist/typegen.js.map +1 -0
- package/dist/version-constant.d.ts +5 -0
- package/dist/version-constant.js +6 -0
- package/dist/version-constant.js.map +1 -0
- package/dist/version.d.ts +1 -2
- package/dist/version.js +1 -2
- package/dist/version.js.map +1 -1
- package/package.json +12 -2
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="mext-inspect-token" content="__MEXT_INSPECT_TOKEN__">
|
|
7
|
+
<title>mext inspect</title>
|
|
8
|
+
<style nonce="__MEXT_INSPECT_NONCE__">
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: light dark;
|
|
11
|
+
--bg: #f6f7f9; --panel: #ffffff; --line: #dfe3e8; --text: #1c2128; --dim: #667085; --accent: #2f6feb; --accent-text: #ffffff;
|
|
12
|
+
--ok: #1a7f37; --bad: #c62828; --warn: #9a6700; --chip: #eef1f5; --code: #f1f3f6; --sel: #e3edff;
|
|
13
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
14
|
+
}
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root { --bg: #0f1216; --panel: #171b21; --line: #2a313a; --text: #e6e9ee; --dim: #8b96a5; --accent: #5b93ff; --accent-text: #0b1220;
|
|
17
|
+
--ok: #4cc26b; --bad: #ff7b72; --warn: #e3b341; --chip: #222830; --code: #11151a; --sel: #1d2a44; }
|
|
18
|
+
}
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
html, body { height: 100%; margin: 0; }
|
|
21
|
+
body { font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); display: flex; flex-direction: column; }
|
|
22
|
+
button, input, select, textarea { font: inherit; color: inherit; }
|
|
23
|
+
button { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
|
|
24
|
+
button:hover:not(:disabled) { border-color: var(--accent); }
|
|
25
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
26
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; }
|
|
27
|
+
button.link { border: 0; background: none; padding: 0; color: var(--accent); }
|
|
28
|
+
input[type=text], input[type=number], input[type=search], select, textarea { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; width: 100%; }
|
|
29
|
+
textarea { font-family: var(--mono); font-size: 12px; resize: vertical; min-height: 60px; }
|
|
30
|
+
pre { margin: 0; font: 12px/1.5 var(--mono); background: var(--code); border-radius: 6px; padding: 8px 10px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
|
|
31
|
+
code { font-family: var(--mono); font-size: 12px; }
|
|
32
|
+
header { display: flex; align-items: center; gap: 14px; padding: 8px 14px; background: var(--panel); border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
|
33
|
+
header h1 { font-size: 14px; margin: 0; }
|
|
34
|
+
header .target { color: var(--dim); font-family: var(--mono); font-size: 12px; }
|
|
35
|
+
.grow { flex: 1; }
|
|
36
|
+
header label { color: var(--dim); display: flex; align-items: center; gap: 6px; }
|
|
37
|
+
header select { width: auto; }
|
|
38
|
+
.chips { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
39
|
+
.chip { display: inline-block; background: var(--chip); border-radius: 999px; padding: 1px 8px; font-size: 11px; white-space: nowrap; }
|
|
40
|
+
button.chip { border: 1px solid transparent; }
|
|
41
|
+
button.chip.on { background: var(--sel); border-color: var(--accent); }
|
|
42
|
+
.chip.ro { color: var(--ok); } .chip.w { color: var(--warn); } .chip.d { color: var(--bad); font-weight: 600; } .chip.c { color: var(--accent); }
|
|
43
|
+
.chip.hidden { color: var(--dim); text-decoration: line-through; }
|
|
44
|
+
main { flex: 1; min-height: 0; display: grid; grid-template-columns: 280px minmax(0, 1fr) 380px; }
|
|
45
|
+
main > section { min-height: 0; overflow: auto; }
|
|
46
|
+
#tree { border-right: 1px solid var(--line); background: var(--panel); padding: 10px; }
|
|
47
|
+
#side { border-left: 1px solid var(--line); background: var(--panel); display: flex; flex-direction: column; overflow: hidden; }
|
|
48
|
+
#center { padding: 14px 18px; }
|
|
49
|
+
#tree details { margin-top: 8px; }
|
|
50
|
+
#tree summary { cursor: pointer; font-weight: 600; }
|
|
51
|
+
#tree summary small { color: var(--dim); font-weight: 400; margin-left: 6px; }
|
|
52
|
+
.cmd { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; border: 0; border-radius: 6px; padding: 3px 6px 3px 14px; background: none; }
|
|
53
|
+
.cmd:hover { background: var(--chip); }
|
|
54
|
+
.cmd.sel { background: var(--sel); }
|
|
55
|
+
.cmd.dim .name { color: var(--dim); }
|
|
56
|
+
.cmd .name { flex: 1; font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
57
|
+
.muted { color: var(--dim); }
|
|
58
|
+
.empty { color: var(--dim); padding: 24px 8px; text-align: center; }
|
|
59
|
+
h2 { font-size: 16px; margin: 0 0 2px; font-family: var(--mono); }
|
|
60
|
+
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); margin: 18px 0 6px; }
|
|
61
|
+
.meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin-top: 10px; }
|
|
62
|
+
.meta dt { color: var(--dim); } .meta dd { margin: 0; }
|
|
63
|
+
.field { display: grid; grid-template-columns: 180px 1fr; gap: 4px 12px; align-items: start; margin-bottom: 8px; }
|
|
64
|
+
.field label { font-family: var(--mono); font-size: 12px; padding-top: 5px; overflow-wrap: anywhere; }
|
|
65
|
+
.field .req { color: var(--bad); }
|
|
66
|
+
.field small { display: block; color: var(--dim); margin-top: 2px; }
|
|
67
|
+
.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
68
|
+
.row.actions { margin-top: 12px; }
|
|
69
|
+
.card { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); margin-top: 10px; overflow: hidden; }
|
|
70
|
+
.card > .head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
|
71
|
+
.card > .body { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
|
72
|
+
.card details summary { cursor: pointer; color: var(--dim); }
|
|
73
|
+
.ok { color: var(--ok); font-weight: 600; } .bad { color: var(--bad); font-weight: 600; }
|
|
74
|
+
.note { border-left: 3px solid var(--warn); padding: 4px 10px; background: var(--chip); border-radius: 0 6px 6px 0; }
|
|
75
|
+
.bar { height: 6px; background: var(--chip); border-radius: 3px; overflow: hidden; }
|
|
76
|
+
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .15s; }
|
|
77
|
+
.grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
|
78
|
+
.tabs { display: flex; border-bottom: 1px solid var(--line); }
|
|
79
|
+
.tabs button { border: 0; border-radius: 0; border-bottom: 2px solid transparent; padding: 8px 12px; background: none; }
|
|
80
|
+
.tabs button.on { border-bottom-color: var(--accent); font-weight: 600; }
|
|
81
|
+
#panel { flex: 1; overflow: auto; padding: 8px; }
|
|
82
|
+
.item { display: block; width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 6px 4px; background: none; }
|
|
83
|
+
.item:hover { background: var(--chip); }
|
|
84
|
+
.item .top { display: flex; gap: 6px; align-items: baseline; }
|
|
85
|
+
.item .top .name { font-family: var(--mono); font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
86
|
+
.item small { color: var(--dim); }
|
|
87
|
+
.steps { margin: 6px 0 0; padding: 0; list-style: none; }
|
|
88
|
+
.steps li { padding: 2px 0; }
|
|
89
|
+
.steps .fail { color: var(--dim); font-family: var(--mono); font-size: 11px; padding-left: 18px; }
|
|
90
|
+
.steps .agent { padding-left: 18px; }
|
|
91
|
+
.steps .agent h5 { margin: 8px 0 2px; font-size: 11px; color: var(--dim); font-weight: 600; }
|
|
92
|
+
.steps .agent .said { white-space: pre-wrap; padding: 2px 0; }
|
|
93
|
+
dialog { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); padding: 16px; width: min(520px, 90vw); }
|
|
94
|
+
dialog::backdrop { background: rgba(0, 0, 0, .45); }
|
|
95
|
+
dialog h4 { margin: 0 0 8px; font-size: 14px; }
|
|
96
|
+
dialog .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
97
|
+
#toast { color: var(--dim); }
|
|
98
|
+
@media (max-width: 1100px) { main { grid-template-columns: 240px minmax(0, 1fr); } #side { display: none; } }
|
|
99
|
+
</style>
|
|
100
|
+
</head>
|
|
101
|
+
<body>
|
|
102
|
+
<header>
|
|
103
|
+
<h1>mext inspect</h1>
|
|
104
|
+
<span class="target" id="target"></span>
|
|
105
|
+
<span class="grow"></span>
|
|
106
|
+
<span id="toast"></span>
|
|
107
|
+
<label>Call as <select id="profile"></select></label>
|
|
108
|
+
<label>Compare <span class="chips" id="compare"></span></label>
|
|
109
|
+
</header>
|
|
110
|
+
<main>
|
|
111
|
+
<section id="tree"></section>
|
|
112
|
+
<section id="center"></section>
|
|
113
|
+
<section id="side">
|
|
114
|
+
<div class="tabs" id="tabs"></div>
|
|
115
|
+
<div id="panel"></div>
|
|
116
|
+
</section>
|
|
117
|
+
</main>
|
|
118
|
+
<dialog id="dialog"></dialog>
|
|
119
|
+
<script nonce="__MEXT_INSPECT_NONCE__">
|
|
120
|
+
"use strict"
|
|
121
|
+
// mext inspect 웹 UI (specs/inspector.md §5). 의존성 없음. 대상 서버 호출은 전부 같은 출처의 /api 를 거친다
|
|
122
|
+
// (토큰 · 비밀 헤더는 inspector 프로세스에만 있다). 서버가 준 값은 textContent 로만 넣는다 (innerHTML 없음)
|
|
123
|
+
|
|
124
|
+
const TOKEN = document.querySelector('meta[name="mext-inspect-token"]').content
|
|
125
|
+
const $ = (id) => document.getElementById(id)
|
|
126
|
+
|
|
127
|
+
/** DOM 생성: h("div", { class: "x", onclick }, ...자식). 문자열 자식 = 텍스트 노드 */
|
|
128
|
+
function h(tag, attrs, ...children) {
|
|
129
|
+
const el = document.createElement(tag)
|
|
130
|
+
for (const [key, value] of Object.entries(attrs || {})) {
|
|
131
|
+
if (value === undefined || value === false || value === null) continue
|
|
132
|
+
if (key.startsWith("on")) el.addEventListener(key.slice(2), value)
|
|
133
|
+
else if (key === "class") el.className = value
|
|
134
|
+
else if (key in el && key !== "list") el[key] = value
|
|
135
|
+
else el.setAttribute(key, value === true ? "" : String(value))
|
|
136
|
+
}
|
|
137
|
+
for (const child of children.flat(Infinity)) {
|
|
138
|
+
if (child === undefined || child === null || child === false) continue
|
|
139
|
+
el.append(child instanceof Node ? child : document.createTextNode(String(child)))
|
|
140
|
+
}
|
|
141
|
+
return el
|
|
142
|
+
}
|
|
143
|
+
const pretty = (value) => (value === undefined ? "undefined" : JSON.stringify(value, null, 2))
|
|
144
|
+
const jsonBlock = (value) => h("pre", {}, typeof value === "string" ? value : pretty(value))
|
|
145
|
+
|
|
146
|
+
/* ---------------------------------------------------------------- API */
|
|
147
|
+
|
|
148
|
+
function api(path, init = {}) {
|
|
149
|
+
const headers = { "X-Mext-Inspect": TOKEN, ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}) }
|
|
150
|
+
return fetch(path, { ...init, headers })
|
|
151
|
+
}
|
|
152
|
+
async function getJson(path, init) {
|
|
153
|
+
const response = await api(path, init)
|
|
154
|
+
const body = await response.json()
|
|
155
|
+
if (!response.ok) throw Object.assign(new Error(body.message || (body.error && body.error.message) || response.statusText), { body })
|
|
156
|
+
return body
|
|
157
|
+
}
|
|
158
|
+
const postJson = (path, body) => getJson(path, { method: "POST", body: JSON.stringify(body) })
|
|
159
|
+
|
|
160
|
+
/** NDJSON 스트림을 한 줄씩 */
|
|
161
|
+
async function* lines(response) {
|
|
162
|
+
const reader = response.body.getReader()
|
|
163
|
+
const decoder = new TextDecoder()
|
|
164
|
+
let buffer = ""
|
|
165
|
+
for (;;) {
|
|
166
|
+
const { done, value } = await reader.read()
|
|
167
|
+
if (done) break
|
|
168
|
+
buffer += decoder.decode(value, { stream: true })
|
|
169
|
+
let i
|
|
170
|
+
while ((i = buffer.indexOf("\n")) >= 0) {
|
|
171
|
+
const text = buffer.slice(0, i)
|
|
172
|
+
buffer = buffer.slice(i + 1)
|
|
173
|
+
if (text !== "") yield JSON.parse(text)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function* stream(path, body, signal) {
|
|
178
|
+
const response = await api(path, { method: "POST", body: JSON.stringify(body), signal })
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
const error = await response.json()
|
|
181
|
+
throw Object.assign(new Error(error.message || response.statusText), { body: error })
|
|
182
|
+
}
|
|
183
|
+
yield* lines(response)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ---------------------------------------------------------------- 상태 */
|
|
187
|
+
|
|
188
|
+
const S = {
|
|
189
|
+
state: null,
|
|
190
|
+
profile: "",
|
|
191
|
+
compare: new Set(),
|
|
192
|
+
lists: {}, // 프로필 → { ok, list | error }
|
|
193
|
+
selected: null, // 명령어 ID
|
|
194
|
+
info: null, // CommandInfo
|
|
195
|
+
visibleTo: {},
|
|
196
|
+
describeError: null,
|
|
197
|
+
mode: "form",
|
|
198
|
+
filter: "",
|
|
199
|
+
tab: "history",
|
|
200
|
+
history: [],
|
|
201
|
+
audit: [],
|
|
202
|
+
scenarios: [],
|
|
203
|
+
scenarioRuns: {}, // 파일 → { running, steps, result }
|
|
204
|
+
cards: [], // 가운데 결과 카드
|
|
205
|
+
running: null, // AbortController
|
|
206
|
+
confirmed: false,
|
|
207
|
+
}
|
|
208
|
+
let form = { fields: [], json: null }
|
|
209
|
+
|
|
210
|
+
const involved = () => [S.profile, ...[...S.compare].filter((p) => p !== S.profile)]
|
|
211
|
+
const toast = (text) => {
|
|
212
|
+
$("toast").textContent = text
|
|
213
|
+
clearTimeout(toast.timer)
|
|
214
|
+
toast.timer = setTimeout(() => ($("toast").textContent = ""), 4000)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ---------------------------------------------------------------- 머리말 · 프로필 */
|
|
218
|
+
|
|
219
|
+
function renderHeader() {
|
|
220
|
+
const { target, server, profiles } = S.state
|
|
221
|
+
const info = server && !server.error ? ` · ${server.name || "server"}${server.version ? ` ${server.version}` : ""} · tools: ${server.tools}` : ""
|
|
222
|
+
$("target").textContent = `${target.url}${target.source === "dev" ? " (mext dev)" : ""}${info}`
|
|
223
|
+
$("profile").replaceChildren(
|
|
224
|
+
...profiles.map((p) => h("option", { value: p.name, selected: p.name === S.profile, disabled: !p.available }, p.available ? p.name : `${p.name} (env not set)`)),
|
|
225
|
+
)
|
|
226
|
+
$("compare").replaceChildren(
|
|
227
|
+
...profiles
|
|
228
|
+
.filter((p) => p.name !== S.profile)
|
|
229
|
+
.map((p) =>
|
|
230
|
+
h(
|
|
231
|
+
"button",
|
|
232
|
+
{
|
|
233
|
+
class: `chip${S.compare.has(p.name) ? " on" : ""}`,
|
|
234
|
+
disabled: !p.available,
|
|
235
|
+
title: p.available ? p.description || "" : `Needs ${[p.token && !p.token.set ? p.token.env : null, ...p.headers.filter((x) => x.set === false).map((x) => x.env)].filter(Boolean).join(", ")}`,
|
|
236
|
+
onclick: () => {
|
|
237
|
+
S.compare.has(p.name) ? S.compare.delete(p.name) : S.compare.add(p.name)
|
|
238
|
+
void reload()
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
p.name,
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
$("profile").addEventListener("change", (event) => {
|
|
247
|
+
S.profile = event.target.value
|
|
248
|
+
S.compare.delete(S.profile)
|
|
249
|
+
void reload()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
/* ---------------------------------------------------------------- 트리 */
|
|
253
|
+
|
|
254
|
+
function safetyChips(c) {
|
|
255
|
+
const chips = [h("span", { class: `chip ${c.readOnly ? "ro" : "w"}` }, c.readOnly ? "read-only" : "writes")]
|
|
256
|
+
if (c.destructive) chips.push(h("span", { class: "chip d" }, "destructive"))
|
|
257
|
+
if (c.requiresConfirmation) chips.push(h("span", { class: "chip c" }, "confirm"))
|
|
258
|
+
else if (c.needsApproval === "conditional") chips.push(h("span", { class: "chip c" }, "may confirm"))
|
|
259
|
+
return chips
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function renderTree() {
|
|
263
|
+
const names = involved()
|
|
264
|
+
const all = new Map() // ID → { summary, visible: Set }
|
|
265
|
+
const descriptions = new Map()
|
|
266
|
+
const errors = []
|
|
267
|
+
for (const name of names) {
|
|
268
|
+
const entry = S.lists[name]
|
|
269
|
+
if (!entry) continue
|
|
270
|
+
if (!entry.ok) {
|
|
271
|
+
errors.push(h("div", { class: "note" }, `${name}: ${entry.error.code} — ${entry.error.message}`))
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
for (const ns of entry.list.namespaces) if (ns.description) descriptions.set(ns.id, ns.description)
|
|
275
|
+
for (const c of entry.list.commands) {
|
|
276
|
+
if (!all.has(c.id)) all.set(c.id, { summary: c, visible: new Set() })
|
|
277
|
+
all.get(c.id).visible.add(name)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const filter = S.filter.trim().toLowerCase()
|
|
281
|
+
const groups = new Map()
|
|
282
|
+
for (const [id, entry] of [...all].sort(([a], [b]) => a.localeCompare(b))) {
|
|
283
|
+
if (filter && !id.toLowerCase().includes(filter) && !entry.summary.description.toLowerCase().includes(filter)) continue
|
|
284
|
+
const ns = id.includes(".") ? id.slice(0, id.lastIndexOf(".")) : ""
|
|
285
|
+
if (!groups.has(ns)) groups.set(ns, [])
|
|
286
|
+
groups.get(ns).push([id, entry])
|
|
287
|
+
}
|
|
288
|
+
const search = h("input", { type: "search", placeholder: "Filter commands", value: S.filter, id: "filter" })
|
|
289
|
+
search.addEventListener("input", () => {
|
|
290
|
+
S.filter = search.value
|
|
291
|
+
renderTree()
|
|
292
|
+
$("filter").focus()
|
|
293
|
+
})
|
|
294
|
+
const body = [...groups].map(([ns, items]) =>
|
|
295
|
+
h(
|
|
296
|
+
"details",
|
|
297
|
+
{ open: true },
|
|
298
|
+
h("summary", {}, ns === "" ? "(root)" : ns, h("small", {}, descriptions.get(ns) || "")),
|
|
299
|
+
items.map(([id, entry]) => {
|
|
300
|
+
const hiddenFrom = names.filter((n) => S.lists[n] && S.lists[n].ok && !entry.visible.has(n))
|
|
301
|
+
return h(
|
|
302
|
+
"button",
|
|
303
|
+
{ class: `cmd${id === S.selected ? " sel" : ""}${entry.visible.has(S.profile) ? "" : " dim"}`, title: entry.summary.description, onclick: () => select(id) },
|
|
304
|
+
h("span", { class: "name" }, ns === "" ? id : id.slice(ns.length + 1)),
|
|
305
|
+
hiddenFrom.map((n) => h("span", { class: "chip hidden", title: `Hidden from ${n} (expose)` }, n)),
|
|
306
|
+
safetyChips(entry.summary).slice(0, entry.summary.destructive ? 2 : 1),
|
|
307
|
+
entry.summary.requiresConfirmation || entry.summary.needsApproval ? h("span", { class: "chip c", title: "Needs confirmation" }, "✓?") : null,
|
|
308
|
+
)
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
$("tree").replaceChildren(search, ...errors, ...(body.length > 0 ? body : [h("div", { class: "empty" }, all.size === 0 ? "No commands visible." : "No match.")]))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* ---------------------------------------------------------------- 명령어 · 입력 폼 */
|
|
316
|
+
|
|
317
|
+
function baseSchema(schema) {
|
|
318
|
+
if (!schema || typeof schema !== "object") return {}
|
|
319
|
+
const variants = schema.anyOf || schema.oneOf
|
|
320
|
+
if (Array.isArray(variants)) {
|
|
321
|
+
const rest = variants.filter((v) => v && v.type !== "null")
|
|
322
|
+
if (rest.length === 1) return { ...schema, ...baseSchema(rest[0]), anyOf: undefined, oneOf: undefined }
|
|
323
|
+
}
|
|
324
|
+
if (Array.isArray(schema.type)) {
|
|
325
|
+
const rest = schema.type.filter((t) => t !== "null")
|
|
326
|
+
if (rest.length === 1) return { ...schema, type: rest[0] }
|
|
327
|
+
}
|
|
328
|
+
return schema
|
|
329
|
+
}
|
|
330
|
+
const isScalar = (schema) => ["string", "number", "integer", "boolean"].includes(schema.type) || Array.isArray(schema.enum)
|
|
331
|
+
|
|
332
|
+
/** 필드 하나: { key, node, get() → 값 | undefined (비움), set(값) }. get 은 형식 오류에 throw */
|
|
333
|
+
function makeField(key, raw, required) {
|
|
334
|
+
const schema = baseSchema(raw)
|
|
335
|
+
const hint = [schema.description, schema.default !== undefined ? `default: ${JSON.stringify(schema.default)}` : null].filter(Boolean).join(" · ")
|
|
336
|
+
let control, get, set
|
|
337
|
+
if (Array.isArray(schema.enum)) {
|
|
338
|
+
control = h("select", {}, h("option", { value: "" }, "—"), schema.enum.map((v, i) => h("option", { value: String(i) }, String(v))))
|
|
339
|
+
get = () => (control.value === "" ? undefined : schema.enum[Number(control.value)])
|
|
340
|
+
set = (v) => (control.value = v === undefined ? "" : String(schema.enum.indexOf(v)))
|
|
341
|
+
} else if (schema.type === "boolean") {
|
|
342
|
+
control = h("select", {}, h("option", { value: "" }, "—"), h("option", { value: "true" }, "true"), h("option", { value: "false" }, "false"))
|
|
343
|
+
get = () => (control.value === "" ? undefined : control.value === "true")
|
|
344
|
+
set = (v) => (control.value = v === undefined ? "" : String(v))
|
|
345
|
+
} else if (schema.type === "number" || schema.type === "integer") {
|
|
346
|
+
control = h("input", { type: "number", step: schema.type === "integer" ? "1" : "any" })
|
|
347
|
+
get = () => (control.value === "" ? undefined : Number(control.value))
|
|
348
|
+
set = (v) => (control.value = v === undefined ? "" : String(v))
|
|
349
|
+
} else if (schema.type === "string") {
|
|
350
|
+
control = h("input", { type: "text", placeholder: schema.format || "" })
|
|
351
|
+
get = () => (control.value === "" ? undefined : control.value)
|
|
352
|
+
set = (v) => (control.value = v === undefined ? "" : String(v))
|
|
353
|
+
} else if (schema.type === "array" && isScalar(baseSchema(schema.items))) {
|
|
354
|
+
const item = baseSchema(schema.items)
|
|
355
|
+
control = h("textarea", { rows: 3, placeholder: "one item per line" })
|
|
356
|
+
get = () => {
|
|
357
|
+
if (control.value.trim() === "") return undefined
|
|
358
|
+
return control.value.split("\n").filter((l) => l !== "").map((l) => {
|
|
359
|
+
if (item.type === "number" || item.type === "integer") {
|
|
360
|
+
if (Number.isNaN(Number(l))) throw new Error(`${key}: "${l}" is not a number`)
|
|
361
|
+
return Number(l)
|
|
362
|
+
}
|
|
363
|
+
if (item.type === "boolean") return l.trim() === "true"
|
|
364
|
+
return l
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
set = (v) => (control.value = Array.isArray(v) ? v.join("\n") : "")
|
|
368
|
+
} else {
|
|
369
|
+
control = h("textarea", { rows: 3, placeholder: "JSON" })
|
|
370
|
+
get = () => {
|
|
371
|
+
if (control.value.trim() === "") return undefined
|
|
372
|
+
try {
|
|
373
|
+
return JSON.parse(control.value)
|
|
374
|
+
} catch {
|
|
375
|
+
throw new Error(`${key}: not valid JSON`)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
set = (v) => (control.value = v === undefined ? "" : pretty(v))
|
|
379
|
+
}
|
|
380
|
+
control.id = `f-${key}`
|
|
381
|
+
const node = h(
|
|
382
|
+
"div",
|
|
383
|
+
{ class: "field" },
|
|
384
|
+
h("label", { for: control.id }, key, required ? h("span", { class: "req", title: "required" }, " *") : null),
|
|
385
|
+
h("div", {}, control, hint ? h("small", {}, hint) : null),
|
|
386
|
+
)
|
|
387
|
+
return { key, node, get, set }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function buildForm(schema) {
|
|
391
|
+
const s = baseSchema(schema)
|
|
392
|
+
const props = s && s.type === "object" && s.properties ? s.properties : null
|
|
393
|
+
const fields = props ? Object.entries(props).map(([key, value]) => makeField(key, value, (s.required || []).includes(key))) : []
|
|
394
|
+
const json = h("textarea", { rows: 8, id: "input-json" })
|
|
395
|
+
json.value = "{}"
|
|
396
|
+
return { fields, json, structured: props !== null }
|
|
397
|
+
}
|
|
398
|
+
function readForm() {
|
|
399
|
+
if (S.mode === "json" || !form.structured) {
|
|
400
|
+
let value
|
|
401
|
+
try {
|
|
402
|
+
value = JSON.parse(form.json.value.trim() === "" ? "{}" : form.json.value)
|
|
403
|
+
} catch {
|
|
404
|
+
throw new Error("Input is not valid JSON")
|
|
405
|
+
}
|
|
406
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Input must be a JSON object")
|
|
407
|
+
return value
|
|
408
|
+
}
|
|
409
|
+
const out = {}
|
|
410
|
+
for (const f of form.fields) {
|
|
411
|
+
const v = f.get()
|
|
412
|
+
if (v !== undefined) out[f.key] = v
|
|
413
|
+
}
|
|
414
|
+
return out
|
|
415
|
+
}
|
|
416
|
+
function fillForm(input) {
|
|
417
|
+
const known = new Set(form.fields.map((f) => f.key))
|
|
418
|
+
const extra = Object.keys(input || {}).some((key) => !known.has(key))
|
|
419
|
+
for (const f of form.fields) f.set((input || {})[f.key])
|
|
420
|
+
form.json.value = pretty(input || {})
|
|
421
|
+
if (extra || !form.structured) setMode("json")
|
|
422
|
+
}
|
|
423
|
+
function setMode(mode) {
|
|
424
|
+
if (mode === S.mode) return
|
|
425
|
+
try {
|
|
426
|
+
const current = readForm()
|
|
427
|
+
S.mode = mode
|
|
428
|
+
if (mode === "json") form.json.value = pretty(current)
|
|
429
|
+
else for (const f of form.fields) f.set(current[f.key])
|
|
430
|
+
} catch (error) {
|
|
431
|
+
return toast(error.message)
|
|
432
|
+
}
|
|
433
|
+
renderCenter()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function select(id, options = {}) {
|
|
437
|
+
S.selected = id
|
|
438
|
+
S.info = null
|
|
439
|
+
S.describeError = null
|
|
440
|
+
if (!options.keepCards) S.cards = []
|
|
441
|
+
if (location.hash.slice(1) !== id) history.replaceState(null, "", `#${id}`)
|
|
442
|
+
renderTree()
|
|
443
|
+
try {
|
|
444
|
+
const { info, visibleTo } = await getJson(`/api/command?id=${encodeURIComponent(id)}&as=${encodeURIComponent(involved().join(","))}`)
|
|
445
|
+
if (S.selected !== id) return
|
|
446
|
+
S.info = info
|
|
447
|
+
S.visibleTo = visibleTo
|
|
448
|
+
form = buildForm(info.inputSchema)
|
|
449
|
+
S.mode = form.structured ? "form" : "json"
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (S.selected !== id) return
|
|
452
|
+
S.describeError = (error.body && error.body.error) || { code: "ERROR", message: error.message }
|
|
453
|
+
S.visibleTo = (error.body && error.body.visibleTo) || {}
|
|
454
|
+
// 어느 프로필에도 보이지 않는 명령어도 호출해 볼 수 있다 (입력 = JSON)
|
|
455
|
+
form = buildForm(null)
|
|
456
|
+
S.mode = "json"
|
|
457
|
+
}
|
|
458
|
+
if (options.input) fillForm(options.input)
|
|
459
|
+
renderCenter()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function renderCenter() {
|
|
463
|
+
const root = $("center")
|
|
464
|
+
if (!S.selected) return root.replaceChildren(h("div", { class: "empty" }, "Select a command."))
|
|
465
|
+
const info = S.info
|
|
466
|
+
const names = involved()
|
|
467
|
+
const head = [h("h2", {}, S.selected)]
|
|
468
|
+
if (info) {
|
|
469
|
+
head.push(h("div", {}, info.description))
|
|
470
|
+
const approval = info.requiresConfirmation ? "required" : info.needsApproval === "conditional" ? "conditional" : "none"
|
|
471
|
+
head.push(
|
|
472
|
+
h(
|
|
473
|
+
"dl",
|
|
474
|
+
{ class: "meta" },
|
|
475
|
+
h("dt", {}, "Safety"), h("dd", { class: "chips" }, safetyChips(info)),
|
|
476
|
+
h("dt", {}, "Approval"), h("dd", {}, approval === "none" ? "none" : approval === "required" ? "required — a person has to confirm each call" : "conditional — depends on the input"),
|
|
477
|
+
h("dt", {}, "Scopes"), h("dd", { class: "chips" }, info.scopes.length ? info.scopes.map((s) => h("span", { class: "chip" }, s)) : h("span", { class: "muted" }, "none")),
|
|
478
|
+
h("dt", {}, "Visible to"), h("dd", { class: "chips" }, names.map((n) => h("span", { class: `chip${S.visibleTo[n] ? "" : " hidden"}`, title: S.visibleTo[n] ? "visible" : "hidden (expose)" }, n))),
|
|
479
|
+
info.aliases.length ? [h("dt", {}, "Aliases"), h("dd", {}, info.aliases.join(", "))] : null,
|
|
480
|
+
info.tool ? [h("dt", {}, "Tool"), h("dd", {}, info.tool)] : null,
|
|
481
|
+
info.task ? [h("dt", {}, "Task"), h("dd", {}, `runs as a task (ttl ${info.task.ttlMs} ms)`)] : null,
|
|
482
|
+
info.errors.length ? [h("dt", {}, "Errors"), h("dd", { class: "chips" }, info.errors.map((e) => h("span", { class: "chip" }, e)))] : null,
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
} else if (S.describeError) {
|
|
486
|
+
head.push(h("div", { class: "note" }, `${S.describeError.code}: ${S.describeError.message} — not visible to ${names.join(", ")}. You can still call it with JSON input to see what each caller gets.`))
|
|
487
|
+
} else head.push(h("div", { class: "muted" }, "Loading…"))
|
|
488
|
+
|
|
489
|
+
const examples = info && info.examples.length ? h("div", { class: "row" }, h("span", { class: "muted" }, "Examples"), info.examples.map((example, i) => h("button", { title: JSON.stringify(example), onclick: () => (fillForm(example), renderCenter()) }, `Fill example ${i + 1}`))) : null
|
|
490
|
+
const modes = form.structured
|
|
491
|
+
? h("span", { class: "row" }, h("button", { class: `chip${S.mode === "form" ? " on" : ""}`, onclick: () => setMode("form") }, "Form"), h("button", { class: `chip${S.mode === "json" ? " on" : ""}`, onclick: () => setMode("json") }, "JSON"))
|
|
492
|
+
: null
|
|
493
|
+
const inputs = S.mode === "form" && form.structured ? (form.fields.length ? form.fields.map((f) => f.node) : [h("div", { class: "muted" }, "No input fields.")]) : [form.json]
|
|
494
|
+
const confirmed = h("input", { type: "checkbox", id: "confirmed", checked: S.confirmed, onchange: (event) => (S.confirmed = event.target.checked) })
|
|
495
|
+
const running = S.running !== null
|
|
496
|
+
const actions = h(
|
|
497
|
+
"div",
|
|
498
|
+
{ class: "row actions" },
|
|
499
|
+
h("button", { class: "primary", id: "call", disabled: running, onclick: () => run([S.profile], confirmed.checked) }, `Call as ${S.profile}`),
|
|
500
|
+
S.compare.size > 0 ? h("button", { id: "call-all", disabled: running, onclick: () => run(names, confirmed.checked) }, `Call as ${names.join(", ")}`) : null,
|
|
501
|
+
h("label", { class: "row", title: 'Send the call with the confirmation mark (same as "--yes")' }, confirmed, "Confirmed by a person"),
|
|
502
|
+
running ? h("button", { id: "cancel", onclick: () => S.running.abort() }, "Cancel") : null,
|
|
503
|
+
)
|
|
504
|
+
root.replaceChildren(...head, h("h3", {}, "Input ", modes), ...(examples ? [examples] : []), ...inputs, actions, h("h3", {}, "Result"), h("div", { class: S.cards.length > 1 ? "grid" : "", id: "cards" }, S.cards.length ? S.cards.map(renderCard) : h("div", { class: "muted" }, "No call yet.")))
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/* ---------------------------------------------------------------- 호출 · 결과 */
|
|
508
|
+
|
|
509
|
+
/** 이 호출의 감사 이벤트 = trace ID 가 같은 것 (inspector 가 요청에 실은 traceparent → 서버가 이벤트의 traceId 로 남긴다) */
|
|
510
|
+
function auditFor(record) {
|
|
511
|
+
return record.traceId ? S.audit.filter((e) => e.traceId === record.traceId) : []
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function renderCard(card) {
|
|
515
|
+
const r = card.record
|
|
516
|
+
const head = [h("strong", {}, card.profile)]
|
|
517
|
+
if (!r) head.push(h("span", { class: "muted" }, card.status || "calling…"))
|
|
518
|
+
else {
|
|
519
|
+
head.push(h("span", { class: r.ok ? "ok" : "bad" }, r.ok ? "✓ ok" : `✗ ${r.error.code}${typeof r.error.reason === "string" ? ` (${r.error.reason})` : ""}`))
|
|
520
|
+
head.push(h("span", { class: "muted" }, `${r.durationMs} ms${r.confirmed ? " · confirmed" : ""}`))
|
|
521
|
+
}
|
|
522
|
+
const body = []
|
|
523
|
+
if (card.note) body.push(h("div", { class: "note" }, card.note))
|
|
524
|
+
const last = card.progress[card.progress.length - 1]
|
|
525
|
+
if (last && !r) {
|
|
526
|
+
const fill = h("i", {})
|
|
527
|
+
if (typeof last.progress === "number" && typeof last.total === "number" && last.total > 0) fill.style.width = `${Math.min(100, (last.progress / last.total) * 100)}%`
|
|
528
|
+
body.push(h("div", {}, `${last.progress !== undefined ? `[${last.progress}${last.total !== undefined ? `/${last.total}` : ""}] ` : ""}${last.message || ""}`), h("div", { class: "bar" }, fill))
|
|
529
|
+
}
|
|
530
|
+
if (card.task) body.push(h("div", { class: "muted" }, `task ${card.task.taskId}: ${card.task.status}${card.task.statusMessage ? ` — ${card.task.statusMessage}` : ""}`))
|
|
531
|
+
if (r) {
|
|
532
|
+
body.push(jsonBlock(r.ok ? r.value : r.error))
|
|
533
|
+
if (r.progress.length) body.push(h("details", {}, h("summary", {}, `Progress (${r.progress.length})`), jsonBlock(r.progress)))
|
|
534
|
+
if (r.inputs.length) body.push(h("details", {}, h("summary", {}, `Input requests (${r.inputs.length})`), jsonBlock(r.inputs)))
|
|
535
|
+
const events = auditFor(r)
|
|
536
|
+
if (events.length) body.push(h("details", { open: true }, h("summary", {}, `Audit events (${events.length})`), events.map((e) => jsonBlock(auditLine(e)))))
|
|
537
|
+
body.push(h("details", { class: "raw" }, h("summary", {}, `Raw requests and responses (${r.exchanges.length})`), r.exchanges.map((x) => [h("div", { class: "muted" }, `${x.request.method} ${x.rpc || ""} → ${x.response ? x.response.status : x.error || "no response"} (${x.durationMs} ms)`), jsonBlock({ request: x.request, response: x.response })])))
|
|
538
|
+
body.push(h("div", { class: "row" }, h("button", { onclick: () => addToScenario(r) }, "Add to scenario…"), h("button", { onclick: () => (fillForm(r.input), renderCenter()) }, "Refill input")))
|
|
539
|
+
}
|
|
540
|
+
return h("div", { class: "card" }, h("div", { class: "head" }, head), h("div", { class: "body" }, body))
|
|
541
|
+
}
|
|
542
|
+
const auditLine = (e) => {
|
|
543
|
+
const { at, command, outcome, surface, transport, actor, client, principal, approval, error, target, summary, input, traceId } = e
|
|
544
|
+
return { at, command, outcome, surface, transport, actor, client, principal, approval, error, target, summary, input, traceId }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** 호출 1건 (프로필 하나). 승인 필요 (unconfirmed) = 확인 창 → 예 = 확인 표시와 함께 다시 호출 */
|
|
548
|
+
async function callOnce(id, input, profile, confirmed, signal) {
|
|
549
|
+
const card = { profile, progress: [], record: null }
|
|
550
|
+
S.cards.push(card)
|
|
551
|
+
renderCenter()
|
|
552
|
+
try {
|
|
553
|
+
for await (const event of stream("/api/call", { id, input, as: profile, confirmed }, signal)) {
|
|
554
|
+
if (event.type === "start") card.call = event.call
|
|
555
|
+
else if (event.type === "progress") card.progress.push(event.update)
|
|
556
|
+
else if (event.type === "task") card.task = event.task
|
|
557
|
+
else if (event.type === "input") {
|
|
558
|
+
card.status = "waiting for input…"
|
|
559
|
+
renderCenter()
|
|
560
|
+
const answer = await askInput(profile, id, event.request)
|
|
561
|
+
card.status = undefined
|
|
562
|
+
await postJson("/api/call/input", { call: card.call, request: event.request.id, answer })
|
|
563
|
+
} else if (event.type === "record") card.record = event.record
|
|
564
|
+
renderCenter()
|
|
565
|
+
}
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (!card.record) card.note = signal.aborted ? "Cancelled." : `Call failed: ${error.message}`
|
|
568
|
+
card.status = signal.aborted ? "cancelled" : "failed"
|
|
569
|
+
}
|
|
570
|
+
const error = card.record && !card.record.ok ? card.record.error : null
|
|
571
|
+
if (error && error.code === "APPROVAL_REQUIRED" && !signal.aborted) {
|
|
572
|
+
if (error.reason === "unconfirmed") {
|
|
573
|
+
const yes = await ask({
|
|
574
|
+
title: "Confirmation required",
|
|
575
|
+
body: [h("p", {}, `Run ${id} as ${profile} with this input?`), jsonBlock(input), h("p", { class: "muted" }, "The server asked for a person's confirmation. “Yes” sends the same call again with the confirmation mark.")],
|
|
576
|
+
buttons: [{ label: "No", value: false }, { label: "Yes, run it", value: true, primary: true }],
|
|
577
|
+
})
|
|
578
|
+
if (yes) {
|
|
579
|
+
card.note = "Not confirmed on the first try (APPROVAL_REQUIRED unconfirmed). Confirmed in the dialog → called again below."
|
|
580
|
+
renderCenter()
|
|
581
|
+
return callOnce(id, input, profile, true, signal)
|
|
582
|
+
}
|
|
583
|
+
card.note = "Not confirmed. The command did not run."
|
|
584
|
+
} else {
|
|
585
|
+
card.note = `Not retried: ${profile} cannot approve this command (reason: ${error.reason}). A person has to run it.`
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
renderCenter()
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function run(profiles, confirmed) {
|
|
592
|
+
let input
|
|
593
|
+
try {
|
|
594
|
+
input = readForm()
|
|
595
|
+
} catch (error) {
|
|
596
|
+
return toast(error.message)
|
|
597
|
+
}
|
|
598
|
+
const id = S.selected
|
|
599
|
+
S.cards = []
|
|
600
|
+
S.running = new AbortController()
|
|
601
|
+
renderCenter()
|
|
602
|
+
try {
|
|
603
|
+
for (const profile of profiles) {
|
|
604
|
+
if (S.running.signal.aborted) break
|
|
605
|
+
await callOnce(id, input, profile, confirmed, S.running.signal)
|
|
606
|
+
}
|
|
607
|
+
} finally {
|
|
608
|
+
S.running = null
|
|
609
|
+
renderCenter()
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/* ---------------------------------------------------------------- 창 (확인 · 입력 요청) */
|
|
614
|
+
|
|
615
|
+
function ask({ title, body, buttons }) {
|
|
616
|
+
const dialog = $("dialog")
|
|
617
|
+
return new Promise((resolve) => {
|
|
618
|
+
let result
|
|
619
|
+
const finish = (value) => {
|
|
620
|
+
result = value
|
|
621
|
+
dialog.close()
|
|
622
|
+
}
|
|
623
|
+
dialog.replaceChildren(h("h4", {}, title), ...body, h("div", { class: "buttons" }, buttons.map((b) => h("button", { class: b.primary ? "primary" : "", "data-value": String(b.label), onclick: () => finish(typeof b.value === "function" ? b.value() : b.value) }, b.label))))
|
|
624
|
+
dialog.addEventListener("close", () => resolve(result), { once: true })
|
|
625
|
+
dialog.showModal()
|
|
626
|
+
})
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function askInput(profile, id, request) {
|
|
630
|
+
const who = h("p", { class: "muted" }, `${id} (as ${profile}) asks:`)
|
|
631
|
+
if (request.kind === "confirm") {
|
|
632
|
+
const yes = await ask({ title: "Confirm", body: [who, h("p", {}, request.message)], buttons: [{ label: "No", value: false }, { label: "Yes", value: true, primary: true }] })
|
|
633
|
+
return yes === true
|
|
634
|
+
}
|
|
635
|
+
if (request.kind === "url") {
|
|
636
|
+
const yes = await ask({
|
|
637
|
+
title: "Open a URL",
|
|
638
|
+
body: [who, h("p", {}, request.message), h("p", {}, h("a", { href: request.url, target: "_blank", rel: "noreferrer noopener" }, request.url))],
|
|
639
|
+
buttons: [{ label: "Decline", value: false }, { label: "I opened it", value: true, primary: true }],
|
|
640
|
+
})
|
|
641
|
+
return yes === true
|
|
642
|
+
}
|
|
643
|
+
const fields = buildForm(request.schema)
|
|
644
|
+
for (;;) {
|
|
645
|
+
const answer = await ask({
|
|
646
|
+
title: "Input requested",
|
|
647
|
+
body: [who, h("p", {}, request.message), ...(fields.structured ? fields.fields.map((f) => f.node) : [fields.json])],
|
|
648
|
+
buttons: [
|
|
649
|
+
{ label: "Decline", value: false },
|
|
650
|
+
{
|
|
651
|
+
label: "Send",
|
|
652
|
+
primary: true,
|
|
653
|
+
value: () => {
|
|
654
|
+
try {
|
|
655
|
+
if (!fields.structured) return { action: "accept", content: JSON.parse(fields.json.value || "{}") }
|
|
656
|
+
const content = {}
|
|
657
|
+
for (const f of fields.fields) {
|
|
658
|
+
const v = f.get()
|
|
659
|
+
if (v !== undefined) content[f.key] = v
|
|
660
|
+
}
|
|
661
|
+
return { action: "accept", content }
|
|
662
|
+
} catch (error) {
|
|
663
|
+
toast(error.message)
|
|
664
|
+
return "retry"
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
],
|
|
669
|
+
})
|
|
670
|
+
if (answer !== "retry") return answer === undefined ? false : answer
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function addToScenario(record) {
|
|
675
|
+
const name = h("input", { type: "text", value: S.scenarios.length ? S.scenarios[0].file : "scenario.json", list: "scenario-files" })
|
|
676
|
+
const picked = await ask({
|
|
677
|
+
title: "Add this call to a scenario",
|
|
678
|
+
body: [
|
|
679
|
+
h("p", { class: "muted" }, "The call and its observed result are appended as a step (inspect/scenarios/<name>.json)."),
|
|
680
|
+
name,
|
|
681
|
+
h("datalist", { id: "scenario-files" }, S.scenarios.map((s) => h("option", { value: s.file }))),
|
|
682
|
+
],
|
|
683
|
+
buttons: [{ label: "Cancel", value: null }, { label: "Add", primary: true, value: () => name.value.trim() }],
|
|
684
|
+
})
|
|
685
|
+
if (!picked) return
|
|
686
|
+
try {
|
|
687
|
+
const result = await postJson("/api/scenarios/append", { file: picked.endsWith(".json") ? picked : `${picked}.json`, record: record.id })
|
|
688
|
+
toast(`Added to ${result.file} (${result.steps} steps)`)
|
|
689
|
+
await loadScenarios()
|
|
690
|
+
} catch (error) {
|
|
691
|
+
toast(error.message)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/* ---------------------------------------------------------------- 오른쪽: 기록 · 시나리오 · 감사 */
|
|
696
|
+
|
|
697
|
+
const two = (n) => String(n).padStart(2, "0")
|
|
698
|
+
const time = (iso) => {
|
|
699
|
+
const d = new Date(iso)
|
|
700
|
+
return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function renderSide() {
|
|
704
|
+
const tabs = [["history", `History (${S.history.length})`], ["scenarios", `Scenarios (${S.scenarios.length})`], ["audit", `Audit (${S.audit.length})`]]
|
|
705
|
+
$("tabs").replaceChildren(...tabs.map(([id, label]) => h("button", { class: S.tab === id ? "on" : "", id: `tab-${id}`, onclick: () => ((S.tab = id), renderSide()) }, label)))
|
|
706
|
+
const panel = $("panel")
|
|
707
|
+
if (S.tab === "history") {
|
|
708
|
+
if (!S.history.length) return panel.replaceChildren(h("div", { class: "empty" }, "Calls made here, by scenarios, or in another tab show up here."))
|
|
709
|
+
panel.replaceChildren(
|
|
710
|
+
h("div", { class: "row" }, h("button", { onclick: clearHistory }, "Clear")),
|
|
711
|
+
...[...S.history].reverse().map((r) =>
|
|
712
|
+
h(
|
|
713
|
+
"button",
|
|
714
|
+
{ class: "item", onclick: () => showRecord(r) },
|
|
715
|
+
h("div", { class: "top" }, h("span", { class: r.ok ? "ok" : "bad" }, r.ok ? "✓" : "✗"), h("span", { class: "name" }, r.command), h("small", {}, time(r.at))),
|
|
716
|
+
h("small", {}, `as ${r.profile} · ${r.ok ? "ok" : `${r.error.code}${typeof r.error.reason === "string" ? ` (${r.error.reason})` : ""}`} · ${r.durationMs} ms`),
|
|
717
|
+
),
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
} else if (S.tab === "scenarios") {
|
|
721
|
+
if (!S.scenarios.length) return panel.replaceChildren(h("div", { class: "empty" }, "No scenario files in inspect/scenarios. Use “Add to scenario…” on a result to create one."))
|
|
722
|
+
panel.replaceChildren(
|
|
723
|
+
...S.scenarios.map((s) => {
|
|
724
|
+
const run = S.scenarioRuns[s.file]
|
|
725
|
+
const steps = run ? run.steps : []
|
|
726
|
+
return h(
|
|
727
|
+
"div",
|
|
728
|
+
{ class: "card" },
|
|
729
|
+
h(
|
|
730
|
+
"div",
|
|
731
|
+
{ class: "head" },
|
|
732
|
+
h("strong", {}, s.name),
|
|
733
|
+
h("span", { class: "muted" }, `${s.file} · ${s.steps} steps`),
|
|
734
|
+
run && run.result ? h("span", { class: run.result.pass ? "ok" : "bad" }, run.result.pass ? "✓ passed" : "✗ failed") : null,
|
|
735
|
+
h("span", { class: "grow" }),
|
|
736
|
+
h("button", { disabled: Boolean(s.error) || (run && run.running), onclick: () => runScenario(s.file) }, run && run.running ? "Running…" : "Run"),
|
|
737
|
+
),
|
|
738
|
+
s.description || s.error || steps.length
|
|
739
|
+
? h(
|
|
740
|
+
"div",
|
|
741
|
+
{ class: "body" },
|
|
742
|
+
s.description ? h("div", { class: "muted" }, s.description) : null,
|
|
743
|
+
s.error ? h("div", { class: "note" }, "Invalid file: ", s.error.join("; ")) : null,
|
|
744
|
+
s.agentSteps > 0 && !S.state.agent.available
|
|
745
|
+
? h("div", { class: "note" }, `${s.agentSteps} agent step${s.agentSteps === 1 ? "" : "s"}: no agent is connected. Start with “mext inspect ui --agent <module>” to run them.`)
|
|
746
|
+
: null,
|
|
747
|
+
s.agentSteps > 0 && S.state.agent.available ? h("div", { class: "muted" }, `Agent: ${S.state.agent.name || "connected"}`) : null,
|
|
748
|
+
steps.length || (run && run.current)
|
|
749
|
+
? h(
|
|
750
|
+
"ul",
|
|
751
|
+
{ class: "steps" },
|
|
752
|
+
steps.map((st) => [
|
|
753
|
+
h("li", {}, h("span", { class: st.pass ? "ok" : "bad" }, st.pass ? "✓ " : "✗ "), `${st.step}. ${st.name} `, h("small", { class: "muted" }, `as ${st.as} · ${st.durationMs} ms`)),
|
|
754
|
+
st.failures.map((f) => h("li", { class: "fail" }, `${f.path}: expected ${JSON.stringify(f.expected)}, got ${JSON.stringify(f.actual)}`)),
|
|
755
|
+
st.outcome && Array.isArray(st.outcome.events) ? h("li", { class: "agent" }, agentDetails(st)) : null,
|
|
756
|
+
]),
|
|
757
|
+
run && run.current ? h("li", { class: "muted" }, `… ${run.current.step}. ${run.current.name} `, h("small", {}, run.current.as === "agent" ? "waiting for the agent" : "running")) : null,
|
|
758
|
+
)
|
|
759
|
+
: null,
|
|
760
|
+
)
|
|
761
|
+
: null,
|
|
762
|
+
)
|
|
763
|
+
}),
|
|
764
|
+
)
|
|
765
|
+
} else {
|
|
766
|
+
const audit = S.state.audit
|
|
767
|
+
if (!audit.available) return panel.replaceChildren(h("div", { class: "note" }, "Audit events are not available for this target. ", audit.reason || ""))
|
|
768
|
+
if (!S.audit.length) return panel.replaceChildren(h("div", { class: "empty" }, "Waiting for audit events. Every call to the server shows up here (including calls made by agents)."))
|
|
769
|
+
panel.replaceChildren(
|
|
770
|
+
...[...S.audit].reverse().map((e) =>
|
|
771
|
+
h(
|
|
772
|
+
"details",
|
|
773
|
+
{ class: "item" },
|
|
774
|
+
h(
|
|
775
|
+
"summary",
|
|
776
|
+
{},
|
|
777
|
+
h("span", { class: e.outcome === "success" ? "ok" : "bad" }, e.outcome),
|
|
778
|
+
" ",
|
|
779
|
+
h("code", {}, e.command),
|
|
780
|
+
" ",
|
|
781
|
+
h("small", {}, `${time(e.at)} · ${e.surface}${e.actor ? ` · ${e.actor.kind}:${e.actor.id}` : ""}${e.client ? ` · ${e.client.name}` : ""}${e.error ? ` · ${e.error.code}` : ""}${e.approval ? ` · approval ${e.approval.required ? (e.approval.confirmed ? "confirmed" : "not confirmed") : "not required"}` : ""}`),
|
|
782
|
+
),
|
|
783
|
+
jsonBlock(e),
|
|
784
|
+
),
|
|
785
|
+
),
|
|
786
|
+
)
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function showRecord(record) {
|
|
791
|
+
await select(record.command, { input: record.input, keepCards: true })
|
|
792
|
+
S.cards = [{ profile: record.profile, progress: [], record }]
|
|
793
|
+
renderCenter()
|
|
794
|
+
}
|
|
795
|
+
async function clearHistory() {
|
|
796
|
+
await getJson("/api/history", { method: "DELETE" })
|
|
797
|
+
S.history = []
|
|
798
|
+
renderSide()
|
|
799
|
+
}
|
|
800
|
+
/** agent 단계의 결과: 지시문 · 에이전트가 한 말 · 에이전트 쪽 도구 호출 · 서버가 본 호출 (감사 이벤트). 판정은 서버가 본 호출로만 한다 */
|
|
801
|
+
function agentDetails(st) {
|
|
802
|
+
const o = st.outcome
|
|
803
|
+
const said = o.messages.length ? o.messages.map((m) => h("div", { class: "said" }, m)) : h("div", { class: "muted" }, "(nothing)")
|
|
804
|
+
const tools = o.tools.map((t) =>
|
|
805
|
+
h("details", {}, h("summary", {}, h("span", { class: t.ok === false ? "bad" : "ok" }, t.ok === false ? "✗ " : "✓ "), h("code", {}, t.name)), jsonBlock({ input: t.input, output: t.output })),
|
|
806
|
+
)
|
|
807
|
+
const saw = o.events.map((e) =>
|
|
808
|
+
h(
|
|
809
|
+
"details",
|
|
810
|
+
{},
|
|
811
|
+
h("summary", {}, h("span", { class: e.outcome === "success" ? "ok" : "bad" }, e.outcome), " ", h("code", {}, e.command), h("small", { class: "muted" }, ` ${[e.actor ? `${e.actor.kind}:${e.actor.id}` : null, e.client ? e.client.name : null, e.error ? e.error.code : null].filter(Boolean).join(" · ")}`)),
|
|
812
|
+
jsonBlock(auditLine(e)),
|
|
813
|
+
),
|
|
814
|
+
)
|
|
815
|
+
return h(
|
|
816
|
+
"details",
|
|
817
|
+
{ open: !st.pass },
|
|
818
|
+
h("summary", {}, `Agent · said ${o.messages.length} · tool calls ${o.tools.length} · server saw ${o.events.length}`),
|
|
819
|
+
o.ok ? null : h("div", { class: "note" }, `The agent did not finish: ${o.problem}`),
|
|
820
|
+
h("h5", {}, "Instruction"),
|
|
821
|
+
jsonBlock(o.instruction),
|
|
822
|
+
h("h5", {}, "Server saw (audit events — the pass criterion)"),
|
|
823
|
+
saw.length ? saw : h("div", { class: "muted" }, "No call from the agent reached the server."),
|
|
824
|
+
h("h5", {}, "Agent said"),
|
|
825
|
+
said,
|
|
826
|
+
h("h5", {}, `Agent's own tool calls (${o.tools.length}, not used for pass / fail)`),
|
|
827
|
+
tools.length ? tools : h("div", { class: "muted" }, "(none reported)"),
|
|
828
|
+
)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function loadScenarios() {
|
|
832
|
+
S.scenarios = await getJson("/api/scenarios")
|
|
833
|
+
renderSide()
|
|
834
|
+
}
|
|
835
|
+
async function runScenario(file) {
|
|
836
|
+
const run = (S.scenarioRuns[file] = { running: true, steps: [], result: null, current: null })
|
|
837
|
+
renderSide()
|
|
838
|
+
try {
|
|
839
|
+
for await (const event of stream("/api/scenarios/run", { file })) {
|
|
840
|
+
if (event.type === "step-start") run.current = { step: event.step, name: event.name, as: event.as }
|
|
841
|
+
else if (event.type === "step") {
|
|
842
|
+
run.current = null
|
|
843
|
+
run.steps.push(event.step)
|
|
844
|
+
}
|
|
845
|
+
else if (event.type === "result") run.result = event.result
|
|
846
|
+
renderSide()
|
|
847
|
+
}
|
|
848
|
+
} catch (error) {
|
|
849
|
+
toast(error.message)
|
|
850
|
+
}
|
|
851
|
+
run.running = false
|
|
852
|
+
run.current = null
|
|
853
|
+
renderSide()
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** 호출 기록 · 감사 이벤트 스트림 (끊기면 다시) */
|
|
857
|
+
async function listen() {
|
|
858
|
+
for (;;) {
|
|
859
|
+
try {
|
|
860
|
+
const response = await api("/api/events")
|
|
861
|
+
for await (const event of lines(response)) {
|
|
862
|
+
if (event.type === "record") {
|
|
863
|
+
if (!S.history.some((r) => r.id === event.record.id)) S.history.push(event.record)
|
|
864
|
+
renderSide()
|
|
865
|
+
} else if (event.type === "audit") {
|
|
866
|
+
S.audit.push(event.event)
|
|
867
|
+
renderSide()
|
|
868
|
+
if (S.cards.some((c) => c.record)) renderCenter()
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
} catch {
|
|
872
|
+
// 다시 연결
|
|
873
|
+
}
|
|
874
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/* ---------------------------------------------------------------- 시작 */
|
|
879
|
+
|
|
880
|
+
async function reload() {
|
|
881
|
+
renderHeader()
|
|
882
|
+
const names = involved()
|
|
883
|
+
const lists = await getJson(`/api/commands?as=${encodeURIComponent(names.join(","))}`)
|
|
884
|
+
S.lists = Object.fromEntries(lists.map((entry) => [entry.profile, entry]))
|
|
885
|
+
renderTree()
|
|
886
|
+
if (S.selected) await select(S.selected, { keepCards: true, input: safeRead() })
|
|
887
|
+
else renderCenter()
|
|
888
|
+
}
|
|
889
|
+
function safeRead() {
|
|
890
|
+
try {
|
|
891
|
+
return S.info ? readForm() : undefined
|
|
892
|
+
} catch {
|
|
893
|
+
return undefined
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function start() {
|
|
898
|
+
S.state = await getJson("/api/state")
|
|
899
|
+
const usable = S.state.profiles.filter((p) => p.available)
|
|
900
|
+
S.profile = (usable.find((p) => p.name === S.state.default) || usable[0] || S.state.profiles[0]).name
|
|
901
|
+
;[S.history, S.audit] = await Promise.all([getJson("/api/history"), getJson("/api/audit")])
|
|
902
|
+
renderSide()
|
|
903
|
+
await loadScenarios()
|
|
904
|
+
S.selected = decodeURIComponent(location.hash.slice(1)) || null
|
|
905
|
+
await reload()
|
|
906
|
+
void listen()
|
|
907
|
+
}
|
|
908
|
+
window.addEventListener("hashchange", () => {
|
|
909
|
+
const id = decodeURIComponent(location.hash.slice(1))
|
|
910
|
+
if (id && id !== S.selected) void select(id)
|
|
911
|
+
})
|
|
912
|
+
start().catch((error) => {
|
|
913
|
+
$("center").replaceChildren(h("div", { class: "note" }, `Could not load: ${error.message}`))
|
|
914
|
+
})
|
|
915
|
+
</script>
|
|
916
|
+
</body>
|
|
917
|
+
</html>
|