LazyBoy2/web/app.js

323 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
})();