feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道

Refs #225

Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
ddCat
2026-08-15 12:45:29 +08:00
parent 8f2232feee
commit 57e1820f41
179 changed files with 49746 additions and 1897 deletions

View File

@@ -0,0 +1,218 @@
import type { NextFunction, Request, RequestHandler, Response } from "express";
import {
archiveCodexThread,
listCodexThreads,
readCodexThread,
resumeCodexThread,
runClaudeTurn,
runCodexTurn,
startCodexThread,
summarizeCodexThread,
verifyCodexThreadWorkspace,
withAgentPrompt,
} from "../agents.js";
import { CanvasSession } from "../canvas-session.js";
import {
ensureCanvasWorkspace,
updateCanvasWorkspace,
type LocalRuntimeConfig,
} from "../config.js";
import type { LocalRuntimeModule, LocalRuntimeProtectedRoute } from "../local-runtime.js";
import type { AgentAttachment } from "../types.js";
export type CanvasAgentSession = Pick<
CanvasSession,
"health" | "openEvents" | "updateState" | "resolveResult" | "emitAll" | "callTool" | "closeRuntimeSession" | "dispose"
>;
export function createCanvasAgentHttpModule(
config: LocalRuntimeConfig,
session: CanvasAgentSession = new CanvasSession(),
): LocalRuntimeModule {
const emit = (type: string, payload: unknown) => session.emitAll(type, payload);
const routes: LocalRuntimeProtectedRoute[] = [
canvasRoute("GET", "/events", (req, res) => {
session.openEvents(
new URL(req.originalUrl || req.url, config.url),
res,
runtimeSessionId(res),
);
}, { queryKeys: ["clientId"], lastEventId: true }),
canvasRoute("POST", "/canvas/state", (req, res) => {
session.updateState(jsonBody(req), queryValue(req, "clientId") || undefined);
res.json({ ok: true });
}, { queryKeys: ["clientId"] }),
canvasRoute("POST", "/canvas/result", (req, res) => {
session.resolveResult(jsonBody(req) as { requestId?: string; error?: string; result?: unknown });
res.json({ ok: true });
}, { queryKeys: ["clientId"] }),
canvasRoute("POST", "/api/tools", async (req, res) => {
const body = jsonRecord(req);
res.json({ ok: true, result: await session.callTool(body.name, body.input || {}) });
}),
canvasRoute("GET", "/agent/codex/workspace", (req, res) => {
const workspace = ensureCanvasWorkspace(config, queryValue(req, "canvasId"));
res.json({ ok: true, workspace });
}, { queryKeys: ["canvasId"] }),
canvasRoute("GET", "/agent/codex/threads", async (req, res) => {
const workspace = ensureCanvasWorkspace(config, queryValue(req, "canvasId"));
const result = await listCodexThreads(emit, {
cwd: workspace.workspacePath,
searchTerm: queryValue(req, "searchTerm"),
});
res.json({ ok: true, workspace, ...result });
}, { queryKeys: ["canvasId", "searchTerm"] }),
canvasRoute("POST", "/agent/codex/threads/new", async (req, res) => {
const body = jsonRecord(req);
const workspace = ensureCanvasWorkspace(config, String(body.canvasId || ""));
const thread = await startCodexThread(emit, workspace.workspacePath);
const activeThreadId = String((thread as Record<string, unknown>).id || "");
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId });
res.json({
ok: true,
workspace: { ...workspace, activeThreadId },
thread: summarizeCodexThread(thread),
messages: [],
});
}),
canvasRoute("GET", "/agent/codex/threads/:threadId", async (req, res) => {
const workspace = ensureCanvasWorkspace(config, queryValue(req, "canvasId"));
const threadId = routeParam(req.params.threadId);
res.json({
ok: true,
workspace,
...(await readCodexThread(emit, threadId, workspace.workspacePath)),
});
}, { queryKeys: ["canvasId"] }),
canvasRoute("POST", "/agent/codex/threads/:threadId/resume", async (req, res) => {
const body = jsonRecord(req);
const workspace = ensureCanvasWorkspace(config, String(body.canvasId || ""));
const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
res.json({
ok: true,
workspace: { ...workspace, activeThreadId: threadId },
...result,
});
}),
canvasRoute("POST", "/agent/codex/threads/:threadId/delete", async (req, res) => {
const body = jsonRecord(req);
const workspace = ensureCanvasWorkspace(config, String(body.canvasId || ""));
const threadId = routeParam(req.params.threadId);
await archiveCodexThread(emit, threadId, workspace.workspacePath);
if (workspace.activeThreadId === threadId) {
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined });
}
res.json({ ok: true });
}),
canvasRoute("POST", "/agent/codex/turn", (req, res) => {
const body = jsonRecord(req);
const attachments = Array.isArray(body.attachments)
? body.attachments as AgentAttachment[]
: [];
const workspace = ensureCanvasWorkspace(config, String(body.canvasId || ""));
let threadId = String(body.threadId || workspace.activeThreadId || "");
void (async () => {
if (!threadId) {
const thread = await startCodexThread(emit, workspace.workspacePath);
threadId = String((thread as Record<string, unknown>).id || "");
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
} else if (threadId !== workspace.activeThreadId) {
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
}
void runCodexTurn(
withAgentPrompt(String(body.prompt || "")),
emit,
attachments,
{
threadId,
cwd: workspace.workspacePath,
onThreadId: (nextThreadId) => updateCanvasWorkspace(
config,
workspace.canvasId,
{ activeThreadId: nextThreadId },
),
},
);
if (!res.headersSent) res.json({ ok: true, threadId });
})().catch((error) => {
if (!res.headersSent) res.status(500).json({ ok: false, error: publicCanvasError(error) });
});
}),
canvasRoute("POST", "/agent/claude/turn", (req, res) => {
const body = jsonRecord(req);
runClaudeTurn(withAgentPrompt(String(body.prompt || "")), emit);
res.json({ ok: true });
}),
];
return {
descriptor: {
id: "canvas-agent",
displayName: "Canvas Agent",
apiVersion: 1,
scopes: ["canvas:connect"],
},
routes,
onRuntimeSessionRevoked: (sessionId) => session.closeRuntimeSession(sessionId),
publicHealth: () => {
const { ok: _ok, ...health } = session.health();
return health;
},
dispose: () => session.dispose(),
};
}
function runtimeSessionId(res: Response) {
const value = (res.locals.runtimeSession as { sessionId?: unknown } | undefined)?.sessionId;
return typeof value === "string" && value ? value : undefined;
}
function canvasRoute(
method: "GET" | "POST",
path: string,
handler: (req: Request, res: Response) => void | Promise<void>,
options: { queryKeys?: readonly string[]; lastEventId?: boolean } = {},
): LocalRuntimeProtectedRoute {
return {
method,
path,
scope: "canvas:connect",
handler: route(handler),
legacy: true,
...options,
};
}
function route(handler: (req: Request, res: Response) => void | Promise<void>): RequestHandler {
return (req, res, next) => void Promise.resolve(handler(req, res)).catch(next);
}
function jsonBody(req: Request) {
if (!Buffer.isBuffer(req.body)) throw new Error("Canvas request body is invalid");
return JSON.parse(req.body.toString("utf8")) as unknown;
}
function jsonRecord(req: Request) {
const value = jsonBody(req);
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Canvas request body is invalid");
}
return value as Record<string, unknown>;
}
function queryValue(req: Request, key: string) {
const value = req.query[key];
return Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");
}
function routeParam(value: string | string[]) {
return Array.isArray(value) ? value[0] || "" : value;
}
function publicCanvasError(_error: unknown) {
return "Canvas Agent request failed";
}

