@oldsuns/pi-switch 0.3.2 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ let language = "zh-CN";
2
+
3
+ export function setLanguage(value) {
4
+ language = value;
5
+ document.documentElement.lang = value;
6
+ }
7
+
8
+ export const t = (zh, en) => language === "zh-CN" ? zh : en;
9
+ export const h = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
10
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
11
+ })[character]);
12
+
13
+ const paths = {
14
+ home: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
15
+ sliders: '<path d="M4 7h8m5 0h3M4 17h3m5 0h8"/><rect x="12" y="4" width="5" height="6" rx="1"/><rect x="7" y="14" width="5" height="6" rx="1"/>',
16
+ messages: '<path d="M21 11a8 8 0 0 1-8 8H7l-5 3 2-6a8 8 0 0 1-1-5 8 8 0 0 1 8-8h2a8 8 0 0 1 8 8Z"/><path d="M8 9h8M8 13h5"/>',
17
+ settings: '<path d="m9 3-.6 2.3-2 .9-2.2-.6-2 3.4 1.6 1.7v2.5l-1.6 1.7 2 3.4 2.2-.6 2 .9L9 21h4l.6-2.4 2-.9 2.2.6 2-3.4-1.6-1.7v-2.5L19.8 9l-2-3.4-2.2.6-2-.9L13 3Z"/><circle cx="11" cy="12" r="3"/>',
18
+ terminal: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="m7 9 3 3-3 3m6 0h4"/>',
19
+ search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/>',
20
+ plus: '<path d="M12 5v14M5 12h14"/>',
21
+ arrow: '<path d="M5 12h14m-5-5 5 5-5 5"/>',
22
+ arrowDown: '<path d="M12 5v14m-5-5 5 5 5-5"/>',
23
+ grip: '<circle cx="9" cy="5" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="19" r="1"/>',
24
+ target: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3"/>',
25
+ chevron: '<path d="m9 5 7 7-7 7"/>',
26
+ chevronDown: '<path d="m6 9 6 6 6-6"/>',
27
+ chevronsUp: '<path d="m7 11 5-5 5 5m-10 7 5-5 5 5"/>',
28
+ chevronsDown: '<path d="m7 6 5 5 5-5m-10 7 5 5 5-5"/>',
29
+ refresh: '<path d="M20 7v5h-5M4 17v-5h5"/><path d="M6.1 6.1a8 8 0 0 1 13.2 3M4.7 14.9a8 8 0 0 0 13.2 3"/>',
30
+ check: '<path d="m5 12 4 4L19 6"/>',
31
+ checkCircle: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
32
+ shield: '<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6Z"/><path d="m8 12 3 3 5-6"/>',
33
+ star: '<path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9Z"/>',
34
+ cpu: '<rect x="6" y="6" width="12" height="12" rx="3"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 3v3m6-3v3M9 18v3m6-3v3M3 9h3m-3 6h3m12-6h3m-3 6h3"/>',
35
+ box: '<path d="m12 3 9 5-9 5-9-5 9-5ZM3 8v9l9 5 9-5V8M12 13v9m-5-16 9 5"/>',
36
+ globe: '<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/>',
37
+ link: '<path d="m10 13 4-4m-6 6-1 1a3.5 3.5 0 0 1-5-5l4-4a3.5 3.5 0 0 1 5 0m2 2 1-1a3.5 3.5 0 0 1 5 5l-4 4a3.5 3.5 0 0 1-5 0"/>',
38
+ unlink: '<path d="m18.8 12.2 1.4-1.4a5 5 0 0 0-7-7l-1.4 1.4m-6.6 6.6-1.4 1.4a5 5 0 0 0 7 7l1.4-1.4M8 2v3M2 8h3m11 11v3m3-6h3"/>',
39
+ folder: '<path d="M3 8V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
40
+ file: '<path d="M14 3H5v18h14V8l-5-5Zm0 0v6h5M8 13h8m-8 4h6"/>',
41
+ edit: '<path d="m15 5 4 4M4 20l5-1L21 7a2.8 2.8 0 0 0-4-4L5 15l-1 5Z"/>',
42
+ copy: '<rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>',
43
+ trash: '<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/>',
44
+ download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
45
+ upload: '<path d="M12 16V4m-5 5 5-5 5 5M4 16v5h16v-5"/>',
46
+ history: '<path d="M3 3v6h6"/><path d="M3.7 9a9 9 0 1 1 .3 7M12 7v5l3 2"/>',
47
+ help: '<circle cx="12" cy="12" r="9"/><path d="M9 9a3 3 0 0 1 6 0c0 2-3 2-3 4m0 3h.01"/>',
48
+ close: '<path d="m6 6 12 12M18 6 6 18"/>',
49
+ more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
50
+ eye: '<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
51
+ key: '<circle cx="8" cy="9" r="5"/><path d="m12 13 8 8m-2-2 3-3m-6 0 3-3"/>',
52
+ bolt: '<path d="m13 2-9 12h7l-1 8 10-12h-8Z"/>',
53
+ brain: '<path d="M12 5c-3-5-8 0-6 3-5 1-4 7-1 7-1 5 5 7 7 3m0-13c3-5 8 0 6 3 5 1 4 7 1 7 1 5-5 7-7 3V5Zm-6 3 2 2m-3 5 4-1m9-6-2 2m3 5-4-1"/>',
54
+ image: '<rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8" cy="8" r="1.5"/><path d="m3 17 5-5 4 4 4-7 5 8"/>',
55
+ branch: '<circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="6" r="2"/><path d="M6 7v10m0-5h7a5 5 0 0 0 5-4"/>',
56
+ book: '<path d="M12 5c-4-3-8-1-9-1v15c3-1 6-1 9 1m0-15c4-3 8-1 9-1v15c-3-1-6-1-9 1V5Z"/>',
57
+ user: '<circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
58
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
59
+ warning: '<path d="m12 3 10 18H2L12 3Z"/><path d="M12 9v5m0 3h.01"/>',
60
+ external: '<path d="M14 3h7v7m0-7L10 14M10 3H3v18h18v-7"/>',
61
+ menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
62
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5"/>',
63
+ moon: '<path d="M20.5 13.1A8.7 8.7 0 0 1 10.9 3.5 8.8 8.8 0 1 0 20.5 13.1Z"/>',
64
+ };
65
+
66
+ export function icon(name, className = "") {
67
+ return '<svg class="icon ' + className + '" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (paths[name] ?? paths.box) + "</svg>";
68
+ }
69
+
70
+ export function initials(value) {
71
+ return h(value.slice(0, 2).toUpperCase());
72
+ }
73
+
74
+ export function tint(value) {
75
+ return ["mauve", "blue", "peach", "teal", "pink"][Array.from(value).reduce((sum, c) => sum + c.charCodeAt(0), 0) % 5];
76
+ }
77
+
78
+ export function number(value) {
79
+ return new Intl.NumberFormat(language).format(value);
80
+ }
81
+
82
+ export function tokens(value) {
83
+ if (value == null) return "—";
84
+ if (value >= 1_000_000) return Number((value / 1_000_000).toFixed(2)) + "M";
85
+ if (value >= 1_000) return Number((value / 1_000).toFixed(1)) + "k";
86
+ return number(value);
87
+ }
88
+
89
+ export function price(value) {
90
+ return value == null ? "—" : "$" + new Intl.NumberFormat("en", { maximumFractionDigits: 4 }).format(value);
91
+ }
92
+
93
+ export function date(value, short = false) {
94
+ if (!value) return "—";
95
+ const parsed = new Date(value);
96
+ if (Number.isNaN(parsed.valueOf())) return h(value);
97
+ return new Intl.DateTimeFormat(language, {
98
+ month: "short", day: "numeric", ...(short ? {} : { hour: "2-digit", minute: "2-digit" }),
99
+ }).format(parsed);
100
+ }
101
+
102
+ export function modelName(model) {
103
+ return model.name || model.id;
104
+ }
105
+
106
+ export function capabilities(model) {
107
+ return (model.reasoning ? '<span class="capability mauve" title="' + t("推理", "Reasoning") + '">' + icon("brain") + t("推理", "Reasoning") + "</span>" : "")
108
+ + (model.input.includes("image") ? '<span class="capability blue" title="' + t("图像输入", "Vision") + '">' + icon("image") + t("视觉", "Vision") + "</span>" : "")
109
+ + (!model.reasoning && !model.input.includes("image") ? '<span class="capability muted">' + t("文本", "Text") + "</span>" : "");
110
+ }
111
+
112
+ export function emptyState(title, description, name = "box", action = "") {
113
+ return '<div class="empty-state"><span class="empty-icon">' + icon(name) + '</span><h3>' + h(title) + '</h3><p>' + h(description) + "</p>" + action + "</div>";
114
+ }
115
+
116
+ export function searchInput(id, value, placeholder) {
117
+ return '<label class="search-box" for="' + id + '">' + icon("search")
118
+ + '<input id="' + id + '" type="search" autocomplete="off" value="' + h(value) + '" placeholder="' + h(placeholder) + '" aria-label="' + h(placeholder) + '"><kbd>/</kbd></label>';
119
+ }
120
+
121
+ export function listSearch({ scope, query, open, label, placeholder = label + "…" }) {
122
+ const id = scope + "-search";
123
+ return {
124
+ button: iconButton("toggle-search", "search", label, `data-scope="${h(scope)}" aria-controls="${h(id)}-region" aria-expanded="${open}"`, "search-toggle"),
125
+ field: `<div id="${h(id)}-region" class="list-search" ${open ? "" : "hidden"}>${searchInput(id, query, placeholder)}</div>`,
126
+ };
127
+ }
128
+
129
+ export function toggle(action, checked, label, attributes = "") {
130
+ return '<label class="switch" title="' + h(label) + '"><input type="checkbox" role="switch" data-action="' + action + '" aria-label="' + h(label) + '" ' + (checked ? "checked " : "") + attributes + '><span class="switch-track"></span></label>';
131
+ }
132
+
133
+ export function iconButton(action, name, label, attributes = "", className = "") {
134
+ return '<button type="button" class="icon-button ' + className + '" data-action="' + action + '" aria-label="' + h(label) + '" title="' + h(label) + '" ' + attributes + ">" + icon(name) + "</button>";
135
+ }
package/web/server.js ADDED
@@ -0,0 +1,137 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import { isAbsolute, relative } from "node:path";
5
+
6
+ const BODY_LIMIT = 512 * 1024;
7
+ const PUBLIC_DIR = new URL("./public/", import.meta.url);
8
+ const MIME_TYPES = {
9
+ ".html": "text/html; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".js": "text/javascript; charset=utf-8",
12
+ ".svg": "image/svg+xml",
13
+ };
14
+ const HEADERS = {
15
+ "Cache-Control": "no-store",
16
+ "X-Content-Type-Options": "nosniff",
17
+ "Referrer-Policy": "no-referrer",
18
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
19
+ };
20
+
21
+ class HttpError extends Error {
22
+ constructor(status, message) {
23
+ super(message);
24
+ this.status = status;
25
+ }
26
+ }
27
+
28
+ async function readJson(request) {
29
+ if (request.headers["content-type"]?.split(";")[0].trim() !== "application/json") {
30
+ throw new HttpError(415, "Expected application/json.");
31
+ }
32
+ const chunks = [];
33
+ let size = 0;
34
+ for await (const chunk of request) {
35
+ size += chunk.length;
36
+ if (size > BODY_LIMIT) throw new HttpError(413, "Request body is too large.");
37
+ chunks.push(chunk);
38
+ }
39
+ let value;
40
+ try {
41
+ value = JSON.parse(Buffer.concat(chunks).toString("utf8"), (_key, item) => {
42
+ if (typeof item === "number" && !Number.isFinite(item)) {
43
+ throw new HttpError(400, "JSON numbers must be finite.");
44
+ }
45
+ return item;
46
+ });
47
+ } catch (error) {
48
+ if (error instanceof HttpError) throw error;
49
+ throw new HttpError(400, "Request body must be valid JSON.");
50
+ }
51
+ if (!value || Array.isArray(value) || typeof value !== "object" || typeof value.action !== "string") {
52
+ throw new HttpError(400, "Expected a JSON object with an action.");
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function sendJson(response, status, value) {
58
+ response.writeHead(status, { ...HEADERS, "Content-Type": "application/json; charset=utf-8" });
59
+ response.end(JSON.stringify(value));
60
+ }
61
+
62
+ async function serveAsset(request, response, pathname) {
63
+ if (request.method !== "GET" && request.method !== "HEAD") {
64
+ throw new HttpError(405, "Method not allowed.");
65
+ }
66
+ const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
67
+ // Only the packaged UI is public; configuration paths never become asset paths.
68
+ if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*\.(html|css|js|svg)$/.test(relativePath)) {
69
+ throw new HttpError(404, "Not found.");
70
+ }
71
+ const assetPath = fileURLToPath(new URL(relativePath, PUBLIC_DIR));
72
+ const withinPublic = relative(fileURLToPath(PUBLIC_DIR), assetPath);
73
+ if (withinPublic.startsWith("..") || isAbsolute(withinPublic)) throw new HttpError(404, "Not found.");
74
+ let body;
75
+ try {
76
+ body = await readFile(assetPath);
77
+ } catch (error) {
78
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new HttpError(404, "Not found.");
79
+ throw error;
80
+ }
81
+ const extension = relativePath.slice(relativePath.lastIndexOf("."));
82
+ response.writeHead(200, { ...HEADERS, "Content-Type": MIME_TYPES[extension] });
83
+ response.end(request.method === "HEAD" ? undefined : body);
84
+ }
85
+
86
+ /**
87
+ * The browser is another client of the native core. Injecting request keeps
88
+ * HTTP validation separate from document operations and makes both testable.
89
+ */
90
+ export function createWebServer({ request: dispatch }) {
91
+ if (typeof dispatch !== "function") throw new TypeError("A native request handler is required.");
92
+
93
+ const server = createServer(async (request, response) => {
94
+ try {
95
+ const address = server.address();
96
+ const authority = "127.0.0.1:" + address.port;
97
+ if (request.headers.host !== authority) throw new HttpError(403, "Use the local URL printed by pi-switch.");
98
+ const origin = "http://" + authority;
99
+ if (request.headers.origin && request.headers.origin !== origin) {
100
+ throw new HttpError(403, "Cross-origin requests are not allowed.");
101
+ }
102
+ const url = new URL(request.url, origin);
103
+ if (url.pathname !== "/api") {
104
+ await serveAsset(request, response, url.pathname);
105
+ return;
106
+ }
107
+ if (request.method !== "POST") throw new HttpError(405, "Use POST for API requests.");
108
+ if (request.headers.origin !== origin) throw new HttpError(403, "A same-origin browser request is required.");
109
+ const payload = await readJson(request);
110
+ let result;
111
+ try {
112
+ result = await dispatch(payload);
113
+ } catch (error) {
114
+ throw new HttpError(422, error.message);
115
+ }
116
+ sendJson(response, 200, result);
117
+ } catch (error) {
118
+ if (!response.headersSent && !response.destroyed) {
119
+ sendJson(response, error.status ?? 500, { error: error.message });
120
+ }
121
+ }
122
+ });
123
+ server.requestTimeout = 30_000;
124
+ server.headersTimeout = 10_000;
125
+ return server;
126
+ }
127
+
128
+ export async function listen(server, port = 0) {
129
+ await new Promise((resolve, reject) => {
130
+ server.once("error", reject);
131
+ server.listen(port, "127.0.0.1", () => {
132
+ server.off("error", reject);
133
+ resolve();
134
+ });
135
+ });
136
+ return "http://127.0.0.1:" + server.address().port;
137
+ }
package/web/start.js ADDED
@@ -0,0 +1,63 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createNativeClient } from "./native-client.js";
3
+ import { createWebServer, listen } from "./server.js";
4
+
5
+ export function parseWebOptions(args) {
6
+ const options = { port: 0, open: true };
7
+ for (let index = 0; index < args.length; index++) {
8
+ const argument = args[index];
9
+ if (argument === "--no-open") {
10
+ options.open = false;
11
+ } else if (argument === "--port") {
12
+ const value = args[++index];
13
+ if (!/^\d+$/.test(value ?? "") || Number(value) < 1 || Number(value) > 65535) {
14
+ throw new Error("--port must be an integer between 1 and 65535.");
15
+ }
16
+ options.port = Number(value);
17
+ } else {
18
+ throw new Error("Unknown Web option: " + argument);
19
+ }
20
+ }
21
+ return options;
22
+ }
23
+
24
+ function openBrowser(url) {
25
+ const [command, args] = process.platform === "win32"
26
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
27
+ : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
28
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
29
+ child.on("error", (error) => console.error("Could not open the browser: " + error.message + "\nOpen " + url + " manually."));
30
+ child.on("exit", (code) => {
31
+ if (code) console.error("The browser launcher exited with " + code + ". Open " + url + " manually.");
32
+ });
33
+ child.unref();
34
+ }
35
+
36
+ export async function startWeb(options = {}) {
37
+ const client = await createNativeClient();
38
+ const server = createWebServer({ request: (payload) => client.request(payload) });
39
+ let url;
40
+ try {
41
+ url = await listen(server, options.port ?? 0);
42
+ } catch (error) {
43
+ await client.close();
44
+ throw error;
45
+ }
46
+ console.log("\n pi-switch · Web\n\n " + url + "\n\n Local access only. Press Ctrl+C to stop.\n");
47
+ if (options.open !== false) openBrowser(url);
48
+ let closing = false;
49
+ async function close() {
50
+ if (closing) return;
51
+ closing = true;
52
+ process.off("SIGINT", close);
53
+ process.off("SIGTERM", close);
54
+ server.closeAllConnections();
55
+ await Promise.all([
56
+ new Promise((resolve) => server.close(resolve)),
57
+ client.close(),
58
+ ]);
59
+ }
60
+ process.once("SIGINT", close);
61
+ process.once("SIGTERM", close);
62
+ return { server, url, close };
63
+ }