main: 初始化 Face to API 项目
- 添加前端抽卡台与模型配置界面 - 添加 Express API 服务与多厂商模型调用适配 - 配置本地环境示例、构建脚本和忽略规则
This commit is contained in:
334
server/index.ts
Normal file
334
server/index.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import "dotenv/config";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
import { findModel, providerForModel, registry } from "./modelRegistry.js";
|
||||
import { saveModelConfig } from "./modelConfigStore.js";
|
||||
import { callProvider, streamProvider } from "./providerClients.js";
|
||||
import { renderPrompt } from "./template.js";
|
||||
import { normalizeUsage } from "./tokens.js";
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT ?? 8787);
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
const runSchema = z.object({
|
||||
template: z.string().trim().optional(),
|
||||
systemTemplate: z.string().trim().optional(),
|
||||
userTemplate: z.string().trim().optional(),
|
||||
modelIds: z.array(z.string()).min(1),
|
||||
draws: z.number().int().min(1).max(20),
|
||||
temperature: z.number().min(0).max(2).default(0.8),
|
||||
maxTokens: z.number().int().min(64).max(8192).default(1024)
|
||||
}).superRefine((data, context) => {
|
||||
const userTemplate = data.userTemplate || data.template || "";
|
||||
if (!userTemplate.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["userTemplate"],
|
||||
message: "User prompt template is required"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const composePrompt = (systemPrompt: string, userPrompt: string) =>
|
||||
[systemPrompt, userPrompt].filter(Boolean).join("\n\n");
|
||||
|
||||
const modelConfigSchema = z.object({
|
||||
baseUrl: z.string().trim().optional(),
|
||||
model: z.string().trim().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
clearApiKey: z.boolean().optional()
|
||||
});
|
||||
|
||||
app.get("/api/health", (_request, response) => {
|
||||
response.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/models", (_request, response) => {
|
||||
response.json({ models: registry() });
|
||||
});
|
||||
|
||||
app.get("/api/model-configs", (_request, response) => {
|
||||
response.json({ models: registry() });
|
||||
});
|
||||
|
||||
app.put("/api/model-configs/:modelId", (request, response) => {
|
||||
const model = findModel(request.params.modelId);
|
||||
if (!model) {
|
||||
response.status(404).json({ error: "Model not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = modelConfigSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
response.status(400).json({ error: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
saveModelConfig(model.id, parsed.data);
|
||||
const updated = findModel(model.id);
|
||||
response.json({ model: updated });
|
||||
});
|
||||
|
||||
app.post("/api/runs", async (request, response) => {
|
||||
const parsed = runSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
response.status(400).json({ error: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
systemTemplate = "",
|
||||
userTemplate: explicitUserTemplate,
|
||||
template: legacyTemplate,
|
||||
modelIds,
|
||||
draws,
|
||||
temperature,
|
||||
maxTokens
|
||||
} = parsed.data;
|
||||
const userTemplate = explicitUserTemplate || legacyTemplate || "";
|
||||
const startedAt = new Date();
|
||||
const uniqueModelIds = [...new Set(modelIds)];
|
||||
const models = uniqueModelIds.map((modelId) => findModel(modelId));
|
||||
const missing = uniqueModelIds.filter((modelId, index) => !models[index]);
|
||||
|
||||
if (missing.length) {
|
||||
response.status(400).json({ error: `Unknown model: ${missing.join(", ")}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs = models.flatMap((model) =>
|
||||
Array.from({ length: draws }, (_, index) => ({
|
||||
model: model!,
|
||||
draw: index + 1
|
||||
}))
|
||||
);
|
||||
|
||||
const results = await Promise.all(
|
||||
jobs.map(async ({ model, draw }) => {
|
||||
const context = { draw, model, startedAt };
|
||||
const systemPrompt = systemTemplate ? renderPrompt(systemTemplate, context) : "";
|
||||
const userPrompt = renderPrompt(userTemplate, context);
|
||||
const prompt = composePrompt(systemPrompt, userPrompt);
|
||||
const provider = providerForModel(model);
|
||||
const jobStarted = Date.now();
|
||||
|
||||
try {
|
||||
const result = await callProvider({
|
||||
model,
|
||||
provider,
|
||||
systemPrompt,
|
||||
prompt: userPrompt,
|
||||
temperature,
|
||||
maxTokens
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${model.id}-${draw}-${jobStarted}`,
|
||||
ok: true,
|
||||
draw,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
output: result.output,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
providerName: model.providerName,
|
||||
rawModel: result.rawModel,
|
||||
durationMs: Date.now() - jobStarted,
|
||||
usage: result.usage
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown provider error";
|
||||
|
||||
return {
|
||||
id: `${model.id}-${draw}-${jobStarted}`,
|
||||
ok: false,
|
||||
draw,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
output: "",
|
||||
error: message,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
providerName: model.providerName,
|
||||
rawModel: model.model,
|
||||
durationMs: Date.now() - jobStarted,
|
||||
usage: normalizeUsage(prompt, "", null)
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
response.json({
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
results
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/runs/stream", async (request, response) => {
|
||||
const parsed = runSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
response.status(400).json({ error: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
systemTemplate = "",
|
||||
userTemplate: explicitUserTemplate,
|
||||
template: legacyTemplate,
|
||||
modelIds,
|
||||
draws,
|
||||
temperature,
|
||||
maxTokens
|
||||
} = parsed.data;
|
||||
const userTemplate = explicitUserTemplate || legacyTemplate || "";
|
||||
const startedAt = new Date();
|
||||
const uniqueModelIds = [...new Set(modelIds)];
|
||||
const models = uniqueModelIds.map((modelId) => findModel(modelId));
|
||||
const missing = uniqueModelIds.filter((modelId, index) => !models[index]);
|
||||
|
||||
if (missing.length) {
|
||||
response.status(400).json({ error: `Unknown model: ${missing.join(", ")}` });
|
||||
return;
|
||||
}
|
||||
|
||||
response.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
response.setHeader("Connection", "keep-alive");
|
||||
response.setHeader("X-Accel-Buffering", "no");
|
||||
response.flushHeaders();
|
||||
|
||||
const controller = new AbortController();
|
||||
response.on("close", () => {
|
||||
if (!response.writableEnded) {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const writeEvent = (event: Record<string, unknown>) => {
|
||||
if (!response.destroyed && !response.writableEnded) {
|
||||
response.write(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const jobs = models.flatMap((model) =>
|
||||
Array.from({ length: draws }, (_, index) => ({
|
||||
model: model!,
|
||||
draw: index + 1
|
||||
}))
|
||||
);
|
||||
|
||||
writeEvent({
|
||||
type: "run_start",
|
||||
startedAt: startedAt.toISOString(),
|
||||
totalJobs: jobs.length
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
jobs.map(async ({ model, draw }, jobIndex) => {
|
||||
const context = { draw, model, startedAt };
|
||||
const systemPrompt = systemTemplate ? renderPrompt(systemTemplate, context) : "";
|
||||
const userPrompt = renderPrompt(userTemplate, context);
|
||||
const prompt = composePrompt(systemPrompt, userPrompt);
|
||||
const provider = providerForModel(model);
|
||||
const jobStarted = Date.now();
|
||||
const id = `${model.id}-${draw}-${startedAt.getTime()}-${jobIndex}`;
|
||||
|
||||
writeEvent({
|
||||
type: "card_start",
|
||||
id,
|
||||
draw,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
providerName: model.providerName,
|
||||
rawModel: model.model
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await streamProvider({
|
||||
model,
|
||||
provider,
|
||||
systemPrompt,
|
||||
prompt: userPrompt,
|
||||
temperature,
|
||||
maxTokens,
|
||||
signal: controller.signal,
|
||||
onDelta: (delta) => {
|
||||
writeEvent({
|
||||
type: "delta",
|
||||
id,
|
||||
channel: delta.channel,
|
||||
text: delta.text
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
writeEvent({
|
||||
type: "card_end",
|
||||
id,
|
||||
ok: true,
|
||||
output: result.output,
|
||||
reasoning: result.reasoning ?? "",
|
||||
rawModel: result.rawModel,
|
||||
durationMs: Date.now() - jobStarted,
|
||||
usage: result.usage
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown provider error";
|
||||
|
||||
writeEvent({
|
||||
type: "card_end",
|
||||
id,
|
||||
ok: false,
|
||||
output: "",
|
||||
reasoning: "",
|
||||
error: message,
|
||||
rawModel: model.model,
|
||||
durationMs: Date.now() - jobStarted,
|
||||
usage: normalizeUsage(prompt, "", null)
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
writeEvent({
|
||||
type: "run_end",
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: new Date().toISOString()
|
||||
});
|
||||
response.end();
|
||||
});
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const distPath = path.resolve(dirname, "../dist");
|
||||
|
||||
app.use(express.static(distPath));
|
||||
app.use((request, response, next) => {
|
||||
if (request.path.startsWith("/api")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const indexPath = path.join(distPath, "index.html");
|
||||
response.sendFile(indexPath, (error) => {
|
||||
if (error) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Face to API server listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
Reference in New Issue
Block a user