feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled

Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-06-10 16:57:08 +08:00
parent 11c3a3a399
commit cf5edd6394
132 changed files with 9288 additions and 1971 deletions

View File

@@ -0,0 +1,16 @@
import type { IncomingMessage, ServerResponse } from "node:http";
/** CORS for desktop / cross-origin clients (e.g. frontend/dist-desketop loading local static files). */
export function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
res.setHeader("Access-Control-Allow-Origin", origin || "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "86400");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return true;
}
return false;
}

View File

@@ -0,0 +1,23 @@
import type { IncomingMessage, ServerResponse } from "node:http";
export function json(res: ServerResponse, data: unknown, status = 200): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
export function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
try {
resolve(JSON.parse(body));
} catch {
reject(new Error("无效 JSON"));
}
});
req.on("error", reject);
});
}

View File

@@ -0,0 +1,36 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { applyCorsHeaders } from "./cors.ts";
import { serveStatic } from "./static.ts";
import { handleChatRoute } from "../routes/chat.ts";
import { handleCommandsRoute } from "../routes/commands.ts";
import { handleModelsRoute } from "../routes/models.ts";
import { handleSessionsRoute } from "../routes/sessions.ts";
import { handleSettingsRoute } from "../routes/settings.ts";
import { handleWebuiConfigRoute } from "../routes/webui-config.ts";
import type { WebUiContext } from "../types/context.ts";
export function createWebUiServer(ctx: WebUiContext) {
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url!, `http://localhost:${ctx.config.port}`);
const pathname = url.pathname;
if (pathname.startsWith("/api/") && applyCorsHeaders(req, res)) {
return;
}
if (req.method === "GET" && !pathname.startsWith("/api/")) {
serveStatic(ctx.config.paths.publicDir, pathname, req, res, true);
return;
}
if (handleChatRoute(req, res, ctx, pathname)) return;
if (handleSessionsRoute(req, res, ctx, pathname)) return;
if (handleModelsRoute(req, res, ctx, pathname)) return;
if (handleWebuiConfigRoute(req, res, ctx, pathname, url)) return;
if (handleSettingsRoute(req, res, ctx, pathname)) return;
if (handleCommandsRoute(req, res, ctx, pathname)) return;
res.writeHead(404);
res.end("Not found");
});
}

View File

@@ -0,0 +1,83 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import type { ServerResponse, IncomingMessage } from "node:http";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".woff2": "font/woff2",
".woff": "font/woff",
".ico": "image/x-icon",
".webmanifest": "application/manifest+json",
".json": "application/json",
};
const GZIPABLE = new Set([".html", ".js", ".css", ".json", ".webmanifest"]);
// Vite build outputs hashed filenames like index-DeT2iZAf.js
const HASHED_FILE_RE = /-[A-Za-z0-9_-]{6,}\.\w+$/;
function getCacheControl(file: string): string | undefined {
if (file === "/index.html") {
return "no-cache";
}
if (HASHED_FILE_RE.test(file) || file.endsWith(".woff2") || file.endsWith(".woff")) {
return "public, max-age=31536000, immutable";
}
return undefined;
}
export function serveStatic(
publicDir: string,
urlPath: string,
req: IncomingMessage,
res: ServerResponse,
spaFallback = false,
): void {
const file = urlPath === "/" ? "/index.html" : urlPath;
const full = join(publicDir, file);
if (!full.startsWith(publicDir)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
if (!existsSync(full)) {
if (spaFallback && !urlPath.includes(".")) {
serveStatic(publicDir, "/index.html", req, res, false);
return;
}
res.writeHead(404);
res.end("Not found");
return;
}
const ext = file.match(/\.\w+$/)?.[0] || ".html";
const mimeType = MIME[ext] || "application/octet-stream";
const content = readFileSync(full);
const headers: Record<string, string> = {
"Content-Type": mimeType,
};
const cacheControl = getCacheControl(file);
if (cacheControl) {
headers["Cache-Control"] = cacheControl;
}
const acceptEncoding = req.headers["accept-encoding"] || "";
const shouldGzip = GZIPABLE.has(ext) && acceptEncoding.includes("gzip");
if (shouldGzip) {
const compressed = gzipSync(content);
headers["Content-Encoding"] = "gzip";
res.writeHead(200, headers);
res.end(compressed);
return;
}
res.writeHead(200, headers);
res.end(content);
}