@xyzbit/chat2ranker 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +3 -0
  2. package/cli.mjs +377 -0
  3. package/package.json +23 -0
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Chat2Ranker
2
+
3
+ `npx -y @xyzbit/chat2ranker start` starts the local service and opens the browser. User data is stored in `~/.chat2ranker`.
package/cli.mjs ADDED
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { createReadStream, createWriteStream, openSync } from "node:fs";
5
+ import { access, chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { createServer, request as httpRequest } from "node:http";
7
+ import { homedir, platform as hostPlatform, arch as hostArch } from "node:os";
8
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
9
+ import { pipeline } from "node:stream/promises";
10
+ import { Readable } from "node:stream";
11
+ import { spawn } from "node:child_process";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const cliPath = fileURLToPath(import.meta.url);
15
+ const packageRoot = dirname(cliPath);
16
+ const argv = process.argv.slice(2);
17
+ const command = argv[0] || "start";
18
+
19
+ function option(name, fallback = "") {
20
+ const index = argv.indexOf(`--${name}`);
21
+ return index >= 0 ? argv[index + 1] || "" : fallback;
22
+ }
23
+
24
+ function has(name) {
25
+ return argv.includes(`--${name}`);
26
+ }
27
+
28
+ async function packageMetadata() {
29
+ try {
30
+ return JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
31
+ } catch {
32
+ return { name: "@xyzbit/chat2ranker", version: "0.0.0-dev" };
33
+ }
34
+ }
35
+
36
+ const metadata = await packageMetadata();
37
+ const home = resolve(option("home") || option("data-dir") || process.env.CHAT2RANKER_HOME || join(homedir(), ".chat2ranker"));
38
+ const pidPath = resolve(home, "chat2ranker.pid");
39
+ const webPort = Number(option("port", process.env.CHAT2RANKER_PORT || "4173"));
40
+ const url = `http://127.0.0.1:${webPort}`;
41
+
42
+ function run(commandName, args, options = {}) {
43
+ return new Promise((resolveRun, rejectRun) => {
44
+ const child = spawn(commandName, args, { stdio: "inherit", ...options });
45
+ child.once("error", rejectRun);
46
+ child.once("exit", (code) => code === 0 ? resolveRun() : rejectRun(new Error(`${commandName} exited with ${code}`)));
47
+ });
48
+ }
49
+
50
+ async function exists(path) {
51
+ try {
52
+ await access(path);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ async function download(source, target) {
60
+ const response = await fetch(source);
61
+ if (!response.ok || !response.body) throw new Error(`下载运行包失败:${response.status} ${source}`);
62
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(target, { mode: 0o600 }));
63
+ }
64
+
65
+ async function sha256(path) {
66
+ const hash = createHash("sha256");
67
+ await pipeline(createReadStream(path), hash);
68
+ return hash.digest("hex");
69
+ }
70
+
71
+ async function verifyChecksum(archive, checksumSource) {
72
+ if (!checksumSource) return;
73
+ let expected = "";
74
+ if (/^https?:/.test(checksumSource)) {
75
+ const response = await fetch(checksumSource);
76
+ if (!response.ok) throw new Error(`下载校验文件失败:${response.status}`);
77
+ expected = (await response.text()).trim().split(/\s+/)[0];
78
+ } else if (await exists(checksumSource)) {
79
+ expected = (await readFile(checksumSource, "utf8")).trim().split(/\s+/)[0];
80
+ }
81
+ if (expected && expected !== await sha256(archive)) throw new Error("运行包 SHA256 校验失败");
82
+ }
83
+
84
+ function runtimeTarget() {
85
+ const platform = hostPlatform();
86
+ const arch = hostArch();
87
+ if (!["darwin", "linux"].includes(platform) || !["arm64", "x64"].includes(arch)) {
88
+ throw new Error(`暂不支持 ${platform}-${arch},当前支持 macOS/Linux arm64/x64`);
89
+ }
90
+ return { platform, arch, id: `${platform}-${arch}` };
91
+ }
92
+
93
+ async function validateRuntime(root, target) {
94
+ const manifest = JSON.parse(await readFile(resolve(root, "manifest.json"), "utf8"));
95
+ if (manifest.platform !== target.platform || manifest.arch !== target.arch) throw new Error(`运行包平台不匹配:需要 ${target.id}`);
96
+ for (const file of ["bin/rankd", "bin/executiond", "bin/execution-worker", "web/index.html", "control-host/src/main.mjs", "node_modules/@deepseek-ai/dsh/lib/bin.js"]) {
97
+ if (!await exists(resolve(root, file))) throw new Error(`运行包缺少 ${file}`);
98
+ }
99
+ await Promise.all(["rankd", "executiond", "execution-worker"].map((name) => chmod(resolve(root, "bin", name), 0o755)));
100
+ return root;
101
+ }
102
+
103
+ async function ensureRuntime() {
104
+ const target = runtimeTarget();
105
+ const explicit = option("runtime-dir") || process.env.CHAT2RANKER_RUNTIME_DIR;
106
+ if (explicit) return validateRuntime(resolve(explicit), target);
107
+ const destination = resolve(home, "runtime", metadata.version, target.id);
108
+ if (await exists(resolve(destination, "manifest.json"))) return validateRuntime(destination, target);
109
+ const base = resolve(home, "runtime", metadata.version);
110
+ await mkdir(base, { recursive: true });
111
+ const temp = await mkdtemp(resolve(base, ".install-"));
112
+ const archiveOption = option("runtime-archive") || process.env.CHAT2RANKER_RUNTIME_ARCHIVE;
113
+ const archiveURL = option("runtime-url") || process.env.CHAT2RANKER_RUNTIME_URL || `https://github.com/xyzbit/chat2ranker/releases/download/v${metadata.version}/chat2ranker-${metadata.version}-${target.id}.tar.gz`;
114
+ const archive = archiveOption ? resolve(archiveOption) : resolve(temp, basename(archiveURL));
115
+ try {
116
+ if (!archiveOption) {
117
+ process.stdout.write(`正在下载 Chat2Ranker ${metadata.version} (${target.id})…\n`);
118
+ await download(archiveURL, archive);
119
+ }
120
+ await verifyChecksum(archive, archiveOption ? `${archive}.sha256` : `${archiveURL}.sha256`);
121
+ const extracted = resolve(temp, "content");
122
+ await mkdir(extracted);
123
+ await run("tar", ["-xzf", archive, "-C", extracted], { stdio: "ignore" });
124
+ await validateRuntime(extracted, target);
125
+ await mkdir(dirname(destination), { recursive: true });
126
+ await rename(extracted, destination);
127
+ return destination;
128
+ } finally {
129
+ await rm(temp, { recursive: true, force: true });
130
+ }
131
+ }
132
+
133
+ function proxy(clientRequest, clientResponse, port) {
134
+ const upstream = httpRequest({ hostname: "127.0.0.1", port, path: clientRequest.url, method: clientRequest.method, headers: { ...clientRequest.headers, host: `127.0.0.1:${port}` } }, (response) => {
135
+ clientResponse.writeHead(response.statusCode || 502, response.headers);
136
+ response.pipe(clientResponse);
137
+ });
138
+ upstream.once("error", (error) => {
139
+ if (!clientResponse.headersSent) clientResponse.writeHead(502, { "content-type": "application/json" });
140
+ clientResponse.end(JSON.stringify({ error: { message: error.message } }));
141
+ });
142
+ clientRequest.pipe(upstream);
143
+ }
144
+
145
+ const mime = { ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon" };
146
+
147
+ async function serveFile(response, webRoot, requestURL) {
148
+ let pathname;
149
+ try {
150
+ pathname = decodeURIComponent(new URL(requestURL, "http://localhost").pathname);
151
+ } catch {
152
+ response.writeHead(400).end();
153
+ return;
154
+ }
155
+ const candidate = resolve(webRoot, pathname === "/" ? "index.html" : pathname.slice(1));
156
+ const safe = relative(webRoot, candidate);
157
+ if (safe === ".." || safe.startsWith(`..${sep}`)) {
158
+ response.writeHead(403).end();
159
+ return;
160
+ }
161
+ const path = await stat(candidate).then((value) => value.isFile() ? candidate : resolve(webRoot, "index.html")).catch(() => resolve(webRoot, "index.html"));
162
+ response.writeHead(200, { "content-type": mime[extname(path)] || "application/octet-stream", "cache-control": path.endsWith("index.html") ? "no-cache" : "public, max-age=31536000, immutable" });
163
+ createReadStream(path).pipe(response);
164
+ }
165
+
166
+ function staticServer(webRoot) {
167
+ return createServer((request, response) => {
168
+ if (request.url?.startsWith("/api")) return proxy(request, response, 8787);
169
+ if (request.url?.startsWith("/control")) return proxy(request, response, 8788);
170
+ void serveFile(response, webRoot, request.url || "/").catch((error) => response.writeHead(500).end(error.message));
171
+ });
172
+ }
173
+
174
+ async function waitFor(targetURL, label, timeout = 30_000) {
175
+ const deadline = Date.now() + timeout;
176
+ while (Date.now() < deadline) {
177
+ try {
178
+ const response = await fetch(targetURL);
179
+ if (response.ok) return;
180
+ } catch {
181
+ // A local listener can refuse connections while its process is starting.
182
+ }
183
+ await new Promise((resolveWait) => setTimeout(resolveWait, 120));
184
+ }
185
+ throw new Error(`${label} 未能启动:${targetURL}`);
186
+ }
187
+
188
+ async function executionAPI(path, options = {}) {
189
+ const response = await fetch(`http://127.0.0.1:8790${path}`, { ...options, headers: { "content-type": "application/json", ...options.headers } });
190
+ const payload = await response.json().catch(() => ({}));
191
+ if (!response.ok) throw new Error(payload.error?.message || `executiond ${response.status}`);
192
+ return payload;
193
+ }
194
+
195
+ async function configureRole(role, config, catalog) {
196
+ if (!config.provider || !config.model || !config.apiKey) throw new Error(`${role} 初始化需要 provider、model 和 api-key`);
197
+ const template = catalog.find((item) => item.id === config.provider);
198
+ const baseUrl = config.baseUrl || template?.baseUrl;
199
+ if (!baseUrl) throw new Error(`未知 Provider ${config.provider},请同时提供 --base-url`);
200
+ const connection = await executionAPI("/v1/model-connections", { method: "POST", body: JSON.stringify({ name: `${template?.name || config.provider} · ${role}`, provider: config.provider, protocol: template?.protocol || "openai-chat-completions", baseUrl, apiKey: config.apiKey, defaultModel: config.model }) });
201
+ const verified = await executionAPI(`/v1/model-connections/${encodeURIComponent(connection.id)}/verify`, { method: "POST", body: "{}" });
202
+ return executionAPI(`/v1/system-model-bindings/${role}`, { method: "PUT", body: JSON.stringify({ connectionId: verified.id, model: config.model }) });
203
+ }
204
+
205
+ async function bootstrapModels() {
206
+ const startup = {
207
+ provider: option("provider") || process.env.RANK_CONTROL_PROVIDER || (process.env.DEEPSEEK_API_KEY ? "deepseek" : ""),
208
+ model: option("model") || process.env.RANK_CONTROL_MODEL || "",
209
+ apiKey: option("api-key") || process.env.RANK_CONTROL_API_KEY || process.env.DEEPSEEK_API_KEY || "",
210
+ baseUrl: option("base-url") || process.env.RANK_CONTROL_BASE_URL || "",
211
+ judgeProvider: option("judge-provider") || process.env.RANK_JUDGE_PROVIDER || "",
212
+ judgeModel: option("judge-model") || process.env.RANK_JUDGE_MODEL || "",
213
+ judgeAPIKey: option("judge-api-key") || process.env.RANK_JUDGE_API_KEY || "",
214
+ judgeBaseUrl: option("judge-base-url") || process.env.RANK_JUDGE_BASE_URL || "",
215
+ };
216
+ const [catalog, bindings] = await Promise.all([executionAPI("/v1/model-catalog"), executionAPI("/v1/system-model-bindings")]);
217
+ const current = Object.fromEntries(bindings.map((item) => [item.role, item]));
218
+ if (current.control && !has("reconfigure")) {
219
+ if (!current.judge) current.judge = await executionAPI("/v1/system-model-bindings/judge", { method: "PUT", body: JSON.stringify({ connectionId: current.control.connectionId, model: current.control.model }) });
220
+ return current;
221
+ }
222
+ if (!startup.provider && !startup.apiKey && !startup.model) return current;
223
+ const control = await configureRole("control", { provider: startup.provider, model: startup.model || catalog.find((item) => item.id === startup.provider)?.models[0]?.id, apiKey: startup.apiKey, baseUrl: startup.baseUrl }, catalog);
224
+ const judge = startup.judgeProvider || startup.judgeModel || startup.judgeAPIKey
225
+ ? await configureRole("judge", { provider: startup.judgeProvider || startup.provider, model: startup.judgeModel || control.model, apiKey: startup.judgeAPIKey || startup.apiKey, baseUrl: startup.judgeBaseUrl }, catalog)
226
+ : await executionAPI("/v1/system-model-bindings/judge", { method: "PUT", body: JSON.stringify({ connectionId: control.connectionId, model: control.model }) });
227
+ return { control, judge };
228
+ }
229
+
230
+ async function openBrowser(target = url) {
231
+ const spec = hostPlatform() === "darwin" ? ["open", [target]] : hostPlatform() === "win32" ? ["cmd", ["/c", "start", "", target]] : ["xdg-open", [target]];
232
+ const child = spawn(spec[0], spec[1], { detached: true, stdio: "ignore" });
233
+ child.once("error", () => {});
234
+ child.unref();
235
+ }
236
+
237
+ async function readPID() {
238
+ try {
239
+ return JSON.parse(await readFile(pidPath, "utf8"));
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ function processAlive(pid) {
246
+ try {
247
+ process.kill(pid, 0);
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ async function serve() {
255
+ const existing = await readPID();
256
+ if (existing && existing.pid !== process.pid && processAlive(existing.pid)) throw new Error(`Chat2Ranker 已在运行:${existing.url}`);
257
+ await Promise.all(["data", "credentials", "artifacts", "sandboxes", "logs"].map((name) => mkdir(resolve(home, name), { recursive: true, mode: 0o700 })));
258
+ const runtime = await ensureRuntime();
259
+ const binary = (name) => resolve(runtime, "bin", name);
260
+ const controlToken = process.env.RANK_CONTROL_TOKEN || `local-${randomBytes(24).toString("hex")}`;
261
+ const actionSecret = process.env.RANK_ACTION_SECRET || `local-${randomBytes(24).toString("hex")}`;
262
+ const sharedEnv = { ...process.env, RANK_REPO_ROOT: runtime, EXECUTION_REPO_ROOT: runtime, RANK_DSH_BIN: resolve(runtime, "node_modules/@deepseek-ai/dsh/lib/bin.js"), EXECUTION_WORKER_BIN: binary("execution-worker"), RANK_EXECUTION_URL: "http://127.0.0.1:8790", RANK_CONTROL_TOKEN: controlToken, RANK_ACTION_SECRET: actionSecret, RANK_API_URL: "http://127.0.0.1:8787", RANK_CONTROL_URL: "http://127.0.0.1:8788" };
263
+ const children = new Set();
264
+ let stopping = false;
265
+ const launch = (name, executable, args, env = sharedEnv) => {
266
+ const child = spawn(executable, args, { env, stdio: "inherit" });
267
+ child.rankName = name;
268
+ children.add(child);
269
+ child.once("exit", (code, signal) => {
270
+ children.delete(child);
271
+ if (!stopping) void stop(1, `${name} 意外退出(${signal || code})`);
272
+ });
273
+ return child;
274
+ };
275
+ let web;
276
+ const stop = async (code, message = "") => {
277
+ if (stopping) return;
278
+ stopping = true;
279
+ if (message) process.stderr.write(`${message}\n`);
280
+ web?.close();
281
+ for (const child of children) child.kill("SIGTERM");
282
+ await Promise.all([...children].map((child) => new Promise((resolveExit) => {
283
+ const timer = setTimeout(() => { child.kill("SIGKILL"); resolveExit(); }, 5_000);
284
+ child.once("exit", () => { clearTimeout(timer); resolveExit(); });
285
+ })));
286
+ const saved = await readPID();
287
+ if (saved?.pid === process.pid) await rm(pidPath, { force: true });
288
+ process.exitCode = code;
289
+ };
290
+ launch("executiond", binary("executiond"), ["-addr", "127.0.0.1:8790", "-db", resolve(home, "data/execution.db"), "-worker", binary("execution-worker"), "-repo-root", runtime, "-artifacts", resolve(home, "artifacts"), "-sandboxes", resolve(home, "sandboxes"), "-credentials", resolve(home, "credentials")]);
291
+ await waitFor("http://127.0.0.1:8790/v1/health", "executiond");
292
+ const models = await bootstrapModels();
293
+ launch("rankd", binary("rankd"), ["-addr", "127.0.0.1:8787", "-db", resolve(home, "data/rank.db"), "-execution-url", "http://127.0.0.1:8790"]);
294
+ launch("control-dsh", process.execPath, [resolve(runtime, "control-host/src/main.mjs")], { ...sharedEnv, RANK_DSH_HOME: resolve(home, "dsh-control") });
295
+ web = staticServer(resolve(runtime, "web"));
296
+ await new Promise((resolveListen, rejectListen) => web.once("error", rejectListen).listen(webPort, "127.0.0.1", resolveListen));
297
+ await Promise.all([waitFor("http://127.0.0.1:8787/api/health", "rankd"), waitFor("http://127.0.0.1:8788/control/v1/health", "Control DSH"), waitFor(url, "Web")]);
298
+ await writeFile(pidPath, `${JSON.stringify({ pid: process.pid, url, version: metadata.version, startedAt: new Date().toISOString() })}\n`, { mode: 0o600 });
299
+ process.stdout.write(`\nChat2Ranker 已启动:${url}\n模型:${models.control ? `${models.control.connection.provider}/${models.control.model}` : "请在浏览器完成首次配置"}\n数据:${home}\n\n`);
300
+ if (!has("no-open")) await openBrowser();
301
+ process.once("SIGINT", () => void stop(0));
302
+ process.once("SIGTERM", () => void stop(0));
303
+ await new Promise(() => {});
304
+ }
305
+
306
+ async function detachedStart() {
307
+ const existing = await readPID();
308
+ if (existing && processAlive(existing.pid)) {
309
+ process.stdout.write(`Chat2Ranker 已在运行:${existing.url}\n`);
310
+ return;
311
+ }
312
+ await mkdir(resolve(home, "logs"), { recursive: true, mode: 0o700 });
313
+ const logPath = resolve(home, "logs/chat2ranker.log");
314
+ const log = openSync(logPath, "a", 0o600);
315
+ const childArgs = argv.slice(1).filter((value) => value !== "--detach");
316
+ const child = spawn(process.execPath, [cliPath, "_serve", ...childArgs], { detached: true, stdio: ["ignore", log, log], env: process.env });
317
+ child.unref();
318
+ const deadline = Date.now() + 120_000;
319
+ let ready = false;
320
+ while (Date.now() < deadline && processAlive(child.pid)) {
321
+ const record = await readPID();
322
+ if (record?.pid === child.pid && await fetch(record.url).then((response) => response.ok).catch(() => false)) {
323
+ ready = true;
324
+ break;
325
+ }
326
+ await new Promise((resolveWait) => setTimeout(resolveWait, 150));
327
+ }
328
+ if (!ready) {
329
+ const output = await readFile(logPath, "utf8").catch(() => "");
330
+ throw new Error(`Chat2Ranker 后台实例启动失败\n${output.slice(-2000)}`);
331
+ }
332
+ process.stdout.write(`Chat2Ranker 已在后台启动:${url}\n日志:${logPath}\n`);
333
+ if (!has("no-open")) await openBrowser();
334
+ }
335
+
336
+ async function stopService() {
337
+ const record = await readPID();
338
+ if (!record || !processAlive(record.pid)) {
339
+ await rm(pidPath, { force: true });
340
+ process.stdout.write("Chat2Ranker 未运行\n");
341
+ return;
342
+ }
343
+ process.kill(record.pid, "SIGTERM");
344
+ const deadline = Date.now() + 10_000;
345
+ while (Date.now() < deadline && processAlive(record.pid)) await new Promise((resolveWait) => setTimeout(resolveWait, 100));
346
+ if (processAlive(record.pid)) throw new Error(`停止超时,请检查 PID ${record.pid}`);
347
+ process.stdout.write("Chat2Ranker 已停止\n");
348
+ }
349
+
350
+ async function status() {
351
+ const record = await readPID();
352
+ if (!record || !processAlive(record.pid)) {
353
+ process.stdout.write("Chat2Ranker 未运行\n");
354
+ process.exitCode = 1;
355
+ return;
356
+ }
357
+ const healthy = await fetch(record.url).then((response) => response.ok).catch(() => false);
358
+ process.stdout.write(`Chat2Ranker ${healthy ? "运行中" : "进程存在但服务未就绪"}\n地址:${record.url}\nPID:${record.pid}\n数据:${home}\n`);
359
+ }
360
+
361
+ function help() {
362
+ process.stdout.write(`Chat2Ranker ${metadata.version}\n\n用法:\n chat2ranker start [--detach] [--home ~/.chat2ranker]\n chat2ranker stop\n chat2ranker status\n chat2ranker open\n\n首次启动会自动下载当前平台运行包并打开浏览器。\n`);
363
+ }
364
+
365
+ try {
366
+ if (command === "start") await (has("detach") ? detachedStart() : serve());
367
+ else if (command === "_serve") await serve();
368
+ else if (command === "stop") await stopService();
369
+ else if (command === "status") await status();
370
+ else if (command === "open") await openBrowser((await readPID())?.url || url);
371
+ else if (["help", "--help", "-h"].includes(command)) help();
372
+ else if (["version", "--version", "-v"].includes(command)) process.stdout.write(`${metadata.version}\n`);
373
+ else throw new Error(`未知命令:${command}`);
374
+ } catch (error) {
375
+ process.stderr.write(`${error.message}\n`);
376
+ process.exitCode = 1;
377
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@xyzbit/chat2ranker",
3
+ "version": "0.1.0",
4
+ "description": "Conversation-first local Agent evaluation platform",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "chat2ranker": "cli.mjs"
9
+ },
10
+ "files": [
11
+ "cli.mjs"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22.19.0"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/xyzbit/chat2ranker.git"
22
+ }
23
+ }