Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
468bb5579d | ||
|
|
9d2aa879e9 | ||
|
|
d9e39702e0 | ||
|
|
7de633d4c9 | ||
|
|
b995662388 |
@@ -16,7 +16,7 @@ const forbiddenDiagnosticPatterns = [
|
||||
/https?:\/\//i,
|
||||
];
|
||||
const safePauseReasons = new Set([
|
||||
"asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
||||
"asset_manifest_invalid", "asset_root_missing", "asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
||||
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
|
||||
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
|
||||
"worker_degraded", "worker_state_missing", "worker_stopped",
|
||||
|
||||
@@ -19,7 +19,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
||||
|
||||
const assetIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||
const fixedDirectories = [
|
||||
"db",
|
||||
"content/references",
|
||||
@@ -82,13 +82,21 @@ export function readConfiguredLocalDataRoot(configFile = defaultInstanceConfigPa
|
||||
return resolve(candidate);
|
||||
}
|
||||
|
||||
export function readConfiguredAssetRoot(configFile = defaultInstanceConfigPath()) {
|
||||
const configuration = JSON.parse(readFileSync(configFile, "utf8")) as Record<string, unknown>;
|
||||
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
|
||||
throw new Error("asset_root_configuration_invalid");
|
||||
}
|
||||
return resolve(configuration.asset_root);
|
||||
}
|
||||
|
||||
export interface ValidatedReadOnlyAssetRoot {
|
||||
absolute_root: string;
|
||||
ok: true;
|
||||
root_ref: string;
|
||||
}
|
||||
|
||||
interface PublicAssetEntry {
|
||||
export interface PublicAssetEntry {
|
||||
assetId: string;
|
||||
mimeType: string;
|
||||
relativePath: string;
|
||||
@@ -317,18 +325,19 @@ export function createPublicAssetResolver(input: {
|
||||
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
|
||||
const entries = new Map<string, PublicAssetEntry>();
|
||||
for (const entry of input.entries) {
|
||||
if (!assetIdPattern.test(entry.assetId) || entries.has(entry.assetId)) throw new Error("asset_id_invalid");
|
||||
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
|
||||
if (!assetIdPattern.test(entry.assetId) || entries.has(key)) throw new Error("asset_id_invalid");
|
||||
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
|
||||
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
|
||||
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
|
||||
entries.set(entry.assetId, { ...entry });
|
||||
entries.set(key, { ...entry });
|
||||
}
|
||||
|
||||
return {
|
||||
read(resourceVersion, assetId) {
|
||||
if (!assetIdPattern.test(assetId)) return undefined;
|
||||
const entry = entries.get(assetId);
|
||||
if (!entry || entry.resourceVersion !== resourceVersion) return undefined;
|
||||
const entry = entries.get(`${resourceVersion}\u0000${assetId}`);
|
||||
if (!entry) return undefined;
|
||||
const root = roots.get(entry.rootRef);
|
||||
if (!root) return undefined;
|
||||
let path: string;
|
||||
|
||||
+29
-2
@@ -5,7 +5,7 @@ import { registrationNotice } from "@dada/shared-contracts";
|
||||
|
||||
import { createApp } from "./app.js";
|
||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
||||
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot, type PublicAssetResolver } from "./local-data-root.js";
|
||||
import { ManagedStorage } from "./managed-storage.js";
|
||||
import { LatestExportService } from "./latest-exports.js";
|
||||
import { CreditService } from "./credits.js";
|
||||
@@ -16,10 +16,16 @@ import { MockResendAdapter } from "./resend-adapter.js";
|
||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||
import { ModelConfigurationService, portableRuntimeModelCandidates } from "./model-configuration.js";
|
||||
import { GenerationSubmissionService } from "./generation-submission.js";
|
||||
import {
|
||||
GenerationModelConfigurationCatalog,
|
||||
ModelConfigurationService,
|
||||
portableRuntimeModelCandidates,
|
||||
} from "./model-configuration.js";
|
||||
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
||||
import { StickerReleaseService } from "./sticker-releases.js";
|
||||
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
||||
import { loadConfiguredRuntimeAssets, type RuntimeAssetState } from "./runtime-assets.js";
|
||||
|
||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||
let registration: RegistrationService | undefined;
|
||||
@@ -28,8 +34,11 @@ let credits: CreditService | undefined;
|
||||
let storage: ManagedStorage | undefined;
|
||||
let latestExports: LatestExportService | undefined;
|
||||
let models: ModelConfigurationService | undefined;
|
||||
let generations: GenerationSubmissionService | undefined;
|
||||
let recentAssets: RecentAssetService | undefined;
|
||||
let stickers: StickerReleaseService | undefined;
|
||||
let publicAssets: PublicAssetResolver | undefined;
|
||||
let assetRootState: RuntimeAssetState | undefined;
|
||||
let amap: AmapAdapter = new MockAmapAdapter();
|
||||
let localTestAuth = false;
|
||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||
@@ -43,6 +52,13 @@ if (credentialChannelEnabled) {
|
||||
.digest();
|
||||
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||
ensureLocalDataRuntimeDirectories(dataRoot);
|
||||
const runtimeAssets = loadConfiguredRuntimeAssets({
|
||||
configFile: instanceConfigPath,
|
||||
dataRoot,
|
||||
trustedManifestPath: resolve("asset-metadata", "manifest.json"),
|
||||
});
|
||||
publicAssets = runtimeAssets.publicAssets;
|
||||
assetRootState = runtimeAssets.state;
|
||||
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||
registration = new RegistrationService({
|
||||
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
||||
@@ -59,6 +75,11 @@ if (credentialChannelEnabled) {
|
||||
stickers = new StickerReleaseService({ databasePath, storage });
|
||||
latestExports = new LatestExportService({ databasePath, storage });
|
||||
models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates });
|
||||
generations = new GenerationSubmissionService({
|
||||
credits,
|
||||
models: new GenerationModelConfigurationCatalog(models),
|
||||
storage,
|
||||
});
|
||||
recentAssets = new RecentAssetService({ database: registration.database });
|
||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||
} catch (error) {
|
||||
@@ -68,6 +89,8 @@ if (credentialChannelEnabled) {
|
||||
stickers = undefined;
|
||||
latestExports?.close();
|
||||
latestExports = undefined;
|
||||
generations?.close();
|
||||
generations = undefined;
|
||||
storage?.close();
|
||||
storage = undefined;
|
||||
credits?.close();
|
||||
@@ -88,6 +111,7 @@ const adminServicesStorage = registration
|
||||
database: registration.database,
|
||||
...(models ? { models } : {}),
|
||||
...(storage ? { storage } : {}),
|
||||
...(assetRootState ? { assetRoot: assetRootState } : {}),
|
||||
})
|
||||
: undefined;
|
||||
const adminDiagnostics = adminServicesStorage
|
||||
@@ -102,10 +126,12 @@ const app = await createApp({
|
||||
amap,
|
||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||
...(credits ? { credits } : {}),
|
||||
...(generations ? { generations } : {}),
|
||||
...(latestExports ? { latestExports } : {}),
|
||||
...(registration && localTestAuth ? { localTestAuth: true } : {}),
|
||||
...(models ? { models } : {}),
|
||||
...(projects ? { projects } : {}),
|
||||
...(publicAssets ? { publicAssets } : {}),
|
||||
...(registration ? { registration } : {}),
|
||||
...(recentAssets ? { recentAssets } : {}),
|
||||
...(stickers ? { stickers } : {}),
|
||||
@@ -125,6 +151,7 @@ if (controlPipeIndex >= 0) {
|
||||
await app.close();
|
||||
amap.dispose?.();
|
||||
latestExports?.close();
|
||||
generations?.close();
|
||||
credits?.close();
|
||||
projects?.close();
|
||||
registration?.close();
|
||||
|
||||
@@ -2,6 +2,8 @@ import { randomUUID, createHash } from "node:crypto";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
|
||||
import type { GenerationModelCatalog, GenerationModelSnapshot } from "./generation-submission.js";
|
||||
import { projectRatios } from "./projects.js";
|
||||
|
||||
export const modelIds = [
|
||||
"gemini-3.1-flash-image-preview",
|
||||
@@ -59,6 +61,45 @@ export interface ModelConfigurationView {
|
||||
models: ModelConfigView[];
|
||||
}
|
||||
|
||||
type ReadableModelConfiguration = Pick<ModelConfigurationService, "read">;
|
||||
|
||||
function generationRuntimeReason(reason: ModelRuntimeReason): GenerationModelSnapshot["runtimeAvailability"]["reason"] {
|
||||
if (reason === "gateway_balance_insufficient") return reason;
|
||||
if (reason === "contract_unverified" || reason === "contract_blocked") return "gateway_contract_invalid";
|
||||
if (reason === "available") return null;
|
||||
return "model_disabled";
|
||||
}
|
||||
|
||||
export class GenerationModelConfigurationCatalog implements GenerationModelCatalog {
|
||||
constructor(private readonly models: ReadableModelConfiguration) {}
|
||||
|
||||
readModel(modelId: string): GenerationModelSnapshot | undefined {
|
||||
const configuration = this.models.read();
|
||||
const model = configuration.models.find((entry) => entry.model_id === modelId);
|
||||
if (!model) return undefined;
|
||||
const supportedRatios = projectRatios.filter((ratio) => model.supported_ratios.includes(ratio));
|
||||
return {
|
||||
configSetVersion: configuration.config_set_version,
|
||||
configVersion: model.config_version,
|
||||
contractValidationStatus: model.contract_validation_status === "verified" ? "verified" : "unverified",
|
||||
creditCost: model.credit_cost,
|
||||
enabled: model.enabled,
|
||||
modelId: model.model_id,
|
||||
promptMaxLength: model.prompt_max_length,
|
||||
referenceLimits: {
|
||||
maxFileBytes: model.reference_limits.max_file_bytes,
|
||||
maxFiles: model.reference_limits.max_files,
|
||||
maxTotalBytes: model.reference_limits.max_total_bytes,
|
||||
},
|
||||
runtimeAvailability: {
|
||||
availableForNewJobs: model.runtime_availability.available_for_new_jobs,
|
||||
reason: generationRuntimeReason(model.runtime_availability.reason),
|
||||
},
|
||||
supportedRatios,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelConfigurationError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
createPublicAssetResolver,
|
||||
readConfiguredAssetRoot,
|
||||
validateReadOnlyAssetRoot,
|
||||
type PublicAssetEntry,
|
||||
type PublicAssetResolver,
|
||||
} from "./local-data-root.js";
|
||||
|
||||
const rootRef = "p0a_runtime_assets";
|
||||
const schemaVersion = "DadaRuntimeAssets/v1";
|
||||
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
||||
const shaPattern = /^[a-f0-9]{64}$/i;
|
||||
|
||||
export interface RuntimeAssetState {
|
||||
checked_at: string;
|
||||
configured: boolean;
|
||||
pause_reason: "asset_manifest_invalid" | "asset_root_missing" | "asset_root_state_missing" | null;
|
||||
status: "active" | "unavailable";
|
||||
}
|
||||
|
||||
export interface LoadedRuntimeAssets {
|
||||
publicAssets?: PublicAssetResolver;
|
||||
state: RuntimeAssetState;
|
||||
}
|
||||
|
||||
function parseRuntimeManifest(bytes: Buffer): PublicAssetEntry[] {
|
||||
const value = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
||||
if (value.schema_version !== schemaVersion || value.source !== "external_read_only" || value.root_ref !== rootRef) {
|
||||
throw new Error("runtime_asset_manifest_invalid");
|
||||
}
|
||||
if (!Array.isArray(value.entries) || value.entries.length === 0) throw new Error("runtime_asset_manifest_invalid");
|
||||
return value.entries.map((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("runtime_asset_manifest_invalid");
|
||||
const entry = candidate as Record<string, unknown>;
|
||||
if (
|
||||
typeof entry.assetId !== "string" || !assetIdPattern.test(entry.assetId)
|
||||
|| typeof entry.mimeType !== "string" || !/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)
|
||||
|| typeof entry.relativePath !== "string" || entry.relativePath.includes("\\") || entry.relativePath.split("/").includes("..")
|
||||
|| typeof entry.resourceVersion !== "string" || !releasePattern.test(entry.resourceVersion)
|
||||
|| entry.rootRef !== rootRef
|
||||
|| typeof entry.sha256 !== "string" || !shaPattern.test(entry.sha256)
|
||||
) throw new Error("runtime_asset_manifest_invalid");
|
||||
return entry as unknown as PublicAssetEntry;
|
||||
});
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
configured: boolean,
|
||||
pauseReason: Exclude<RuntimeAssetState["pause_reason"], null>,
|
||||
checkedAt: string,
|
||||
): LoadedRuntimeAssets {
|
||||
return { state: { checked_at: checkedAt, configured, pause_reason: pauseReason, status: "unavailable" } };
|
||||
}
|
||||
|
||||
export function loadConfiguredRuntimeAssets(input: {
|
||||
configFile: string;
|
||||
dataRoot: string;
|
||||
trustedManifestPath: string;
|
||||
clock?: () => number;
|
||||
}): LoadedRuntimeAssets {
|
||||
const checkedAt = new Date((input.clock ?? Date.now)()).toISOString();
|
||||
let assetRoot: string;
|
||||
try {
|
||||
assetRoot = readConfiguredAssetRoot(input.configFile);
|
||||
} catch {
|
||||
return unavailable(false, "asset_root_state_missing", checkedAt);
|
||||
}
|
||||
if (!existsSync(input.trustedManifestPath)) return unavailable(true, "asset_manifest_invalid", checkedAt);
|
||||
try {
|
||||
const trustedBytes = readFileSync(input.trustedManifestPath);
|
||||
const entries = parseRuntimeManifest(trustedBytes);
|
||||
const validatedRoot = validateReadOnlyAssetRoot({
|
||||
dataRoot: input.dataRoot,
|
||||
expectedSha256: createHash("sha256").update(trustedBytes).digest("hex"),
|
||||
manifestRelativePath: "manifest.json",
|
||||
root: assetRoot,
|
||||
rootRef,
|
||||
});
|
||||
if (!validatedRoot.ok) {
|
||||
return unavailable(true, validatedRoot.reason === "asset_root_missing" ? "asset_root_missing" : "asset_manifest_invalid", checkedAt);
|
||||
}
|
||||
return {
|
||||
publicAssets: createPublicAssetResolver({ entries, roots: [validatedRoot] }),
|
||||
state: { checked_at: checkedAt, configured: true, pause_reason: null, status: "active" },
|
||||
};
|
||||
} catch {
|
||||
return unavailable(true, "asset_manifest_invalid", checkedAt);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
.editor-page-shell {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
||||
background: #e8e8e5;
|
||||
color: #111111;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-page-shell :focus-visible {
|
||||
@@ -124,6 +127,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-assets-panel,
|
||||
@@ -542,13 +546,13 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
|
||||
.editor-page-shell { height: auto; min-height: 100dvh; grid-template-rows: auto minmax(0, 1fr) auto; overflow: visible; }
|
||||
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
|
||||
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
|
||||
.editor-history-actions { order: 3; }
|
||||
.editor-save-status { order: 4; flex: 1 1 128px; }
|
||||
.editor-toolbar-controls > button { display: block; order: 5; }
|
||||
.editor-layout { grid-template-columns: 1fr; }
|
||||
.editor-layout { grid-template-columns: 1fr; overflow: visible; }
|
||||
.editor-assets-panel, .editor-inspector { border: 0; }
|
||||
.editor-assets-panel { order: 2; }
|
||||
.editor-inspector { order: 3; }
|
||||
|
||||
@@ -160,6 +160,20 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
||||
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
function showNotice(message: string) {
|
||||
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
||||
setNotice(message);
|
||||
noticeTimerRef.current = setTimeout(() => {
|
||||
setNotice("");
|
||||
noticeTimerRef.current = undefined;
|
||||
}, 3_000);
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -175,7 +189,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
setDraftAdjustments(initial.background.adjustments);
|
||||
historyRef.current = new CanvasEditHistory(initial);
|
||||
elementControllerRef.current = new CanvasElementController(initial);
|
||||
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
||||
}).catch(() => { if (active) showNotice("编辑器暂时无法读取项目"); });
|
||||
return () => { active = false; };
|
||||
}, [projectId]);
|
||||
|
||||
@@ -291,7 +305,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
function applyPreview() {
|
||||
if (!canvasState) return;
|
||||
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
||||
setNotice("底图调整已提交");
|
||||
showNotice("底图调整已提交");
|
||||
}
|
||||
|
||||
function undo() {
|
||||
@@ -334,9 +348,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const palette = await paletteForAsset(pendingBackground);
|
||||
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
||||
setPendingBackground(undefined);
|
||||
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||
showNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||
} catch {
|
||||
setNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||
showNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +367,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
commitCanvas(next);
|
||||
setGuides([]);
|
||||
setCandidateMenu(undefined);
|
||||
setNotice(message);
|
||||
showNotice(message);
|
||||
}
|
||||
|
||||
function addSticker(sticker: StaticStickerCatalogItem) {
|
||||
@@ -369,8 +383,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
}));
|
||||
commitElementOperation(controller, "贴纸已加入画布");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("贴纸未能加入画布");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("贴纸未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,8 +397,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
|
||||
commitElementOperation(controller, "色卡已按原始底图加入画布");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +408,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
if (templateId === "DYN012") {
|
||||
const font = fontOption("FONT081");
|
||||
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
||||
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||
showNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -407,8 +421,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
commitElementOperation(controller, "动态值已确认并加入画布");
|
||||
setLocationDialog(undefined);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("动态贴纸未能加入画布");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("动态贴纸未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +470,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
||||
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
||||
} catch {
|
||||
setNotice("动态贴纸显示文字不能为空");
|
||||
showNotice("动态贴纸显示文字不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +494,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
if (!template.fontUrl || !canvasState) return;
|
||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||
if (status !== "ready") {
|
||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||
showNotice("素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
const controller = controllerForCurrent();
|
||||
@@ -490,8 +504,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
commitElementOperation(controller, "文字模板已加入画布");
|
||||
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("文字模板未能加入画布");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("文字模板未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +517,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
action(edit);
|
||||
return { ...current, draft: edit.value };
|
||||
} catch {
|
||||
setNotice("文字参数不在允许范围内");
|
||||
showNotice("文字参数不在允许范围内");
|
||||
return current;
|
||||
}
|
||||
});
|
||||
@@ -514,7 +528,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
if (!template?.fontUrl) return;
|
||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||
if (status !== "ready") {
|
||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||
showNotice("素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
||||
@@ -527,7 +541,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
}
|
||||
const option = fontOption(fontId);
|
||||
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
||||
setNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||
showNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
||||
@@ -546,8 +560,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
|
||||
else setNotice("文字编辑未能完成");
|
||||
if (error instanceof Error && error.message === "text_content_required") showNotice("请输入文字内容或删除该元素。");
|
||||
else showNotice("文字编辑未能完成");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,7 +570,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
|
||||
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
||||
}
|
||||
setNotice("已取消未提交的文字修改");
|
||||
showNotice("已取消未提交的文字修改");
|
||||
}
|
||||
|
||||
function pendingTextDraft() {
|
||||
@@ -624,7 +638,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
return;
|
||||
}
|
||||
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
||||
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
|
||||
if (!ran) showNotice("版本冲突时仅允许导出本页版本一次");
|
||||
}
|
||||
|
||||
async function retryExportDownload() {
|
||||
@@ -639,8 +653,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
action(controller);
|
||||
commitElementOperation(controller, message);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("对象操作未完成");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else showNotice("对象操作未完成");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,7 +682,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
opacityGestureRef.current = undefined;
|
||||
if (gesture.last === gesture.base) return;
|
||||
commitCanvas(gesture.last);
|
||||
setNotice("贴纸透明度已提交");
|
||||
showNotice("贴纸透明度已提交");
|
||||
}
|
||||
|
||||
function duplicateSelection() {
|
||||
@@ -688,7 +702,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || selectedIds.length === 0) return;
|
||||
clipboardRef.current = controller.copySelected();
|
||||
setNotice("已复制到画布剪贴板");
|
||||
showNotice("已复制到画布剪贴板");
|
||||
}
|
||||
|
||||
function pasteSelection() {
|
||||
@@ -698,7 +712,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
||||
commitElementOperation(controller, "已粘贴画布对象");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,7 +742,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
commitCanvas(drag.last);
|
||||
setNotice("对象位置已提交");
|
||||
showNotice("对象位置已提交");
|
||||
setGuides([]);
|
||||
dragRef.current = undefined;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,17 @@ import { drawColorCard } from "./palette-provider.js";
|
||||
|
||||
interface Gesture {
|
||||
append: boolean;
|
||||
bounds: DOMRect;
|
||||
hit: boolean;
|
||||
longPressOpened: boolean;
|
||||
moved: boolean;
|
||||
pointerId: number;
|
||||
start: CanvasPoint;
|
||||
startClient: CanvasPoint;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
interface EditorStageProps {
|
||||
assetId: string | null;
|
||||
canvasState: CanvasState;
|
||||
@@ -38,11 +43,10 @@ interface EditorStageProps {
|
||||
selectedIds: readonly string[];
|
||||
}
|
||||
|
||||
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
function pointFromClient(clientX: number, clientY: number, bounds: DOMRect): CanvasPoint {
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
|
||||
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
|
||||
x: Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width)),
|
||||
y: Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,10 +360,14 @@ export function EditorStage(props: EditorStageProps) {
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
||||
if (event.button !== 0) return;
|
||||
const start = pointFromEvent(event);
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const start = pointFromClient(event.clientX, event.clientY, bounds);
|
||||
const append = event.shiftKey;
|
||||
const hit = props.onSelect(start, append);
|
||||
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
|
||||
gestureRef.current = {
|
||||
append, bounds, hit, longPressOpened: false, moved: false, pointerId: event.pointerId, start,
|
||||
startClient: { x: event.clientX, y: event.clientY },
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
longPressRef.current = setTimeout(() => {
|
||||
const gesture = gestureRef.current;
|
||||
@@ -375,9 +383,11 @@ export function EditorStage(props: EditorStageProps) {
|
||||
props.onPointerMoved();
|
||||
return;
|
||||
}
|
||||
const point = pointFromEvent(event);
|
||||
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
||||
if (!gesture.moved && clientDistance < DRAG_THRESHOLD_PX) return;
|
||||
gesture.moved = true;
|
||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
|
||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||
props.onPointerMoved();
|
||||
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
||||
@@ -388,11 +398,18 @@ export function EditorStage(props: EditorStageProps) {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||
const point = pointFromEvent(event);
|
||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
|
||||
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
||||
const moved = gesture.moved || clientDistance >= DRAG_THRESHOLD_PX;
|
||||
if (moved && !gesture.moved && !gesture.longPressOpened) {
|
||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||
if (gesture.hit) props.onMovePreview(delta);
|
||||
}
|
||||
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
||||
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
||||
else if (!gesture.hit && moved) {
|
||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||
props.onMarquee({ height: point.y - gesture.start.y, width: point.x - gesture.start.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
||||
}
|
||||
setMarquee(undefined);
|
||||
gestureRef.current = undefined;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface GenerationPollingProcessor {
|
||||
processNext(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class GenerationPollingLoop {
|
||||
private closed = false;
|
||||
private inFlight = false;
|
||||
private readonly timer: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
private readonly processor: GenerationPollingProcessor,
|
||||
intervalMilliseconds = 250,
|
||||
) {
|
||||
if (!Number.isSafeInteger(intervalMilliseconds) || intervalMilliseconds <= 0) {
|
||||
throw new Error("generation_polling_interval_invalid");
|
||||
}
|
||||
this.timer = setInterval(() => this.run(), intervalMilliseconds);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private run() {
|
||||
if (this.closed || this.inFlight) return;
|
||||
this.inFlight = true;
|
||||
void this.processor.processNext()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
this.inFlight = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
|
||||
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
|
||||
const maximumResponseBytes = 32 * 1024 * 1024;
|
||||
const requestTimeoutMilliseconds = 180_000;
|
||||
const geminiImageSystemInstruction = "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.";
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
|
||||
@@ -141,7 +142,10 @@ function buildRequest(request: GenerationAdapterRequest) {
|
||||
return {
|
||||
body: JSON.stringify({
|
||||
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
|
||||
messages: [{ content, role: "user" }],
|
||||
messages: [
|
||||
{ content: geminiImageSystemInstruction, role: "system" },
|
||||
{ content, role: "user" },
|
||||
],
|
||||
model: geminiProviderModelId,
|
||||
stream: false,
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
|
||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
|
||||
import { GenerationPollingLoop } from "./generation-polling-loop.js";
|
||||
import { GenerationProcessor } from "./generation-processor.js";
|
||||
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
|
||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||
@@ -52,13 +53,13 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
||||
let projectCleanup: ProjectPurgeCleanup | undefined;
|
||||
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let processor: GenerationProcessor | undefined;
|
||||
let generationTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let generationLoop: GenerationPollingLoop | undefined;
|
||||
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
||||
clearInterval(keepAlive);
|
||||
if (retentionTimer) clearInterval(retentionTimer);
|
||||
retention?.close();
|
||||
projectCleanup?.close();
|
||||
if (generationTimer) clearInterval(generationTimer);
|
||||
generationLoop?.close();
|
||||
processor?.close();
|
||||
storage?.close();
|
||||
});
|
||||
@@ -102,7 +103,7 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
||||
});
|
||||
logger.write({ error_category: "none", status_category: "ready" });
|
||||
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
||||
generationTimer = setInterval(() => { void processor?.processNext().catch(() => undefined); }, 250);
|
||||
generationLoop = new GenerationPollingLoop(processor);
|
||||
} catch {
|
||||
storageStatus = "unavailable";
|
||||
control.reportStatus("storage_unavailable");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@
|
||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
||||
"package:portable": "node scripts/build-portable.mjs",
|
||||
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
|
||||
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
|
||||
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||
"check:openapi": "node scripts/check-openapi.mjs",
|
||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||
|
||||
@@ -23,6 +23,27 @@ export const P0A_DYNAMIC_STICKER_IDS = [
|
||||
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||
] as const;
|
||||
|
||||
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
|
||||
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
|
||||
{ assetId: "46f8336813e4c48d06a1aef294fdccf6", sourceReference: "fonts/46f8336813e4c48d06a1aef294fdccf6", templateId: "DYN016" },
|
||||
{ assetId: "53ca6b704728520da50c145eabb2e635", sourceReference: "fonts/53ca6b704728520da50c145eabb2e635", templateId: "DYN007" },
|
||||
{ assetId: "cca5efc0e02fb1bf62349bd68ef30fc1", sourceReference: "fonts/cca5efc0e02fb1bf62349bd68ef30fc1", templateId: "DYN015" },
|
||||
{ assetId: "dd25b35dcb7ba4476cbaa9a9592e39e2", sourceReference: "fonts/dd25b35dcb7ba4476cbaa9a9592e39e2", templateId: "DYN001" },
|
||||
{ assetId: "e4210c9872f0c279b35273f230809821", sourceReference: "fonts/e4210c9872f0c279b35273f230809821", templateId: "DYN011" },
|
||||
{ assetId: "f4bfd4132df2d6be97ceabadf3853505", sourceReference: "fonts/f4bfd4132df2d6be97ceabadf3853505", templateId: "DYN008" },
|
||||
] as const;
|
||||
|
||||
export const P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES = [
|
||||
{ assetId: "DYN001-image28", sourceReference: "resource/image28.png", templateId: "DYN001" },
|
||||
{ assetId: "DYN002-image29", sourceReference: "resource/image29.png", templateId: "DYN002" },
|
||||
{ assetId: "DYN003-image30", sourceReference: "resource/image30.png", templateId: "DYN003" },
|
||||
{ assetId: "DYN004-image32", sourceReference: "resource/image32.png", templateId: "DYN004" },
|
||||
{ assetId: "DYN008-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN008" },
|
||||
{ assetId: "DYN011-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN011" },
|
||||
{ assetId: "DYN015-imager2", sourceReference: "resource/imager2_2.png", templateId: "DYN015" },
|
||||
{ assetId: "DYN016-image21", sourceReference: "resource/image21.png", templateId: "DYN016" },
|
||||
] as const;
|
||||
|
||||
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
|
||||
|
||||
export interface RegisteredComplexAsset extends Record<string, unknown> {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
buildP0aRuntimeAssetPlan,
|
||||
defaultReplicationRoot,
|
||||
deployRuntimeAssetPlan,
|
||||
readRuntimeAssetManifest,
|
||||
serializeRuntimeAssetManifest,
|
||||
} from "./lib/runtime-assets.mjs";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function defaultConfigPath() {
|
||||
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) throw new Error("local_app_data_unavailable");
|
||||
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
|
||||
}
|
||||
|
||||
const configFile = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
|
||||
const configuration = JSON.parse(readFileSync(configFile, "utf8"));
|
||||
const assetRootCandidate = option("--asset-root") ?? configuration.asset_root;
|
||||
if (typeof assetRootCandidate !== "string" || !isAbsolute(assetRootCandidate)) {
|
||||
throw new Error("asset_root_configuration_invalid");
|
||||
}
|
||||
const assetRoot = resolve(assetRootCandidate);
|
||||
const trustedManifest = readRuntimeAssetManifest(resolve(option("--trusted-manifest") ?? "config/runtime-assets-manifest.json"));
|
||||
const plan = await buildP0aRuntimeAssetPlan({
|
||||
replicationRoot: resolve(option("--replication-root") ?? defaultReplicationRoot()),
|
||||
});
|
||||
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
|
||||
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
|
||||
}
|
||||
const result = deployRuntimeAssetPlan({ assetRoot, manifest: trustedManifest, resources: plan.resources });
|
||||
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
buildP0aRuntimeAssetPlan,
|
||||
defaultReplicationRoot,
|
||||
serializeRuntimeAssetManifest,
|
||||
writeRuntimeAssetManifest,
|
||||
} from "./lib/runtime-assets.mjs";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const replicationRoot = resolve(option("--replication-root") ?? defaultReplicationRoot());
|
||||
const outputPath = resolve(option("--output") ?? "config/runtime-assets-manifest.json");
|
||||
const plan = await buildP0aRuntimeAssetPlan({ replicationRoot });
|
||||
writeRuntimeAssetManifest(outputPath, plan.manifest);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
counts: plan.manifest.counts,
|
||||
manifest_bytes: Buffer.byteLength(serializeRuntimeAssetManifest(plan.manifest)),
|
||||
status: "generated",
|
||||
})}\n`);
|
||||
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { frozenRuntime } from "../frozen-versions.mjs";
|
||||
import { readRuntimeAssetManifest } from "./runtime-assets.mjs";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
||||
const fixedPort = 43121;
|
||||
@@ -363,7 +364,10 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
||||
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
||||
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
||||
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
||||
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" });
|
||||
writeJson(
|
||||
join(packageDirectory, "asset-metadata", "manifest.json"),
|
||||
readRuntimeAssetManifest(join(repositoryRoot, "config", "runtime-assets-manifest.json")),
|
||||
);
|
||||
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
||||
|
||||
const commit = run("git", ["rev-parse", "HEAD"]);
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
|
||||
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
|
||||
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
|
||||
|
||||
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||
const mimePattern = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i;
|
||||
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
||||
const shaPattern = /^[a-f0-9]{64}$/i;
|
||||
const fontMimeTypes = new Map([
|
||||
[".otf", "font/otf"],
|
||||
[".ttf", "font/ttf"],
|
||||
[".woff", "font/woff"],
|
||||
[".woff2", "font/woff2"],
|
||||
]);
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function fileSha256(path) {
|
||||
return sha256(readFileSync(path));
|
||||
}
|
||||
|
||||
function stableEntries(entries) {
|
||||
return entries.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new Error("runtime_asset_entry_invalid");
|
||||
if (!assetIdPattern.test(entry.assetId)) throw new Error("runtime_asset_id_invalid");
|
||||
if (!mimePattern.test(entry.mimeType)) throw new Error("runtime_asset_mime_invalid");
|
||||
if (!releasePattern.test(entry.resourceVersion)) throw new Error("runtime_asset_version_invalid");
|
||||
if (entry.rootRef !== P0A_RUNTIME_ASSET_ROOT_REF) throw new Error("runtime_asset_root_ref_invalid");
|
||||
if (!shaPattern.test(entry.sha256)) throw new Error("runtime_asset_sha256_invalid");
|
||||
if (
|
||||
typeof entry.relativePath !== "string"
|
||||
|| isAbsolute(entry.relativePath)
|
||||
|| entry.relativePath.includes("\\")
|
||||
|| entry.relativePath.split("/").some((part) => part === "" || part === "..")
|
||||
) throw new Error("runtime_asset_relative_path_invalid");
|
||||
return { ...entry, sha256: entry.sha256.toLowerCase() };
|
||||
}).sort((left, right) => {
|
||||
const byVersion = left.resourceVersion.localeCompare(right.resourceVersion);
|
||||
return byVersion || left.assetId.localeCompare(right.assetId);
|
||||
});
|
||||
}
|
||||
|
||||
function derivedCounts(entries) {
|
||||
return {
|
||||
dynamic_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^[a-f0-9]{32}$/.test(entry.assetId)).length,
|
||||
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
|
||||
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
|
||||
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeAssetManifest({ counts, entries, sourceManifestSha256 }) {
|
||||
const normalizedEntries = stableEntries(entries);
|
||||
const keys = new Set();
|
||||
const paths = new Set();
|
||||
for (const entry of normalizedEntries) {
|
||||
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
|
||||
if (keys.has(key)) throw new Error("runtime_asset_id_duplicate");
|
||||
if (paths.has(entry.relativePath)) throw new Error("runtime_asset_path_duplicate");
|
||||
keys.add(key);
|
||||
paths.add(entry.relativePath);
|
||||
}
|
||||
const actualCounts = derivedCounts(normalizedEntries);
|
||||
if (JSON.stringify(counts) !== JSON.stringify(actualCounts)) throw new Error("runtime_asset_counts_invalid");
|
||||
if (sourceManifestSha256 !== undefined && !shaPattern.test(sourceManifestSha256)) {
|
||||
throw new Error("runtime_asset_source_manifest_sha256_invalid");
|
||||
}
|
||||
return {
|
||||
counts: actualCounts,
|
||||
entries: normalizedEntries,
|
||||
root_ref: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||
schema_version: RUNTIME_ASSET_MANIFEST_SCHEMA,
|
||||
source: "external_read_only",
|
||||
...(sourceManifestSha256 ? { source_manifest_sha256: sourceManifestSha256.toLowerCase() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function readRuntimeAssetManifest(path) {
|
||||
const value = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (
|
||||
value?.schema_version !== RUNTIME_ASSET_MANIFEST_SCHEMA
|
||||
|| value?.source !== "external_read_only"
|
||||
|| value?.root_ref !== P0A_RUNTIME_ASSET_ROOT_REF
|
||||
|| !Array.isArray(value.entries)
|
||||
) throw new Error("runtime_asset_manifest_invalid");
|
||||
return createRuntimeAssetManifest({
|
||||
counts: value.counts,
|
||||
entries: value.entries,
|
||||
...(value.source_manifest_sha256 ? { sourceManifestSha256: value.source_manifest_sha256 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeRuntimeAssetManifest(manifest) {
|
||||
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function writeRuntimeAssetManifest(path, manifest) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, serializeRuntimeAssetManifest(manifest));
|
||||
}
|
||||
|
||||
function targetWithinRoot(root, relativePath) {
|
||||
const absoluteRoot = resolve(root);
|
||||
const target = resolve(absoluteRoot, ...relativePath.split("/"));
|
||||
if (target === absoluteRoot || !target.startsWith(`${absoluteRoot}${sep}`)) throw new Error("asset_target_path_invalid");
|
||||
return target;
|
||||
}
|
||||
|
||||
function sameFile(left, right) {
|
||||
const leftStat = statSync(left);
|
||||
const rightStat = statSync(right);
|
||||
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
||||
}
|
||||
|
||||
export function deployRuntimeAssetPlan({ assetRoot, manifest, resources }) {
|
||||
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
|
||||
const normalizedManifest = createRuntimeAssetManifest({
|
||||
counts: manifest.counts,
|
||||
entries: manifest.entries,
|
||||
...(manifest.source_manifest_sha256 ? { sourceManifestSha256: manifest.source_manifest_sha256 } : {}),
|
||||
});
|
||||
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
|
||||
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
|
||||
mkdirSync(assetRoot, { recursive: true });
|
||||
for (const resource of resources) {
|
||||
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
||||
const entry = entries.get(key);
|
||||
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
|
||||
throw new Error("asset_resource_plan_mismatch");
|
||||
}
|
||||
if (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink()) {
|
||||
throw new Error("asset_source_invalid");
|
||||
}
|
||||
if (fileSha256(resource.sourcePath) !== entry.sha256) throw new Error("asset_source_hash_invalid");
|
||||
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
if (existsSync(targetPath)) {
|
||||
if (fileSha256(targetPath) !== entry.sha256) throw new Error("asset_target_conflict");
|
||||
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
linkSync(resource.sourcePath, targetPath);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
|
||||
throw new Error("asset_hardlink_volume_mismatch");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
|
||||
}
|
||||
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
|
||||
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
|
||||
}
|
||||
|
||||
function oneDirectoryWithPrefix(root, prefix) {
|
||||
const matches = readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
|
||||
if (matches.length !== 1) throw new Error(`runtime_asset_source_directory_invalid:${prefix}`);
|
||||
return join(root, matches[0].name);
|
||||
}
|
||||
|
||||
function oneSupportedFont(root) {
|
||||
const matches = readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && fontMimeTypes.has(extname(entry.name).toLowerCase()));
|
||||
if (matches.length !== 1) throw new Error(`runtime_font_source_invalid:${basename(root)}`);
|
||||
return join(root, matches[0].name);
|
||||
}
|
||||
|
||||
function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType) {
|
||||
return {
|
||||
assetId,
|
||||
mimeType,
|
||||
relativePath,
|
||||
resourceVersion,
|
||||
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||
sha256: fileSha256(sourcePath),
|
||||
};
|
||||
}
|
||||
|
||||
function dynamicMetadata(templateRoot, descriptor, field) {
|
||||
const templateDirectory = join(templateRoot, descriptor.templateId);
|
||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||
if (!Array.isArray(metadata?.files?.[field]) || !metadata.files[field].includes(descriptor.sourceReference)) {
|
||||
throw new Error(`runtime_dynamic_reference_invalid:${descriptor.assetId}`);
|
||||
}
|
||||
return templateDirectory;
|
||||
}
|
||||
|
||||
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
|
||||
import("../../packages/asset-compiler/dist/index.js"),
|
||||
import("../../packages/template-registry/dist/index.js"),
|
||||
]);
|
||||
const compilerOutput = mkdtempSync(join(tmpdir(), "dada-runtime-asset-plan-"));
|
||||
try {
|
||||
const staticSourceRoot = join(replicationRoot, "sticker_normal");
|
||||
const staticResult = compileStaticStickerCatalog({
|
||||
outputDirectory: compilerOutput,
|
||||
releaseVersion: registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||
sourceRoot: staticSourceRoot,
|
||||
});
|
||||
const resources = staticResult.catalog.items.map((item) => {
|
||||
const sourcePath = join(staticSourceRoot, ...item.relative_path.split("/"));
|
||||
const entry = entryFor(
|
||||
sourcePath,
|
||||
item.stable_id,
|
||||
registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||
`${registry.P0A_STATIC_STICKER_RELEASE_VERSION}/${item.stable_id}.png`,
|
||||
"image/png",
|
||||
);
|
||||
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
|
||||
return { entry, sourcePath };
|
||||
});
|
||||
|
||||
const fontPackagesRoot = join(
|
||||
replicationRoot,
|
||||
"sticker_text",
|
||||
"字体",
|
||||
"面板全量采集",
|
||||
"font_panel_full_20260722",
|
||||
"resources",
|
||||
"font_packages",
|
||||
);
|
||||
for (const assetId of registry.P0A_REQUIRED_FONT_PANEL_IDS) {
|
||||
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
|
||||
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
resources.push({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
|
||||
fontMimeTypes.get(extension),
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES) {
|
||||
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "fonts");
|
||||
const sourcePath = oneSupportedFont(join(templateDirectory, ...descriptor.sourceReference.split("/")));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
resources.push({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
descriptor.assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}${extension}`,
|
||||
fontMimeTypes.get(extension),
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES) {
|
||||
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "images");
|
||||
const sourcePath = join(templateDirectory, ...descriptor.sourceReference.split("/"));
|
||||
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||
throw new Error(`runtime_dynamic_image_invalid:${descriptor.assetId}`);
|
||||
}
|
||||
resources.push({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
descriptor.assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}.png`,
|
||||
"image/png",
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: {
|
||||
dynamic_fonts: registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES.length,
|
||||
dynamic_images: registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES.length,
|
||||
font_panel_items: registry.P0A_REQUIRED_FONT_PANEL_IDS.length,
|
||||
static_stickers: staticResult.catalog.count,
|
||||
},
|
||||
entries: resources.map((resource) => resource.entry),
|
||||
sourceManifestSha256: fileSha256(manifestPath),
|
||||
});
|
||||
const resourcesByKey = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
|
||||
return {
|
||||
manifest,
|
||||
resources: manifest.entries.map((entry) => resourcesByKey.get(`${entry.resourceVersion}\u0000${entry.assetId}`)),
|
||||
};
|
||||
} finally {
|
||||
rmSync(compilerOutput, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultReplicationRoot(environment = process.env) {
|
||||
if (!environment.USERPROFILE || !isAbsolute(environment.USERPROFILE)) throw new Error("user_profile_unavailable");
|
||||
return join(environment.USERPROFILE, "Desktop", "sticker_web_replication_assets");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GenerationModelConfigurationCatalog, type ModelConfigurationView } from "../../apps/api/src/model-configuration.js";
|
||||
|
||||
describe("POSTV1-04 generation runtime wiring", () => {
|
||||
it("constructs, injects and closes the production generation submission service", () => {
|
||||
const main = readFileSync("apps/api/src/main.ts", "utf8");
|
||||
|
||||
expect(main).toContain('import { GenerationSubmissionService } from "./generation-submission.js";');
|
||||
expect(main).toContain("let generations: GenerationSubmissionService | undefined;");
|
||||
expect(main).toContain("generations = new GenerationSubmissionService({");
|
||||
expect(main).toContain("models: new GenerationModelConfigurationCatalog(models),");
|
||||
expect(main).toContain("...(generations ? { generations } : {}),");
|
||||
expect(main).toContain("generations?.close();");
|
||||
});
|
||||
|
||||
it("maps the current model configuration into the generation submission contract", () => {
|
||||
const configuration: ModelConfigurationView = {
|
||||
config_set_version: 7,
|
||||
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||
recommended_model_id: "gemini-3.1-flash-image-preview",
|
||||
models: [{
|
||||
config_version: 3,
|
||||
contract_evidence_ref: "fixture-contract",
|
||||
contract_validation_status: "verified",
|
||||
credit_cost: 2,
|
||||
display_name: "Fixture model",
|
||||
enabled: true,
|
||||
error_mapping_profile: {},
|
||||
gateway_account_ref: "fixture-gateway",
|
||||
is_default: true,
|
||||
model_id: "gemini-3.1-flash-image-preview",
|
||||
prompt_max_length: 1_000,
|
||||
recommendation_priority: 1,
|
||||
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
|
||||
route_profile: {},
|
||||
runtime_availability: { available_for_new_jobs: true, checked_at: "2026-08-05T00:00:00.000Z", reason: "available" },
|
||||
safety_source: "provider",
|
||||
supported_ratios: ["3:4", "invalid"],
|
||||
}],
|
||||
};
|
||||
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
|
||||
|
||||
expect(catalog.readModel("gemini-3.1-flash-image-preview")).toEqual({
|
||||
configSetVersion: 7,
|
||||
configVersion: 3,
|
||||
contractValidationStatus: "verified",
|
||||
creditCost: 2,
|
||||
enabled: true,
|
||||
modelId: "gemini-3.1-flash-image-preview",
|
||||
promptMaxLength: 1_000,
|
||||
referenceLimits: { maxFileBytes: 10, maxFiles: 2, maxTotalBytes: 20 },
|
||||
runtimeAvailability: { availableForNewJobs: true, reason: null },
|
||||
supportedRatios: ["3:4"],
|
||||
});
|
||||
expect(catalog.readModel("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps model and contract blocks into generation error categories", () => {
|
||||
const configuration = {
|
||||
config_set_version: 1,
|
||||
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||
recommended_model_id: null,
|
||||
models: [],
|
||||
} satisfies ModelConfigurationView;
|
||||
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
|
||||
const base = {
|
||||
config_version: 1,
|
||||
contract_evidence_ref: null,
|
||||
contract_validation_status: "verified" as const,
|
||||
credit_cost: 1,
|
||||
display_name: "Fixture model",
|
||||
enabled: true,
|
||||
error_mapping_profile: {},
|
||||
gateway_account_ref: "fixture-gateway",
|
||||
is_default: true,
|
||||
model_id: "gemini-3.1-flash-image-preview" as const,
|
||||
prompt_max_length: 1_000,
|
||||
recommendation_priority: 1,
|
||||
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
|
||||
route_profile: {},
|
||||
safety_source: "provider",
|
||||
supported_ratios: ["3:4"],
|
||||
};
|
||||
|
||||
configuration.models = [{
|
||||
...base,
|
||||
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "contract_blocked" },
|
||||
}];
|
||||
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("gateway_contract_invalid");
|
||||
|
||||
configuration.models = [{
|
||||
...base,
|
||||
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "worker_degraded" },
|
||||
}];
|
||||
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("model_disabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfiguredRuntimeAssets } from "../../apps/api/src/runtime-assets.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
if (resolve(directory).startsWith(resolve(tmpdir()))) rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const base = mkdtempSync(join(tmpdir(), "dada-postv1-assets-"));
|
||||
temporaryDirectories.push(base);
|
||||
const assetRoot = join(base, "assets");
|
||||
const dataRoot = join(base, "data");
|
||||
const configFile = join(base, "instance.json");
|
||||
const trustedManifestPath = join(base, "trusted-manifest.json");
|
||||
const bytes = Buffer.from("synthetic sticker bytes");
|
||||
const entry = {
|
||||
assetId: "STK001",
|
||||
mimeType: "image/png",
|
||||
relativePath: "p0a-static-v1/STK001.png",
|
||||
resourceVersion: "p0a-static-v1",
|
||||
rootRef: "p0a_runtime_assets",
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
const manifest = {
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||
entries: [entry],
|
||||
root_ref: "p0a_runtime_assets",
|
||||
schema_version: "DadaRuntimeAssets/v1",
|
||||
source: "external_read_only",
|
||||
};
|
||||
mkdirSync(join(assetRoot, "p0a-static-v1"), { recursive: true });
|
||||
writeFileSync(join(assetRoot, entry.relativePath), bytes);
|
||||
writeFileSync(join(assetRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
writeFileSync(trustedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
|
||||
return { assetRoot, configFile, dataRoot, trustedManifestPath };
|
||||
}
|
||||
|
||||
describe("POSTV1-06 portable runtime assets", () => {
|
||||
it("activates a validated external asset root without exposing its path", () => {
|
||||
const input = fixture();
|
||||
const loaded = loadConfiguredRuntimeAssets(input);
|
||||
|
||||
expect(loaded.state).toMatchObject({ configured: true, pause_reason: null, status: "active" });
|
||||
expect(loaded.publicAssets?.read("p0a-static-v1", "STK001")?.bytes.toString()).toBe("synthetic sticker bytes");
|
||||
expect(JSON.stringify(loaded.state)).not.toContain(input.assetRoot);
|
||||
});
|
||||
|
||||
it("rejects a changed external manifest and leaves unrelated API features available", () => {
|
||||
const input = fixture();
|
||||
writeFileSync(join(input.assetRoot, "manifest.json"), "{}\n");
|
||||
const loaded = loadConfiguredRuntimeAssets(input);
|
||||
|
||||
expect(loaded.publicAssets).toBeUndefined();
|
||||
expect(loaded.state).toMatchObject({ configured: true, pause_reason: "asset_manifest_invalid", status: "unavailable" });
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ describe("TDD-WP0-DATA-001-root-validation resource boundary", () => {
|
||||
const assetRoot = join(base, "read-only-assets");
|
||||
const relativePath = "images/source.png";
|
||||
const bytes = Buffer.from("synthetic png fixture");
|
||||
const assetId = randomUUID();
|
||||
const assetId = "STK001";
|
||||
mkdirSync(join(assetRoot, "images"), { recursive: true });
|
||||
writeFileSync(join(assetRoot, relativePath), bytes);
|
||||
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
|
||||
|
||||
@@ -212,3 +212,81 @@ test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges
|
||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
|
||||
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
|
||||
});
|
||||
|
||||
test("POSTV1-07 keeps the canvas anchored when the text template panel opens", async ({ page }) => {
|
||||
const projectId = uuid(760);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 5 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
|
||||
const stage = page.getByLabel("编辑画布");
|
||||
const before = await stage.boundingBox();
|
||||
if (!before) throw new Error("Canvas geometry is unavailable before opening text templates.");
|
||||
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
|
||||
const after = await stage.boundingBox();
|
||||
if (!after) throw new Error("Canvas geometry is unavailable after opening text templates.");
|
||||
const assetsPanelScroll = await page.getByLabel("素材与底图来源").evaluate((panel) => ({
|
||||
clientHeight: panel.clientHeight,
|
||||
overflowY: getComputedStyle(panel).overflowY,
|
||||
scrollHeight: panel.scrollHeight,
|
||||
}));
|
||||
|
||||
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1);
|
||||
expect(assetsPanelScroll.overflowY).toBe("auto");
|
||||
expect(assetsPanelScroll.scrollHeight).toBeGreaterThan(assetsPanelScroll.clientHeight);
|
||||
});
|
||||
|
||||
test("POSTV1-07 keeps text selection stable and dismisses move feedback", async ({ page }) => {
|
||||
const projectId = uuid(770);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 6 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||
|
||||
await page.reload();
|
||||
const stage = page.getByLabel("编辑画布");
|
||||
const bounds = await stage.boundingBox();
|
||||
const element = backend.canvas.elements[0];
|
||||
if (!bounds || !element) throw new Error("Text selection geometry is unavailable.");
|
||||
const positionBefore = structuredClone(element.position);
|
||||
const savesBefore = backend.saves;
|
||||
const clientX = bounds.x + bounds.width * element.position.x;
|
||||
const clientY = bounds.y + bounds.height * element.position.y;
|
||||
|
||||
await page.mouse.move(clientX, clientY);
|
||||
await page.mouse.down();
|
||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
||||
const selectedBounds = await stage.boundingBox();
|
||||
const inspectorScroll = await page.getByLabel("对象参数").evaluate((panel) => ({
|
||||
clientHeight: panel.clientHeight,
|
||||
overflowY: getComputedStyle(panel).overflowY,
|
||||
scrollHeight: panel.scrollHeight,
|
||||
}));
|
||||
if (!selectedBounds) throw new Error("Canvas geometry is unavailable after selecting text.");
|
||||
expect(Math.abs(selectedBounds.y - bounds.y)).toBeLessThanOrEqual(1);
|
||||
expect(inspectorScroll.overflowY).toBe("auto");
|
||||
expect(inspectorScroll.scrollHeight).toBeGreaterThan(inspectorScroll.clientHeight);
|
||||
await page.mouse.move(clientX + 1, clientY);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
||||
await page.waitForTimeout(800);
|
||||
expect(backend.canvas.elements[0]?.position).toEqual(positionBefore);
|
||||
expect(backend.saves).toBe(savesBefore);
|
||||
|
||||
await page.mouse.move(clientX, clientY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(clientX + 12, clientY);
|
||||
await page.mouse.up();
|
||||
await expect(page.getByText("对象位置已提交", { exact: true })).toBeVisible();
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(savesBefore);
|
||||
await expect(page.getByText("对象位置已提交", { exact: true })).toBeHidden({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
defaultLocalDataRoot,
|
||||
initializeLocalDataRoot,
|
||||
inspectInitializedLocalDataRoot,
|
||||
readConfiguredAssetRoot,
|
||||
resolvePathWithinRoot,
|
||||
validateLocalDataRoot,
|
||||
validateReadOnlyAssetRoot,
|
||||
@@ -66,6 +67,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("TDD-WP0-DATA-001-root-validation", () => {
|
||||
it("reads only an absolute configured read-only asset root", () => {
|
||||
const base = temporaryDirectory();
|
||||
const configFile = join(base, "instance.json");
|
||||
const assetRoot = join(base, "runtime-assets");
|
||||
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
|
||||
|
||||
expect(readConfiguredAssetRoot(configFile)).toBe(resolve(assetRoot));
|
||||
writeFileSync(configFile, JSON.stringify({ asset_root: "relative-assets" }));
|
||||
expect(() => readConfiguredAssetRoot(configFile)).toThrow("asset_root_configuration_invalid");
|
||||
});
|
||||
|
||||
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
|
||||
const localAppData = join(temporaryDirectory(), "LocalAppData");
|
||||
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createRuntimeAssetManifest,
|
||||
deployRuntimeAssetPlan,
|
||||
readRuntimeAssetManifest,
|
||||
serializeRuntimeAssetManifest,
|
||||
} from "../../scripts/lib/runtime-assets.mjs";
|
||||
|
||||
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
|
||||
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
|
||||
assert.deepEqual(manifest.counts, {
|
||||
dynamic_fonts: 7,
|
||||
dynamic_images: 8,
|
||||
font_panel_items: 11,
|
||||
static_stickers: 1407,
|
||||
});
|
||||
assert.equal(manifest.entries.length, 1433);
|
||||
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
|
||||
});
|
||||
|
||||
test("runtime asset deployment creates verified hardlinks and a path-free manifest", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-"));
|
||||
t.after(() => rm(root, { force: true, recursive: true }));
|
||||
const sourceRoot = join(root, "source");
|
||||
const assetRoot = join(root, "assets");
|
||||
const sourcePath = join(sourceRoot, "sticker.png");
|
||||
const bytes = Buffer.from("runtime asset fixture");
|
||||
await mkdir(sourceRoot);
|
||||
await writeFile(sourcePath, bytes);
|
||||
const entry = {
|
||||
assetId: "STK001",
|
||||
mimeType: "image/png",
|
||||
relativePath: "p0a-static-v1/STK001.png",
|
||||
resourceVersion: "p0a-static-v1",
|
||||
rootRef: "p0a_runtime_assets",
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
await deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] });
|
||||
|
||||
const targetPath = join(assetRoot, entry.relativePath);
|
||||
const [sourceStat, targetStat] = await Promise.all([stat(sourcePath), stat(targetPath)]);
|
||||
assert.equal(sourceStat.ino, targetStat.ino);
|
||||
assert.deepEqual(await readFile(targetPath), bytes);
|
||||
const writtenManifest = await readFile(join(assetRoot, "manifest.json"), "utf8");
|
||||
assert.deepEqual(JSON.parse(writtenManifest), manifest);
|
||||
assert.doesNotMatch(writtenManifest, /[A-Za-z]:[\\/]/);
|
||||
});
|
||||
|
||||
test("runtime asset deployment refuses a mismatched existing target", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-conflict-"));
|
||||
t.after(() => rm(root, { force: true, recursive: true }));
|
||||
const sourcePath = join(root, "source.png");
|
||||
const assetRoot = join(root, "assets");
|
||||
const targetPath = join(assetRoot, "p0a-static-v1", "STK001.png");
|
||||
await mkdir(join(assetRoot, "p0a-static-v1"), { recursive: true });
|
||||
await writeFile(sourcePath, "expected");
|
||||
await writeFile(targetPath, "unexpected");
|
||||
const entry = {
|
||||
assetId: "STK001",
|
||||
mimeType: "image/png",
|
||||
relativePath: "p0a-static-v1/STK001.png",
|
||||
resourceVersion: "p0a-static-v1",
|
||||
rootRef: "p0a_runtime_assets",
|
||||
sha256: createHash("sha256").update("expected").digest("hex"),
|
||||
};
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] }),
|
||||
/asset_target_conflict/,
|
||||
);
|
||||
});
|
||||
@@ -112,6 +112,14 @@ async function verifyWorkerStartup(configPath) {
|
||||
test("portable package serves the product and keeps SQLite data across API restart", async () => {
|
||||
assert.ok(existsSync(join(packageRoot, "Dada.exe")));
|
||||
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
||||
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
|
||||
assert.deepEqual(runtimeAssetManifest.counts, {
|
||||
dynamic_fonts: 7,
|
||||
dynamic_images: 8,
|
||||
font_panel_items: 11,
|
||||
static_stickers: 1407,
|
||||
});
|
||||
assert.equal(runtimeAssetManifest.entries.length, 1433);
|
||||
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
||||
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
||||
assert.match(packagedWorker, /GenerationProcessor/);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GenerationPollingLoop } from "../../apps/worker/src/generation-polling-loop.js";
|
||||
|
||||
describe("POSTV1-05 generation polling loop", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not start another processor call while the current call is unresolved", async () => {
|
||||
vi.useFakeTimers();
|
||||
let finishCurrentCall: (() => void) | undefined;
|
||||
const processNext = vi.fn(() => new Promise<void>((resolve) => {
|
||||
finishCurrentCall = resolve;
|
||||
}));
|
||||
const loop = new GenerationPollingLoop({ processNext }, 250);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(processNext).toHaveBeenCalledTimes(1);
|
||||
|
||||
finishCurrentCall?.();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(processNext).toHaveBeenCalledTimes(2);
|
||||
|
||||
loop.close();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,14 @@ describe("POSTV1-02 OneAPI runtime adapter", () => {
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
|
||||
expect(init?.redirect).toBe("error");
|
||||
const payload = JSON.parse(String(init?.body)) as { messages: Array<{ content: unknown; role: string }> };
|
||||
expect(payload.messages).toEqual([
|
||||
{
|
||||
content: "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.",
|
||||
role: "system",
|
||||
},
|
||||
{ content: "一张用于本机验收的抽象色彩图", role: "user" },
|
||||
]);
|
||||
return new Response(JSON.stringify({
|
||||
choices: [{ message: { content: `})` } }],
|
||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||
|
||||
Reference in New Issue
Block a user