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