const $ = (id) => document.getElementById(id); const state = { sessionId: localStorage.getItem("lazyboy.session") || "", sessions: [], running: false, source: null, }; function tokenHeaders() { const token = localStorage.getItem("lazyboy.token") || ""; return token ? { Authorization: `Bearer ${token}` } : {}; } async function api(path, opts = {}) { const res = await fetch(path, { ...opts, headers: { "content-type": "application/json", ...tokenHeaders(), ...(opts.headers || {}), }, }); const raw = await res.text(); let data = null; try { data = raw ? JSON.parse(raw) : null; } catch { data = null; } if (!res.ok) { throw new Error((data && data.error) || raw || String(res.status)); } return data; } function setStatus(text) { $("header-status").textContent = text; } function renderSessions() { const list = $("session-list"); list.innerHTML = ""; for (const s of state.sessions) { const btn = document.createElement("button"); btn.className = "session-item" + (s.id === state.sessionId ? " active" : ""); btn.innerHTML = `${s.running ? "進行中" : "對話"}${escapeHtml(s.preview || s.id)}`; btn.onclick = () => openSession(s.id); list.appendChild(btn); } } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">"); } function addBubble(role, content) { const row = document.createElement("div"); row.className = `row ${role}`; row.innerHTML = `
${escapeHtml(content)}
`; $("transcript").appendChild(row); $("transcript").scrollTop = $("transcript").scrollHeight; } function addActivity(text) { const el = document.createElement("div"); el.className = "activity"; el.textContent = text; $("transcript").appendChild(el); $("transcript").scrollTop = $("transcript").scrollHeight; } function showTyping(on) { $("transcript").querySelector(".typing")?.remove(); if (!on) return; const el = document.createElement("div"); el.className = "typing"; el.innerHTML = ""; $("transcript").appendChild(el); $("transcript").scrollTop = $("transcript").scrollHeight; } function renderQuestion(q) { const card = $("question-card"); if (!q) { card.classList.add("hidden"); card.innerHTML = ""; return; } const prompt = q.question || q.reason || "需要你的回覆"; const options = (q.options || []).map((o) => (typeof o === "string" ? o : o.label || o.value)).filter(Boolean); card.classList.remove("hidden"); card.innerHTML = `

${escapeHtml(prompt)}

` + (options.length ? `
${options.map((o) => ``).join("")}
` : ""); card.querySelectorAll("button").forEach((b) => { b.onclick = () => send(b.dataset.answer); }); } function renderPlan(plan) { if (!plan || !plan.length) return; const el = document.createElement("div"); el.className = "plan"; el.innerHTML = "計畫
    " + plan.map((step) => { const text = step.step || step; const status = step.status || ""; return `
  1. ${escapeHtml(text)}
  2. `; }).join("") + "
"; $("transcript").appendChild(el); } async function loadSessions() { const data = await api("/api/sessions"); state.sessions = data.sessions || []; renderSessions(); } async function openSession(id) { state.sessionId = id; localStorage.setItem("lazyboy.session", id); renderSessions(); closeSidebar(); const data = await api(`/api/sessions/${id}`); $("transcript").innerHTML = ""; for (const item of data.transcript || []) addBubble(item.role, item.content); renderPlan(data.plan); renderQuestion(data.pending_question); state.running = !!data.running; $("stop-btn").classList.toggle("hidden", !state.running); showTyping(state.running); setStatus(state.running ? "工作中" : data.last_verdict || "準備好了"); listen(id); } function listen(id) { state.source?.close(); clearInterval(state.poll); const src = new EventSource(`/api/sessions/${id}/events`); state.source = src; src.onmessage = (ev) => { let data; try { data = JSON.parse(ev.data); } catch { return; } handleEvent(data); }; state.poll = setInterval(async () => { if (!state.running) return; try { const data = await api(`/api/sessions/${id}`); if (data.running !== state.running && !data.running) { handleEvent({ type: "turn_ended", verdict: data.last_verdict, pending_question: data.pending_question, plan: data.plan, }); } } catch { /* keep polling */ } }, 2000); } function handleEvent(data) { switch (data.type) { case "user": addBubble("user", data.content); break; case "message": showTyping(false); addBubble("assistant", data.content); break; case "status": case "progress": addActivity(data.message); break; case "tool_started": addActivity(`〔開始〕${data.name}`); showTyping(true); break; case "tool_finished": addActivity(`〔${data.success ? "完成" : "失敗"}〕${data.name}`); break; case "waiting": setStatus(data.stage || "等待中"); break; case "question": renderQuestion(data.question); showTyping(false); break; case "plan_updated": renderPlan(data.plan); break; case "turn_ended": state.running = false; $("stop-btn").classList.add("hidden"); showTyping(false); renderQuestion(data.pending_question); setStatus(data.verdict || "完成"); loadSessions(); break; default: break; } } async function send(text) { const value = (text ?? $("prompt").value).trim(); if (!value) return; if (!state.sessionId) { const created = await api("/api/sessions", { method: "POST", body: "{}" }); state.sessionId = created.id; localStorage.setItem("lazyboy.session", created.id); await loadSessions(); listen(created.id); } $("prompt").value = ""; resizePrompt(); $("send-btn").disabled = true; addBubble("user", value); renderQuestion(null); state.running = true; $("stop-btn").classList.remove("hidden"); showTyping(true); setStatus("工作中"); try { await api(`/api/sessions/${state.sessionId}/messages`, { method: "POST", body: JSON.stringify({ text: value }), }); } catch (err) { state.running = false; $("stop-btn").classList.add("hidden"); showTyping(false); setStatus(String(err.message || err)); addActivity("送出失敗:" + (err.message || err)); } } async function newChat() { const created = await api("/api/sessions", { method: "POST", body: "{}" }); await loadSessions(); await openSession(created.id); } function resizePrompt() { const el = $("prompt"); el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, window.innerHeight * 0.3) + "px"; $("send-btn").disabled = !el.value.trim(); } function openSidebar() { $("sidebar").classList.add("open"); $("sidebar-backdrop").classList.remove("hidden"); } function closeSidebar() { $("sidebar").classList.remove("open"); $("sidebar-backdrop").classList.add("hidden"); } async function openComputer() { $("computer-pane").hidden = false; $("tab-computer").classList.add("active"); $("tab-chat").classList.remove("active"); $("computer-status").textContent = "正在啟動我的電腦…"; try { const data = await api("/api/computer", { method: "POST", body: "{}" }); $("computer-frame").src = data.viewer_url; $("computer-status").textContent = data.ready ? "已連線同一台 Docker 桌面" : (data.error || "桌面啟動中"); } catch (err) { $("computer-status").textContent = String(err.message || err); } } function closeComputer() { $("computer-pane").hidden = true; $("tab-chat").classList.add("active"); $("tab-computer").classList.remove("active"); } $("composer").addEventListener("submit", (e) => { e.preventDefault(); send(); }); $("prompt").addEventListener("input", resizePrompt); $("prompt").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && window.matchMedia("(min-width: 861px)").matches) { e.preventDefault(); send(); } }); $("new-chat").onclick = () => newChat(); $("menu-btn").onclick = openSidebar; $("sidebar-backdrop").onclick = closeSidebar; $("computer-btn").onclick = openComputer; $("computer-close").onclick = closeComputer; $("tab-chat").onclick = () => { closeComputer(); closeSidebar(); }; $("tab-computer").onclick = openComputer; $("stop-btn").onclick = async () => { if (state.sessionId) await api(`/api/sessions/${state.sessionId}/stop`, { method: "POST", body: "{}" }); }; if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } (async () => { try { const health = await api("/api/health"); $("model-label").textContent = health.model || "grok-4.6"; await loadSessions(); if (state.sessionId) await openSession(state.sessionId); else if (state.sessions[0]) await openSession(state.sessions[0].id); } catch (err) { setStatus("無法連線:" + err.message); } })();