- Remove unused .pi extensions and prompts - Update generated model files - Add context.md - Update pi-test.sh and package-lock.json
This commit is contained in:
@@ -1,158 +0,0 @@
|
||||
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Container, Text } from "@earendil-works/pi-tui";
|
||||
|
||||
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
||||
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
||||
|
||||
type PromptMatch = {
|
||||
kind: "pr" | "issue";
|
||||
url: string;
|
||||
};
|
||||
|
||||
type GhMetadata = {
|
||||
title?: string;
|
||||
author?: {
|
||||
login?: string;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||
if (prMatch?.[1]) {
|
||||
return { kind: "pr", url: prMatch[1].trim() };
|
||||
}
|
||||
|
||||
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
||||
if (issueMatch?.[1]) {
|
||||
return { kind: "issue", url: issueMatch[1].trim() };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchGhMetadata(
|
||||
pi: ExtensionAPI,
|
||||
kind: PromptMatch["kind"],
|
||||
url: string,
|
||||
): Promise<GhMetadata | undefined> {
|
||||
const args =
|
||||
kind === "pr" ? ["pr", "view", url, "--json", "title,author"] : ["issue", "view", url, "--json", "title,author"];
|
||||
|
||||
try {
|
||||
const result = await pi.exec("gh", args);
|
||||
if (result.code !== 0 || !result.stdout) return undefined;
|
||||
return JSON.parse(result.stdout) as GhMetadata;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
||||
if (!author) return undefined;
|
||||
const name = author.name?.trim();
|
||||
const login = author.login?.trim();
|
||||
if (name && login) return `${name} (@${login})`;
|
||||
if (login) return `@${login}`;
|
||||
if (name) return name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
const setWidget = (ctx: ExtensionContext, match: PromptMatch, title?: string, authorText?: string) => {
|
||||
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
||||
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
|
||||
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
|
||||
const urlLine = thm.fg("dim", match.url);
|
||||
|
||||
const lines = [titleText];
|
||||
if (authorLine) lines.push(authorLine);
|
||||
lines.push(urlLine);
|
||||
|
||||
const container = new Container();
|
||||
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
|
||||
container.addChild(new Text(lines.join("\n"), 1, 0));
|
||||
return container;
|
||||
});
|
||||
};
|
||||
|
||||
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
||||
const label = match.kind === "pr" ? "PR" : "Issue";
|
||||
const trimmedTitle = title?.trim();
|
||||
const fallbackName = `${label}: ${match.url}`;
|
||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
||||
const currentName = pi.getSessionName()?.trim();
|
||||
if (!currentName) {
|
||||
pi.setSessionName(desiredName);
|
||||
return;
|
||||
}
|
||||
if (currentName === match.url || currentName === fallbackName) {
|
||||
pi.setSessionName(desiredName);
|
||||
}
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
const match = extractPromptMatch(event.prompt);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
|
||||
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
return (
|
||||
content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n") ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
const rebuildFromSession = (ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const lastMatch = [...entries].reverse().find((entry) => {
|
||||
if (entry.type !== "message" || entry.message.role !== "user") return false;
|
||||
const text = getUserText(entry.message.content);
|
||||
return !!extractPromptMatch(text);
|
||||
});
|
||||
|
||||
const content =
|
||||
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
|
||||
const text = getUserText(content);
|
||||
const match = text ? extractPromptMatch(text) : undefined;
|
||||
if (!match) {
|
||||
ctx.ui.setWidget("prompt-url", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Redraws Extension
|
||||
*
|
||||
* Exposes /tui to show TUI redraw stats.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("tui", {
|
||||
description: "Show TUI stats",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
let redraws = 0;
|
||||
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
||||
redraws = tui.fullRedraws;
|
||||
done(undefined);
|
||||
return new Text("", 0, 0);
|
||||
});
|
||||
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* sproutclaw / mengya 命令安装扩展
|
||||
*
|
||||
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令,
|
||||
* 方便快速启动 sproutclaw(cd 到项目目录并执行 ./pi-test.sh)。
|
||||
*
|
||||
* 命令 /install-commands 可随时手动重装。
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw";
|
||||
const BIN_DIR = "/usr/local/bin";
|
||||
const COMMANDS = ["mengya", "sproutclaw"];
|
||||
|
||||
/** 创建或修复命令脚本 */
|
||||
function installCommand(name: string): boolean {
|
||||
const path = `${BIN_DIR}/${name}`;
|
||||
|
||||
const script = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd ${SPROUTCLAW_DIR}
|
||||
exec ./pi-test.sh "$@"
|
||||
`;
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const current = readFileSync(path, "utf-8");
|
||||
if (current === script) return false;
|
||||
} catch {
|
||||
// Rewrite unreadable or invalid command files below.
|
||||
}
|
||||
}
|
||||
writeFileSync(path, script, "utf-8");
|
||||
chmodSync(path, 0o755);
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// 启动时自动安装
|
||||
for (const name of COMMANDS) {
|
||||
const created = installCommand(name);
|
||||
if (created) {
|
||||
console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 命令:手动重装
|
||||
pi.registerCommand("install-commands", {
|
||||
description: "Install mengya and sproutclaw commands to /usr/local/bin",
|
||||
handler: async (_args, ctx) => {
|
||||
let count = 0;
|
||||
for (const name of COMMANDS) {
|
||||
if (installCommand(name)) count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
ctx.ui.notify(`Installed ${count} command(s): mengya, sproutclaw`, "success");
|
||||
} else {
|
||||
ctx.ui.notify("Both commands already exist", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 启动界面中文翻译扩展
|
||||
*
|
||||
* 将 pi 的初始启动界面中的介绍和引导文字翻译为中文,
|
||||
* 快捷键和命令提示保持原样。
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { keyHint, keyText, rawKeyHint, VERSION } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
ctx.ui.setHeader((_tui, theme) => {
|
||||
const expandedInstructions = [
|
||||
keyHint("app.interrupt", "to interrupt"),
|
||||
keyHint("app.clear", "to clear"),
|
||||
rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
|
||||
keyHint("app.exit", "to exit (empty)"),
|
||||
keyHint("app.suspend", "to suspend"),
|
||||
keyHint("tui.editor.deleteToLineEnd" as any, "to delete to end"),
|
||||
keyHint("app.thinking.cycle", "to cycle thinking level"),
|
||||
rawKeyHint(
|
||||
`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`,
|
||||
"to cycle models",
|
||||
),
|
||||
keyHint("app.model.select", "to select model"),
|
||||
keyHint("app.tools.expand", "to expand tools"),
|
||||
keyHint("app.thinking.toggle", "to expand thinking"),
|
||||
keyHint("app.editor.external", "for external editor"),
|
||||
rawKeyHint("/", "for commands"),
|
||||
rawKeyHint("!", "to run bash"),
|
||||
rawKeyHint("!!", "to run bash (no context)"),
|
||||
keyHint("app.message.followUp", "to queue follow-up"),
|
||||
keyHint("app.message.dequeue", "to edit all queued messages"),
|
||||
keyHint("app.clipboard.pasteImage", "to paste image"),
|
||||
rawKeyHint("drop files", "to attach"),
|
||||
].join("\n");
|
||||
const compactInstructions = [
|
||||
keyHint("app.interrupt", "interrupt"),
|
||||
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
|
||||
rawKeyHint("/", "commands"),
|
||||
rawKeyHint("!", "bash"),
|
||||
keyHint("app.tools.expand", "more"),
|
||||
].join(theme.fg("muted", " · "));
|
||||
const compactOnboarding = theme.fg(
|
||||
"dim",
|
||||
`按 ${keyText("app.tools.expand")} 查看完整帮助和已加载资源。`,
|
||||
);
|
||||
const onboarding = theme.fg(
|
||||
"dim",
|
||||
"Pi 可以解释自身功能并查阅文档。询问它如何使用或扩展 Pi。",
|
||||
);
|
||||
|
||||
let expanded = false;
|
||||
|
||||
return {
|
||||
render(_width: number): string[] {
|
||||
const logo =
|
||||
theme.bold(theme.fg("accent", "pi")) + theme.fg("dim", ` v${VERSION}`);
|
||||
|
||||
const lines: string[] = [logo];
|
||||
if (expanded) {
|
||||
lines.push(expandedInstructions);
|
||||
lines.push("");
|
||||
lines.push(onboarding);
|
||||
} else {
|
||||
lines.push(compactInstructions);
|
||||
lines.push(compactOnboarding);
|
||||
lines.push("");
|
||||
lines.push(onboarding);
|
||||
}
|
||||
return lines;
|
||||
},
|
||||
invalidate() {},
|
||||
setExpanded(v: boolean) {
|
||||
expanded = v;
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
function isAssistantMessage(message: unknown): message is AssistantMessage {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const role = (message as { role?: unknown }).role;
|
||||
return role === "assistant";
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let agentStartMs: number | null = null;
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
agentStartMs = Date.now();
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
if (agentStartMs === null) return;
|
||||
|
||||
const elapsedMs = Date.now() - agentStartMs;
|
||||
agentStartMs = null;
|
||||
if (elapsedMs <= 0) return;
|
||||
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let cacheRead = 0;
|
||||
let cacheWrite = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
for (const message of event.messages) {
|
||||
if (!isAssistantMessage(message)) continue;
|
||||
input += message.usage.input || 0;
|
||||
output += message.usage.output || 0;
|
||||
cacheRead += message.usage.cacheRead || 0;
|
||||
cacheWrite += message.usage.cacheWrite || 0;
|
||||
totalTokens += message.usage.totalTokens || 0;
|
||||
}
|
||||
|
||||
if (output <= 0) return;
|
||||
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const tokensPerSecond = output / elapsedSeconds;
|
||||
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
|
||||
ctx.ui.notify(message, "info");
|
||||
});
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
/**
|
||||
* WebUI 扩展 —— 在浏览器中通过网页与 pi 对话
|
||||
*
|
||||
* 安装:
|
||||
* 将本文件放入 .pi/extensions/webui/index.ts
|
||||
*
|
||||
* 用法:
|
||||
* /webui on → 启动网页服务(默认端口 19133)
|
||||
* /webui off → 停止网页服务
|
||||
* /webui on 8080 → 指定端口启动
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = join(__dirname, ".webui.pid");
|
||||
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverPort = 19133;
|
||||
|
||||
function findTsx(): string {
|
||||
// 从扩展所在目录向上找 repo 根目录
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = join(dir, "node_modules", ".bin", "tsx");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
// fallback
|
||||
return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx");
|
||||
}
|
||||
|
||||
function readPidFile(): number | null {
|
||||
if (!existsSync(PID_FILE)) return null;
|
||||
try {
|
||||
const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
|
||||
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePidFile(pid: number): void {
|
||||
try {
|
||||
writeFileSync(PID_FILE, String(pid), "utf8");
|
||||
} catch {
|
||||
// 忽略:PID 文件只是辅助信息
|
||||
}
|
||||
}
|
||||
|
||||
function clearPidFile(): void {
|
||||
try {
|
||||
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForStartup(port: number, child: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
const check = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
finish(new Error(`webui 子进程已退出,code=${child.exitCode ?? "null"}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const http = require("node:http");
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1500 },
|
||||
(res: any) => {
|
||||
res.resume();
|
||||
if (res.statusCode === 200) finish();
|
||||
else setTimeout(check, 200);
|
||||
},
|
||||
);
|
||||
req.on("error", () => setTimeout(check, 200));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
setTimeout(check, 200);
|
||||
});
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => finish(new Error("webui 启动超时")), 10000);
|
||||
setTimeout(check, 300);
|
||||
});
|
||||
}
|
||||
|
||||
function probePort(port: number): Promise<"free" | "running" | "occupied"> {
|
||||
return new Promise((resolve) => {
|
||||
const http = require("node:http");
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1200 },
|
||||
(res: any) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 200 ? "running" : "occupied");
|
||||
},
|
||||
);
|
||||
req.on("error", () => resolve("free"));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve("free");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer(port: number, ctx: any): Promise<void> {
|
||||
if (serverProcess) {
|
||||
ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const oldPid = readPidFile();
|
||||
if (oldPid && !isProcessAlive(oldPid)) {
|
||||
clearPidFile();
|
||||
}
|
||||
|
||||
const state = await probePort(port);
|
||||
if (state === "running") {
|
||||
serverPort = port;
|
||||
ctx.ui.notify(`网页服务已在端口 ${port} 运行`, "info");
|
||||
return;
|
||||
}
|
||||
if (state === "occupied") {
|
||||
ctx.ui.notify(`端口 ${port} 已被其他进程占用`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const tsxBin = findTsx();
|
||||
const serverFile = join(__dirname, "server.ts");
|
||||
|
||||
if (!existsSync(serverFile)) {
|
||||
ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
serverPort = port;
|
||||
serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], {
|
||||
cwd: join(__dirname, "..", "..", ".."), // repo 根目录
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
serverProcess = null;
|
||||
clearPidFile();
|
||||
});
|
||||
|
||||
if (serverProcess.pid) {
|
||||
writePidFile(serverProcess.pid);
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForStartup(port, serverProcess);
|
||||
} catch (err: any) {
|
||||
clearPidFile();
|
||||
serverProcess = null;
|
||||
ctx.ui.notify(`网页服务启动失败: ${err.message}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info");
|
||||
|
||||
// 显示局域网地址
|
||||
const { networkInterfaces } = require("node:os");
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name]) {
|
||||
if (net.family === "IPv4" && !net.internal) {
|
||||
ctx.ui.notify(` 局域网: http://${net.address}:${port}`, "info");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServer(ctx: any): Promise<void> {
|
||||
if (!serverProcess) {
|
||||
const pid = readPidFile();
|
||||
if (!pid) {
|
||||
const state = await probePort(serverPort || 19133);
|
||||
if (state === "running") {
|
||||
ctx.ui.notify(
|
||||
`网页服务正在端口 ${serverPort || 19133} 运行,但当前没有 PID 记录,无法直接停止`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify("网页服务未运行", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isProcessAlive(pid)) {
|
||||
process.kill(pid, "SIGTERM");
|
||||
ctx.ui.notify(`网页服务已停止(PID ${pid})`, "info");
|
||||
} else {
|
||||
ctx.ui.notify("网页服务未运行,已清理旧 PID", "info");
|
||||
}
|
||||
clearPidFile();
|
||||
} catch {
|
||||
clearPidFile();
|
||||
ctx.ui.notify("网页服务未运行", "info");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
serverProcess.kill("SIGTERM");
|
||||
serverProcess = null;
|
||||
serverPort = 0;
|
||||
clearPidFile();
|
||||
ctx.ui.notify("网页服务已停止", "info");
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("webui", {
|
||||
description: "通过 /webui on 启动、/webui off 停止 Web 聊天界面",
|
||||
handler: async (args, ctx) => {
|
||||
const trimmed = args.trim();
|
||||
const [command = "", value = ""] = trimmed.split(/\s+/, 2);
|
||||
|
||||
if (command === "off" || command === "stop" || command === "0") {
|
||||
await stopServer(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const portInput = command === "on" ? value : command;
|
||||
const port = portInput ? parseInt(portInput, 10) : 19133;
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 19133`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await startServer(port, ctx);
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,125 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
|
||||
<title>萌小芽</title>
|
||||
<link rel="icon" href="logo.png" type="image/png" sizes="any">
|
||||
<link rel="apple-touch-icon" href="logo.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Source+Code+Pro:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" crossorigin>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="marked.umd.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" crossorigin></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<!-- 侧边栏 -->
|
||||
<aside id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-brand">
|
||||
<img class="sidebar-logo" src="logo.png" width="36" height="36" alt="" decoding="async">
|
||||
<h2 class="sidebar-title">萌小芽</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header">
|
||||
<span>历史会话</span>
|
||||
<button class="section-refresh" onclick="loadSessions()" title="刷新">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 4v6h6M23 20v-6h-6"/><path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sessionList" class="session-list">
|
||||
<div class="session-empty">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-actions">
|
||||
<button onclick="newSession()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
|
||||
新会话
|
||||
</button>
|
||||
<button onclick="location.href='settings.html'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 01-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 01-4 0v-.1a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1A2 2 0 014.2 17l.1-.1A1.7 1.7 0 004.6 15a1.7 1.7 0 00-1.5-1H3a2 2 0 010-4h.1a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9L4.2 7A2 2 0 017 4.2l.1.1A1.7 1.7 0 009 4.6a1.7 1.7 0 001-1.5V3a2 2 0 014 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.9-.3l.1-.1A2 2 0 0119.8 7l-.1.1a1.7 1.7 0 00-.3 1.9 1.7 1.7 0 001.5 1h.1a2 2 0 010 4h-.1a1.7 1.7 0 00-1.5 1z"/></svg>
|
||||
设置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-status">
|
||||
<div class="status-row">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span id="statusText">就绪</span>
|
||||
</div>
|
||||
<div class="status-model" id="statusModel"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主区域 -->
|
||||
<main id="main">
|
||||
<!-- 顶部栏 -->
|
||||
<header id="header">
|
||||
<button id="menuBtn" class="menu-btn" onclick="toggleSidebar()" aria-label="切换菜单">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||
</button>
|
||||
<div class="header-info">
|
||||
<h1 id="headerTitle">聊天</h1>
|
||||
<span class="header-meta" id="headerMeta"></span>
|
||||
</div>
|
||||
<div class="header-trailing">
|
||||
<div class="model-controls" id="modelControls">
|
||||
<select id="modelSelect" class="model-select" aria-label="选择模型" title="选择模型">
|
||||
<option value="">加载模型...</option>
|
||||
</select>
|
||||
<select id="thinkingSelect" class="thinking-select" aria-label="选择推理强度" title="推理强度">
|
||||
<option value="off">off</option>
|
||||
<option value="minimal">minimal</option>
|
||||
<option value="low">low</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="high">high</option>
|
||||
<option value="xhigh">xhigh</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="sessionContextBar" class="session-context-bar session-context-in-header" hidden>
|
||||
<div class="session-context-primary-wrap" id="sessionContextPrimary"></div>
|
||||
<div id="sessionContextSecondary"></div>
|
||||
</div>
|
||||
<button class="header-btn" onclick="abortStream()" id="abortBtn" title="中止回复" style="display:none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
中止
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 聊天消息 -->
|
||||
<div id="chat">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
</div>
|
||||
<p>发送一条消息开始对话</p>
|
||||
<p class="empty-hint">按 Enter 发送 · Shift+Enter 换行</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div id="inputArea">
|
||||
<div class="input-wrapper">
|
||||
<textarea id="input" rows="1" placeholder="输入消息..." aria-label="消息输入框" enterkeyhint="send" inputmode="text"></textarea>
|
||||
<button id="sendBtn" type="button" onclick="send()" aria-label="发送">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="sidebarOverlay" class="sidebar-overlay" onclick="toggleSidebar()"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 615 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,86 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
|
||||
<title>设置 - 萌小芽</title>
|
||||
<link rel="icon" href="logo.png" type="image/png" sizes="any">
|
||||
<link rel="apple-touch-icon" href="logo.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Source+Code+Pro:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="settings-page">
|
||||
<main class="settings-page-main">
|
||||
<header class="settings-page-header">
|
||||
<div class="settings-page-title">
|
||||
<img class="sidebar-logo" src="logo.png" width="34" height="34" alt="" decoding="async">
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<p>萌小芽 Agent</p>
|
||||
</div>
|
||||
</div>
|
||||
<a class="settings-secondary-btn settings-link-btn" href="index.html">返回聊天</a>
|
||||
</header>
|
||||
|
||||
<div class="settings-page-shell">
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-sidebar" role="tablist" aria-label="设置分区">
|
||||
<button type="button" id="tab-prompt" class="settings-nav-btn is-active" role="tab" aria-selected="true" aria-controls="pane-prompt" data-pane="prompt">
|
||||
<span class="settings-nav-btn-label">系统提示词</span>
|
||||
</button>
|
||||
<button type="button" id="tab-skills" class="settings-nav-btn" role="tab" aria-selected="false" aria-controls="pane-skills" data-pane="skills">
|
||||
<span class="settings-nav-btn-label">Skills</span>
|
||||
</button>
|
||||
<button type="button" id="tab-extensions" class="settings-nav-btn" role="tab" aria-selected="false" aria-controls="pane-extensions" data-pane="extensions">
|
||||
<span class="settings-nav-btn-label">插件扩展</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="settings-panes">
|
||||
<section id="pane-prompt" class="settings-panel settings-pane is-active" role="tabpanel" aria-labelledby="tab-prompt">
|
||||
<div class="settings-panel-header">
|
||||
<div>
|
||||
<h2>系统提示词</h2>
|
||||
<p id="systemPromptPath"></p>
|
||||
</div>
|
||||
<button id="saveSystemPromptBtn" class="settings-primary-btn" type="button">保存</button>
|
||||
</div>
|
||||
<textarea id="systemPromptInput" class="settings-textarea" spellcheck="false" aria-label="系统提示词"></textarea>
|
||||
<div id="settingsStatus" class="settings-status"></div>
|
||||
</section>
|
||||
|
||||
<section id="pane-skills" class="settings-panel settings-pane" role="tabpanel" aria-labelledby="tab-skills">
|
||||
<div class="settings-panel-header">
|
||||
<div>
|
||||
<h2>Skills</h2>
|
||||
<p id="skillsCount"></p>
|
||||
</div>
|
||||
<button id="refreshSettingsBtn" class="settings-secondary-btn" type="button">刷新</button>
|
||||
</div>
|
||||
<div id="skillsList" class="skills-list">
|
||||
<div class="settings-empty">加载中...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pane-extensions" class="settings-panel settings-pane" role="tabpanel" aria-labelledby="tab-extensions">
|
||||
<div class="settings-panel-header">
|
||||
<div>
|
||||
<h2>插件扩展</h2>
|
||||
<p id="extensionsMeta"></p>
|
||||
</div>
|
||||
<button id="refreshExtensionsBtn" class="settings-secondary-btn" type="button">刷新</button>
|
||||
</div>
|
||||
<div id="extensionsList" class="extensions-list">
|
||||
<div class="settings-empty">加载中...</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,177 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const systemPromptInput = document.getElementById("systemPromptInput");
|
||||
const systemPromptPath = document.getElementById("systemPromptPath");
|
||||
const saveSystemPromptBtn = document.getElementById("saveSystemPromptBtn");
|
||||
const refreshSettingsBtn = document.getElementById("refreshSettingsBtn");
|
||||
const settingsStatus = document.getElementById("settingsStatus");
|
||||
const skillsList = document.getElementById("skillsList");
|
||||
const skillsCount = document.getElementById("skillsCount");
|
||||
const extensionsList = document.getElementById("extensionsList");
|
||||
const extensionsMeta = document.getElementById("extensionsMeta");
|
||||
const settingsNavBtns = document.querySelectorAll(".settings-nav-btn");
|
||||
const settingsPanes = document.querySelectorAll(".settings-pane");
|
||||
|
||||
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
|
||||
|
||||
function escHtml(s) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function setSettingsStatus(text, kind = "") {
|
||||
if (!settingsStatus) return;
|
||||
settingsStatus.textContent = text || "";
|
||||
settingsStatus.className = `settings-status${kind ? ` ${kind}` : ""}`;
|
||||
}
|
||||
|
||||
function showSettingsPane(paneId) {
|
||||
const id = ["prompt", "skills", "extensions"].includes(paneId) ? paneId : "prompt";
|
||||
settingsNavBtns.forEach((btn) => {
|
||||
const active = btn.dataset.pane === id;
|
||||
btn.classList.toggle("is-active", active);
|
||||
btn.setAttribute("aria-selected", active ? "true" : "false");
|
||||
});
|
||||
settingsPanes.forEach((pane) => {
|
||||
const active = pane.id === `pane-${id}`;
|
||||
pane.classList.toggle("is-active", active);
|
||||
pane.toggleAttribute("hidden", !active);
|
||||
});
|
||||
try {
|
||||
sessionStorage.setItem(SETTINGS_PANE_KEY, id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function initSettingsNav() {
|
||||
settingsNavBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => showSettingsPane(btn.dataset.pane));
|
||||
});
|
||||
let initial = "prompt";
|
||||
try {
|
||||
const saved = sessionStorage.getItem(SETTINGS_PANE_KEY);
|
||||
if (["prompt", "skills", "extensions"].includes(saved)) initial = saved;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
showSettingsPane(initial);
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
if (skillsList) skillsList.innerHTML = '<div class="settings-empty">加载中...</div>';
|
||||
if (extensionsList) extensionsList.innerHTML = '<div class="settings-empty">加载中...</div>';
|
||||
setSettingsStatus("");
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.error) throw new Error(data.error || res.statusText);
|
||||
if (systemPromptInput) systemPromptInput.value = data.systemPrompt || "";
|
||||
if (systemPromptPath) systemPromptPath.textContent = data.systemPromptPath || "";
|
||||
renderSkills(data.skills || []);
|
||||
renderExtensions(data.extensions || [], data.extensionsPath || "");
|
||||
} catch (err) {
|
||||
if (skillsList) skillsList.innerHTML = `<div class="settings-empty">加载失败: ${escHtml(err.message)}</div>`;
|
||||
if (extensionsList) extensionsList.innerHTML = `<div class="settings-empty">加载失败: ${escHtml(err.message)}</div>`;
|
||||
setSettingsStatus(`加载设置失败: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkills(skills) {
|
||||
if (!skillsList) return;
|
||||
if (skillsCount) skillsCount.textContent = `${skills.length} 个已加载`;
|
||||
if (!skills.length) {
|
||||
skillsList.innerHTML = '<div class="settings-empty">暂无已加载 skills</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
skillsList.innerHTML = skills.map((skill) => {
|
||||
const meta = [skill.scope, skill.source].filter(Boolean).join(" · ");
|
||||
const path = skill.path || "";
|
||||
return `<div class="skill-item">
|
||||
<div class="skill-title-row">
|
||||
<div class="skill-name">${escHtml(skill.name || skill.command || "未命名")}</div>
|
||||
${meta ? `<div class="skill-meta">${escHtml(meta)}</div>` : ""}
|
||||
</div>
|
||||
${skill.description ? `<div class="skill-desc">${escHtml(skill.description)}</div>` : ""}
|
||||
${path ? `<div class="skill-path">${escHtml(path)}</div>` : ""}
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderExtensions(extensions, extensionsPath) {
|
||||
if (!extensionsList) return;
|
||||
if (extensionsMeta) {
|
||||
extensionsMeta.textContent = extensionsPath
|
||||
? `${extensions.length} 个已加载 · ${extensionsPath}`
|
||||
: `${extensions.length} 个已加载`;
|
||||
}
|
||||
if (!extensions.length) {
|
||||
extensionsList.innerHTML = '<div class="settings-empty">暂无已加载插件扩展</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
extensionsList.innerHTML = extensions.map((extension) => {
|
||||
const meta = extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||
const counts = [
|
||||
[`命令`, extension.commands?.length || 0],
|
||||
[`工具`, extension.tools?.length || 0],
|
||||
[`事件`, extension.handlers?.length || 0],
|
||||
[`Flags`, extension.flags?.length || 0],
|
||||
[`快捷键`, extension.shortcuts?.length || 0],
|
||||
].filter(([, count]) => count > 0);
|
||||
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some((key) => extension[key]?.length);
|
||||
return `<div class="extension-item">
|
||||
<div class="extension-title-row">
|
||||
<div class="extension-name">${escHtml(extension.name || extension.path || "未命名")}</div>
|
||||
${meta ? `<div class="extension-meta">${escHtml(meta)}</div>` : ""}
|
||||
</div>
|
||||
${counts.length ? `<div class="extension-counts">${counts.map(([label, count]) => `<span>${escHtml(label)} ${count}</span>`).join("")}</div>` : ""}
|
||||
${hasDetails ? `<details class="extension-details">
|
||||
<summary>查看注册项</summary>
|
||||
${renderNameList("命令", extension.commands)}
|
||||
${renderNameList("工具", extension.tools)}
|
||||
${renderNameList("事件", extension.handlers)}
|
||||
${renderNameList("Flags", extension.flags)}
|
||||
${renderNameList("快捷键", extension.shortcuts)}
|
||||
</details>` : ""}
|
||||
${extension.location ? `<div class="extension-path">${escHtml(extension.location)}</div>` : ""}
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderNameList(label, items) {
|
||||
if (!Array.isArray(items) || !items.length) return "";
|
||||
return `<div class="extension-name-list">
|
||||
<span>${escHtml(label)}</span>
|
||||
<div>${items.map((item) => `<code>${escHtml(item)}</code>`).join("")}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function saveSystemPrompt() {
|
||||
if (!systemPromptInput || !saveSystemPromptBtn) return;
|
||||
saveSystemPromptBtn.disabled = true;
|
||||
setSettingsStatus("保存中...");
|
||||
try {
|
||||
const res = await fetch("/api/settings/system-prompt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ systemPrompt: systemPromptInput.value }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.error) throw new Error(data.error || res.statusText);
|
||||
if (systemPromptPath && data.systemPromptPath) systemPromptPath.textContent = data.systemPromptPath;
|
||||
setSettingsStatus("已保存,Agent 已重新加载", "ok");
|
||||
} catch (err) {
|
||||
setSettingsStatus(`保存失败: ${err.message}`, "error");
|
||||
} finally {
|
||||
saveSystemPromptBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
initSettingsNav();
|
||||
saveSystemPromptBtn?.addEventListener("click", saveSystemPrompt);
|
||||
refreshSettingsBtn?.addEventListener("click", loadSettings);
|
||||
document.getElementById("refreshExtensionsBtn")?.addEventListener("click", loadSettings);
|
||||
loadSettings();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,552 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* pi-mono WebUI Server
|
||||
*
|
||||
* 被 webui 扩展启动,作为独立子进程运行。
|
||||
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
|
||||
*
|
||||
* 用法(由扩展自动调用):
|
||||
* tsx server.ts --port 19133
|
||||
*
|
||||
* 前端静态文件位于同目录 public/ 下。
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname, resolve, basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = join(__dirname, "public");
|
||||
|
||||
// 从 CWD 确定 repo 根目录(启动时 CWD 为 repo 根目录)
|
||||
const REPO_ROOT = process.cwd();
|
||||
const SESSIONS_DIR = join(REPO_ROOT, ".pi", "sessions");
|
||||
const PROJECT_EXTENSIONS_DIR = join(REPO_ROOT, ".pi", "extensions");
|
||||
const SYSTEM_PROMPT_FILE = join(REPO_ROOT, ".pi", "agent", "AGENTS.md");
|
||||
const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx");
|
||||
|
||||
// ─── 解析 CLI 参数 ─────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function getArg(flag: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx !== -1 ? args[idx + 1] : undefined;
|
||||
}
|
||||
const PORT = parseInt(getArg("--port") || "19133", 10);
|
||||
|
||||
// ─── 启动 pi RPC 子进程 ────────────────────────────────────────────
|
||||
|
||||
const piArgs = [
|
||||
join(REPO_ROOT, "packages", "coding-agent", "src", "cli.ts"),
|
||||
"--mode", "rpc",
|
||||
];
|
||||
|
||||
console.log(`[webui] 启动 pi RPC: ${TSX_BIN} ${piArgs.join(" ")}`);
|
||||
|
||||
const pi = spawn(TSX_BIN, piArgs, {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: join(REPO_ROOT, ".pi", "agent"),
|
||||
},
|
||||
});
|
||||
|
||||
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
|
||||
pi.on("exit", (code) => {
|
||||
console.log(`[webui] pi 退出, code=${code}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// ─── JSONL 行协议 ──────────────────────────────────────────────────
|
||||
|
||||
let buffer = "";
|
||||
const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
||||
const sseClients = new Set<any>();
|
||||
let reqId = 0;
|
||||
|
||||
function onLine(line: string) {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.type === "response" && msg.id && pending.has(msg.id)) {
|
||||
const p = pending.get(msg.id)!;
|
||||
pending.delete(msg.id);
|
||||
p.resolve(msg);
|
||||
return;
|
||||
}
|
||||
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
||||
for (const res of sseClients) res.write(data);
|
||||
} catch {
|
||||
// 忽略非 JSON 行
|
||||
}
|
||||
}
|
||||
|
||||
pi.stdout.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) onLine(line);
|
||||
});
|
||||
|
||||
function sendCmd(command: Record<string, unknown>): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `req_${++reqId}`;
|
||||
const line = JSON.stringify({ ...command, id }) + "\n";
|
||||
pending.set(id, { resolve, reject });
|
||||
pi.stdin.write(line);
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`命令超时: ${command.type}`));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 会话文件读取 ──────────────────────────────────────────────────
|
||||
|
||||
function listSessionFiles(): string[] {
|
||||
if (!existsSync(SESSIONS_DIR)) return [];
|
||||
return readdirSync(SESSIONS_DIR)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((f) => join(SESSIONS_DIR, f));
|
||||
}
|
||||
|
||||
function readSessionSummary(filePath: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const lines = content.trim().split("\n");
|
||||
if (!lines.length) return null;
|
||||
const header = JSON.parse(lines[0]);
|
||||
if (header.type !== "session") return null;
|
||||
|
||||
let nameFromInfo = "";
|
||||
let messageCount = 0;
|
||||
let firstMessage = "";
|
||||
const stats = statSync(filePath);
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "session_info" && entry.name) {
|
||||
const n = String(entry.name).trim();
|
||||
if (n && !isMachineSessionLabel(n, header.id)) {
|
||||
nameFromInfo = n;
|
||||
}
|
||||
}
|
||||
if (entry.type === "message") {
|
||||
messageCount++;
|
||||
if (!firstMessage && entry.message?.role === "user") {
|
||||
firstMessage = extractPreview(entry.message);
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const fromFirstUser = titleFromFirstUserMessage(firstMessage);
|
||||
let name = nameFromInfo;
|
||||
if (!name || isMachineSessionLabel(name, header.id)) {
|
||||
name = fromFirstUser || "";
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
id: header.id,
|
||||
name,
|
||||
created: header.timestamp,
|
||||
modified: stats.mtime.toISOString(),
|
||||
messageCount,
|
||||
firstMessage: firstMessage || "(空)",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
|
||||
const t = (text ?? "").trim();
|
||||
if (!t) return true;
|
||||
if (sessionHeaderId && t === sessionHeaderId) return true;
|
||||
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
|
||||
if (/^[0-9]{10,}$/.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 侧边栏标题:优先用户首句(遇句号等截断),否则整段截取 */
|
||||
function titleFromFirstUserMessage(text: string, maxChars = 56): string {
|
||||
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!cleaned) return "";
|
||||
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
|
||||
let candidate =
|
||||
sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned;
|
||||
if (candidate.length > maxChars) {
|
||||
candidate = `${candidate.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function readSessionMessages(filePath: string): unknown[] {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
return content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => { try { const e = JSON.parse(l); return e.type === "message" ? e.message : null; } catch { return null; } })
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractPreview(msg: any): string {
|
||||
const c = msg.content;
|
||||
if (!c) return "";
|
||||
if (typeof c === "string") return c.slice(0, 200);
|
||||
if (Array.isArray(c)) return c.filter((x: any) => x.type === "text").map((x: any) => x.text).join("").slice(0, 200);
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveSessionFile(filePath: string): string {
|
||||
const resolved = resolve(filePath);
|
||||
const sessionsRoot = resolve(SESSIONS_DIR);
|
||||
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
|
||||
throw new Error("无效会话路径");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function readSystemPrompt(): string {
|
||||
if (!existsSync(SYSTEM_PROMPT_FILE)) return "";
|
||||
return readFileSync(SYSTEM_PROMPT_FILE, "utf8");
|
||||
}
|
||||
|
||||
function writeSystemPrompt(content: string): void {
|
||||
mkdirSync(dirname(SYSTEM_PROMPT_FILE), { recursive: true });
|
||||
writeFileSync(SYSTEM_PROMPT_FILE, content, "utf8");
|
||||
}
|
||||
|
||||
function normalizeSkillCommand(command: any): Record<string, unknown> {
|
||||
const rawName = String(command.name || "");
|
||||
const sourceInfo = command.sourceInfo || {};
|
||||
return {
|
||||
name: rawName.replace(/^skill:/, ""),
|
||||
command: rawName,
|
||||
description: command.description || "",
|
||||
scope: sourceInfo.scope || "",
|
||||
source: sourceInfo.source || "",
|
||||
path: sourceInfo.path || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function listLoadedSkills(): Promise<Record<string, unknown>[]> {
|
||||
const response = await sendCmd({ type: "get_commands" });
|
||||
if (!response.success) throw new Error(response.error || "读取 skills 失败");
|
||||
const commands = response.data?.commands || [];
|
||||
return commands
|
||||
.filter((command: any) => command?.source === "skill")
|
||||
.map(normalizeSkillCommand)
|
||||
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
|
||||
function normalizeExtension(extension: any): Record<string, unknown> {
|
||||
const sourceInfo = extension.sourceInfo || {};
|
||||
const pathValue = String(extension.path || "");
|
||||
const source = String(sourceInfo.source || "");
|
||||
const resolvedPath = String(extension.resolvedPath || "");
|
||||
return {
|
||||
name: displayExtensionName(pathValue, source),
|
||||
rawName: extension.name || "",
|
||||
path: pathValue,
|
||||
resolvedPath,
|
||||
scope: sourceInfo.scope || "",
|
||||
source,
|
||||
sourcePath: sourceInfo.path || "",
|
||||
location: displayExtensionLocation(pathValue, resolvedPath),
|
||||
kind: displayExtensionKind(sourceInfo.scope, source),
|
||||
commands: extension.commands || [],
|
||||
tools: extension.tools || [],
|
||||
flags: extension.flags || [],
|
||||
shortcuts: extension.shortcuts || [],
|
||||
handlers: extension.handlers || [],
|
||||
};
|
||||
}
|
||||
|
||||
function isProjectExtension(extension: any): boolean {
|
||||
const pathValue = String(extension.path || extension.resolvedPath || "");
|
||||
if (!pathValue) return false;
|
||||
const resolved = resolve(pathValue);
|
||||
const projectExtensionsRoot = resolve(PROJECT_EXTENSIONS_DIR);
|
||||
return resolved === projectExtensionsRoot || resolved.startsWith(`${projectExtensionsRoot}/`);
|
||||
}
|
||||
|
||||
function displayExtensionName(extensionPath: string, source: string): string {
|
||||
const sourceMatch = source.match(/^npm:(.+)$/);
|
||||
if (sourceMatch?.[1]) return sourceMatch[1];
|
||||
|
||||
const normalized = extensionPath.replace(/\\/g, "/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const file = parts[parts.length - 1] || normalized;
|
||||
if (/^index\.[tj]s$/i.test(file) && parts.length >= 2) {
|
||||
return cleanExtensionName(parts[parts.length - 2]);
|
||||
}
|
||||
return cleanExtensionName(file);
|
||||
}
|
||||
|
||||
function cleanExtensionName(name: string): string {
|
||||
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
|
||||
}
|
||||
|
||||
function displayExtensionKind(scope: string, source: string): string {
|
||||
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "未知";
|
||||
if (source.startsWith("npm:")) return `${scopeText} NPM`;
|
||||
if (source === "auto") return `${scopeText}本地`;
|
||||
return source ? `${scopeText} · ${source}` : scopeText;
|
||||
}
|
||||
|
||||
function displayExtensionLocation(extensionPath: string, resolvedPath: string): string {
|
||||
const pathValue = extensionPath || resolvedPath;
|
||||
if (!pathValue) return "";
|
||||
return pathValue.replace(REPO_ROOT, ".");
|
||||
}
|
||||
|
||||
async function listLoadedExtensions(): Promise<Record<string, unknown>[]> {
|
||||
const response = await sendCmd({ type: "get_extensions" });
|
||||
if (!response.success) throw new Error(response.error || "读取扩展失败");
|
||||
return (response.data?.extensions || [])
|
||||
.filter(isProjectExtension)
|
||||
.map(normalizeExtension)
|
||||
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
|
||||
// ─── 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",
|
||||
};
|
||||
|
||||
function serveStatic(urlPath: string, res: any): void {
|
||||
const file = urlPath === "/" ? "/index.html" : urlPath;
|
||||
const full = join(PUBLIC_DIR, file);
|
||||
if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end("Forbidden"); return; }
|
||||
if (!existsSync(full)) { res.writeHead(404); res.end("Not found"); return; }
|
||||
const ext = file.match(/\.\w+$/)?.[0] || ".html";
|
||||
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
||||
res.end(readFileSync(full));
|
||||
}
|
||||
|
||||
function json(res: any, data: unknown, status = 200): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function readBody(req: any): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (c: Buffer) => (body += c.toString()));
|
||||
req.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("无效 JSON")); } });
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost:${PORT}`);
|
||||
|
||||
// 静态文件
|
||||
if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
|
||||
return serveStatic(url.pathname, res);
|
||||
}
|
||||
|
||||
// POST /api/chat
|
||||
if (req.method === "POST" && url.pathname === "/api/chat") {
|
||||
return readBody(req)
|
||||
.then(({ message }) => sendCmd({ type: "prompt", message }).then(() => json(res, { ok: true })))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/events (SSE)
|
||||
if (req.method === "GET" && url.pathname === "/api/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write('data: {"type":"connected"}\n\n');
|
||||
sseClients.add(res);
|
||||
req.on("close", () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/messages
|
||||
if (req.method === "POST" && url.pathname === "/api/messages") {
|
||||
return sendCmd({ type: "get_messages" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/new-session
|
||||
if (req.method === "POST" && url.pathname === "/api/new-session") {
|
||||
return sendCmd({ type: "new_session" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/abort
|
||||
if (req.method === "POST" && url.pathname === "/api/abort") {
|
||||
return sendCmd({ type: "abort" })
|
||||
.then(() => json(res, { ok: true }))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/models
|
||||
if (req.method === "GET" && url.pathname === "/api/models") {
|
||||
return sendCmd({ type: "get_available_models" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/model
|
||||
if (req.method === "POST" && url.pathname === "/api/model") {
|
||||
return readBody(req)
|
||||
.then(({ provider, modelId }) =>
|
||||
sendCmd({ type: "set_model", provider, modelId }).then((r: any) =>
|
||||
r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500),
|
||||
),
|
||||
)
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/thinking
|
||||
if (req.method === "POST" && url.pathname === "/api/thinking") {
|
||||
return readBody(req)
|
||||
.then(({ level }) =>
|
||||
sendCmd({ type: "set_thinking_level", level }).then((r: any) =>
|
||||
r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500),
|
||||
),
|
||||
)
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/settings
|
||||
if (req.method === "GET" && url.pathname === "/api/settings") {
|
||||
return Promise.all([listLoadedSkills(), listLoadedExtensions()])
|
||||
.then(([skills, extensions]) => json(res, {
|
||||
systemPrompt: readSystemPrompt(),
|
||||
systemPromptPath: SYSTEM_PROMPT_FILE,
|
||||
extensionsPath: PROJECT_EXTENSIONS_DIR,
|
||||
skills,
|
||||
extensions,
|
||||
}))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/settings/system-prompt
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/system-prompt") {
|
||||
return readBody(req)
|
||||
.then(async ({ systemPrompt }) => {
|
||||
if (typeof systemPrompt !== "string") {
|
||||
throw new Error("systemPrompt 必须是字符串");
|
||||
}
|
||||
writeSystemPrompt(systemPrompt);
|
||||
const reload = await sendCmd({ type: "reload" });
|
||||
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
|
||||
json(res, { ok: true, systemPromptPath: SYSTEM_PROMPT_FILE });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/sessions
|
||||
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
||||
const summaries = listSessionFiles().map(readSessionSummary).filter(Boolean);
|
||||
return json(res, { sessions: summaries });
|
||||
}
|
||||
|
||||
// POST /api/sessions/history
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/history") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp }) => {
|
||||
const messages = readSessionMessages(sp as string);
|
||||
const summary = readSessionSummary(sp as string);
|
||||
json(res, { messages, session: summary });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/delete
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/delete") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp }) => {
|
||||
const sessionPath = resolveSessionFile(sp as string);
|
||||
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||
unlinkSync(sessionPath);
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/load
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/load") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp }) => {
|
||||
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||
if (!sw.success) throw new Error(sw.error);
|
||||
const mr = await sendCmd({ type: "get_messages" });
|
||||
if (!mr.success) throw new Error(mr.error);
|
||||
const summary = readSessionSummary(sp as string);
|
||||
json(res, { messages: mr.data.messages, session: summary });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/activate
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/activate") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp }) => {
|
||||
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||
if (!sw.success) throw new Error(sw.error);
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/name
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/name") {
|
||||
return readBody(req)
|
||||
.then(({ name }) => sendCmd({ type: "set_session_name", name }).then(() => json(res, { ok: true })))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/session-state
|
||||
if (req.method === "GET" && url.pathname === "/api/session-state") {
|
||||
return sendCmd({ type: "get_state" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
console.error(`[webui] 端口 ${PORT} 已被占用`);
|
||||
} else {
|
||||
console.error(`[webui] HTTP 服务启动失败: ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`[webui] HTTP 服务已启动: http://localhost:${PORT}`);
|
||||
console.log(`[webui] 局域网访问: http://smallmengya:${PORT}`);
|
||||
});
|
||||
2
.pi/git/.gitignore
vendored
2
.pi/git/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
.pi/npm/.gitignore
vendored
2
.pi/npm/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
Audit changelog entries for all commits since the last release.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Find the last release tag:**
|
||||
```bash
|
||||
git tag --sort=-version:refname | head -1
|
||||
```
|
||||
|
||||
2. **List all commits since that tag:**
|
||||
```bash
|
||||
git log <tag>..HEAD --oneline
|
||||
```
|
||||
|
||||
3. **Read each package's [Unreleased] section:**
|
||||
- packages/ai/CHANGELOG.md
|
||||
- packages/tui/CHANGELOG.md
|
||||
- packages/coding-agent/CHANGELOG.md
|
||||
|
||||
4. **For each commit, check:**
|
||||
- Skip: changelog updates, doc-only changes, release housekeeping
|
||||
- Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs.
|
||||
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
|
||||
- Verify a changelog entry exists in the affected package(s)
|
||||
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
|
||||
|
||||
5. **Cross-package duplication rule:**
|
||||
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
|
||||
|
||||
6. **Add New Features section after changelog fixes:**
|
||||
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
|
||||
- Propose the top new features to the user for confirmation before writing them.
|
||||
- Link to relevant docs and sections whenever possible.
|
||||
|
||||
7. **Report:**
|
||||
- List commits with missing entries
|
||||
- List entries that need cross-package duplication
|
||||
- Add any missing entries directly
|
||||
|
||||
## Changelog Format Reference
|
||||
|
||||
Sections (in order):
|
||||
- `### Breaking Changes` - API changes requiring migration
|
||||
- `### Added` - New features
|
||||
- `### Changed` - Changes to existing functionality
|
||||
- `### Fixed` - Bug fixes
|
||||
- `### Removed` - Removed features
|
||||
|
||||
Attribution:
|
||||
- Internal: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
|
||||
- External: `Added bar ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@user](https://github.com/user))`
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS
|
||||
|
||||
For each issue:
|
||||
|
||||
1. Add the `inprogress` label to the issue via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
|
||||
2. Read the issue in full, including all comments and linked issues/PRs.
|
||||
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
|
||||
|
||||
4. **For bugs**:
|
||||
- Ignore any root cause analysis in the issue (likely wrong)
|
||||
- Read all related code files in full (no truncation)
|
||||
- Trace the code path and identify the actual root cause
|
||||
- Propose a fix
|
||||
|
||||
5. **For feature requests**:
|
||||
- Do not trust implementation proposals in the issue without verification
|
||||
- Read all related code files in full (no truncation)
|
||||
- Propose the most concise implementation approach
|
||||
- List affected files and changes needed
|
||||
|
||||
Do NOT implement unless explicitly asked. Analyze and propose only.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@
|
||||
|
||||
For each PR URL, do the following in order:
|
||||
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
|
||||
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
|
||||
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
|
||||
4. Analyze the PR diff. Read all relevant code files in full with no truncation from the current main branch and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
|
||||
5. Check for a changelog entry in the relevant `packages/*/CHANGELOG.md` files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. Verify:
|
||||
- Entry uses correct section (`### Breaking Changes`, `### Added`, `### Fixed`, etc.)
|
||||
- External contributions include PR link and author: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/pull/123) by [@user](https://github.com/user))`
|
||||
- Breaking changes are in `### Breaking Changes`, not just `### Fixed`
|
||||
6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added.
|
||||
7. Provide a structured review with these sections:
|
||||
- Good: solid choices or improvements
|
||||
- Bad: concrete issues, regressions, missing tests, or risks
|
||||
- Ugly: subtle or high impact problems
|
||||
8. Add Questions or Assumptions if anything is unclear.
|
||||
9. Add Change summary and Tests.
|
||||
|
||||
Output format per PR:
|
||||
PR: <url>
|
||||
Changelog:
|
||||
- ...
|
||||
Good:
|
||||
- ...
|
||||
Bad:
|
||||
- ...
|
||||
Ugly:
|
||||
- ...
|
||||
Questions or Assumptions:
|
||||
- ...
|
||||
Change summary:
|
||||
- ...
|
||||
Tests:
|
||||
- ...
|
||||
|
||||
If no issues are found, say so under Bad and Ugly.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it.
|
||||
|
||||
Additional instructions: $ARGUMENTS
|
||||
|
||||
Determine context from the conversation history first.
|
||||
|
||||
Rules for context detection:
|
||||
- If the conversation already mentions a GitHub issue or PR, use that existing context.
|
||||
- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done.
|
||||
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
|
||||
|
||||
Unless I explicitly override something in this request, do the following in order:
|
||||
|
||||
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
|
||||
2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment.
|
||||
3. Commit only files you changed in this session.
|
||||
4. If this task is tied to exactly one GitHub issue, include `closes #<issue>` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message.
|
||||
5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so.
|
||||
6. Push the current branch.
|
||||
|
||||
Constraints:
|
||||
- Never stage unrelated files.
|
||||
- Never use `git add .` or `git add -A`.
|
||||
- Run required checks before committing if code changed.
|
||||
- Do not open a PR unless I explicitly ask.
|
||||
- If this is not GitHub issue or PR work, do not post a GitHub comment.
|
||||
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.
|
||||
142
context.md
Normal file
142
context.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Code Context
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
```
|
||||
.sproutclaw/
|
||||
├── .claude/ # Claude configuration
|
||||
├── .github/ # Issue templates, workflows (issue-gate, pr-gate, approve-contributor)
|
||||
├── .husky/ # Git hooks
|
||||
├── .pi/ # pi/pi-mono agent configuration (AGENTS.md)
|
||||
├── node_modules/
|
||||
├── packages/ # 5 monorepo workspaces
|
||||
│ ├── ai/ # @earendil-works/pi-ai v0.74.0
|
||||
│ ├── agent/ # @earendil-works/pi-agent-core v0.74.0
|
||||
│ ├── coding-agent/ # @earendil-works/pi-coding-agent v0.74.0
|
||||
│ ├── tui/ # @earendil-works/pi-tui v0.74.0
|
||||
│ └── web-ui/ # @earendil-works/pi-web-ui v0.74.0
|
||||
├── scripts/ # CI/release/utility scripts
|
||||
├── tmp/ # Temp files
|
||||
├── pi-test.sh # Interactive TUI test script
|
||||
├── README.md
|
||||
├── CONTRIBUTING.md
|
||||
├── AGENTS.md
|
||||
├── biome.json # Linting/formatting config
|
||||
├── tsconfig.base.json # Shared TS config
|
||||
├── tsconfig.json # Root TS config
|
||||
├── package.json # Workspace root (npm workspaces)
|
||||
└── bun.lock / package-lock.json
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
### 1. `packages/ai` — `@earendil-works/pi-ai`
|
||||
**Unified LLM API** — automatic model discovery, provider configuration, streaming.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (`Api`, `StreamOptions`, `Model`, `KnownProvider`)
|
||||
- `src/stream.ts` — main streaming implementation
|
||||
- `src/models.ts` / `src/models.generated.ts` — model registry + auto-generated
|
||||
- `src/providers/` — individual provider implementations (Anthropic, Google, OpenAI, Mistral, etc.)
|
||||
- `src/cli.ts` — CLI for model listing etc.
|
||||
- `src/env-api-keys.ts` — credential detection from env vars
|
||||
- `src/oauth.ts` — OAuth support
|
||||
- `src/api-registry.ts` — API/stream provider registration
|
||||
- `scripts/generate-models.ts` — model list generation script
|
||||
- Subpath exports per provider (e.g. `./anthropic`, `./google`, etc.)
|
||||
|
||||
### 2. `packages/agent` — `@earendil-works/pi-agent-core`
|
||||
**General-purpose agent** — transport abstraction, state management, attachment support.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (agent state, attachments, transports)
|
||||
- `src/agent.ts` — agent implementation
|
||||
- `src/agent-loop.ts` — agent main loop
|
||||
- `src/proxy.ts` — proxy support
|
||||
- `src/harness/` — test harness utilities
|
||||
|
||||
### 3. `packages/coding-agent` — `@earendil-works/pi-coding-agent`
|
||||
**Coding agent CLI** — the `pi` CLI binary with read, bash, edit, write tools and session management.
|
||||
|
||||
- `src/cli.ts` / `src/cli/` — CLI argument parsing / entry point
|
||||
- `src/main.ts` — main agent runner
|
||||
- `src/index.ts` — package entry
|
||||
- `src/config.ts` — configuration management
|
||||
- `src/core/` — core logic (model resolution, hooks, tool execution, session management)
|
||||
- `src/modes/` — operational modes
|
||||
- `src/bun/` — Bun-specific support
|
||||
- `src/migrations.ts` — data migration utilities
|
||||
- `src/package-manager-cli.ts` — package manager interaction
|
||||
- `docs/` — provider setup docs
|
||||
- `examples/` — extension examples (custom provider, sandbox)
|
||||
- Binary name: `pi`
|
||||
|
||||
### 4. `packages/tui` — `@earendil-works/pi-tui`
|
||||
**Terminal UI library** — differential rendering for text-based terminal applications.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/tui.ts` — core TUI rendering engine
|
||||
- `src/terminal.ts` — low-level terminal I/O
|
||||
- `src/keys.ts` / `src/keybindings.ts` — key input handling
|
||||
- `src/components/` — reusable UI components
|
||||
- `src/editor-component.ts` — text editor component
|
||||
- `src/autocomplete.ts` — autocomplete support
|
||||
- `src/fuzzy.ts` — fuzzy matching
|
||||
- `src/terminal-image.ts` — image rendering in terminal
|
||||
- `src/kill-ring.ts` / `src/undo-stack.ts` — editing helpers
|
||||
- `src/stdin-buffer.ts` / `src/utils.ts` — utilities
|
||||
|
||||
### 5. `packages/web-ui` — `@earendil-works/pi-web-ui`
|
||||
**Reusable web UI components** — AI chat interfaces powered by pi-ai.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/app.css` — Tailwind CSS stylesheet
|
||||
- `src/ChatPanel.ts` — main chat panel component
|
||||
- `src/components/` — reusable React components
|
||||
- `src/dialogs/` — dialog components
|
||||
- `src/prompts/` — prompt templates/management
|
||||
- `src/storage/` — client-side storage abstraction
|
||||
- `src/tools/` — tool integration
|
||||
- `src/utils/` — utilities
|
||||
- Build: TypeScript + Tailwind CSS
|
||||
|
||||
## Root Scripts (`scripts/`)
|
||||
|
||||
- `release.mjs` — automated release workflow (version + changelog + publish)
|
||||
- `sync-versions.js` — lockstep version sync across all packages
|
||||
- `cost.ts` / `stats.ts` — analytics/telemetry stats
|
||||
- `tool-stats.ts` / `edit-tool-stats.mjs` / `read-tool-stats.mjs` — tool usage statistics
|
||||
- `profile-coding-agent-node.mjs` — Node.js profiling for coding agent
|
||||
- `build-binaries.sh` — binary build script
|
||||
- `browser-smoke-entry.ts` / `check-browser-smoke.mjs` — browser smoke tests
|
||||
- `session-transcripts.ts` / `session-context-stats.mjs` — session logging
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
pi-tui (terminal UI) ─── pi-coding-agent (CLI + tools) ─── pi-agent-core (agent loop)
|
||||
│
|
||||
pi-web-ui (web UI) ────────────┤
|
||||
│
|
||||
pi-ai (LLM provider abstraction)
|
||||
```
|
||||
|
||||
- **pi-ai** is the foundation layer providing unified LLM API access (Anthropic, Google, OpenAI, Azure, Mistral, Bedrock, etc.)
|
||||
- **pi-agent-core** builds on pi-ai with agent loop, transport abstraction, and state management
|
||||
- **pi-coding-agent** is the main product: a coding agent CLI (`pi` command) with read/bash/edit/write tools and session management
|
||||
- **pi-tui** is a standalone TUI rendering library used by pi-coding-agent
|
||||
- **pi-web-ui** provides reusable chat UI components (React) powered by pi-ai
|
||||
|
||||
## Key Facts
|
||||
|
||||
- **Lockstep versioning**: all packages share the same version (currently 0.74.0)
|
||||
- **Release automation**: `npm run release:patch` / `release:minor` via `scripts/release.mjs`
|
||||
- **TypeScript with tsgo**: uses `tsgo` (TypeScript native preview) for fast builds; web-ui uses `tsc`
|
||||
- **Linting**: Biome (v2.3.5) for formatting and linting (`npm run check`)
|
||||
- **Testing**: vitest for ai/agent/coding-agent; node --test for tui
|
||||
- **No `any` types allowed**, standard top-level imports only (no inline `import()`)
|
||||
- **GitHub CI**: issue-gate, pr-gate, approve-contributor workflows for contributor management
|
||||
|
||||
## Start Here
|
||||
|
||||
Open `package.json` (root) for workspace structure and available scripts. For a specific package, start with its `package.json` then `src/index.ts` for the public API surface.
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -5515,6 +5515,7 @@
|
||||
"node_modules/lit": {
|
||||
"version": "3.3.2",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
"lit-element": "^4.2.0",
|
||||
@@ -6639,6 +6640,7 @@
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.5.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
@@ -6663,7 +6665,8 @@
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.4",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
@@ -6761,6 +6764,7 @@
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6839,6 +6843,7 @@
|
||||
"version": "4.21.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -6926,6 +6931,7 @@
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.3",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -7034,6 +7040,7 @@
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7281,6 +7288,7 @@
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
import type { ImagesApi, ImagesModel } from "./types.js";
|
||||
|
||||
export const IMAGE_MODELS = {
|
||||
openrouter: {
|
||||
"openrouter": {
|
||||
"black-forest-labs/flux.2-flex": {
|
||||
id: "black-forest-labs/flux.2-flex",
|
||||
name: "Black Forest Labs: FLUX.2 Flex",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-klein-4b": {
|
||||
id: "black-forest-labs/flux.2-klein-4b",
|
||||
@@ -26,14 +26,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-max": {
|
||||
id: "black-forest-labs/flux.2-max",
|
||||
@@ -41,14 +41,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-pro": {
|
||||
id: "black-forest-labs/flux.2-pro",
|
||||
@@ -56,14 +56,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"bytedance-seed/seedream-4.5": {
|
||||
id: "bytedance-seed/seedream-4.5",
|
||||
@@ -71,14 +71,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
input: ["image","text"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-2.5-flash-image": {
|
||||
id: "google/gemini-2.5-flash-image",
|
||||
@@ -86,14 +86,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.3,
|
||||
output: 2.5,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.08333333333333334,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 0.3,
|
||||
"output": 2.5,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0.08333333333333334
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3-pro-image-preview": {
|
||||
id: "google/gemini-3-pro-image-preview",
|
||||
@@ -101,14 +101,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 12,
|
||||
cacheRead: 0.19999999999999998,
|
||||
cacheWrite: 0.375,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 2,
|
||||
"output": 12,
|
||||
"cacheRead": 0.19999999999999998,
|
||||
"cacheWrite": 0.375
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3.1-flash-image-preview": {
|
||||
id: "google/gemini-3.1-flash-image-preview",
|
||||
@@ -116,14 +116,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.5,
|
||||
output: 3,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 0.5,
|
||||
"output": 3,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image": {
|
||||
id: "openai/gpt-5-image",
|
||||
@@ -131,14 +131,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 10,
|
||||
output: 10,
|
||||
cacheRead: 1.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 10,
|
||||
"output": 10,
|
||||
"cacheRead": 1.25,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image-mini": {
|
||||
id: "openai/gpt-5-image-mini",
|
||||
@@ -146,14 +146,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2.5,
|
||||
output: 2,
|
||||
cacheRead: 0.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 2.5,
|
||||
"output": 2,
|
||||
"cacheRead": 0.25,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5.4-image-2": {
|
||||
id: "openai/gpt-5.4-image-2",
|
||||
@@ -161,14 +161,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 8,
|
||||
output: 15,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 8,
|
||||
"output": 15,
|
||||
"cacheRead": 2,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openrouter/auto": {
|
||||
id: "openrouter/auto",
|
||||
@@ -176,14 +176,59 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["text", "image"],
|
||||
cost: {
|
||||
input: -1000000,
|
||||
output: -1000000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["text","image"],
|
||||
output: ["text","image"],
|
||||
cost: {
|
||||
"input": -1000000,
|
||||
"output": -1000000,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v3": {
|
||||
id: "recraft/recraft-v3",
|
||||
name: "Recraft: Recraft V3",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4": {
|
||||
id: "recraft/recraft-v4",
|
||||
name: "Recraft: Recraft V4",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-pro": {
|
||||
id: "recraft/recraft-v4-pro",
|
||||
name: "Recraft: Recraft V4 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-fast": {
|
||||
id: "sourceful/riverflow-v2-fast",
|
||||
@@ -191,14 +236,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-fast-preview": {
|
||||
id: "sourceful/riverflow-v2-fast-preview",
|
||||
@@ -206,14 +251,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-max-preview": {
|
||||
id: "sourceful/riverflow-v2-max-preview",
|
||||
@@ -221,14 +266,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-pro": {
|
||||
id: "sourceful/riverflow-v2-pro",
|
||||
@@ -236,14 +281,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-standard-preview": {
|
||||
id: "sourceful/riverflow-v2-standard-preview",
|
||||
@@ -251,14 +296,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
},
|
||||
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Default to the repository-local pi config so this source checkout uses the
|
||||
# copied learning config instead of ~/.pi.
|
||||
export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$SCRIPT_DIR/.pi/agent}"
|
||||
export PI_CODING_AGENT_SESSION_DIR="${PI_CODING_AGENT_SESSION_DIR:-$SCRIPT_DIR/.pi/sessions}"
|
||||
|
||||
# Check for --no-env flag
|
||||
NO_ENV=false
|
||||
|
||||
Reference in New Issue
Block a user