Track bundled vendor runtime sources
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled

This commit is contained in:
2026-07-07 10:05:50 +08:00
parent fbe179ab53
commit 52636c91ae
2111 changed files with 1850012 additions and 14 deletions

View File

@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AigcPanel 接口文档</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f7fa; color: #1d2129; line-height: 1.6; }
.header { background: #1d2129; color: #fff; padding: 24px 40px; }
.header h1 { font-size: 22px; font-weight: 600; }
.header p { font-size: 14px; color: #aaa; margin-top: 4px; }
.container { max-width: 900px; margin: 32px auto; padding: 0 24px; }
.card { background: #fff; border-radius: 8px; border: 1px solid #e5e8ef; margin-bottom: 24px; overflow: hidden; }
.card-header { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid #f0f1f5; }
.method { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 4px; letter-spacing: 0.5px; }
.get { background: #e8f7f0; color: #00b42a; }
.post { background: #e8f4ff; color: #165dff; }
.path { font-family: monospace; font-size: 15px; font-weight: 600; color: #1d2129; }
.desc { margin-left: auto; font-size: 13px; color: #86909c; }
.card-body { padding: 20px; }
.section-title { font-size: 13px; font-weight: 600; color: #86909c; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; padding: 8px 12px; background: #f7f8fa; color: #86909c; font-weight: 500; border-bottom: 1px solid #e5e8ef; }
td { padding: 10px 12px; border-bottom: 1px solid #f0f1f5; color: #4e5969; vertical-align: top; }
td:first-child { font-family: monospace; color: #165dff; }
tr:last-child td { border-bottom: none; }
.required { color: #f53f3f; font-size: 11px; margin-left: 4px; }
.optional { color: #86909c; font-size: 11px; margin-left: 4px; }
pre { background: #f7f8fa; border-radius: 6px; padding: 14px 16px; font-size: 13px; overflow-x: auto; line-height: 1.7; color: #1d2129; border: 1px solid #e5e8ef; margin-top: 12px; }
.divider { height: 1px; background: #f0f1f5; margin: 16px 0; }
</style>
</head>
<body>
<div class="header">
<h1>AigcPanel HTTP 接口文档</h1>
<p>Base URLhttp://localhost:{{PORT}}</p>
</div>
<div class="container">
<div class="card">
<div class="card-header">
<span class="method get">GET</span>
<span class="path">/api/model/list</span>
<span class="desc">获取已安装的服务列表</span>
</div>
<div class="card-body">
<div class="section-title">请求参数</div>
<p style="font-size:14px;color:#86909c;"></p>
<div class="divider"></div>
<div class="section-title">返回字段说明</div>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td>id</td><td>string</td><td>服务唯一标识,格式 <code>name|version</code>,用于 /api/model/call 的 model 参数</td></tr>
<tr><td>functions[].name</td><td>string</td><td>功能名称,用于 /api/model/call 的 function 参数</td></tr>
<tr><td>functions[].args</td><td>string[]</td><td>调用时 param 对象的顶层参数字段名,对应 <code>param.字段名</code></td></tr>
<tr><td>functions[].param</td><td>object[]</td><td>调用时 param.param 的参数定义列表,每项含 name/title/type/defaultValue 等</td></tr>
</table>
<div class="divider"></div>
<div class="section-title">返回示例</div>
<pre>{
"code": 0,
"data": [
{
"id": "aigcpanel-server-tts|1.0.0",
"name": "aigcpanel-server-tts",
"version": "1.0.0",
"title": "语音合成",
"functions": [
{
"name": "soundTts",
"args": ["text"],
"param": [
{ "name": "speaker", "title": "发音人", "type": "select", "defaultValue": "", "options": [...] }
]
},
{
"name": "soundClone",
"args": ["text", "promptAudio", "promptText"],
"param": []
}
]
},
{
"id": "aigcpanel-server-sd|1.2.0",
"name": "aigcpanel-server-sd",
"version": "1.2.0",
"title": "图像生成",
"functions": [
{
"name": "textToImage",
"args": ["prompt"],
"param": [
{ "name": "width", "title": "宽度", "type": "inputNumber", "defaultValue": 512 },
{ "name": "height", "title": "高度", "type": "inputNumber", "defaultValue": 512 }
]
}
]
}
]
}</pre>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="method post">POST</span>
<span class="path">/api/model/call</span>
<span class="desc">调用服务功能,异步返回 taskId</span>
</div>
<div class="card-body">
<div class="section-title">请求体 (JSON)</div>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td>model<span class="required">*必填</span></td><td>string</td><td>服务标识,格式为 <code>name|version</code>,来自 /api/model/list 的 id 字段</td></tr>
<tr><td>function<span class="required">*必填</span></td><td>string</td><td>调用的功能名称,来自 functions[].name</td></tr>
<tr><td>param<span class="required">*必填</span></td><td>object</td><td>功能参数对象,顶层字段来自 functions[].args嵌套 param 字段来自 functions[].param</td></tr>
<tr><td>param.&lt;arg&gt;</td><td>any</td><td>各 args 字段直接放在 param 顶层,如 <code>text</code><code>prompt</code><code>audio</code></td></tr>
<tr><td>param.param</td><td>object</td><td>动态参数对象,字段定义来自 functions[].paramkey 为各项的 name</td></tr>
</table>
<div class="divider"></div>
<div class="section-title">请求示例(语音合成)</div>
<pre>{
"model": "aigcpanel-server-tts|1.0.0",
"function": "soundTts",
"param": {
"text": "你好,欢迎使用 AigcPanel",
"param": { "speaker": "zh-CN-XiaoxiaoNeural" }
}
}</pre>
<div class="section-title" style="margin-top:12px;">请求示例(图像生成)</div>
<pre>{
"model": "aigcpanel-server-sd|1.2.0",
"function": "textToImage",
"param": {
"prompt": "a beautiful landscape, 4k",
"param": { "width": 512, "height": 512, "steps": 20 }
}
}</pre>
<div class="divider"></div>
<div class="section-title">返回示例</div>
<pre>{
"code": 0,
"data": { "taskId": "lz3k8m2abc1" }
}</pre>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="method get">GET</span>
<span class="path">/api/model/query</span>
<span class="desc">查询任务结果</span>
</div>
<div class="card-body">
<div class="section-title">Query 参数</div>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td>taskId<span class="required">*必填</span></td><td>string</td><td>由 /api/model/call 返回的任务 ID</td></tr>
</table>
<div class="divider"></div>
<div class="section-title">返回示例(处理中)</div>
<pre>{ "code": 0, "data": { "status": "pending" } }</pre>
<div class="section-title" style="margin-top:12px;">返回示例(成功)</div>
<pre>{
"code": 0,
"data": {
"status": "success",
"result": {
"type": "success",
"start": 1714300000000,
"end": 1714300005000,
"data": { "url": "/path/to/output.wav" }
}
}
}</pre>
<div class="section-title" style="margin-top:12px;">返回示例(失败)</div>
<pre>{ "code": 0, "data": { "status": "error", "error": "错误信息" } }</pre>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,825 @@
import express from "express";
import type { Request, Response } from "express";
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { ipcMain } from "electron";
import { Log } from "../log/main";
import ConfigMain from "../config/main";
import StorageMain from "../storage/main";
import { Events } from "../event/main";
import { DBMain } from "../db/main";
import { AppEnv } from "../env";
import docHtml from "./doc.html?raw";
let server: http.Server | null = null;
let isRunning = false;
let runningPort = 0;
let runningToken = "";
const getAvailablePort = (): Promise<number> => {
return new Promise((resolve, reject) => {
const s = http.createServer();
s.listen(0, "127.0.0.1", () => {
const addr = s.address() as { port: number };
const port = addr.port;
s.close(() => resolve(port));
});
s.on("error", reject);
});
};
const generateToken = (): string => {
return (
crypto.randomUUID().replace(/-/g, "") +
crypto.randomUUID().replace(/-/g, "")
);
};
const writeCliAuthFile = (port: number, token: string): void => {
try {
const filePath = path.join(AppEnv.userData, "cli-auth.json");
fs.writeFileSync(filePath, JSON.stringify({ port, token }), "utf-8");
} catch (e) {
Log.error("httpserver.writeCliAuthFile.error", e);
}
};
const functionArgsMap: Record<string, string[]> = {
soundTts: ["text"],
soundClone: ["text", "promptAudio", "promptText"],
videoGen: ["video", "audio"],
asr: ["audio"],
textToImage: ["prompt"],
imageToImage: ["image", "prompt"],
live: [],
};
const functionBizMap: Record<string, string> = {
soundTts: "SoundGenerate",
soundClone: "SoundGenerate",
videoGen: "VideoGen",
asr: "SoundAsr",
textToImage: "TextToImage",
imageToImage: "ImageToImage",
};
const getInstalledServers = async () => {
const storageData = await StorageMain.read("server", null);
const records = storageData?.records || [];
return records
.filter((r: any) => r.name && r.version)
.map((r: any) => ({
id: `${r.name}|${r.version}`,
name: r.name,
version: r.version,
title: r.title || r.name,
functions: (r.functions || []).map((funcName: string) => ({
name: funcName,
args: functionArgsMap[funcName] || [],
param: r.config?.functions?.[funcName]?.param || [],
})),
}));
};
const getServerRecord = async (serverName: string, serverVersion: string) => {
const storageData = await StorageMain.read("server", null);
const records = storageData?.records || [];
return records.find(
(r: any) => r.name === serverName && r.version === serverVersion,
);
};
const buildModelConfig = (
funcName: string,
serverName: string,
serverTitle: string,
serverVersion: string,
param: any,
) => {
switch (funcName) {
case "soundTts":
return {
type: "SoundTts",
ttsServerKey: `${serverName}|${serverVersion}`,
ttsParam: param?.param || {},
text: param?.text || "",
};
case "soundClone":
return {
type: "SoundClone",
cloneServerKey: `${serverName}|${serverVersion}`,
cloneParam: param?.param || {},
text: param?.text || "",
promptUrl: param?.promptAudio || "",
promptText: param?.promptText || "",
};
case "videoGen":
return {
soundType: "soundCustom",
soundCustomFile: param?.audio || "",
videoTemplateUrl: param?.video || "",
};
case "asr":
return {
audio: param?.audio || "",
};
case "textToImage":
return {
prompt: param?.prompt || "",
textToImage: {
serverName,
serverTitle,
serverVersion,
type: "TextToImage",
serverKey: `${serverName}|${serverVersion}`,
param: param?.param || {},
},
};
case "imageToImage":
return {
image: param?.image || "",
prompt: param?.prompt || "",
imageToImage: {
serverName,
serverTitle,
serverVersion,
type: "ImageToImage",
serverKey: `${serverName}|${serverVersion}`,
param: param?.param || {},
},
};
default:
return param || {};
}
};
const buildTaskParam = (funcName: string, param: any) => {
if (funcName === "videoGen" || funcName === "asr") {
return param?.param || {};
}
return {};
};
const buildTaskTitle = (funcName: string, param: any): string => {
switch (funcName) {
case "soundTts":
return param?.text ? String(param.text).slice(0, 20) : "TTS任务";
case "soundClone":
return param?.text
? String(param.text).slice(0, 20)
: "音色克隆任务";
case "videoGen":
return "AI数字人视频";
case "asr":
return "ASR识别任务";
case "textToImage":
return param?.prompt
? String(param.prompt).slice(0, 20)
: "文生图任务";
case "imageToImage":
return param?.prompt
? String(param.prompt).slice(0, 20)
: "图生图任务";
default:
return "任务";
}
};
const sendJson = (res: Response, statusCode: number, data: any) => {
res.status(statusCode).json(data);
};
const createApp = (port: number, token: string) => {
const app = express();
app.use(express.json());
app.use((_req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
);
if (_req.method === "OPTIONS") {
res.status(200).end();
return;
}
next();
});
app.get("/doc", (_req, res) => {
const html = docHtml.replace(/\{\{PORT\}\}/g, String(port));
res.status(200)
.set("Content-Type", "text/html; charset=utf-8")
.send(html);
});
app.use((_req, res, next) => {
const auth = _req.headers["authorization"] || "";
if (!auth.startsWith("Bearer ") || auth.slice(7) !== token) {
sendJson(res, 401, { code: -1, msg: "Unauthorized" });
return;
}
next();
});
app.post("/api/model/list", async (_req, res) => {
try {
const servers = await getInstalledServers();
sendJson(res, 200, { code: 0, data: servers });
} catch (e) {
sendJson(res, 500, { code: -1, msg: String(e) });
}
});
app.post("/api/model/call", async (req, res) => {
try {
const {
model,
version,
function: funcName,
param,
} = req.body || {};
let serverName: string;
let serverVersion: string;
if (version !== undefined) {
// 新格式model 和 version 分别传递
serverName = model || "";
serverVersion = version || "";
} else {
// 兼容旧格式model 为 "name|version"
const parts = (model || "").split("|");
serverName = parts[0];
serverVersion = parts.slice(1).join("|");
}
if (!serverName || !serverVersion) {
sendJson(res, 400, {
code: -1,
msg: "Missing model or version",
});
return;
}
if (!funcName) {
sendJson(res, 400, { code: -1, msg: "Missing function" });
return;
}
const biz = functionBizMap[funcName];
if (!biz) {
sendJson(res, 400, {
code: -1,
msg: `Unknown function: ${funcName}`,
});
return;
}
const serverRecord = await getServerRecord(
serverName,
serverVersion,
);
if (!serverRecord) {
sendJson(res, 400, {
code: -1,
msg: `Server not found: ${serverName}|${serverVersion}`,
});
return;
}
const serverTitle = serverRecord.title || serverName;
const modelConfig = buildModelConfig(
funcName,
serverName,
serverTitle,
serverVersion,
param,
);
const taskParam = buildTaskParam(funcName, param);
const title = buildTaskTitle(funcName, param);
const taskDbId = await DBMain.insert(
`INSERT INTO data_task (biz, title, status, startTime, serverName, serverTitle, serverVersion, param, jobResult, modelConfig, result, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
biz,
title,
"queue",
Date.now(),
serverName,
serverTitle,
serverVersion,
JSON.stringify(taskParam),
JSON.stringify({}),
JSON.stringify(modelConfig),
JSON.stringify({}),
1,
],
);
const taskId = String(taskDbId);
sendJson(res, 200, { code: 0, data: { taskId } });
Events.callPage("main", "httpserver:submitTask", {
biz,
taskId,
}).catch((err) => {
Log.error("httpserver.submitTask.error", err);
});
} catch (e) {
sendJson(res, 500, { code: -1, msg: `Internal error: ${e}` });
}
});
app.post("/api/model/query", async (req, res) => {
try {
const { taskId } = req.body || {};
if (!taskId) {
sendJson(res, 400, { code: -1, msg: "Missing taskId" });
return;
}
const LONG_POLL_MS = 60_000;
const POLL_INTERVAL_MS = 500;
const deadline = Date.now() + LONG_POLL_MS;
const queryOnce = async () => {
const record = await DBMain.first(
"SELECT * FROM data_task WHERE id = ?",
[taskId],
);
if (!record) {
return {
done: true,
payload: {
code: 0,
data: { status: "error", error: "Task not found" },
},
};
}
if (record.status === "success") {
let result: any = null;
try {
const parsed = JSON.parse(record.result);
if (parsed && Object.keys(parsed).length > 0)
result = parsed;
} catch (_) {}
if (result) {
return {
done: true,
payload: {
code: 0,
data: {
status: "success",
result: {
code: 0,
msg: "ok",
data: {
type: "success",
start: record.startTime || 0,
end: record.endTime || 0,
data: result,
},
},
},
},
};
}
} else if (record.status === "fail") {
return {
done: true,
payload: {
code: 0,
data: {
status: "error",
error: record.statusMsg || "Task failed",
},
},
};
} else if (record.status === "pause") {
let jobResult: any = null;
try {
jobResult = JSON.parse(record.jobResult);
} catch (_) {}
return {
done: true,
payload: {
code: 0,
data: {
status: "pause",
taskId: String(record.id),
step: jobResult?.step || null,
statusMsg: record.statusMsg || "Task paused",
},
},
};
}
return { done: false, payload: null };
};
while (true) {
const { done, payload } = await queryOnce();
if (done) {
sendJson(res, 200, payload!);
return;
}
if (Date.now() >= deadline) break;
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
sendJson(res, 200, { code: 0, data: { status: "pending" } });
} catch (e) {
sendJson(res, 500, { code: -1, msg: `Internal error: ${e}` });
}
});
// ── GET /api/workflow/list ───────────────────────────────────────────
app.get("/api/workflow/list", async (_req, res) => {
try {
const rows = await DBMain.select(
"SELECT id, name, createdAt, updatedAt FROM workflow ORDER BY createdAt DESC",
[],
);
sendJson(res, 200, { code: 0, data: { list: rows } });
} catch (e) {
sendJson(res, 500, { code: -1, msg: `Internal error: ${e}` });
}
});
// ── POST /api/workflow/run-named ─────────────────────────────────────
// 运行已存在的工作流(按名称查找),创建运行记录并执行
app.post("/api/workflow/run-named", async (req, res) => {
try {
const { name } = req.body || {};
if (!name) {
sendJson(res, 400, { code: -1, msg: "Missing name" });
return;
}
const wf = await DBMain.first(
"SELECT * FROM workflow WHERE name = ? ORDER BY createdAt DESC LIMIT 1",
[name],
);
if (!wf) {
sendJson(res, 404, {
code: -1,
msg: `Workflow not found: ${name}`,
});
return;
}
let workflowData: any;
try {
workflowData = JSON.parse(wf.data);
} catch {
workflowData = {};
}
workflowData.status = "idle";
const now = Math.floor(Date.now() / 1000);
const workflowLogId = await DBMain.insert(
`INSERT INTO workflow_log (createdAt, updatedAt, workflowId, name, data, status, startTime, endTime) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
now,
now,
wf.id,
name,
JSON.stringify(workflowData),
"idle",
Date.now(),
0,
],
);
sendJson(res, 200, {
code: 0,
data: {
workflowLogId: String(workflowLogId),
workflowId: String(wf.id),
},
});
Events.callPage("main", "httpserver:submitWorkflow", {
workflowLogId: String(workflowLogId),
}).catch((err) => {
Log.error("httpserver.submitWorkflow.error", err);
});
} catch (e) {
sendJson(res, 500, { code: -1, msg: `Internal error: ${e}` });
}
});
// ── POST /api/workflow/cleanup-test ──────────────────────────────────
// 删除所有 _test_ 前缀及"新建工作流"命名的工作流及其运行记录
app.post("/api/workflow/cleanup-test", async (_req, res) => {
try {
const testWorkflows = await DBMain.select(
`SELECT id FROM workflow WHERE name LIKE '_test_%' OR name = '新建工作流'`,
[],
);
for (const wf of testWorkflows) {
await DBMain.execute(
"DELETE FROM workflow_log WHERE workflowId = ?",
[wf.id],
);
await DBMain.execute("DELETE FROM workflow WHERE id = ?", [
wf.id,
]);
}
sendJson(res, 200, {
code: 0,
data: { deleted: testWorkflows.length },
});
} catch (e) {
sendJson(res, 500, { code: -1, msg: `Internal error: ${e}` });
}
});
// ── POST /api/workflow/run ───────────────────────────────────────────
app.post("/api/workflow/run", async (req, res) => {
try {
const { data: workflowData } = req.body || {};
if (!workflowData || !Array.isArray(workflowData.nodes)) {
sendJson(res, 400, { code: -1, msg: "Missing workflow data" });
return;
}
const now = Math.floor(Date.now() / 1000);
const workflowId = await DBMain.insert(
`INSERT INTO workflow (createdAt, updatedAt, name, data) VALUES (?, ?, ?, ?)`,
[now, now, "_test_" + now, JSON.stringify(workflowData)],
);
workflowData.status = "idle";
const workflowLogId = await DBMain.insert(
`INSERT INTO workflow_log (createdAt, updatedAt, workflowId, name, data, status, startTime, endTime) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
now,
now,
workflowId,
"_test_" + now,
JSON.stringify(workflowData),
"idle",
Date.now(),
0,
],
);
sendJson(res, 200, {
code: 0,
data: {
workflowLogId: String(workflowLogId),
workflowId: String(workflowId),
},
});
Events.callPage("main", "httpserver:submitWorkflow", {
workflowLogId: String(workflowLogId),
}).catch((err) => {
Log.error("httpserver.submitWorkflow.error", err);
});
} catch (e) {
sendJson(res, 500, { code: -1, msg: `Internal error: ${e}` });
}
});
// ── POST /api/workflow/query ─────────────────────────────────────────
app.post("/api/workflow/query", async (req, res) => {
try {
const { workflowLogId } = req.body || {};
if (!workflowLogId) {
sendJson(res, 400, { code: -1, msg: "Missing workflowLogId" });
return;
}
const LONG_POLL_MS = 10_000;
const POLL_INTERVAL_MS = 500;
const deadline = Date.now() + LONG_POLL_MS;
while (true) {
const record = await DBMain.first(
"SELECT * FROM workflow_log WHERE id = ?",
[workflowLogId],
);
if (!record) {
sendJson(res, 200, {
code: 0,
data: { status: "error", statusMsg: "Not found" },
});
return;
}
if (
record.status === "success" ||
record.status === "error" ||
record.status === "pause"
) {
let logData: any = null;
try {
logData = JSON.parse(record.data);
} catch {}
sendJson(res, 200, {
code: 0,
data: {
status: record.status,
statusMsg: record.statusMsg,
logData,
},
});
return;
}
if (Date.now() >= deadline) break;
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
sendJson(res, 200, { code: 0, data: { status: "pending" } });
} catch (e) {
sendJson(res, 500, { code: -1, msg: `Internal error: ${e}` });
}
});
// ── POST /api/task/submit ────────────────────────────────────────────
app.post("/api/task/submit", async (req, res) => {
try {
const {
biz,
modelConfig,
param,
title,
serverName,
serverTitle,
serverVersion,
} = req.body || {};
if (!biz) {
sendJson(res, 400, { code: -1, msg: "Missing biz" });
return;
}
const taskTitle = title || biz + "-task";
const taskDbId = await DBMain.insert(
`INSERT INTO data_task (biz, title, status, startTime, serverName, serverTitle, serverVersion, param, jobResult, modelConfig, result, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
biz,
taskTitle,
"queue",
Date.now(),
serverName || "",
serverTitle || serverName || "",
serverVersion || "",
JSON.stringify(param || {}),
JSON.stringify({}),
JSON.stringify(modelConfig || {}),
JSON.stringify({}),
1,
],
);
const taskId = String(taskDbId);
sendJson(res, 200, { code: 0, data: { taskId } });
Events.callPage("main", "httpserver:submitTask", {
biz,
taskId,
}).catch((err) => {
Log.error("httpserver.submitTask.error", err);
});
} catch (e) {
sendJson(res, 500, { code: -1, msg: `Internal error: ${e}` });
}
});
// ── POST /api/task/continue ──────────────────────────────────────────
// Continue a paused task with stage-specific data.
// Body: { taskId, stage, data }
// stage: the current paused step (e.g. "Config", "Confirm")
// data: the stage-specific payload (e.g. { times: [...] })
app.post("/api/task/continue", async (req, res) => {
try {
const { taskId, stage, data } = req.body || {};
if (!taskId || !stage) {
sendJson(res, 400, {
code: -1,
msg: "Missing taskId or stage",
});
return;
}
const record = await DBMain.first(
"SELECT * FROM data_task WHERE id = ?",
[taskId],
);
if (!record) {
sendJson(res, 200, {
code: -1,
msg: `Task not found: ${taskId}`,
});
return;
}
if (record.status !== "pause") {
sendJson(res, 200, {
code: -1,
msg: `Task is not paused (current status: ${record.status})`,
});
return;
}
// Map stage → next step
const stageTransitions: Record<string, Record<string, string>> = {
VideoZoom: { Config: "Render", RenderConfirm: "End" },
VideoMark: { Config: "Render", RenderConfirm: "End" },
VideoSpeedPart: { Config: "Render" },
VideoKeepPart: { Config: "Render" },
VideoQuickCut: { Confirm: "Merge" },
};
const biz = record.biz;
const transitions = stageTransitions[biz] || {};
const nextStep = transitions[stage];
if (!nextStep) {
sendJson(res, 200, {
code: -1,
msg: `Unknown stage "${stage}" for biz "${biz}"`,
});
return;
}
// Merge jobResult update
let currentJobResult: any = {};
try {
currentJobResult = JSON.parse(record.jobResult) || {};
} catch (_) {}
const updatedJobResult = {
...currentJobResult,
step: nextStep,
[stage]: {
...((currentJobResult[stage] as any) || {}),
...(data || {}),
},
};
await DBMain.execute(
"UPDATE data_task SET status = ?, jobResult = ?, statusMsg = ? WHERE id = ?",
["queue", JSON.stringify(updatedJobResult), "", taskId],
);
sendJson(res, 200, { code: 0, data: { taskId } });
Events.callPage("main", "httpserver:submitTask", {
biz,
taskId,
}).catch((err) => {
Log.error("httpserver.continueTask.error", err);
});
} catch (e) {
sendJson(res, 500, { code: -1, msg: `Internal error: ${e}` });
}
});
app.use((_req, res) => {
sendJson(res, 404, { code: -1, msg: "Not found" });
});
return app;
};
const start = async (port?: number): Promise<void> => {
if (isRunning) {
await stop();
}
const resolvedPort = port || (await getAvailablePort());
const token = generateToken();
return new Promise((resolve, reject) => {
const app = createApp(resolvedPort, token);
const s = http.createServer(app);
s.listen(resolvedPort, "127.0.0.1", async () => {
server = s;
isRunning = true;
runningPort = resolvedPort;
runningToken = token;
await ConfigMain.set("httpServerPort", resolvedPort);
await ConfigMain.set("httpServerToken", token);
writeCliAuthFile(resolvedPort, token);
Log.info("httpserver.start", { port: resolvedPort });
resolve();
});
s.on("error", (err: any) => {
Log.error("httpserver.error", err);
reject(err);
});
});
};
const stop = async (): Promise<void> => {
return new Promise((resolve) => {
if (!server) {
isRunning = false;
runningPort = 0;
resolve();
return;
}
server.close(() => {
server = null;
isRunning = false;
runningPort = 0;
resolve();
});
});
};
const status = () => ({
running: isRunning,
port: runningPort,
});
ipcMain.handle("httpserver:status", async () => {
return status();
});
ipcMain.handle("httpserver:start", async (_, port?: number) => {
try {
await start(port);
return { code: 0 };
} catch (e) {
return { code: -1, msg: String(e) };
}
});
ipcMain.handle("httpserver:stop", async () => {
await stop();
return { code: 0 };
});
export const HttpServerMain = {
start,
stop,
status,
};
export default HttpServerMain;

View File

@@ -0,0 +1,21 @@
import { ipcRenderer } from "electron";
const status = async (): Promise<{ running: boolean; port: number }> => {
return ipcRenderer.invoke("httpserver:status");
};
const start = async (
port?: number,
): Promise<{ code: number; msg?: string }> => {
return ipcRenderer.invoke("httpserver:start", port);
};
const stop = async (): Promise<{ code: number }> => {
return ipcRenderer.invoke("httpserver:stop");
};
export default {
status,
start,
stop,
};