feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled
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:
16
.pi/agent/extensions/webui/backend/http/cors.ts
Normal file
16
.pi/agent/extensions/webui/backend/http/cors.ts
Normal 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;
|
||||
}
|
||||
23
.pi/agent/extensions/webui/backend/http/request.ts
Normal file
23
.pi/agent/extensions/webui/backend/http/request.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
36
.pi/agent/extensions/webui/backend/http/server.ts
Normal file
36
.pi/agent/extensions/webui/backend/http/server.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
83
.pi/agent/extensions/webui/backend/http/static.ts
Normal file
83
.pi/agent/extensions/webui/backend/http/static.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user