feat(*): 短剧创作工作台 - 完善章节分镜、资产管理、技能运行与生成任务恢复

This commit is contained in:
ddCat
2026-08-29 23:34:46 +08:00
parent 4f07daae9e
commit 2f6832f99c
113 changed files with 7016 additions and 723 deletions

View File

@@ -13,7 +13,8 @@ import type { AgentAttachment, AgentEmit } from "./types.js";
type Json = Record<string, unknown>;
type AgentEvent = Json & { type: string; usage?: unknown };
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
export type AgentSkillReference = { skillId?: string; name: string; description?: string; instruction?: string };
export type AgentSkillFile = { path: string; mimeType?: string; contentBase64: string };
export type AgentSkillReference = { skillId?: string; name: string; description?: string; version?: string; files?: AgentSkillFile[]; instruction?: string };
export type CodexSkillInput = { type: "skill"; name: string; path: string };
type CodexRunOptions = { threadId?: string; cwd?: string; skills?: AgentSkillReference[]; onThreadId?: (threadId: string) => void };
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
@@ -471,17 +472,32 @@ export async function writeSkillFiles(skills: AgentSkillReference[]) {
const usedNames = new Set<string>();
try {
for (const skill of skills.slice(0, 8)) {
const instruction = String(skill.instruction || "").trim().slice(0, 24_000);
if (!instruction) continue;
const baseName = `canvas-${safeSkillSegment(skill.skillId || skill.name)}`;
const name = uniqueSkillName(baseName, usedNames);
usedNames.add(name);
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "infinite-canvas-skill-"));
directories.push(directory);
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "infinite-canvas-skill-"));
directories.push(temporaryRoot);
const directory = path.join(temporaryRoot, name);
await fs.mkdir(directory, { recursive: true });
const packageFiles = Array.isArray(skill.files) ? skill.files : [];
if (packageFiles.length) {
for (const item of packageFiles) {
const relativePath = safeSkillFilePath(item.path);
const target = path.join(directory, ...relativePath.split("/"));
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, Buffer.from(item.contentBase64, "base64"));
}
} else {
const instruction = String(skill.instruction || "").trim().slice(0, 24_000);
if (!instruction) continue;
await fs.writeFile(path.join(directory, "SKILL.md"), skillMarkdown(name, skill.name, skill.description, instruction), "utf8");
}
const file = path.join(directory, "SKILL.md");
const description = String(skill.description || skill.name).trim().slice(0, 500).replace(/[\r\n]+/g, " ");
const body = [`---`, `name: ${name}`, `description: ${JSON.stringify(description)}`, `---`, ``, `# ${skill.name}`, ``, instruction, ``].join("\n");
await fs.writeFile(file, body, "utf8");
let entry = await fs.readFile(file, "utf8");
if (!hasSkillFrontmatter(entry)) {
entry = skillMarkdown(name, skill.name, skill.description, entry);
await fs.writeFile(file, entry, "utf8");
}
inputs.push({ type: "skill", name, path: file });
}
return { directories, inputs };
@@ -491,6 +507,29 @@ export async function writeSkillFiles(skills: AgentSkillReference[]) {
}
}
function skillMarkdown(name: string, title: string, description: string | undefined, instruction: string) {
const summary = String(description || title).trim().slice(0, 500).replace(/[\r\n]+/g, " ");
return [`---`, `name: ${name}`, `description: ${JSON.stringify(summary)}`, `---`, ``, `# ${title}`, ``, instruction, ``].join("\n");
}
function hasSkillFrontmatter(value: string) {
const normalized = value.replace(/^\uFEFF/, "");
if (!normalized.startsWith("---\n") && !normalized.startsWith("---\r\n")) return false;
const end = normalized.indexOf("\n---", 4);
if (end < 0) return false;
const frontmatter = normalized.slice(0, end);
return /^name\s*:/m.test(frontmatter) && /^description\s*:/m.test(frontmatter);
}
function safeSkillFilePath(value: string) {
const normalized = String(value || "").trim().replace(/\\/g, "/");
const segments = normalized.split("/");
if (!normalized || normalized.startsWith("/") || segments.some((segment) => !segment || segment === "." || segment === "..") || normalized.includes("\0")) {
throw new Error(`技能文件路径无效:${normalized || "空路径"}`);
}
return normalized;
}
function uniqueSkillName(baseName: string, usedNames: Set<string>) {
if (!usedNames.has(baseName)) return baseName;
let suffix = 2;

View File

@@ -1,3 +1,5 @@
import type { Express } from "express";
import { CanvasSession } from "./canvas-session.js";
import { CONFIG_DIR, type LocalRuntimeConfig } from "./config.js";
import { createLocalRuntimeApp } from "./local-runtime.js";
@@ -21,7 +23,7 @@ type CanvasAgentHttpOptions = {
dependencies: CanvasAgentHttpDependencies;
};
export function startHttpServer(options?: CanvasAgentHttpOptions) {
export function startHttpServer(options?: CanvasAgentHttpOptions): ReturnType<typeof startLocalRuntime> | Express {
if (!options) return startLocalRuntime();
return createHttpApp(options.config, options.session, options.dependencies);
}
@@ -30,7 +32,7 @@ export function createHttpApp(
config: LocalRuntimeConfig,
session: CanvasAgentSession = new CanvasSession(),
dependencies: CanvasAgentHttpDependencies = {},
) {
): Express {
const endpoint = config.url;
const manager = new LocalRuntimeSessionManager({
endpoint,

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { createServer, type Server } from "node:http";
import type { Express } from "express";
import {
CONFIG_DIR,
@@ -25,6 +26,14 @@ export type StartLocalRuntimeOptions = {
persistConfig?: (config: LocalRuntimeConfig) => void;
};
export type LocalRuntimeHandle = {
app: Express;
server: Server;
sessions: LocalRuntimeSessionManager;
ready: Promise<void>;
close: () => Promise<void>;
};
export function createDefaultLocalRuntimeModules(config: LocalRuntimeConfig): LocalRuntimeModule[] {
return [
createCanvasAgentHttpModule(config),
@@ -40,7 +49,7 @@ export function createDefaultLocalRuntimeModules(config: LocalRuntimeConfig): Lo
];
}
export function startLocalRuntime(options: StartLocalRuntimeOptions = {}) {
export function startLocalRuntime(options: StartLocalRuntimeOptions = {}): LocalRuntimeHandle {
const config = options.config ?? loadConfig(true);
const persistConfig = options.persistConfig ?? saveConfig;
const requestedPort = options.port ?? (

View File

@@ -1,4 +1,4 @@
import type { Request, RequestHandler, Response } from "express";
import type { Express, Request, RequestHandler, Response } from "express";
import express from "express";
import {
@@ -55,14 +55,14 @@ export type CreateLocalRuntimeAppOptions = {
legacyOrigins?: readonly string[];
};
export function createLocalRuntimeApp(options: CreateLocalRuntimeAppOptions) {
export function createLocalRuntimeApp(options: CreateLocalRuntimeAppOptions): Express {
const modules = validateModules(options.modules);
const policies = corsPolicies(modules, options.legacyOrigins ?? []);
const app = express();
app.disable("x-powered-by");
app.use(express.raw({
type: "application/json",
limit: "30mb",
limit: "64mb",
}));
app.use(noStore);
app.use(exactAuthorityGuard(options.authority));

View File

@@ -221,16 +221,54 @@ export function parseAgentSkills(value: unknown) {
const input = item as Record<string, unknown>;
const name = typeof input.name === "string" ? input.name.trim().slice(0, 120) : "";
const instruction = typeof input.instruction === "string" ? input.instruction.trim().slice(0, 24_000) : "";
if (!name || !instruction) return [];
const files = parseAgentSkillFiles(input.files);
if (!name || (!instruction && !files.length)) return [];
return [{
...(typeof input.skillId === "string" ? { skillId: input.skillId.trim().slice(0, 120) } : {}),
name,
...(typeof input.description === "string" ? { description: input.description.trim().slice(0, 500) } : {}),
instruction,
...(typeof input.version === "string" ? { version: input.version.trim().slice(0, 120) } : {}),
...(files.length ? { files } : { instruction }),
}];
});
}
function parseAgentSkillFiles(value: unknown) {
if (!Array.isArray(value)) return [];
const files: Array<{ path: string; mimeType?: string; contentBase64: string }> = [];
const paths = new Set<string>();
let totalBytes = 0;
for (const item of value.slice(0, 512)) {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const input = item as Record<string, unknown>;
const filePath = typeof input.path === "string" ? normalizeAgentSkillPath(input.path) : "";
const contentBase64 = typeof input.contentBase64 === "string" ? input.contentBase64.trim() : "";
if (!filePath || paths.has(filePath) || !validBase64(contentBase64)) return [];
const size = Buffer.from(contentBase64, "base64").byteLength;
if (size > 8 * 1024 * 1024) return [];
totalBytes += size;
if (totalBytes > 20 * 1024 * 1024) return [];
paths.add(filePath);
files.push({
path: filePath,
...(typeof input.mimeType === "string" ? { mimeType: input.mimeType.trim().slice(0, 255) } : {}),
contentBase64,
});
}
return files.some((file) => file.path === "SKILL.md") ? files : [];
}
function normalizeAgentSkillPath(value: string) {
const normalized = value.trim().replace(/\\/g, "/");
const segments = normalized.split("/");
if (!normalized || normalized.startsWith("/") || normalized.length > 1000 || segments.some((segment) => !segment || segment === "." || segment === "..") || normalized.includes("\0")) return "";
return normalized;
}
function validBase64(value: string) {
return value.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(value);
}
function queryValue(req: Request, key: string) {
const value = req.query[key];
return Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");