323 lines
9.2 KiB
JavaScript
323 lines
9.2 KiB
JavaScript
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 = `<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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
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("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);
|
||
}
|
||
})();
|