View File

@@ -0,0 +1,712 @@
import path from "node:path";
import type { Request, RequestHandler, Response } from "express";
import { CONFIG_DIR } from "../config.js";
import { DreaminaCliArbiter, type DreaminaCliSessionSnapshot } from "../dreamina-cli-arbiter.js";
import { dreaminaCliInputSchema, type DreaminaGenerationInput } from "../dreamina-cli-contract.js";
import {
DreaminaGenerationAdapter,
LocalDreaminaGenerationError,
type DreaminaGenerationResult,
} from "../dreamina-generation.js";
import { projectDreaminaModelCatalog } from "../dreamina-model-catalog.js";
import { DreaminaProviderArtifactStore } from "../dreamina-provider-artifacts.js";
import { projectDreaminaPublicRunError, projectDreaminaPublicRuntimeResult } from "../dreamina-public-result.js";
import { DreaminaCliError, isStableDreaminaErrorCode } from "../dreamina-cli-process.js";
import { DreaminaTaskProjector, readSafeJournal } from "../dreamina-task-projection.js";
import { DreaminaTaskStore, type DreaminaStoredTask } from "../dreamina-task-store.js";
import {
DreaminaCliRuntime,
type DreaminaCliRuntimeOptions,
type DreaminaPublicGenerationTask,
type DreaminaRuntimeResult,
} from "../dreamina-cli-runtime.js";
import { DreaminaCliService, type DreaminaPublicStatus } from "../dreamina-cli.js";
import type { LocalRuntimeModule } from "../local-runtime.js";
export type DreaminaHttpModuleOptions = {
ownerId: string;
dreamina?: Pick<DreaminaCliService, "status" | "login" | "logout"> & Partial<Pick<DreaminaCliService, "statusWithSession">>;
dreaminaRuntime?: {
run(input: unknown, options?: { signal?: AbortSignal }): Promise<DreaminaRuntimeResult>;
generateToResult(input: unknown, options?: { signal?: AbortSignal; requestFingerprint?: string }): Promise<DreaminaGenerationResult>;
resumeToResult(idempotencyKey: string, mode: "image" | "video", options?: { signal?: AbortSignal }): Promise<DreaminaGenerationResult>;
getTask(idempotencyKey: string, options?: { signal?: AbortSignal }): Promise<DreaminaPublicGenerationTask>;
waitForTask(idempotencyKey: string, mode: "image" | "video", options?: { signal?: AbortSignal }): Promise<DreaminaGenerationResult>;
refreshTask(idempotencyKey: string, options?: { signal?: AbortSignal }): Promise<DreaminaPublicGenerationTask>;
listTasks(options?: { signal?: AbortSignal }): Promise<DreaminaPublicGenerationTask[]>;
listTaskPage?(options?: { limit?: number; cursor?: string; projectId?: string; activeOnly?: boolean; signal?: AbortSignal }): Promise<{ tasks: DreaminaPublicGenerationTask[]; nextCursor?: string }>;
cancelTask(idempotencyKey: string): Promise<DreaminaPublicGenerationTask>;
deleteTask(idempotencyKey: string): Promise<{ deleted: true }>;
enqueue(input: unknown, options?: { signal?: AbortSignal; requestFingerprint?: string }): Promise<DreaminaPublicGenerationTask>;
start?(): Promise<void>;
dispose?(): Promise<void>;
};
generation?: {
run(input: unknown, options?: { signal?: AbortSignal }): Promise<DreaminaGenerationResult>;
submit(input: unknown, options?: { signal?: AbortSignal }): Promise<DreaminaPublicGenerationTask>;
};
configDir?: string;
referenceRoots?: () => readonly string[];
runtimeDependencies?: Partial<Pick<DreaminaCliRuntimeOptions,
"discover" | "runProcess" | "now" | "sleep" | "maxPollAttempts" | "pollIntervalMs">>;
arbiter?: DreaminaCliArbiter;
};
type ProviderTaskMetadata = {
receiptRecorded: boolean;
fenceEpoch?: number;
};
export function createDreaminaHttpModule(options: DreaminaHttpModuleOptions): LocalRuntimeModule {
const configDir = options.configDir ?? CONFIG_DIR;
const arbiter = options.arbiter ?? new DreaminaCliArbiter({
stateFile: path.join(configDir, "dreamina-cli-arbiter.json"),
});
const dreamina = options.dreamina ?? new DreaminaCliService({ ownerId: options.ownerId, arbiter });
const generationRoot = path.join(configDir, "dreamina-generation-inputs");
const journalFile = path.join(configDir, "dreamina-runtime-state.json");
const taskStore = new DreaminaTaskStore({ stateFile: path.join(configDir, "dreamina-generation-task-store.json") });
const taskProjector = new DreaminaTaskProjector({ store: taskStore, ownerId: options.ownerId, journalFile });
const artifactStore = new DreaminaProviderArtifactStore({ root: path.join(configDir, "dreamina-provider-artifacts") });
let projectionTail = Promise.resolve();
const recoverTaskProjection = () => {
const next = projectionTail.catch(() => undefined).then(() => taskProjector.recover());
projectionTail = next;
return next;
};
const withTaskProjection = async <T>(action: () => Promise<T>) => {
// Recovery before the action is a hard gate. Once the action succeeds, its provider
// journal is the durable recovery fact; a projection-only failure must not turn that
// success into a retry-looking HTTP failure or replay a paid mutation.
await recoverTaskProjection();
const result = await action();
try {
await recoverTaskProjection();
} catch {
// Keep the failed projection tail durable in memory; the next task read retries
// recovery from the provider journal before returning task state.
}
return result;
};
const dreaminaRuntime = options.dreaminaRuntime ?? new DreaminaCliRuntime({
...options.runtimeDependencies,
ownerId: options.ownerId,
arbiter,
stateFile: journalFile,
taskStore,
taskProjector,
artifactStore,
referenceRoots: () => [
generationRoot,
...(options.referenceRoots?.() ?? [path.join(configDir, "codex-workspaces")]),
],
ensureReady: async (signal) => {
if (signal?.aborted) throw cancelled();
const observed = dreamina.statusWithSession
? await dreamina.statusWithSession({ signal })
: { status: await dreamina.status({ signal }), session: await arbiter.readSession(signal) };
if (signal?.aborted) throw cancelled();
if (!observed.status.authenticated) {
throw new DreaminaCliError("dreamina_login_required", "Dreamina CLI 需要先登录", 401);
}
return observed.session;
},
});
const generation = options.generation ?? new DreaminaGenerationAdapter({
root: generationRoot,
models: projectDreaminaModelCatalog(),
runtime: dreaminaRuntime,
});
const providerMetadata = async (idempotencyKey: string): Promise<ProviderTaskMetadata | undefined> => {
const record = (await readSafeJournal(journalFile, options.ownerId)).find((candidate) => candidate.recordId === idempotencyKey);
return record ? {
receiptRecorded: record.hasProviderTask,
...(record.fenceEpoch ? { fenceEpoch: record.fenceEpoch } : {}),
} : undefined;
};
const publicTask = async (idempotencyKey: string, metadata?: ProviderTaskMetadata) => {
const task = await taskStore.getTask(`dreamina:${idempotencyKey}`);
if (!task || task.visibility === "deleted") throw taskNotFound();
return projectPublicTask(task, metadata ?? await providerMetadata(idempotencyKey), artifactStore, options.ownerId);
};
const publicTaskPage = async (query: GenerationListQuery) => {
const visible = (await taskStore.listTasks())
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)
|| right.journalRecordId.localeCompare(left.journalRecordId))
.filter((task) => !query.projectId || (task.context.scope === "scoped" && task.context.projectId === query.projectId))
.filter((task) => !query.activeOnly || task.lifecycle !== "TERMINAL");
const cursor = query.cursor ? decodeStoreTaskCursor(query.cursor) : undefined;
const afterCursor = visible.filter((task) => !cursor || task.updatedAt < cursor.updatedAt
|| (task.updatedAt === cursor.updatedAt && task.journalRecordId < cursor.id));
const selected = afterCursor.slice(0, query.limit);
const metadata = new Map((await readSafeJournal(journalFile, options.ownerId)).map((record) => [record.recordId, {
receiptRecorded: record.hasProviderTask,
...(record.fenceEpoch ? { fenceEpoch: record.fenceEpoch } : {}),
}]));
const tasks = await Promise.all(selected.map((task) => projectPublicTask(task, metadata.get(task.journalRecordId), artifactStore, options.ownerId, false)));
const last = selected.at(-1);
return {
tasks,
...(last && afterCursor.length > selected.length ? { nextCursor: encodeStoreTaskCursor(last.updatedAt, last.journalRecordId) } : {}),
};
};
const withProjectedTaskAction = async (
idempotencyKey: string,
action: () => Promise<Pick<DreaminaPublicGenerationTask, "receiptRecorded">>,
) => {
await recoverTaskProjection();
const metadata = await action();
await recoverTaskProjection();
return publicTask(idempotencyKey, await providerMetadata(idempotencyKey) ?? metadata);
};
const submitProductTask = async (input: unknown, signal: AbortSignal) => {
await recoverTaskProjection();
const metadata = await generation.submit(input, { signal });
try {
await recoverTaskProjection();
} catch {
// The paid boundary may already have been crossed. Preserve the provider action
// response so callers never interpret a projection-only failure as permission to retry.
return metadata;
}
// Artifact binding and Store projection errors are not projection-tail failures and must
// never be hidden by falling back to a provider-journal DTO.
return publicTask(metadata.id, await providerMetadata(metadata.id) ?? metadata);
};
return {
descriptor: {
id: "dreamina",
displayName: "Dreamina CLI",
apiVersion: 1,
scopes: ["dreamina:status", "dreamina:login", "dreamina:logout", "dreamina:run", "dreamina:models", "dreamina:generate"],
},
routes: [
{
method: "GET",
path: "/dreamina/status",
scope: "dreamina:status",
handler: lifecycleHandler((signal) => dreamina.status({ signal })),
},
{
method: "POST",
path: "/dreamina/login",
scope: "dreamina:login",
handler: lifecycleHandler((signal) => dreamina.login({ signal }), true),
},
{
method: "POST",
path: "/dreamina/logout",
scope: "dreamina:logout",
handler: lifecycleHandler((signal) => dreamina.logout({ signal }), true),
},
{
method: "POST",
path: "/dreamina/run",
scope: "dreamina:run",
legacy: true,
handler: runHandler((input, signal) => withTaskProjection(() => dreaminaRuntime.run(input, { signal }))),
},
{
method: "GET",
path: "/dreamina/models",
scope: "dreamina:models",
handler: modelsHandler((signal) => arbiter.readSession(signal)),
},
{
method: "POST",
path: "/dreamina/generate",
scope: "dreamina:generate",
handler: generationHandler((input, signal) => submitProductTask(input, signal)),
},
{
method: "GET",
path: "/dreamina/generate/tasks",
scope: "dreamina:generate",
queryKeys: ["activeOnly", "cursor", "limit", "projectId"],
handler: generationListHandler(async (query, signal) => {
await recoverTaskProjection();
if (signal.aborted) throw cancelled();
return publicTaskPage(query);
}),
},
{
method: "POST",
path: "/dreamina/generate/query",
scope: "dreamina:generate",
handler: generationHandler((input, signal) => {
const recovery = parseGenerationRecovery(input);
return withProjectedTaskAction(recovery.idempotencyKey, () => (
dreaminaRuntime.getTask(recovery.idempotencyKey, { signal })
));
}),
},
{
method: "POST",
path: "/dreamina/generate/wait",
scope: "dreamina:generate",
handler: generationHandler((input, signal) => {
const recovery = parseGenerationRecovery(input);
return withProjectedTaskAction(recovery.idempotencyKey, async () => {
const current = await dreaminaRuntime.getTask(recovery.idempotencyKey, { signal });
await dreaminaRuntime.waitForTask(recovery.idempotencyKey, recovery.mode ?? current.mode, { signal });
return dreaminaRuntime.getTask(recovery.idempotencyKey, { signal });
});
}),
},
{
method: "POST",
path: "/dreamina/generate/refresh",
scope: "dreamina:generate",
handler: generationHandler((input, signal) => (
withProjectedTaskAction(parseGenerationTaskId(input), () => (
dreaminaRuntime.refreshTask(parseGenerationTaskId(input), { signal })
))
)),
},
{
method: "POST",
path: "/dreamina/generate/cancel",
scope: "dreamina:generate",
handler: generationHandler((input) => withProjectedTaskAction(parseGenerationTaskId(input), () => (
dreaminaRuntime.cancelTask(parseGenerationTaskId(input))
))),
},
{
method: "POST",
path: "/dreamina/generate/delete",
scope: "dreamina:generate",
handler: generationHandler((input) => withTaskProjection(() => dreaminaRuntime.deleteTask(parseGenerationTaskId(input)))),
},
{
method: "POST",
path: "/dreamina/generate/effects/claim",
scope: "dreamina:generate",
handler: generationHandler((input) => withTaskProjection(() => (
taskStore.claimProductEffect(parseProductEffectClaim(input))
))),
},
{
method: "POST",
path: "/dreamina/generate/effects/renew",
scope: "dreamina:generate",
handler: generationHandler((input) => taskStore.renewProductEffect(parseProductEffectRenewal(input))),
},
{
method: "POST",
path: "/dreamina/generate/effects/complete",
scope: "dreamina:generate",
handler: generationHandler(async (input) => ({
completed: await taskStore.completeProductEffect(parseProductEffectCompletion(input)),
})),
},
{
method: "POST",
path: "/dreamina/generate/effects/release",
scope: "dreamina:generate",
handler: generationHandler(async (input) => ({
released: await taskStore.releaseProductEffect(parseProductEffectRelease(input)),
})),
},
],
start: () => dreaminaRuntime.start?.(),
dispose: () => dreaminaRuntime.dispose?.(),
};
}
async function projectPublicTask(
task: DreaminaStoredTask,
metadata: ProviderTaskMetadata | undefined,
artifactStore: DreaminaProviderArtifactStore,
ownerId: string,
includeResult = true,
): Promise<DreaminaPublicGenerationTask & Pick<DreaminaStoredTask,
"lifecycle" | "terminalOutcome" | "syncState" | "resultState" | "outputs" | "accountBinding" | "context"
>> {
if (!task.mode || !task.operation || !task.model || !task.createdAt) throw new Error("Dreamina product task is incomplete");
const state = productPublicState(task);
const result = includeResult && task.terminalOutcome === "SUCCEEDED" && task.outputs.length > 0
? await artifactStore.readResult(task.outputs.map((output) => {
if ((output.mediaType !== "image" && output.mediaType !== "video") || !output.providerArtifactRef) {
throw new Error("Dreamina product artifact metadata is invalid");
}
return { outputIndex: output.outputIndex, mediaType: output.mediaType, providerArtifactRef: output.providerArtifactRef };
}), {
ownerId,
idempotencyKey: task.journalRecordId,
...(task.accountBinding ? { accountBinding: task.accountBinding } : {}),
...(metadata?.fenceEpoch ? { fenceEpoch: metadata.fenceEpoch } : {}),
mode: task.mode,
})
: undefined;
return {
id: task.journalRecordId,
...(task.clientOperationId !== task.journalRecordId ? { clientOperationId: task.clientOperationId } : {}),
context: task.context,
provider: "dreamina-cli",
mode: task.mode,
operation: task.operation as DreaminaGenerationInput["operation"],
model: task.model,
...state,
receiptRecorded: metadata?.receiptRecorded ?? (
task.lifecycle === "ACCEPTED" || task.lifecycle === "RUNNING" || task.lifecycle === "TERMINAL"
),
...(task.lastSyncErrorCode ? { errorCode: task.lastSyncErrorCode } : {}),
...(task.officialStatus ? { officialStatus: task.officialStatus } : {}),
lifecycle: task.lifecycle,
...(task.terminalOutcome ? { terminalOutcome: task.terminalOutcome } : {}),
syncState: task.syncState,
resultState: task.resultState,
outputs: task.outputs.map((output) => ({ ...output })),
...(task.accountBinding ? { accountBinding: task.accountBinding } : {}),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
...(result ? { result } : {}),
};
}
function productPublicState(task: DreaminaStoredTask): Pick<DreaminaPublicGenerationTask, "status" | "stage" | "progress"> {
if (task.lifecycle === "QUEUED_LOCAL") return { status: "queued", stage: "queued", progress: 0 };
if (task.lifecycle === "SUBMITTING") return { status: "running", stage: "submitting" };
if (task.lifecycle === "SUBMISSION_UNCERTAIN") return { status: "failed", stage: "submission_unknown" };
if (task.lifecycle === "ACCEPTED") return { status: "running", stage: "submitted" };
if (task.lifecycle === "RUNNING") return { status: "running", stage: "generating" };
if (task.terminalOutcome === "SUCCEEDED") return { status: "succeeded", stage: "succeeded", progress: 100 };
if (task.terminalOutcome === "CANCELLED") return { status: "cancelled", stage: "cancelled" };
return { status: "failed", stage: "failed" };
}
function encodeStoreTaskCursor(updatedAt: string, id: string) {
return Buffer.from(JSON.stringify([updatedAt, id]), "utf8").toString("base64url");
}
function decodeStoreTaskCursor(cursor: string) {
try {
if (!/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) throw new Error("invalid cursor");
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown;
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string"
|| !Number.isFinite(Date.parse(value[0])) || !/^[A-Za-z0-9._:-]{8,160}$/.test(value[1])) throw new Error("invalid cursor");
return { updatedAt: value[0], id: value[1] };
} catch {
throw requestInvalid();
}
}
function modelsHandler(session: (signal: AbortSignal) => Promise<DreaminaCliSessionSnapshot>): RequestHandler {
return (req, res) => { void runModels(req, res, session); };
}
async function runModels(
req: Request,
res: Response,
session: (signal: AbortSignal) => Promise<DreaminaCliSessionSnapshot>,
) {
const controller = requestController(req, res);
try {
const result = await session(controller.signal);
if (!result.accountBinding) throw loginRequired();
if (!cannotRespond(controller, res)) {
res.json({
ok: true,
provider: "dreamina-cli",
accountBinding: result.accountBinding,
sessionEpoch: result.sessionEpoch,
models: projectDreaminaModelCatalog(),
});
}
} catch (error) {
if (!cannotRespond(controller, res)) sendDreaminaError(res, error);
} finally {
removeRequestController(req, res, controller);
}
}
function lifecycleHandler(
action: (signal: AbortSignal) => Promise<DreaminaPublicStatus>,
mutation = false,
): RequestHandler {
return (req, res) => { void runLifecycle(req, res, action, mutation); };
}
function runHandler(
action: (input: unknown, signal: AbortSignal) => Promise<DreaminaRuntimeResult>,
): RequestHandler {
return (req, res) => { void runDreamina(req, res, action); };
}
function generationHandler(
action: (input: unknown, signal: AbortSignal) => Promise<unknown>,
): RequestHandler {
return (req, res) => { void runGeneration(req, res, action); };
}
type GenerationListQuery = { limit: number; cursor?: string; projectId?: string; activeOnly?: boolean };
function generationListHandler(action: (query: GenerationListQuery, signal: AbortSignal) => Promise<unknown>): RequestHandler {
return (req, res) => { void runGenerationList(req, res, action); };
}
async function runGenerationList(
req: Request,
res: Response,
action: (query: GenerationListQuery, signal: AbortSignal) => Promise<unknown>,
) {
const controller = requestController(req, res);
try {
const result = await action(parseGenerationListQuery(req.query), controller.signal);
if (!cannotRespond(controller, res)) res.json({ ok: true, result });
} catch (error) {
if (!cannotRespond(controller, res)) sendDreaminaError(res, error);
} finally {
removeRequestController(req, res, controller);
}
}
function parseGenerationListQuery(value: unknown): GenerationListQuery {
const query = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const limit = Number(typeof query.limit === "string" ? query.limit : "50");
const cursor = typeof query.cursor === "string" && query.cursor ? query.cursor : undefined;
const projectId = typeof query.projectId === "string" && query.projectId.trim() ? query.projectId.trim() : undefined;
if (!Number.isInteger(limit) || limit < 1 || limit > 100 || (cursor && cursor.length > 512) || (projectId && projectId.length > 160)) {
throw new DreaminaCliError("dreamina_request_invalid", "Dreamina 请求参数无效", 400);
}
return {
limit,
...(cursor ? { cursor } : {}),
...(projectId ? { projectId } : {}),
...(query.activeOnly === "true" ? { activeOnly: true } : {}),
};
}
async function runLifecycle(
req: Request,
res: Response,
action: (signal: AbortSignal) => Promise<DreaminaPublicStatus>,
mutation: boolean,
) {
const controller = requestController(req, res);
try {
if (mutation) assertEmptyJsonBody(req.body);
const status = await action(controller.signal);
if (cannotRespond(controller, res)) return;
res.json({ ok: true, status });
} catch (error) {
if (!cannotRespond(controller, res)) sendDreaminaError(res, error);
} finally {
removeRequestController(req, res, controller);
}
}
async function runDreamina(
req: Request,
res: Response,
action: (input: unknown, signal: AbortSignal) => Promise<DreaminaRuntimeResult>,
) {
const controller = requestController(req, res);
try {
const result = await action(parseRunBody(req.body), controller.signal);
if (cannotRespond(controller, res)) return;
res.json({ ok: true, result: projectDreaminaPublicRuntimeResult(result) });
} catch (error) {
if (!cannotRespond(controller, res)) sendDreaminaPublicRunError(res, error);
} finally {
removeRequestController(req, res, controller);
}
}
async function runGeneration(
req: Request,
res: Response,
action: (input: unknown, signal: AbortSignal) => Promise<unknown>,
) {
const controller = requestController(req, res);
try {
const result = await action(parseJsonBody(req.body), controller.signal);
if (!cannotRespond(controller, res)) res.json({ ok: true, result });
} catch (error) {
if (!cannotRespond(controller, res)) sendDreaminaError(res, error);
} finally {
removeRequestController(req, res, controller);
}
}
function parseGenerationTaskId(value: unknown) {
const source = record(value);
if (Object.keys(source).length !== 1
|| typeof source.idempotencyKey !== "string"
|| !/^[A-Za-z0-9._:-]{16,120}$/.test(source.idempotencyKey)) throw requestInvalid();
return source.idempotencyKey;
}
function parseProductEffectClaim(value: unknown) {
const source = record(value);
if (Object.keys(source).some((key) => !["consumerId", "taskId", "effectKey", "leaseMs"].includes(key))
|| typeof source.consumerId !== "string"
|| typeof source.taskId !== "string"
|| typeof source.effectKey !== "string"
|| (source.leaseMs !== undefined && !Number.isSafeInteger(source.leaseMs))) throw requestInvalid();
return {
consumerId: source.consumerId,
taskId: source.taskId,
effectKey: source.effectKey,
...(typeof source.leaseMs === "number" ? { leaseMs: source.leaseMs } : {}),
};
}
function parseProductEffectRenewal(value: unknown) {
const source = record(value);
if (Object.keys(source).some((key) => !["consumerId", "taskId", "effectKey", "leaseToken", "fence", "leaseMs"].includes(key))
|| typeof source.consumerId !== "string"
|| typeof source.taskId !== "string"
|| typeof source.effectKey !== "string"
|| typeof source.leaseToken !== "string"
|| !Number.isSafeInteger(source.fence)
|| (source.leaseMs !== undefined && !Number.isSafeInteger(source.leaseMs))) throw requestInvalid();
return {
consumerId: source.consumerId,
taskId: source.taskId,
effectKey: source.effectKey,
leaseToken: source.leaseToken,
fence: source.fence as number,
...(typeof source.leaseMs === "number" ? { leaseMs: source.leaseMs } : {}),
};
}
function parseProductEffectCompletion(value: unknown) {
const source = record(value);
const result = record(source.result);
if (Object.keys(source).some((key) => !["consumerId", "taskId", "effectKey", "leaseToken", "fence", "result"].includes(key))
|| Object.keys(result).some((key) => key !== "materializedAssetId")
|| typeof source.consumerId !== "string"
|| typeof source.taskId !== "string"
|| typeof source.effectKey !== "string"
|| typeof source.leaseToken !== "string"
|| !Number.isSafeInteger(source.fence)
|| (result.materializedAssetId !== undefined && typeof result.materializedAssetId !== "string")) throw requestInvalid();
return {
consumerId: source.consumerId,
taskId: source.taskId,
effectKey: source.effectKey,
leaseToken: source.leaseToken,
fence: source.fence as number,
result: {
...(typeof result.materializedAssetId === "string"
? { materializedAssetId: result.materializedAssetId }
: {}),
},
};
}
function parseProductEffectRelease(value: unknown) {
const source = record(value);
if (Object.keys(source).some((key) => !["consumerId", "taskId", "effectKey", "leaseToken", "fence"].includes(key))
|| typeof source.consumerId !== "string"
|| typeof source.taskId !== "string"
|| typeof source.effectKey !== "string"
|| typeof source.leaseToken !== "string"
|| !Number.isSafeInteger(source.fence)) throw requestInvalid();
return {
consumerId: source.consumerId,
taskId: source.taskId,
effectKey: source.effectKey,
leaseToken: source.leaseToken,
fence: source.fence as number,
};
}
type RequestController = AbortController & { cancel: () => void };
function requestController(req: Request, res: Response): RequestController {
const controller = new AbortController() as RequestController;
controller.cancel = () => controller.abort();
req.once("aborted", controller.cancel);
res.once("close", controller.cancel);
return controller;
}
function removeRequestController(req: Request, res: Response, controller: RequestController) {
req.removeListener("aborted", controller.cancel);
res.removeListener("close", controller.cancel);
}
function cannotRespond(controller: AbortController, res: Response) {
return controller.signal.aborted || res.destroyed || res.writableEnded;
}
function assertEmptyJsonBody(value: unknown) {
if (Buffer.isBuffer(value)) {
try {
const body = JSON.parse(value.toString("utf8")) as unknown;
if (body && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0) return;
} catch {
// Use one stable public error for malformed and non-empty lifecycle bodies.
}
}
throw requestInvalid();
}
function parseRunBody(value: unknown) {
if (!Buffer.isBuffer(value)) throw requestInvalid();
let parsed: unknown;
try { parsed = JSON.parse(value.toString("utf8")); } catch { throw requestInvalid(); }
const result = dreaminaCliInputSchema.safeParse(parsed);
if (!result.success || result.data.operation === "query_result") throw requestInvalid();
return result.data;
}
function parseJsonBody(value: unknown): unknown {
if (!Buffer.isBuffer(value)) throw requestInvalid();
try { return JSON.parse(value.toString("utf8")) as unknown; } catch { throw requestInvalid(); }
}
function parseGenerationRecovery(value: unknown): { idempotencyKey: string; mode?: "image" | "video" } {
const source = record(value);
const mode = source.mode;
if (!([1, 2].includes(Object.keys(source).length))
|| typeof source.idempotencyKey !== "string"
|| !/^[A-Za-z0-9._:-]{16,120}$/.test(source.idempotencyKey)
|| (mode !== undefined && mode !== "image" && mode !== "video")
|| Object.keys(source).some((key) => key !== "idempotencyKey" && key !== "mode")) throw requestInvalid();
return { idempotencyKey: source.idempotencyKey, ...(mode ? { mode } : {}) };
}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw requestInvalid();
return value as Record<string, unknown>;
}
function sendDreaminaPublicRunError(res: Response, error: unknown) {
const projected = projectDreaminaPublicRunError(error);
res.status(projected.statusCode).json({
ok: false,
code: projected.code,
message: projected.message,
});
}
function sendDreaminaError(res: Response, error: unknown) {
const stable = (error instanceof DreaminaCliError && isStableDreaminaErrorCode(error.code))
|| error instanceof LocalDreaminaGenerationError;
res.status(stable ? error.statusCode : 500).json({
ok: false,
code: stable ? error.code : "dreamina_internal_error",
message: stable ? error.message : "Dreamina CLI 请求失败",
});
}
function requestInvalid() {
return new DreaminaCliError("dreamina_request_invalid", "Dreamina 请求参数无效", 400);
}
function taskNotFound() {
return new DreaminaCliError("dreamina_task_not_found", "Dreamina generation task was not found", 404);
}
function loginRequired() {
return new DreaminaCliError("dreamina_login_required", "Dreamina CLI 需要先登录", 401);
}
function cancelled() {
return new DreaminaCliError("dreamina_cancelled", "Dreamina 操作已取消", 499);
}

