add presets

This commit is contained in:
musistudio
2025-12-27 21:51:32 +08:00
parent 837ff8e9e4
commit a0ec618f4d
28 changed files with 3423 additions and 103 deletions

View File

@@ -17,8 +17,10 @@
"author": "musistudio",
"license": "MIT",
"dependencies": {
"@fastify/multipart": "^9.0.0",
"@fastify/static": "^8.2.0",
"@musistudio/llms": "^1.0.51",
"adm-zip": "^0.5.16",
"dotenv": "^16.4.7",
"json5": "^2.2.3",
"lru-cache": "^11.2.2",
@@ -29,6 +31,7 @@
},
"devDependencies": {
"@CCR/shared": "workspace:*",
"@types/adm-zip": "^0.5.7",
"@types/node": "^24.0.15",
"esbuild": "^0.25.1",
"fastify": "^5.4.0",

View File

@@ -121,7 +121,7 @@ async function getServer(options: RunOptions = {}) {
}
}
const serverInstance = createServer({
const serverInstance = await createServer({
jsonPath: CONFIG_FILE,
initialConfig: {
// ...config,
@@ -370,11 +370,11 @@ async function getServer(options: RunOptions = {}) {
// Add global error handlers to prevent the service from crashing
process.on("uncaughtException", (err) => {
serverInstance.logger.error("Uncaught exception:", err);
serverInstance.app.log.error("Uncaught exception:", err);
});
process.on("unhandledRejection", (reason, promise) => {
serverInstance.logger.error("Unhandled rejection at:", promise, "reason:", reason);
serverInstance.app.log.error("Unhandled rejection at:", promise, "reason:", reason);
});
return serverInstance;

View File

@@ -2,27 +2,53 @@ import Server from "@musistudio/llms";
import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
import { join } from "path";
import fastifyStatic from "@fastify/static";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, rmSync } from "fs";
import { homedir } from "os";
import { calculateTokenCount } from "./utils/router";
import {
getPresetDir,
readManifestFromDir,
manifestToPresetFile,
extractPreset,
validatePreset,
loadPreset,
saveManifest,
isPresetInstalled,
downloadPresetToTemp,
getTempDir,
HOME_DIR,
type PresetFile,
type ManifestFile,
type PresetMetadata,
MergeStrategy
} from "@CCR/shared";
export const createServer = (config: any): any => {
export const createServer = async (config: any): Promise<any> => {
const server = new Server(config);
const app = server.app;
server.app.post("/v1/messages/count_tokens", async (req: any, reply: any) => {
// Register multipart plugin for file uploads (dynamic import)
const fastifyMultipart = await import('@fastify/multipart');
app.register(fastifyMultipart.default, {
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
},
});
app.post("/v1/messages/count_tokens", async (req: any, reply: any) => {
const {messages, tools, system} = req.body;
const tokenCount = calculateTokenCount(messages, system, tools);
return { "input_tokens": tokenCount }
});
// Add endpoint to read config.json with access control
server.app.get("/api/config", async (req: any, reply: any) => {
app.get("/api/config", async (req: any, reply: any) => {
return await readConfigFile();
});
server.app.get("/api/transformers", async (req: any, reply: any) => {
app.get("/api/transformers", async (req: any, reply: any) => {
const transformers =
(server.app as any)._server!.transformerService.getAllTransformers();
(app as any)._server!.transformerService.getAllTransformers();
const transformerList = Array.from(transformers.entries()).map(
([name, transformer]: any) => ({
name,
@@ -33,7 +59,7 @@ export const createServer = (config: any): any => {
});
// Add endpoint to save config.json with access control
server.app.post("/api/config", async (req: any, reply: any) => {
app.post("/api/config", async (req: any, reply: any) => {
const newConfig = req.body;
// Backup existing config file if it exists
@@ -47,19 +73,19 @@ export const createServer = (config: any): any => {
});
// Register static file serving with caching
server.app.register(fastifyStatic, {
app.register(fastifyStatic, {
root: join(__dirname, "..", "dist"),
prefix: "/ui/",
maxAge: "1h",
});
// Redirect /ui to /ui/ for proper static file serving
server.app.get("/ui", async (_: any, reply: any) => {
app.get("/ui", async (_: any, reply: any) => {
return reply.redirect("/ui/");
});
// 获取日志文件列表端点
server.app.get("/api/logs/files", async (req: any, reply: any) => {
app.get("/api/logs/files", async (req: any, reply: any) => {
try {
const logDir = join(homedir(), ".claude-code-router", "logs");
const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = [];
@@ -93,7 +119,7 @@ export const createServer = (config: any): any => {
});
// 获取日志内容端点
server.app.get("/api/logs", async (req: any, reply: any) => {
app.get("/api/logs", async (req: any, reply: any) => {
try {
const filePath = (req.query as any).file as string;
let logFilePath: string;
@@ -121,7 +147,7 @@ export const createServer = (config: any): any => {
});
// 清除日志内容端点
server.app.delete("/api/logs", async (req: any, reply: any) => {
app.delete("/api/logs", async (req: any, reply: any) => {
try {
const filePath = (req.query as any).file as string;
let logFilePath: string;
@@ -145,5 +171,252 @@ export const createServer = (config: any): any => {
}
});
// ========== Preset 相关 API ==========
// 获取预设列表
app.get("/api/presets", async (req: any, reply: any) => {
try {
const presetsDir = join(HOME_DIR, "presets");
if (!existsSync(presetsDir)) {
return { presets: [] };
}
const entries = readdirSync(presetsDir, { withFileTypes: true });
const presetDirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
const presets: Array<PresetMetadata & { installed: boolean; id: string }> = [];
for (const dirName of presetDirs) {
const presetDir = join(presetsDir, dirName);
try {
const manifestPath = join(presetDir, "manifest.json");
const content = readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(content);
// 提取 metadata 字段
const { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE, requiredInputs, ...metadata } = manifest;
presets.push({
id: dirName, // 目录名作为唯一标识
name: metadata.name || dirName,
version: metadata.version || '1.0.0',
description: metadata.description,
author: metadata.author,
homepage: metadata.homepage,
repository: metadata.repository,
license: metadata.license,
keywords: metadata.keywords,
ccrVersion: metadata.ccrVersion,
source: metadata.source,
sourceType: metadata.sourceType,
checksum: metadata.checksum,
installed: true,
});
} catch (error) {
console.error(`Failed to read preset ${dirName}:`, error);
}
}
return { presets };
} catch (error) {
console.error("Failed to get presets:", error);
reply.status(500).send({ error: "Failed to get presets" });
}
});
// 获取预设详情
app.get("/api/presets/:name", async (req: any, reply: any) => {
try {
const { name } = req.params;
const presetDir = getPresetDir(name);
if (!existsSync(presetDir)) {
reply.status(404).send({ error: "Preset not found" });
return;
}
const manifest = await readManifestFromDir(presetDir);
const preset = manifestToPresetFile(manifest);
return preset;
} catch (error: any) {
console.error("Failed to get preset:", error);
reply.status(500).send({ error: error.message || "Failed to get preset" });
}
});
// 上传并安装预设(支持文件上传)
app.post("/api/presets/install", async (req: any, reply: any) => {
try {
const { source, name, url } = req.body;
// 如果提供了 URL从 URL 下载
if (url) {
const tempFile = await downloadPresetToTemp(url);
const preset = await loadPresetFromZip(tempFile);
// 确定预设名称
const presetName = name || preset.metadata?.name || `preset-${Date.now()}`;
// 检查是否已安装
if (await isPresetInstalled(presetName)) {
reply.status(409).send({ error: "Preset already installed" });
return;
}
// 解压到目标目录
const targetDir = getPresetDir(presetName);
await extractPreset(tempFile, targetDir);
// 清理临时文件
unlinkSync(tempFile);
return {
success: true,
presetName,
preset: {
...preset.metadata,
installed: true,
}
};
}
// 如果没有 URL需要处理文件上传使用 multipart/form-data
// 这部分需要在客户端使用 FormData 上传
reply.status(400).send({ error: "Please provide a URL or upload a file" });
} catch (error: any) {
console.error("Failed to install preset:", error);
reply.status(500).send({ error: error.message || "Failed to install preset" });
}
});
// 上传预设文件multipart/form-data
app.post("/api/presets/upload", async (req: any, reply: any) => {
try {
const data = await req.file();
if (!data) {
reply.status(400).send({ error: "No file uploaded" });
return;
}
const tempDir = getTempDir();
mkdirSync(tempDir, { recursive: true });
const tempFile = join(tempDir, `preset-${Date.now()}.ccrsets`);
// 保存上传的文件到临时位置
const buffer = await data.toBuffer();
writeFileSync(tempFile, buffer);
// 加载预设
const preset = await loadPresetFromZip(tempFile);
// 确定预设名称
const presetName = data.fields.name?.value || preset.metadata?.name || `preset-${Date.now()}`;
// 检查是否已安装
if (await isPresetInstalled(presetName)) {
unlinkSync(tempFile);
reply.status(409).send({ error: "Preset already installed" });
return;
}
// 解压到目标目录
const targetDir = getPresetDir(presetName);
await extractPreset(tempFile, targetDir);
// 清理临时文件
unlinkSync(tempFile);
return {
success: true,
presetName,
preset: {
...preset.metadata,
installed: true,
}
};
} catch (error: any) {
console.error("Failed to upload preset:", error);
reply.status(500).send({ error: error.message || "Failed to upload preset" });
}
});
// 应用预设(配置敏感信息)
app.post("/api/presets/:name/apply", async (req: any, reply: any) => {
try {
const { name } = req.params;
const { secrets } = req.body;
const presetDir = getPresetDir(name);
if (!existsSync(presetDir)) {
reply.status(404).send({ error: "Preset not found" });
return;
}
// 读取现有 manifest
const manifest = await readManifestFromDir(presetDir);
// 将 secrets 信息应用到 manifest 中
if (secrets) {
for (const [fieldPath, value] of Object.entries(secrets)) {
const keys = fieldPath.split(/[.\[\]]+/).filter(k => k !== '');
let current = manifest as any;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!current[key]) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
}
// 保存更新后的 manifest
await saveManifest(name, manifest);
return { success: true, message: "Preset applied successfully" };
} catch (error: any) {
console.error("Failed to apply preset:", error);
reply.status(500).send({ error: error.message || "Failed to apply preset" });
}
});
// 删除预设
app.delete("/api/presets/:name", async (req: any, reply: any) => {
try {
const { name } = req.params;
const presetDir = getPresetDir(name);
if (!existsSync(presetDir)) {
reply.status(404).send({ error: "Preset not found" });
return;
}
// 递归删除整个目录
rmSync(presetDir, { recursive: true, force: true });
return { success: true, message: "Preset deleted successfully" };
} catch (error: any) {
console.error("Failed to delete preset:", error);
reply.status(500).send({ error: error.message || "Failed to delete preset" });
}
});
// 辅助函数:从 ZIP 加载预设
async function loadPresetFromZip(zipFile: string): Promise<PresetFile> {
const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(zipFile);
const entry = zip.getEntry('manifest.json');
if (!entry) {
throw new Error('Invalid preset file: manifest.json not found');
}
const manifest = JSON.parse(entry.getData().toString('utf-8')) as ManifestFile;
return manifestToPresetFile(manifest);
}
return server;
};