Files
QwenClaw-with-Auth/src/ui/server.ts

161 lines
5.5 KiB
TypeScript

import { htmlPage } from "./page/html";
import { clampInt, json } from "./http";
import type { StartWebUiOptions, WebServerHandle } from "./types";
import { buildState, buildTechnicalInfo, sanitizeSettings } from "./services/state";
import { readHeartbeatSettings, updateHeartbeatSettings } from "./services/settings";
import { createQuickJob, deleteJob } from "./services/jobs";
import { readLogs } from "./services/logs";
export function startWebUi(opts: StartWebUiOptions): WebServerHandle {
const server = Bun.serve({
hostname: opts.host,
port: opts.port,
fetch: async (req) => {
const url = new URL(req.url);
if (url.pathname === "/" || url.pathname === "/index.html") {
return new Response(htmlPage(), {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (url.pathname === "/api/health") {
return json({ ok: true, now: Date.now() });
}
if (url.pathname === "/api/state") {
return json(await buildState(opts.getSnapshot()));
}
if (url.pathname === "/api/settings") {
return json(sanitizeSettings(opts.getSnapshot().settings));
}
if (url.pathname === "/api/settings/heartbeat" && req.method === "POST") {
try {
const body = await req.json();
const payload = body as {
enabled?: unknown;
interval?: unknown;
prompt?: unknown;
excludeWindows?: unknown;
};
const patch: {
enabled?: boolean;
interval?: number;
prompt?: string;
excludeWindows?: Array<{ days?: number[]; start: string; end: string }>;
} = {};
if ("enabled" in payload) patch.enabled = Boolean(payload.enabled);
if ("interval" in payload) {
const iv = Number(payload.interval);
if (!Number.isFinite(iv)) throw new Error("interval must be numeric");
patch.interval = iv;
}
if ("prompt" in payload) patch.prompt = String(payload.prompt ?? "");
if ("excludeWindows" in payload) {
if (!Array.isArray(payload.excludeWindows)) {
throw new Error("excludeWindows must be an array");
}
patch.excludeWindows = payload.excludeWindows
.filter((entry) => entry && typeof entry === "object")
.map((entry) => {
const row = entry as Record<string, unknown>;
const start = String(row.start ?? "").trim();
const end = String(row.end ?? "").trim();
const days = Array.isArray(row.days)
? row.days
.map((d) => Number(d))
.filter((d) => Number.isInteger(d) && d >= 0 && d <= 6)
: undefined;
return {
start,
end,
...(days && days.length > 0 ? { days } : {}),
};
});
}
if (
!("enabled" in patch) &&
!("interval" in patch) &&
!("prompt" in patch) &&
!("excludeWindows" in patch)
) {
throw new Error("no heartbeat fields provided");
}
const next = await updateHeartbeatSettings(patch);
if (opts.onHeartbeatEnabledChanged && "enabled" in patch) {
await opts.onHeartbeatEnabledChanged(Boolean(patch.enabled));
}
if (opts.onHeartbeatSettingsChanged) {
await opts.onHeartbeatSettingsChanged(patch);
}
return json({ ok: true, heartbeat: next });
} catch (err) {
return json({ ok: false, error: String(err) });
}
}
if (url.pathname === "/api/settings/heartbeat" && req.method === "GET") {
try {
return json({ ok: true, heartbeat: await readHeartbeatSettings() });
} catch (err) {
return json({ ok: false, error: String(err) });
}
}
if (url.pathname === "/api/technical-info") {
return json(await buildTechnicalInfo(opts.getSnapshot()));
}
if (url.pathname === "/api/jobs/quick" && req.method === "POST") {
try {
const body = await req.json();
const result = await createQuickJob(body as { time?: unknown; prompt?: unknown });
if (opts.onJobsChanged) await opts.onJobsChanged();
return json({ ok: true, ...result });
} catch (err) {
return json({ ok: false, error: String(err) });
}
}
if (url.pathname.startsWith("/api/jobs/") && req.method === "DELETE") {
try {
const encodedName = url.pathname.slice("/api/jobs/".length);
const name = decodeURIComponent(encodedName);
await deleteJob(name);
if (opts.onJobsChanged) await opts.onJobsChanged();
return json({ ok: true });
} catch (err) {
return json({ ok: false, error: String(err) });
}
}
if (url.pathname === "/api/jobs") {
const jobs = opts.getSnapshot().jobs.map((j) => ({
name: j.name,
schedule: j.schedule,
promptPreview: j.prompt.slice(0, 160),
}));
return json({ jobs });
}
if (url.pathname === "/api/logs") {
const tail = clampInt(url.searchParams.get("tail") ?? "", 200, 20, 2000);
return json(await readLogs(tail));
}
return new Response("Not found", { status: 404 });
},
});
return {
stop: () => server.stop(),
host: opts.host,
port: server.port ?? opts.port,
};
}