modelroom 0.0.0-stage → 0.0.2

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -2
  4. package/dist/bin/modelroom.d.ts +3 -0
  5. package/dist/bin/modelroom.d.ts.map +1 -0
  6. package/dist/bin/modelroom.js +242 -0
  7. package/dist/bin/modelroom.js.map +1 -0
  8. package/dist/lib/agents/index.d.ts +22 -0
  9. package/dist/lib/agents/index.d.ts.map +1 -0
  10. package/dist/lib/agents/index.js +68 -0
  11. package/dist/lib/agents/index.js.map +1 -0
  12. package/dist/lib/artifact.d.ts +22 -0
  13. package/dist/lib/artifact.d.ts.map +1 -0
  14. package/dist/lib/artifact.js +62 -0
  15. package/dist/lib/artifact.js.map +1 -0
  16. package/dist/lib/config.d.ts +53 -0
  17. package/dist/lib/config.d.ts.map +1 -0
  18. package/dist/lib/config.js +92 -0
  19. package/dist/lib/config.js.map +1 -0
  20. package/dist/lib/facets.d.ts +62 -0
  21. package/dist/lib/facets.d.ts.map +1 -0
  22. package/dist/lib/facets.js +181 -0
  23. package/dist/lib/facets.js.map +1 -0
  24. package/dist/lib/frontmatter.d.ts +41 -0
  25. package/dist/lib/frontmatter.d.ts.map +1 -0
  26. package/dist/lib/frontmatter.js +219 -0
  27. package/dist/lib/frontmatter.js.map +1 -0
  28. package/dist/lib/lint.d.ts +26 -0
  29. package/dist/lib/lint.d.ts.map +1 -0
  30. package/dist/lib/lint.js +139 -0
  31. package/dist/lib/lint.js.map +1 -0
  32. package/dist/lib/opencode.d.ts +42 -0
  33. package/dist/lib/opencode.d.ts.map +1 -0
  34. package/dist/lib/opencode.js +229 -0
  35. package/dist/lib/opencode.js.map +1 -0
  36. package/dist/lib/pkg.d.ts +2 -0
  37. package/dist/lib/pkg.d.ts.map +1 -0
  38. package/dist/lib/pkg.js +19 -0
  39. package/dist/lib/pkg.js.map +1 -0
  40. package/dist/lib/rooms.d.ts +63 -0
  41. package/dist/lib/rooms.d.ts.map +1 -0
  42. package/dist/lib/rooms.js +178 -0
  43. package/dist/lib/rooms.js.map +1 -0
  44. package/dist/lib/server.d.ts +53 -0
  45. package/dist/lib/server.d.ts.map +1 -0
  46. package/dist/lib/server.js +538 -0
  47. package/dist/lib/server.js.map +1 -0
  48. package/docs/01_concept.md +69 -0
  49. package/docs/02-convention.md +109 -0
  50. package/docs/03-cli.md +103 -0
  51. package/docs/04-agents.md +88 -0
  52. package/examples/rooms/sign-in/BEHAVIOUR.md +23 -0
  53. package/examples/rooms/sign-in/BRIEF.md +15 -0
  54. package/examples/rooms/sign-in/SPEC.md +14 -0
  55. package/examples/rooms/sign-in/index.html +56 -0
  56. package/package.json +83 -4
  57. package/templates/AGENTS.md +31 -0
  58. package/templates/modelroom.json +14 -0
  59. package/templates/room/BEHAVIOUR.md +19 -0
  60. package/templates/room/BRIEF.md +16 -0
  61. package/templates/room/SPEC.md +9 -0
  62. package/templates/room/index.html +53 -0
  63. package/ui/dashboard.html +332 -0
  64. package/ui/modelroom.css +2 -0
  65. package/ui/room.html +466 -0
  66. package/ui/settings.html +125 -0
