返回 CodeWhale
runtime_mobile.html
根目录 / crates / tui / src / runtime_mobile.html
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, viewport-fit=cover">
6 <title>Codewhale Mobile</title>
7 <style>
8 :root {
9 color-scheme: dark;
10 --bg: #101214;
11 --panel: #181b1f;
12 --panel-2: #20242a;
13 --line: #323840;
14 --text: #f4f1e8;
15 --muted: #a8a49a;
16 --accent: #48c2a8;
17 --accent-2: #c89543;
18 --danger: #e86458;
19 --ok: #76c46b;
20 }
21 * { box-sizing: border-box; }
22 html, body { min-height: 100%; }
23 body {
24 margin: 0;
25 background: var(--bg);
26 color: var(--text);
27 font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
28 letter-spacing: 0;
29 }
30 header {
31 position: sticky;
32 top: 0;
33 z-index: 4;
34 display: flex;
35 align-items: center;
36 justify-content: space-between;
37 gap: 12px;
38 padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
39 background: rgba(16, 18, 20, 0.96);
40 border-bottom: 1px solid var(--line);
41 backdrop-filter: blur(12px);
42 }
43 h1 {
44 margin: 0;
45 font-size: 18px;
46 font-weight: 740;
47 }
48 main {
49 display: grid;
50 gap: 12px;
51 padding: 12px 12px calc(14px + env(safe-area-inset-bottom));
52 }
53 section {
54 border: 1px solid var(--line);
55 background: var(--panel);
56 border-radius: 8px;
57 overflow: hidden;
58 }
59 .head {
60 display: flex;
61 align-items: center;
62 justify-content: space-between;
63 gap: 8px;
64 padding: 10px 12px;
65 border-bottom: 1px solid var(--line);
66 min-height: 46px;
67 }
68 .body { padding: 12px; }
69 .grid { display: grid; gap: 10px; }
70 .row { display: flex; align-items: center; gap: 8px; }
71 .row > * { flex: 1; min-width: 0; }
72 button, input, textarea, select {
73 color: var(--text);
74 background: var(--panel-2);
75 border: 1px solid var(--line);
76 border-radius: 8px;
77 font: inherit;
78 letter-spacing: 0;
79 }
80 button {
81 min-height: 40px;
82 padding: 8px 11px;
83 font-weight: 680;
84 cursor: pointer;
85 }
86 button.primary {
87 background: #173d36;
88 border-color: #2a8b77;
89 }
90 button.warn {
91 background: #3c2b14;
92 border-color: #8b682b;
93 }
94 button.danger {
95 background: #421f1d;
96 border-color: #9b4038;
97 }
98 button:disabled {
99 cursor: not-allowed;
100 opacity: 0.55;
101 }
102 input, textarea, select {
103 width: 100%;
104 padding: 10px;
105 }
106 textarea {
107 min-height: 96px;
108 resize: vertical;
109 }
110 label.toggle {
111 display: flex;
112 align-items: center;
113 gap: 8px;
114 min-height: 40px;
115 padding: 8px 10px;
116 color: var(--muted);
117 background: var(--panel-2);
118 border: 1px solid var(--line);
119 border-radius: 8px;
120 font-size: 13px;
121 }
122 label.toggle input { width: auto; }
123 .status {
124 min-height: 38px;
125 padding: 9px 10px;
126 border-radius: 8px;
127 background: #121518;
128 border: 1px solid var(--line);
129 color: var(--muted);
130 overflow-wrap: anywhere;
131 }
132 .status.ok { color: var(--ok); }
133 .status.bad { color: var(--danger); }
134 .status.warn { color: var(--accent-2); }
135 #threads {
136 display: grid;
137 gap: 8px;
138 max-height: 260px;
139 overflow: auto;
140 }
141 .thread {
142 width: 100%;
143 padding: 10px;
144 text-align: left;
145 border-radius: 8px;
146 background: var(--panel-2);
147 }
148 .thread.active { border-color: var(--accent); }
149 .thread-title {
150 font-weight: 720;
151 white-space: nowrap;
152 overflow: hidden;
153 text-overflow: ellipsis;
154 }
155 .meta {
156 margin-top: 3px;
157 color: var(--muted);
158 font-size: 12px;
159 overflow-wrap: anywhere;
160 }
161 #events {
162 display: grid;
163 gap: 8px;
164 max-height: 54vh;
165 overflow: auto;
166 padding-right: 2px;
167 }
168 .event {
169 padding: 9px 10px;
170 border: 1px solid var(--line);
171 border-radius: 8px;
172 background: #121518;
173 white-space: pre-wrap;
174 overflow-wrap: anywhere;
175 }
176 .event.agent { border-color: #357969; }
177 .event.tool { border-color: #8b682b; }
178 .event.error { border-color: #9b4038; }
179 .event.ok { border-color: #4d8048; }
180 .approval-actions {
181 display: flex;
182 flex-wrap: wrap;
183 gap: 8px;
184 margin-top: 9px;
185 }
186 .approval-actions button {
187 flex: 0 0 auto;
188 min-width: 84px;
189 }
190 .empty {
191 color: var(--muted);
192 font-size: 13px;
193 }
194 @media (min-width: 900px) {
195 main {
196 grid-template-columns: 330px minmax(0, 1fr);
197 align-items: start;
198 }
199 section.chat {
200 grid-column: 2;
201 grid-row: 1 / span 3;
202 }
203 #threads { max-height: 420px; }
204 #events { max-height: calc(100vh - 150px); }
205 }
206 </style>
207 </head>
208 <body>
209 <header>
210 <h1>Codewhale Mobile</h1>
211 <button id="refresh" title="Refresh threads">Refresh</button>
212 </header>
213
214 <main>
215 <section>
216 <div class="head">
217 <strong>Connection</strong>
218 <button id="save-token">Connect</button>
219 </div>
220 <div class="body grid">
221 <input id="token" autocomplete="off" spellcheck="false" placeholder="Runtime token (used once)">
222 <div id="conn" class="status">Not connected</div>
223 </div>
224 </section>
225
226 <section>
227 <div class="head">
228 <strong>Threads</strong>
229 <button id="new-thread" class="primary">New</button>
230 </div>
231 <div class="body"><div id="threads"></div></div>
232 </section>
233
234 <section class="chat">
235 <div class="head">
236 <strong id="active-title">No thread selected</strong>
237 <span id="event-count" class="meta">0 events</span>
238 </div>
239 <div class="body"><div id="events"></div></div>
240 </section>
241
242 <section>
243 <div class="head">
244 <strong>Composer</strong>
245 <button id="interrupt" class="danger">Interrupt</button>
246 </div>
247 <div class="body grid">
248 <textarea id="prompt" spellcheck="true" placeholder="Message"></textarea>
249 <div class="row">
250 <label class="toggle"><input id="allow-shell" type="checkbox"> shell</label>
251 <label class="toggle"><input id="auto-approve" type="checkbox"> auto</label>
252 <label class="toggle"><input id="remember-approval" type="checkbox"> remember</label>
253 </div>
254 <div class="row">
255 <button id="send" class="primary">Send</button>
256 <button id="steer" class="warn">Steer</button>
257 </div>
258 </div>
259 </section>
260 </main>
261
262 <script>
263 const $ = (id) => document.getElementById(id);
264 const MAX_VISIBLE_EVENTS = 100;
265 const LEGACY_RUNTIME_TOKEN_STORAGE_KEY = "codewhale_runtime_token";
266 const LEGACY_RUNTIME_TOKEN_COOKIE = "codewhale_runtime_token";
267 const SESSION_STORAGE_KEY = "codewhale_mobile_session_proof";
268 const MOBILE_REQUEST_HEADER = "X-Codewhale-Mobile-Request";
269 const MOBILE_STREAM_TICKET_QUERY = "mobile_stream_ticket";
270 const state = {
271 threadId: "",
272 activeTurnId: "",
273 source: null,
274 eventCount: 0,
275 requestProof: "",
276 streamTicket: ""
277 };
278
279 function setStatus(message, tone = "") {
280 const el = $("conn");
281 el.textContent = message;
282 el.className = "status" + (tone ? " " + tone : "");
283 }
284
285 function token() {
286 return $("token").value.trim();
287 }
288
289 function clearLegacyRuntimeToken() {
290 localStorage.removeItem(LEGACY_RUNTIME_TOKEN_STORAGE_KEY);
291 document.cookie = LEGACY_RUNTIME_TOKEN_COOKIE + "=; Max-Age=0; Path=/; SameSite=Strict";
292 }
293
294 function saveMobileSession() {
295 if (!state.requestProof) {
296 sessionStorage.removeItem(SESSION_STORAGE_KEY);
297 return;
298 }
299 sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({
300 requestProof: state.requestProof,
301 streamTicket: state.streamTicket
302 }));
303 }
304
305 function clearMobileSession() {
306 state.requestProof = "";
307 state.streamTicket = "";
308 sessionStorage.removeItem(SESSION_STORAGE_KEY);
309 }
310
311 function loadMobileSession() {
312 try {
313 const stored = JSON.parse(sessionStorage.getItem(SESSION_STORAGE_KEY) || "null");
314 if (stored && typeof stored.requestProof === "string") {
315 state.requestProof = stored.requestProof;
316 state.streamTicket = typeof stored.streamTicket === "string" ? stored.streamTicket : "";
317 }
318 } catch (_) {
319 clearMobileSession();
320 }
321 }
322
323 function useMobileSession(session) {
324 if (!session || typeof session.request_proof !== "string") {
325 throw new Error("Mobile session bootstrap was incomplete");
326 }
327 state.requestProof = session.request_proof;
328 state.streamTicket = typeof session.stream_ticket === "string" ? session.stream_ticket : "";
329 saveMobileSession();
330 }
331
332 function takeMobileSessionFromUrl() {
333 const hash = location.hash.startsWith("#") ? location.hash.slice(1) : "";
334 const params = new URLSearchParams(hash);
335 const requestProof = params.get("request_proof");
336 const streamTicket = params.get("stream_ticket");
337 if (!requestProof || !streamTicket) return;
338 useMobileSession({ request_proof: requestProof, stream_ticket: streamTicket });
339 params.delete("request_proof");
340 params.delete("stream_ticket");
341 const nextHash = params.toString();
342 history.replaceState(
343 null,
344 "",
345 location.pathname + location.search + (nextHash ? "#" + nextHash : "")
346 );
347 }
348
349 function headers(extra = {}) {
350 const out = Object.assign({ "Content-Type": "application/json" }, extra);
351 if (state.requestProof) out[MOBILE_REQUEST_HEADER] = state.requestProof;
352 return out;
353 }
354
355 async function api(path, options = {}) {
356 const res = await fetch(path, Object.assign({}, options, {
357 credentials: "same-origin",
358 headers: headers(options.headers || {})
359 }));
360 if (!res.ok) {
361 if (res.status === 401) clearMobileSession();
362 let detail = await res.text();
363 try {
364 const parsed = JSON.parse(detail);
365 detail = parsed.error?.message || detail;
366 } catch (_) {}
367 throw new Error(detail || ("HTTP " + res.status));
368 }
369 if (res.status === 204) return null;
370 return res.json();
371 }
372
373 async function connectMobileSession() {
374 const runtimeToken = token();
375 if (!runtimeToken) throw new Error("Enter a Runtime token to connect once");
376 let res;
377 try {
378 res = await fetch("/__codewhale/mobile/session", {
379 method: "POST",
380 credentials: "same-origin",
381 headers: { "Authorization": "Bearer " + runtimeToken }
382 });
383 } finally {
384 $("token").value = "";
385 }
386 if (!res.ok) {
387 let detail = await res.text();
388 try {
389 const parsed = JSON.parse(detail);
390 detail = parsed.error?.message || detail;
391 } catch (_) {}
392 throw new Error(detail || ("HTTP " + res.status));
393 }
394 useMobileSession(await res.json());
395 }
396
397 async function refreshStreamTicket() {
398 const result = await api("/__codewhale/mobile/stream-ticket", {
399 method: "POST",
400 body: "{}"
401 });
402 if (!result || typeof result.stream_ticket !== "string") {
403 throw new Error("Mobile stream ticket was unavailable");
404 }
405 state.streamTicket = result.stream_ticket;
406 saveMobileSession();
407 return state.streamTicket;
408 }
409
410 async function takeStreamTicket() {
411 if (!state.requestProof) return "";
412 const ticket = state.streamTicket || await refreshStreamTicket();
413 state.streamTicket = "";
414 saveMobileSession();
415 return ticket;
416 }
417
418 function escapeHtml(raw) {
419 return String(raw).replace(/[&<>"']/g, (char) => ({
420 "&": "&amp;",
421 "<": "&lt;",
422 ">": "&gt;",
423 "\"": "&quot;",
424 "'": "&#039;"
425 }[char]));
426 }
427
428 function eventPayload(data) {
429 return data && typeof data === "object" && "payload" in data ? data.payload : data;
430 }
431
432 function eventText(name, data) {
433 const payload = eventPayload(data) || {};
434 if (name === "item.delta") return payload.delta || "";
435 if (name === "item.started") {
436 const tool = payload.tool;
437 if (tool) return "Tool started: " + tool.name + "\n" + JSON.stringify(tool.input || {}, null, 2);
438 }
439 if (name === "item.completed" || name === "item.failed") {
440 const item = payload.item || {};
441 return (name === "item.completed" ? "Completed: " : "Failed: ") + (item.summary || item.kind || "");
442 }
443 if (name === "approval.required") {
444 return "Approval required: " + (payload.tool_name || "") + "\n" + (payload.description || "");
445 }
446 if (name === "approval.decided") {
447 return "Approval " + (payload.decision || "decided") + ": " + (payload.approval_id || "");
448 }
449 if (name === "approval.timeout") {
450 return "Approval timed out: " + (payload.approval_id || "");
451 }
452 if (name === "sandbox.denied") {
453 return "Sandbox denied: " + (payload.tool_name || "") + "\n" + (payload.reason || "");
454 }
455 if (name === "turn.lifecycle") return "Turn status: " + (payload.status || "");
456 if (name === "turn.completed") return "Turn completed";
457 return JSON.stringify(payload, null, 2);
458 }
459
460 function approvalInfo(name, data) {
461 if (name !== "approval.required") return null;
462 const payload = eventPayload(data) || {};
463 const approvalId = payload.approval_id || payload.id;
464 if (!approvalId) return null;
465 return { id: approvalId };
466 }
467
468 function pruneVisibleEvents() {
469 const events = $("events");
470 while (events.childElementCount > MAX_VISIBLE_EVENTS) {
471 events.firstElementChild.remove();
472 }
473 }
474
475 async function decideApproval(approvalId, decision, container) {
476 for (const button of container.querySelectorAll("button")) button.disabled = true;
477 const remember = $("remember-approval").checked;
478 try {
479 const result = await api("/v1/approvals/" + encodeURIComponent(approvalId), {
480 method: "POST",
481 body: JSON.stringify({ decision, remember })
482 });
483 const decided = result?.decision ?? decision;
484 container.innerHTML = "<span class='meta'>Decision sent: " + escapeHtml(decided) + "</span>";
485 } catch (err) {
486 for (const button of container.querySelectorAll("button")) button.disabled = false;
487 throw err;
488 }
489 }
490
491 function appendEvent(name, data) {
492 const text = eventText(name, data);
493 if (!text) return;
494 const item = document.createElement("div");
495 item.className = "event";
496 if (name === "item.delta") item.classList.add("agent");
497 if (name === "item.started" || name.includes("tool")) item.classList.add("tool");
498 if (name.includes("failed") || name.includes("denied") || name.includes("timeout")) item.classList.add("error");
499 if (name === "turn.completed" || name === "approval.decided") item.classList.add("ok");
500 item.innerHTML = "<div class='meta'>" + escapeHtml(name) + "</div>" + escapeHtml(text);
501
502 const approval = approvalInfo(name, data);
503 if (approval) {
504 const actions = document.createElement("div");
505 actions.className = "approval-actions";
506 const allow = document.createElement("button");
507 allow.className = "primary";
508 allow.textContent = "Allow";
509 allow.onclick = () => decideApproval(approval.id, "allow", actions)
510 .catch((err) => setStatus(err.message, "bad"));
511 const deny = document.createElement("button");
512 deny.className = "danger";
513 deny.textContent = "Deny";
514 deny.onclick = () => decideApproval(approval.id, "deny", actions)
515 .catch((err) => setStatus(err.message, "bad"));
516 actions.appendChild(allow);
517 actions.appendChild(deny);
518 item.appendChild(actions);
519 }
520
521 $("events").appendChild(item);
522 pruneVisibleEvents();
523 state.eventCount += 1;
524 const visible = Math.min(state.eventCount, MAX_VISIBLE_EVENTS);
525 $("event-count").textContent = visible + " of " + state.eventCount + " events";
526 $("events").scrollTop = $("events").scrollHeight;
527 }
528
529 async function loadThreads() {
530 const threads = await api("/v1/threads/summary?limit=60&include_archived=false");
531 const list = $("threads");
532 list.innerHTML = "";
533 if (!threads || !threads.length) {
534 list.innerHTML = "<div class='empty'>No active threads.</div>";
535 return;
536 }
537 for (const thread of threads) {
538 const el = document.createElement("button");
539 el.className = "thread" + (thread.id === state.threadId ? " active" : "");
540 el.innerHTML =
541 "<div class='thread-title'>" + escapeHtml(thread.title || thread.id) + "</div>" +
542 "<div class='meta'>" + escapeHtml(thread.mode || "") + " / " +
543 escapeHtml(thread.model || "") + "</div>";
544 el.onclick = () => selectThread(thread.id, thread.title || thread.id);
545 list.appendChild(el);
546 }
547 }
548
549 async function newThread() {
550 const thread = await api("/v1/threads", {
551 method: "POST",
552 body: JSON.stringify({
553 mode: "agent",
554 allow_shell: $("allow-shell").checked,
555 trust_mode: false,
556 auto_approve: $("auto-approve").checked
557 })
558 });
559 await loadThreads();
560 selectThread(thread.id, thread.title || thread.id);
561 }
562
563 async function selectThread(id, title) {
564 state.threadId = id;
565 state.activeTurnId = "";
566 state.eventCount = 0;
567 $("event-count").textContent = "0 events";
568 $("active-title").textContent = title || id;
569 $("events").innerHTML = "";
570 if (state.source) state.source.close();
571 await openThreadEvents(id);
572 await loadThreads();
573 }
574
575 async function openThreadEvents(id) {
576 const streamTicket = await takeStreamTicket();
577 const query = new URLSearchParams({
578 since_seq: "0",
579 replay_limit: String(MAX_VISIBLE_EVENTS)
580 });
581 if (streamTicket) query.set(MOBILE_STREAM_TICKET_QUERY, streamTicket);
582 const source = new EventSource(
583 "/v1/threads/" + encodeURIComponent(id) + "/events?" + query.toString()
584 );
585 state.source = source;
586 const names = [
587 "thread.started",
588 "turn.started",
589 "turn.lifecycle",
590 "turn.steered",
591 "turn.interrupt_requested",
592 "turn.completed",
593 "item.started",
594 "item.delta",
595 "item.completed",
596 "item.failed",
597 "approval.required",
598 "approval.decided",
599 "approval.timeout",
600 "sandbox.denied"
601 ];
602 for (const name of names) {
603 source.addEventListener(name, (ev) => {
604 let data = {};
605 try { data = JSON.parse(ev.data || "{}"); } catch (_) {}
606 if (data.turn_id) state.activeTurnId = data.turn_id;
607 appendEvent(name, data);
608 });
609 }
610 source.onopen = () => setStatus("Connected", "ok");
611 source.onerror = () => {
612 if (state.source !== source || state.threadId !== id) return;
613 source.close();
614 setStatus("Event stream disconnected; reconnecting", "warn");
615 window.setTimeout(() => {
616 if (state.threadId === id) {
617 openThreadEvents(id).catch((err) => setStatus(err.message, "bad"));
618 }
619 }, 750);
620 };
621 }
622
623 async function sendPrompt() {
624 if (!state.threadId) await newThread();
625 const prompt = $("prompt").value.trim();
626 if (!prompt) return;
627 const res = await api("/v1/threads/" + encodeURIComponent(state.threadId) + "/turns", {
628 method: "POST",
629 body: JSON.stringify({
630 prompt,
631 allow_shell: $("allow-shell").checked,
632 trust_mode: false,
633 auto_approve: $("auto-approve").checked
634 })
635 });
636 state.activeTurnId = res.turn?.id || state.activeTurnId;
637 $("prompt").value = "";
638 await loadThreads();
639 }
640
641 async function steerTurn() {
642 if (!state.threadId || !state.activeTurnId) throw new Error("No active turn");
643 const prompt = $("prompt").value.trim();
644 if (!prompt) return;
645 await api(
646 "/v1/threads/" + encodeURIComponent(state.threadId) +
647 "/turns/" + encodeURIComponent(state.activeTurnId) + "/steer",
648 { method: "POST", body: JSON.stringify({ prompt }) }
649 );
650 $("prompt").value = "";
651 }
652
653 async function interruptTurn() {
654 if (!state.threadId || !state.activeTurnId) throw new Error("No active turn");
655 await api(
656 "/v1/threads/" + encodeURIComponent(state.threadId) +
657 "/turns/" + encodeURIComponent(state.activeTurnId) + "/interrupt",
658 { method: "POST", body: "{}" }
659 );
660 }
661
662 async function boot() {
663 clearLegacyRuntimeToken();
664 loadMobileSession();
665 takeMobileSessionFromUrl();
666 $("save-token").onclick = async () => {
667 await connectMobileSession();
668 await loadThreads().catch((err) => setStatus(err.message, "bad"));
669 };
670 $("refresh").onclick = () => loadThreads().catch((err) => setStatus(err.message, "bad"));
671 $("new-thread").onclick = () => newThread().catch((err) => setStatus(err.message, "bad"));
672 $("send").onclick = () => sendPrompt().catch((err) => setStatus(err.message, "bad"));
673 $("steer").onclick = () => steerTurn().catch((err) => setStatus(err.message, "bad"));
674 $("interrupt").onclick = () => interruptTurn().catch((err) => setStatus(err.message, "bad"));
675 await loadThreads();
676 setStatus("Connected", "ok");
677 }
678
679 boot().catch((err) => setStatus(err.message, "bad"));
680 </script>
681 </body>
682 </html>
683
683 lines HTML