LazyBoy2/web/app.js

323 lines
9.2 KiB
JavaScript
Raw Normal View History

2026-09-14 09:08:20 +00:00
const $ = (id) => document.getElementById(id);
const state = {
sessionId: localStorage.getItem("grokboy.session") || "",
sessions: [],
running: false,
source: null,
};
function tokenHeaders() {
const token = localStorage.getItem("grokboy.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 = `<strong>${s.running ? "進行中" : "對話"}</strong><small>${escapeHtml(s.preview || s.id)}</small>`;
btn.onclick = () => openSession(s.id);
list.appendChild(btn);
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function addBubble(role, content) {
const row = document.createElement("div");
row.className = `row ${role}`;
row.innerHTML = `<div class="bubble">${escapeHtml(content)}</div>`;
$("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 = "<i></i><i></i><i></i>";
$("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 = `<p>${escapeHtml(prompt)}</p>` + (options.length
? `<div class="options">${options.map((o) => `<button type="button" data-answer="${escapeHtml(o)}">${escapeHtml(o)}</button>`).join("")}</div>`
: "");
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 = "<strong>計畫</strong><ol>" + plan.map((step) => {
const text = step.step || step;
const status = step.status || "";
return `<li class="${status}">${escapeHtml(text)}</li>`;
}).join("") + "</ol>";
$("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("grokboy.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("grokboy.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);
}
})();