package/ui/room.html ADDED
@@ -0,0 +1,466 @@
1
+ <!doctype html>
2
+ <!-- modelroom — a room: chat · docs · artifact, one screen at a time -->
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>Room — modelroom</title>
8
+ <link rel="stylesheet" href="/ui/modelroom.css">
9
+ </head>
10
+ <body class="h-dvh overflow-hidden bg-base-200 text-base-content font-sans flex flex-col">
11
+
12
+ <div class="navbar min-h-12 bg-base-100 border-b border-base-300 px-3">
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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>
465
+ </body>
466
+ </html>
@@ -0,0 +1,125 @@
1
+ <!doctype html>
2
+ <!-- modelroom — settings: how modelroom talks to your agent -->
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>Settings — modelroom</title>
8
+ <link rel="stylesheet" href="/ui/modelroom.css">
9
+ </head>
10
+ <body class="min-h-dvh bg-base-200 text-base-content font-sans">
11
+
12
+ <div class="navbar bg-base-100 border-b border-base-300">
13
+ <div class="flex-1 gap-3">
14
+ <a class="btn btn-ghost btn-sm" href="/">← Rooms</a>
15
+ <span class="font-semibold">Settings</span>
16
+ </div>
17
+ </div>
18
+
19
+ <main class="mx-auto w-full max-w-3xl p-5 flex flex-col gap-4">
20
+
21
+ <section class="card bg-base-100 border border-base-300">
22
+ <div class="card-body gap-3">
23
+ <h2 class="card-title text-base">Agent</h2>
24
+ <p class="text-sm opacity-70">modelroom drives the agent you already use. Nothing here is sent anywhere by modelroom itself.</p>
25
+
26
+ <label class="form-control w-full">
27
+ <span class="label-text">Adapter</span>
28
+ <select class="select select-bordered" id="adapter">
29
+ <option value="opencode">opencode — OpenCode CLI (sessions, model, agent)</option>
30
+ <option value="command">command — any CLI, with args containing {prompt} and {session}</option>
31
+ </select>
32
+ </label>
33
+
34
+ <div class="grid gap-3 sm:grid-cols-2">
35
+ <label class="form-control">
36
+ <span class="label-text">Command</span>
37
+ <input class="input input-bordered font-mono text-sm" id="command" placeholder="opencode">
38
+ </label>
39
+ <label class="form-control">
40
+ <span class="label-text">Agent name</span>
41
+ <input class="input input-bordered font-mono text-sm" id="agent" placeholder="build">
42
+ </label>
43
+ </div>
44
+
45
+ <label class="form-control">
46
+ <span class="label-text">Model <span class="opacity-60">(optional — many agents fail on an unset default)</span></span>
47
+ <input class="input input-bordered font-mono text-sm" id="model" placeholder="e.g. openrouter/deepseek/deepseek-v4.1-flash">
48
+ </label>
49
+
50
+ <label class="label cursor-pointer justify-start gap-3">
51
+ <input type="checkbox" class="checkbox checkbox-sm" id="standalone">
52
+ <span class="label-text text-sm">Run the agent against its own server, using this process's environment (opencode)</span>
53
+ </label>
54
+
55
+ <div class="flex items-center gap-3">
56
+ <button class="btn btn-primary btn-sm" id="save">Save</button>
57
+ <span class="text-sm text-success" id="saved"></span>
58
+ </div>
59
+ </div>
60
+ </section>
61
+
62
+ <section class="card bg-base-100 border border-base-300">
63
+ <div class="card-body gap-2">
64
+ <h2 class="card-title text-base">Config</h2>
65
+ <div class="text-sm opacity-80 mr-prose" id="configSummary">…</div>
66
+ <p class="text-xs opacity-60 mr-prose">
67
+ Committed settings live in <code>modelroom.json</code>. Anything you save here is kept locally in
68
+ <code>.modelroom/state.json</code> (git-ignored). Editable room files are:
69
+ <span id="docList" class="font-mono"></span>.
70
+ </p>
71
+ </div>
72
+ </section>
73
+
74
+ <section class="card bg-base-100 border border-base-300">
75
+ <div class="card-body gap-2">
76
+ <h2 class="card-title text-base">Thumbnails</h2>
77
+ <p class="text-sm opacity-70 mr-prose">
78
+ Rendering an artifact to PNG needs Playwright and a browser:
79
+ <code>npm i -D playwright &amp;&amp; npx playwright install chromium</code>.
80
+ modelroom works without it — rooms simply show no thumbnail.
81
+ </p>
82
+ <div class="text-xs opacity-60" id="pngState"></div>
83
+ </div>
84
+ </section>
85
+
86
+ </main>
87
+
88
+ <script>
89
+ 'use strict';
90
+ const $ = (id) => document.getElementById(id);
91
+
92
+ function summary(cfg) {
93
+ return `
94
+ <div>root <code>${cfg.root}</code> · rooms <code>${cfg.rooms}/</code> · artifact <code>${cfg.artifact}</code></div>
95
+ <div>agent <code>${cfg.agent.adapter}</code> · command <code>${cfg.agent.command}</code> · agent <code>${cfg.agent.agent || '-'}</code> · model <code>${cfg.agent.model || 'default'}</code></div>`;
96
+ }
97
+
98
+ (async function init() {
99
+ const data = await (await fetch('/api/rooms')).json();
100
+ const cfg = data.config;
101
+ $('adapter').value = cfg.agent.adapter;
102
+ $('command').value = cfg.agent.command || '';
103
+ $('agent').value = cfg.agent.agent || '';
104
+ $('model').value = cfg.agent.model || '';
105
+ $('standalone').checked = cfg.agent.standalone !== false;
106
+ $('configSummary').innerHTML = summary(cfg);
107
+ $('docList').textContent = cfg.docs.join(', ');
108
+ })();
109
+
110
+ $('save').addEventListener('click', async () => {
111
+ const body = {
112
+ adapter: $('adapter').value,
113
+ command: $('command').value.trim(),
114
+ agent: $('agent').value.trim(),
115
+ model: $('model').value.trim(),
116
+ standalone: $('standalone').checked,
117
+ };
118
+ const res = await fetch('/api/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
119
+ const data = await res.json();
120
+ $('saved').textContent = res.ok ? `saved ${new Date().toLocaleTimeString()}` : `failed: ${data.error || res.status}`;
121
+ if (res.ok && data.config) $('configSummary').innerHTML = summary(data.config);
122
+ });
123
+ </script>
124
+ </body>
125
+ </html>