modelroom 0.0.1 → 0.0.3
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/CHANGELOG.md +19 -0
- package/dist/lib/server.d.ts.map +1 -1
- package/dist/lib/server.js +73 -24
- package/dist/lib/server.js.map +1 -1
- package/package.json +39 -6
- package/ui/assets/app.js +68 -0
- package/ui/kit.html +16 -0
- package/ui/modelroom.css +1 -1
- package/ui/room.html +6 -456
package/ui/room.html
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<!-- modelroom — a room: chat · docs · artifact, one screen at a time
|
|
2
|
+
<!-- modelroom — a room: chat · docs · artifact, one screen at a time.
|
|
3
|
+
The shell is static; the app itself lives in web/ and is built to
|
|
4
|
+
/ui/assets/app.js (committed). -->
|
|
3
5
|
<html lang="en">
|
|
4
6
|
<head>
|
|
5
7
|
<meta charset="utf-8">
|
|
@@ -7,460 +9,8 @@
|
|
|
7
9
|
<title>Room — modelroom</title>
|
|
8
10
|
<link rel="stylesheet" href="/ui/modelroom.css">
|
|
9
11
|
</head>
|
|
10
|
-
<body class="h-dvh overflow-hidden bg-
|
|
11
|
-
|
|
12
|
-
<
|
|
13
|
-
<div class="flex-1 gap-2 min-w-0">
|
|
14
|
-
<a class="btn btn-ghost btn-sm" href="/">← Rooms</a>
|
|
15
|
-
<span class="font-semibold truncate" id="title">…</span>
|
|
16
|
-
<span class="badge badge-sm font-mono opacity-70" id="roomName"></span>
|
|
17
|
-
<span id="statusBadge"></span>
|
|
18
|
-
</div>
|
|
19
|
-
<div class="flex-none flex items-center gap-2">
|
|
20
|
-
<span class="text-xs opacity-60 font-mono hidden md:inline" id="sessionLabel"></span>
|
|
21
|
-
<button class="btn btn-ghost btn-xs" id="sessionToggle" title="セッション情報">セッション</button>
|
|
22
|
-
<button class="btn btn-ghost btn-xs" id="newSession">new session</button>
|
|
23
|
-
<span class="badge" id="conn" data-phase="idle"><span class="loading loading-dots loading-xs hidden" id="spinner"></span><span id="statusText">ready</span></span>
|
|
24
|
-
</div>
|
|
25
|
-
</div>
|
|
26
|
-
|
|
27
|
-
<main id="cols" class="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[340px_minmax(240px,1fr)_minmax(300px,1.1fr)_320px]">
|
|
28
|
-
|
|
29
|
-
<!-- chat -->
|
|
30
|
-
<section class="min-w-0 min-h-0 flex flex-col border-r border-base-300">
|
|
31
|
-
<header class="flex items-center gap-2 px-3 py-2 border-b border-base-300 bg-base-100">
|
|
32
|
-
<span class="text-sm font-medium">Chat</span>
|
|
33
|
-
<span class="text-xs opacity-60 font-mono truncate" id="agentLabel"></span>
|
|
34
|
-
</header>
|
|
35
|
-
<div class="flex-1 overflow-auto p-3 flex flex-col gap-2 mr-scroll" id="messages"></div>
|
|
36
|
-
<div class="border-t border-base-300 p-2 bg-base-100 flex gap-2">
|
|
37
|
-
<textarea class="textarea textarea-bordered flex-1 text-sm" rows="3" id="prompt"
|
|
38
|
-
placeholder="Ask the agent to change this room. It edits the room's files; the artifact updates here."></textarea>
|
|
39
|
-
<button class="btn btn-primary self-end" id="send">Send</button>
|
|
40
|
-
</div>
|
|
41
|
-
</section>
|
|
42
|
-
|
|
43
|
-
<!-- docs -->
|
|
44
|
-
<section class="min-w-0 min-h-0 flex flex-col border-r border-base-300">
|
|
45
|
-
<header class="flex items-center gap-2 px-3 py-1.5 border-b border-base-300 bg-base-100 overflow-x-auto">
|
|
46
|
-
<div class="tabs tabs-box tabs-xs" id="docTabs"></div>
|
|
47
|
-
<span class="ml-auto text-xs" id="saveState"></span>
|
|
48
|
-
</header>
|
|
49
|
-
<div class="flex-1 min-h-0 relative" id="docPanes"></div>
|
|
50
|
-
</section>
|
|
51
|
-
|
|
52
|
-
<!-- artifact -->
|
|
53
|
-
<section class="min-w-0 min-h-0 flex flex-col">
|
|
54
|
-
<header class="flex flex-wrap items-center gap-2 px-3 py-1.5 border-b border-base-300 bg-base-100">
|
|
55
|
-
<div class="join">
|
|
56
|
-
<button class="btn btn-xs join-item" data-device="sp">sp 390</button>
|
|
57
|
-
<button class="btn btn-xs join-item btn-active" data-device="tablet">tablet 1024</button>
|
|
58
|
-
<button class="btn btn-xs join-item" data-device="pc">pc 1280</button>
|
|
59
|
-
</div>
|
|
60
|
-
<label class="label cursor-pointer gap-2 py-0"><input type="checkbox" class="checkbox checkbox-xs" id="fit" checked><span class="label-text text-xs">fit</span></label>
|
|
61
|
-
<div class="tabs tabs-box tabs-xs">
|
|
62
|
-
<button class="tab tab-active" data-view="artifact">artifact</button>
|
|
63
|
-
<button class="tab" data-view="png">png</button>
|
|
64
|
-
</div>
|
|
65
|
-
<button class="btn btn-xs" id="render">make png</button>
|
|
66
|
-
<a class="btn btn-xs btn-ghost ml-auto" id="openRaw" target="_blank" rel="noopener">open raw ↗</a>
|
|
67
|
-
</header>
|
|
68
|
-
<div class="flex-1 min-h-0 overflow-auto p-3" id="stage">
|
|
69
|
-
<div class="mx-auto relative" id="stageInner">
|
|
70
|
-
<div class="absolute top-0 left-0 origin-top-left rounded-box overflow-hidden border border-base-300 bg-base-100 shadow-sm" id="frame">
|
|
71
|
-
<iframe id="view" class="block w-full h-full border-0 bg-white" title="artifact"></iframe>
|
|
72
|
-
</div>
|
|
73
|
-
</div>
|
|
74
|
-
<img id="png" class="hidden w-full rounded-box border border-base-300 bg-base-100" alt="artifact thumbnail">
|
|
75
|
-
<div id="noArtifact" class="hidden text-sm opacity-70"></div>
|
|
76
|
-
</div>
|
|
77
|
-
</section>
|
|
78
|
-
|
|
79
|
-
<!-- session: what the agent did in this room (opencode) -->
|
|
80
|
-
<section id="sessionPanel" class="min-w-0 min-h-0 flex flex-col border-l border-base-300 bg-base-100">
|
|
81
|
-
<header class="flex items-center gap-2 px-3 py-2 border-b border-base-300 bg-base-100">
|
|
82
|
-
<span class="text-sm font-medium">セッション</span>
|
|
83
|
-
<button class="btn btn-ghost btn-xs ml-auto" id="sessionRefresh" title="更新">refresh</button>
|
|
84
|
-
</header>
|
|
85
|
-
<div class="flex-1 min-h-0 overflow-auto p-3 flex flex-col gap-3" id="sessionBody"></div>
|
|
86
|
-
</section>
|
|
87
|
-
</main>
|
|
88
|
-
|
|
89
|
-
<script>
|
|
90
|
-
'use strict';
|
|
91
|
-
const DEVICE_WIDTH = { sp: 390, tablet: 1024, pc: 1280 };
|
|
92
|
-
const $ = (id) => document.getElementById(id);
|
|
93
|
-
const room = decodeURIComponent(location.pathname.replace(/^\/room\//, '').split('?')[0]);
|
|
94
|
-
const view = $('view'), stage = $('stage'), frame = $('frame');
|
|
95
|
-
|
|
96
|
-
const st = { device: 'tablet', fit: true, session: null, running: false, docs: [], artifact: 'index.html', thumb: null };
|
|
97
|
-
const docs = {};
|
|
98
|
-
let pendingScroll = null, reloadTimer = null, chatEl = null;
|
|
99
|
-
|
|
100
|
-
const now = (ms) => new Date(ms || Date.now()).toLocaleTimeString();
|
|
101
|
-
const setConn = (phase, message) => {
|
|
102
|
-
$('conn').dataset.phase = phase;
|
|
103
|
-
$('statusText').textContent = message;
|
|
104
|
-
$('spinner').classList.toggle('hidden', !(phase === 'chat' || phase === 'rendering'));
|
|
105
|
-
};
|
|
106
|
-
function say(kind, text) {
|
|
107
|
-
const el = document.createElement('div');
|
|
108
|
-
el.className = kind === 'user'
|
|
109
|
-
? 'chat chat-end'
|
|
110
|
-
: kind === 'meta' ? 'text-xs opacity-60 font-mono mr-prose'
|
|
111
|
-
: 'chat chat-start';
|
|
112
|
-
el.innerHTML = kind === 'user'
|
|
113
|
-
? `<div class="chat-bubble chat-bubble-primary text-sm mr-prose">${escapeHtml(text)}</div>`
|
|
114
|
-
: kind === 'meta' ? escapeHtml(text) : `<div class="chat-bubble text-sm mr-prose">${escapeHtml(text)}</div>`;
|
|
115
|
-
$('messages').appendChild(el);
|
|
116
|
-
$('messages').scrollTop = $('messages').scrollHeight;
|
|
117
|
-
return el;
|
|
118
|
-
}
|
|
119
|
-
const escapeHtml = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
120
|
-
|
|
121
|
-
/* ── docs ─────────────────────────────────────────────────────────────── */
|
|
122
|
-
function buildDocs() {
|
|
123
|
-
$('docTabs').innerHTML = st.docs.map((n, i) =>
|
|
124
|
-
`<button class="tab ${i === 0 ? 'tab-active' : ''}" data-doc="${n}">${n.replace(/\.md$/, '')}</button>`).join('');
|
|
125
|
-
$('docPanes').innerHTML = st.docs.map((n, i) => `
|
|
126
|
-
<div class="absolute inset-0 flex-col ${i === 0 ? 'flex' : 'hidden'}" data-pane="${n}">
|
|
127
|
-
<textarea class="textarea flex-1 w-full h-full rounded-none border-0 focus:outline-none mr-doc-area p-4" spellcheck="false" data-area="${n}"></textarea>
|
|
128
|
-
</div>`).join('');
|
|
129
|
-
|
|
130
|
-
for (const name of st.docs) {
|
|
131
|
-
const pane = document.querySelector(`[data-pane="${name}"]`);
|
|
132
|
-
const ta = pane.querySelector('textarea');
|
|
133
|
-
docs[name] = { text: '', mtime: 0, dirty: false, ta };
|
|
134
|
-
let t = null;
|
|
135
|
-
ta.addEventListener('input', () => {
|
|
136
|
-
docs[name].dirty = true;
|
|
137
|
-
$('saveState').textContent = `${name}: unsaved…`;
|
|
138
|
-
clearTimeout(t); t = setTimeout(() => saveDoc(name), 700);
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
$('docTabs').addEventListener('click', (e) => {
|
|
142
|
-
const b = e.target.closest('[data-doc]');
|
|
143
|
-
if (!b) return;
|
|
144
|
-
document.querySelectorAll('#docTabs .tab').forEach((x) => x.classList.toggle('tab-active', x === b));
|
|
145
|
-
document.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== b.dataset.doc));
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function loadDoc(name, force) {
|
|
150
|
-
const d = docs[name]; if (!d) return;
|
|
151
|
-
const data = await (await fetch(`/api/doc?room=${encodeURIComponent(room)}&name=${encodeURIComponent(name)}`)).json();
|
|
152
|
-
if (data.error) { $('saveState').textContent = data.error; return; }
|
|
153
|
-
if (d.dirty && !force) return; // don't clobber what you are typing
|
|
154
|
-
d.text = data.text; d.mtime = data.mtime; d.dirty = false;
|
|
155
|
-
d.ta.value = data.text;
|
|
156
|
-
$('saveState').textContent = `${name}: saved ${now(data.mtime)}`;
|
|
157
|
-
}
|
|
158
|
-
async function saveDoc(name) {
|
|
159
|
-
const d = docs[name]; if (!d || !d.dirty) return;
|
|
160
|
-
const res = await fetch('/api/doc', {
|
|
161
|
-
method: 'PUT', headers: { 'content-type': 'application/json' },
|
|
162
|
-
body: JSON.stringify({ room, name, text: d.ta.value, mtime: d.mtime }),
|
|
163
|
-
});
|
|
164
|
-
const data = await res.json();
|
|
165
|
-
if (res.status === 409) {
|
|
166
|
-
const keep = confirm(`${name} changed somewhere else.\n\nOK = take the other version (your edits are lost)\nCancel = overwrite with yours`);
|
|
167
|
-
if (keep) await loadDoc(name, true);
|
|
168
|
-
else { d.mtime = data.mtime; await saveDoc(name); }
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (!res.ok) { $('saveState').textContent = `failed: ${data.error || res.status}`; return; }
|
|
172
|
-
d.mtime = data.mtime; d.dirty = false;
|
|
173
|
-
$('saveState').textContent = `${name}: saved ${now(data.mtime)}`;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/* ── artifact ─────────────────────────────────────────────────────────── */
|
|
177
|
-
function fitFrame() {
|
|
178
|
-
const w = DEVICE_WIDTH[st.device] || 1024;
|
|
179
|
-
const scale = st.fit ? Math.min(1, Math.max(0.2, (stage.clientWidth - 24) / w)) : 1;
|
|
180
|
-
let h = 0;
|
|
181
|
-
try { h = view.contentDocument.documentElement.scrollHeight; } catch { /* not loaded yet */ }
|
|
182
|
-
if (!h) h = Math.max(420, stage.clientHeight - 24);
|
|
183
|
-
frame.style.width = w + 'px'; frame.style.height = h + 'px';
|
|
184
|
-
frame.style.transform = `scale(${scale})`;
|
|
185
|
-
stage.style.setProperty('aspect-ratio', 'auto');
|
|
186
|
-
$('stageInner').style.width = Math.round(w * scale) + 'px';
|
|
187
|
-
$('stageInner').style.height = Math.round(h * scale) + 'px';
|
|
188
|
-
}
|
|
189
|
-
function loadArtifact(keepScroll = true) {
|
|
190
|
-
try { pendingScroll = keepScroll ? (view.contentWindow.scrollY || 0) : 0; } catch { pendingScroll = 0; }
|
|
191
|
-
view.src = `/files/${st.roomsPath}/${room}/${st.artifact}?v=${Date.now()}`;
|
|
192
|
-
}
|
|
193
|
-
view.addEventListener('load', () => {
|
|
194
|
-
fitFrame();
|
|
195
|
-
if (pendingScroll !== null) { try { view.contentWindow.scrollTo(0, pendingScroll); } catch { /* ignore */ } pendingScroll = null; }
|
|
196
|
-
});
|
|
197
|
-
window.addEventListener('resize', fitFrame);
|
|
198
|
-
|
|
199
|
-
/* ── events ───────────────────────────────────────────────────────────── */
|
|
200
|
-
const es = new EventSource('/events');
|
|
201
|
-
es.addEventListener('status', (e) => { const d = JSON.parse(e.data); setConn(d.phase, d.message); });
|
|
202
|
-
es.addEventListener('change', (e) => {
|
|
203
|
-
const d = JSON.parse(e.data);
|
|
204
|
-
if (!d.file.startsWith(st.roomsPath + '/' + room + '/')) return;
|
|
205
|
-
const name = d.file.split('/').pop();
|
|
206
|
-
if (st.docs.includes(name)) { loadDoc(name, false); return; }
|
|
207
|
-
clearTimeout(reloadTimer);
|
|
208
|
-
reloadTimer = setTimeout(() => {
|
|
209
|
-
if (name.endsWith('.png')) { refreshPng(); return; }
|
|
210
|
-
if (name === st.artifact) loadArtifact(true);
|
|
211
|
-
}, 150);
|
|
212
|
-
});
|
|
213
|
-
es.addEventListener('chat-start', (e) => {
|
|
214
|
-
const d = JSON.parse(e.data);
|
|
215
|
-
if (d.room !== room) return;
|
|
216
|
-
st.running = true; chatEl = null; $('send').disabled = true;
|
|
217
|
-
say('meta', `$ agent running (session ${String(d.sessionId).slice(0, 18)}…)`);
|
|
218
|
-
});
|
|
219
|
-
es.addEventListener('chat', (e) => {
|
|
220
|
-
const d = JSON.parse(e.data);
|
|
221
|
-
if (d.room !== room) return;
|
|
222
|
-
if (!chatEl) chatEl = say('assistant', '');
|
|
223
|
-
chatEl.querySelector('.chat-bubble').textContent += d.chunk;
|
|
224
|
-
$('messages').scrollTop = $('messages').scrollHeight;
|
|
225
|
-
});
|
|
226
|
-
es.addEventListener('chat-done', (e) => {
|
|
227
|
-
const d = JSON.parse(e.data);
|
|
228
|
-
if (d.room !== room) return;
|
|
229
|
-
st.running = false; chatEl = null; $('send').disabled = false;
|
|
230
|
-
say('meta', `— agent exited (${d.code})${d.error ? ': ' + d.error : ''}`);
|
|
231
|
-
st.docs.forEach((n) => loadDoc(n, false));
|
|
232
|
-
loadRoom();
|
|
233
|
-
loadSession();
|
|
234
|
-
});
|
|
235
|
-
es.addEventListener('render-done', (e) => {
|
|
236
|
-
const d = JSON.parse(e.data);
|
|
237
|
-
if (d.room && d.room !== room) return;
|
|
238
|
-
if (d.ok) refreshPng(); else say('meta', d.error || 'render failed');
|
|
239
|
-
});
|
|
240
|
-
es.addEventListener('error', () => setConn('error', 'disconnected — retrying'));
|
|
241
|
-
|
|
242
|
-
/* ── prompt history + draft ───────────────────────────────────────────── */
|
|
243
|
-
// ↑/↓ walk the prompts you sent (per room). The text you were writing is kept
|
|
244
|
-
// as a draft — in memory and in localStorage — so it comes back when you pass
|
|
245
|
-
// the newest entry, and survives a reload.
|
|
246
|
-
const HIST_KEY = `modelroom.history.${room}`;
|
|
247
|
-
const DRAFT_KEY = `modelroom.draft.${room}`;
|
|
248
|
-
const HIST_MAX = 50;
|
|
249
|
-
const storeGet = (k, fallback) => {
|
|
250
|
-
try { return JSON.parse(localStorage.getItem(k) ?? 'null') ?? fallback; } catch { return fallback; }
|
|
251
|
-
};
|
|
252
|
-
const storeSet = (k, v) => {
|
|
253
|
-
try { if (v === null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(v)); } catch { /* storage off */ }
|
|
254
|
-
};
|
|
255
|
-
const history = storeGet(HIST_KEY, []);
|
|
256
|
-
let histIndex = null; // null = editing the draft; otherwise an index into history
|
|
257
|
-
let draft = storeGet(DRAFT_KEY, '');
|
|
258
|
-
|
|
259
|
-
function rememberPrompt(text) {
|
|
260
|
-
if (!text) return;
|
|
261
|
-
if (history[history.length - 1] !== text) history.push(text);
|
|
262
|
-
if (history.length > HIST_MAX) history.splice(0, history.length - HIST_MAX);
|
|
263
|
-
storeSet(HIST_KEY, history);
|
|
264
|
-
}
|
|
265
|
-
function saveDraft(text) { draft = text; storeSet(DRAFT_KEY, text || null); }
|
|
266
|
-
function setPrompt(text) {
|
|
267
|
-
const ta = $('prompt');
|
|
268
|
-
ta.value = text;
|
|
269
|
-
ta.selectionStart = ta.selectionEnd = text.length;
|
|
270
|
-
ta.scrollTop = ta.scrollHeight;
|
|
271
|
-
}
|
|
272
|
-
function browseHistory(e) {
|
|
273
|
-
const ta = e.target;
|
|
274
|
-
if (e.key === 'ArrowUp') {
|
|
275
|
-
// Only take over the key when the caret is on the first line: multiline editing still works.
|
|
276
|
-
const onFirstLine = ta.selectionStart === ta.selectionEnd && !ta.value.slice(0, ta.selectionStart).includes('\n');
|
|
277
|
-
if (!history.length || (histIndex === null && !onFirstLine)) return;
|
|
278
|
-
e.preventDefault();
|
|
279
|
-
if (histIndex === null) { saveDraft(ta.value); histIndex = history.length; }
|
|
280
|
-
if (histIndex > 0) { histIndex -= 1; setPrompt(history[histIndex]); }
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
if (e.key === 'ArrowDown' && histIndex !== null) {
|
|
284
|
-
e.preventDefault();
|
|
285
|
-
if (histIndex < history.length - 1) { histIndex += 1; setPrompt(history[histIndex]); }
|
|
286
|
-
else { histIndex = null; setPrompt(draft); }
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/* ── actions ──────────────────────────────────────────────────────────── */
|
|
291
|
-
async function send() {
|
|
292
|
-
const ta = $('prompt');
|
|
293
|
-
const prompt = ta.value.trim();
|
|
294
|
-
if (!prompt || st.running) return;
|
|
295
|
-
say('user', prompt);
|
|
296
|
-
ta.value = '';
|
|
297
|
-
histIndex = null;
|
|
298
|
-
saveDraft('');
|
|
299
|
-
rememberPrompt(prompt);
|
|
300
|
-
const res = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room, prompt }) });
|
|
301
|
-
const data = await res.json();
|
|
302
|
-
if (!res.ok) say('meta', `cannot send: ${data.reason || data.error || res.status}`);
|
|
303
|
-
}
|
|
304
|
-
$('send').addEventListener('click', send);
|
|
305
|
-
$('prompt').addEventListener('input', () => { histIndex = null; saveDraft($('prompt').value); });
|
|
306
|
-
$('prompt').addEventListener('keydown', (e) => {
|
|
307
|
-
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); send(); return; }
|
|
308
|
-
browseHistory(e);
|
|
309
|
-
});
|
|
310
|
-
$('newSession').addEventListener('click', async () => {
|
|
311
|
-
if (st.running) { alert('the agent is running'); return; }
|
|
312
|
-
if (!confirm('Start a new session for this room? The conversation context resets.')) return;
|
|
313
|
-
const res = await fetch('/api/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room }) });
|
|
314
|
-
const data = await res.json();
|
|
315
|
-
if (res.ok) { st.session = data.sessionId; $('sessionLabel').textContent = `session ${String(st.session).slice(0, 16)}…`; say('meta', 'new session started'); }
|
|
316
|
-
});
|
|
317
|
-
document.querySelectorAll('[data-device]').forEach((b) => b.addEventListener('click', () => {
|
|
318
|
-
st.device = b.dataset.device;
|
|
319
|
-
document.querySelectorAll('[data-device]').forEach((x) => x.classList.toggle('btn-active', x === b));
|
|
320
|
-
fitFrame();
|
|
321
|
-
}));
|
|
322
|
-
$('fit').addEventListener('change', (e) => { st.fit = e.target.checked; fitFrame(); });
|
|
323
|
-
document.querySelectorAll('[data-view]').forEach((b) => b.addEventListener('click', () => {
|
|
324
|
-
document.querySelectorAll('[data-view]').forEach((x) => x.classList.toggle('tab-active', x === b));
|
|
325
|
-
const png = b.dataset.view === 'png';
|
|
326
|
-
$('stageInner').classList.toggle('hidden', png);
|
|
327
|
-
$('png').classList.toggle('hidden', !png);
|
|
328
|
-
if (!png) fitFrame();
|
|
329
|
-
}));
|
|
330
|
-
$('render').addEventListener('click', async () => {
|
|
331
|
-
const res = await fetch('/api/artifact', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room, device: st.device }) });
|
|
332
|
-
const data = await res.json();
|
|
333
|
-
if (!res.ok) say('meta', data.error || `render failed (${res.status})`);
|
|
334
|
-
else refreshPng();
|
|
335
|
-
});
|
|
336
|
-
function refreshPng() {
|
|
337
|
-
const img = $('png');
|
|
338
|
-
if (!st.hasThumb) { img.removeAttribute('src'); img.classList.add('hidden'); return; }
|
|
339
|
-
img.src = `/files/${st.roomsPath}/${room}/${st.artifact.replace(/\.html?$/i, '.png')}?v=${Date.now()}`;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/* ── session panel ────────────────────────────────────────────────────── */
|
|
343
|
-
// Two grid templates, both written out so Tailwind keeps them: the 4th column
|
|
344
|
-
// is the session panel. Collapsing swaps the template and hides the section.
|
|
345
|
-
const COLS_CLOSED = 'lg:grid-cols-[340px_minmax(280px,1fr)_minmax(360px,1.15fr)]';
|
|
346
|
-
const COLS_OPEN = 'lg:grid-cols-[340px_minmax(240px,1fr)_minmax(300px,1.1fr)_320px]';
|
|
347
|
-
let sessionOpen = true;
|
|
348
|
-
|
|
349
|
-
const fmtNum = (n) => {
|
|
350
|
-
if (n === null || n === undefined) return '—';
|
|
351
|
-
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
|
|
352
|
-
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
353
|
-
return String(n);
|
|
354
|
-
};
|
|
355
|
-
const bar = (pct) => {
|
|
356
|
-
const color = pct >= 85 ? 'bg-error' : pct >= 70 ? 'bg-warning' : 'bg-success';
|
|
357
|
-
return `<div class="h-2 w-full rounded-full bg-base-300 overflow-hidden"><div class="${color} h-full" style="width:${Math.max(0, Math.min(100, pct))}%"></div></div>`;
|
|
358
|
-
};
|
|
359
|
-
const rowHtml = (label, value) =>
|
|
360
|
-
`<div class="flex items-baseline justify-between gap-2 text-sm"><span class="opacity-70">${escapeHtml(label)}</span><span class="font-mono text-right break-all">${escapeHtml(String(value))}</span></div>`;
|
|
361
|
-
const headHtml = (t) => `<div class="text-xs uppercase tracking-wide opacity-60">${escapeHtml(t)}</div>`;
|
|
362
|
-
|
|
363
|
-
function setSessionOpen(open) {
|
|
364
|
-
sessionOpen = open;
|
|
365
|
-
$('cols').classList.toggle(COLS_OPEN, open);
|
|
366
|
-
$('cols').classList.toggle(COLS_CLOSED, !open);
|
|
367
|
-
$('sessionPanel').classList.toggle('hidden', !open);
|
|
368
|
-
$('sessionPanel').classList.toggle('flex', open);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function renderSession(d) {
|
|
372
|
-
const body = $('sessionBody');
|
|
373
|
-
if (!d || !d.available) {
|
|
374
|
-
body.innerHTML = '<div class="text-sm opacity-70">この部屋のセッション情報は opencode アダプタのときだけ表示します。</div>';
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
const out = [];
|
|
378
|
-
const s = d.stats || {}, t = s.tokens;
|
|
379
|
-
out.push(headHtml('セッション'));
|
|
380
|
-
if (d.context) {
|
|
381
|
-
out.push(`<div class="flex items-baseline justify-between gap-2 text-sm"><span class="opacity-70">コンテキスト</span><span class="font-mono">${d.context.pct}% ・ ${fmtNum(d.context.used)}</span></div>`);
|
|
382
|
-
out.push(bar(d.context.pct));
|
|
383
|
-
if (d.context.pct >= 70) out.push(`<div class="text-xs text-warning">${d.context.pct}% — compact を検討(上限 ${fmtNum(d.context.limit)})</div>`);
|
|
384
|
-
} else {
|
|
385
|
-
out.push('<div class="text-xs opacity-60">コンテキスト: 計測できません(まだ実行がないか、上限が不明)</div>');
|
|
386
|
-
}
|
|
387
|
-
out.push(rowHtml('費用', s.cost != null ? '$' + Number(s.cost).toFixed(4) : '—'));
|
|
388
|
-
if (t) {
|
|
389
|
-
const total = t.input + t.cacheRead;
|
|
390
|
-
const cached = total > 0 ? Math.round((t.cacheRead / total) * 100) : 0;
|
|
391
|
-
out.push(rowHtml('トークン ↑', fmtNum(t.input)));
|
|
392
|
-
out.push(rowHtml('トークン ↓', fmtNum(t.output)));
|
|
393
|
-
out.push(rowHtml('reasoning', fmtNum(t.reasoning)));
|
|
394
|
-
out.push(rowHtml('キャッシュ', cached + '%'));
|
|
395
|
-
}
|
|
396
|
-
out.push(rowHtml('状態', s.outcome || '—'));
|
|
397
|
-
if (s.model) out.push(rowHtml('model', s.model));
|
|
398
|
-
if (d.workspace) out.push(rowHtml('workspace', d.workspace));
|
|
399
|
-
if (d.mcp && d.mcp.length) {
|
|
400
|
-
out.push(headHtml('MCP'));
|
|
401
|
-
for (const m of d.mcp) out.push(rowHtml(m.name, m.error ? `${m.status}: ${m.error}` : m.status));
|
|
402
|
-
}
|
|
403
|
-
if (d.skills && d.skills.length) out.push(rowHtml('スキル', String(d.skills.length)));
|
|
404
|
-
body.innerHTML = out.join('');
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function renderTranscript(lines) {
|
|
408
|
-
if (!lines || !lines.length) return;
|
|
409
|
-
for (const l of lines) {
|
|
410
|
-
say(l.role === 'assistant' ? 'assistant' : l.role === 'user' ? 'user' : 'meta', l.text);
|
|
411
|
-
}
|
|
412
|
-
say('meta', `— このセッションを再開(${lines.length} 行)`);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
async function loadSession({ transcript = false } = {}) {
|
|
416
|
-
let d = null;
|
|
417
|
-
try { d = await (await fetch(`/api/session?room=${encodeURIComponent(room)}`)).json(); } catch { return null; }
|
|
418
|
-
renderSession(d);
|
|
419
|
-
if (transcript) renderTranscript(d.lines);
|
|
420
|
-
if (d && d.session) { st.session = d.session; $('sessionLabel').textContent = `session ${String(d.session).slice(0, 16)}…`; }
|
|
421
|
-
return d;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
$('sessionToggle').addEventListener('click', () => setSessionOpen(!sessionOpen));
|
|
425
|
-
$('sessionRefresh').addEventListener('click', () => loadSession());
|
|
426
|
-
|
|
427
|
-
/* ── init ─────────────────────────────────────────────────────────────── */
|
|
428
|
-
async function loadRoom() {
|
|
429
|
-
const data = await (await fetch(`/api/room?room=${encodeURIComponent(room)}`)).json();
|
|
430
|
-
if (data.error) { $('title').textContent = data.error; return; }
|
|
431
|
-
const r = data.room;
|
|
432
|
-
$('title').textContent = r.title || r.name;
|
|
433
|
-
$('roomName').textContent = `${data.config.rooms}/${r.name}`;
|
|
434
|
-
if (r.status) {
|
|
435
|
-
const cls = /adopt|approv|done/i.test(r.status) ? 'badge-success' : /review/i.test(r.status) ? 'badge-warning' : 'badge-ghost';
|
|
436
|
-
$('statusBadge').innerHTML = `<span class="badge badge-sm ${cls}">${r.status}</span>`;
|
|
437
|
-
}
|
|
438
|
-
st.roomsPath = data.config.rooms;
|
|
439
|
-
st.artifact = data.config.artifact;
|
|
440
|
-
st.session = data.session;
|
|
441
|
-
$('sessionLabel').textContent = st.session ? `session ${String(st.session).slice(0, 16)}…` : '';
|
|
442
|
-
const a = data.config.agent;
|
|
443
|
-
$('agentLabel').textContent = `${a.adapter} · ${a.command}${a.model ? ' · ' + a.model : ''}`;
|
|
444
|
-
$('openRaw').href = `/files/${data.config.rooms}/${r.name}/${data.config.artifact}`;
|
|
445
|
-
const exists = r.hasArtifact;
|
|
446
|
-
st.hasThumb = Boolean(r.hasThumb);
|
|
447
|
-
$('noArtifact').classList.toggle('hidden', exists);
|
|
448
|
-
if (!exists) $('noArtifact').textContent = `No artifact yet (${data.config.artifact}). Ask the agent to create it.`;
|
|
449
|
-
$('stageInner').classList.toggle('hidden', !exists);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
(async function init() {
|
|
453
|
-
await loadRoom();
|
|
454
|
-
const data = await (await fetch(`/api/room?room=${encodeURIComponent(room)}`)).json();
|
|
455
|
-
st.docs = data.config.docs;
|
|
456
|
-
buildDocs();
|
|
457
|
-
await Promise.all(st.docs.map((n) => loadDoc(n, true)));
|
|
458
|
-
loadArtifact(false);
|
|
459
|
-
refreshPng();
|
|
460
|
-
if (draft) $('prompt').value = draft;
|
|
461
|
-
say('meta', `room ${room} — this chat runs your agent in ${data.config.root}`);
|
|
462
|
-
await loadSession({ transcript: true });
|
|
463
|
-
})();
|
|
464
|
-
</script>
|
|
12
|
+
<body class="h-dvh overflow-hidden bg-background text-foreground font-sans">
|
|
13
|
+
<div id="root" data-app="room" class="h-full"></div>
|
|
14
|
+
<script type="module" src="/ui/assets/app.js"></script>
|
|
465
15
|
</body>
|
|
466
16
|
</html>
|