View File

@@ -0,0 +1,147 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { LocalRuntimeConfig } from "../config.js";
import {
dreaminaGenerationInputSchema,
dreaminaMcpToolShape,
type DreaminaGenerationInput,
} from "../dreamina-cli-contract.js";
import {
parseDreaminaPublicRunError,
parseDreaminaPublicRuntimeResult,
} from "../dreamina-public-result.js";
type RuntimeConfig = Pick<LocalRuntimeConfig, "url" | "token">;
type DreaminaMcpRequestOptions = { signal?: AbortSignal; timeoutMs?: number };
const MCP_DEADLINE_MS = 180_000;
const MCP_RESPONSE_LIMIT_BYTES = 256 * 1024;
export type DreaminaMcpDependencies = {
postPublicTool?: typeof postDreaminaCliTool;
};
export function registerDreaminaMcp(
server: McpServer,
config: RuntimeConfig,
dependencies: DreaminaMcpDependencies = {},
) {
const postPublicTool = dependencies.postPublicTool ?? postDreaminaCliTool;
server.registerTool("dreamina_cli", {
description: "仅当用户明确要求使用 Dreamina 本机 OAuth CLI 时调用。生成会消耗 Dreamina credits;不得替代宿主自定义渠道或火山即梦 AK/SK API。For image generation with automatic resolution, omit resolutionType instead of inventing a tier or passing auto; image_upscale still requires an explicit tier.",
inputSchema: dreaminaMcpToolShape.shape,
}, async (input: unknown, extra) => {
const parsedInput = dreaminaGenerationInputSchema.parse(input);
if (extra.signal?.aborted) throw publicError("dreamina_cancelled");
let unsafeResult;
try {
unsafeResult = await postPublicTool(config, parsedInput, { signal: extra.signal });
} catch {
throw publicError("dreamina_submission_unknown");
}
let result;
try {
result = parseDreaminaPublicRuntimeResult(unsafeResult);
} catch {
throw publicError("dreamina_submission_unknown");
}
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
});
}
export async function postDreaminaCliTool(
config: RuntimeConfig,
input: DreaminaGenerationInput,
options: DreaminaMcpRequestOptions = {},
) {
if (options.signal?.aborted) throw publicError("dreamina_cancelled");
const controller = new AbortController();
const timeoutMs = Math.max(1, Math.min(options.timeoutMs ?? MCP_DEADLINE_MS, MCP_DEADLINE_MS));
const cancel = () => controller.abort();
options.signal?.addEventListener("abort", cancel, { once: true });
const deadline = setTimeout(cancel, timeoutMs);
deadline.unref();
let dispatched = false;
let statusCode = 0;
let body: unknown;
try {
const responsePromise = fetch(`${config.url}/dreamina/run`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-canvas-agent-token": config.token,
},
body: JSON.stringify(input),
signal: controller.signal,
redirect: "manual",
});
dispatched = true;
const response = await responsePromise;
statusCode = response.status;
body = await readBoundedJson(response);
} catch {
throw publicError(dispatched ? "dreamina_submission_unknown" : "dreamina_internal_error");
} finally {
clearTimeout(deadline);
options.signal?.removeEventListener("abort", cancel);
}
const envelope = parsePublicEnvelope(statusCode, body);
if (!envelope.ok) throw publicError(envelope.code);
return envelope.result;
}
async function readBoundedJson(response: Response): Promise<unknown> {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > MCP_RESPONSE_LIMIT_BYTES) throw new Error("response too large");
if (!response.body) throw new Error("missing response body");
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MCP_RESPONSE_LIMIT_BYTES) throw new Error("response too large");
chunks.push(Buffer.from(value));
}
} catch (error) {
await reader.cancel().catch(() => undefined);
throw error;
}
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid response");
return parsed;
}
function parsePublicEnvelope(statusCode: number, value: unknown):
| { ok: true; result: ReturnType<typeof parseDreaminaPublicRuntimeResult> }
| { ok: false; code: string } {
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, code: "dreamina_submission_unknown" };
const source = value as Record<string, unknown>;
const successStatus = statusCode >= 200 && statusCode < 300;
if (source.ok === true && exactKeys(source, ["ok", "result"])) {
if (!successStatus) return { ok: false, code: "dreamina_submission_unknown" };
try {
return { ok: true, result: parseDreaminaPublicRuntimeResult(source.result) };
} catch {
return { ok: false, code: "dreamina_submission_unknown" };
}
}
if (source.ok === false && exactKeys(source, ["ok", "code", "message"])) {
if (successStatus) return { ok: false, code: "dreamina_submission_unknown" };
const error = parseDreaminaPublicRunError(source.code, source.message);
return error.code !== "dreamina_submission_unknown" && error.statusCode === statusCode
? { ok: false, code: error.code }
: { ok: false, code: "dreamina_submission_unknown" };
}
return { ok: false, code: "dreamina_submission_unknown" };
}
function exactKeys(source: Record<string, unknown>, expected: string[]) {
const keys = Object.keys(source);
return keys.length === expected.length && expected.every((key) => Object.hasOwn(source, key));
}
function publicError(code: string) {
return new Error(`Dreamina CLI request failed (${code})`);
}